API Authorization at the Gateway with Apigee, Okta, and OPA (Part 2)

This is the second post in a two-part series about enforcing API authorization policies using Apigee, Okta and OPA. While the first post explained how to set up all three to work together, this post dives into detail on the policies that go along with the working code.

The application we will be discussing is based on a hypothetical medical insurance provider Acme Health Care. Acme Health Care provides a web application that helps members and healthcare providers interact to process insurance claims for medical procedures undergone by the members. 

This application requires enforcing the following policies:

  • Each member should have an unexpired access token.
  • Primary members can create, read and update claims, whereas secondary members can only read claims.
  • Each member should be able to see their own insurance claims. 
  • For example, Alice should be able to see her insurance claims.

Apigee Input to OPA

When Apigee queries OPA to check whether an authenticated user can perform a given action on a given resource, the input value defined in the Example Policy resembles the JSON below. The method and path represent the HTTP method and requested HTTP API endpoint respectively. The token is the JWT issued to the user by Okta after a successful login.

 

{

  "method":"GET",

  "path":["v1","claims","enrollee","1001"],

  "token":"eyJhbGciOiJIUzUxMiIsImlhdCI6MTU5..."

}

 

User Token

This is what a typical JWT generated by Okta looks like:

Apigee, Okta, and OPA Styra blog


The decoded JWT contains all the information about the Acme Health Care member and is used in the OPA policy.

 

{
  "sub": "00udob7ptHzEvO19X4x6",
  "name": "Alice Opa",
  "email": "alice@hooli.com",
  "ver": 1,
  "iss": "https://dev-969069.okta.com/oauth2/default",
  "aud": "0oadnudt2Kwi0srr04x6",
  "iat": 1591396492,
  "exp": 1591400092,
  "jti": "ID.oYk95bbbWkY1o_m2kjY9SjOJtoVWcpg1_POpP2qjVlU",
  "amr": [
    "pwd"
  ],
  "idp": "00odmkk5wRCCvoZcp4x6",
  "preferred_username": "alice@hooli.com",
  "auth_time": 1591396491,
  "at_hash": "Of9b4zfMXkeghWMo0qBETA",
  "enrolleeId": "1001",
  "enrolleeType": "primary",
  "age": "32"
}

 

Example Policy

The demo uses the following OPA policy. Apigee queries the allow rule to authorize incoming user requests.

 

package authz

default allow = false

# helper to get the token payload
token = {"payload": payload} { io.jwt.decode(input.token, [_, payload, _]) }

# rule to allow members to read their own claims
allow {

  is_token_valid
  input.method == "GET"
  input.path = ["v1", "claims", "enrollee", enrolleeId]

  token.payload.enrolleeId == enrolleeId
}

# rule to allow primary members to create claims

allow {

  is_token_valid
  input.method == "POST"
  input.path = ["v1", "claims", "enrollee"]

  token.payload.enrolleeType == “primary”
}

# rule to allow primary members to update claims

allow {

  is_token_valid
  input.method == "PUT"
  input.path = ["v1", "claims", "enrollee", _]

  token.payload.enrolleeType == “primary”
}

# helper rule to check if the user’s token is valid

is_token_valid {

  now := time.now_ns() / 1000000000

  now < token.payload.exp

}

 

Now let's understand the above policy in detail. The allow rule returns true if an authenticated user with a valid token makes a GET API call to an endpoint that returns the user’s insurance claims OR if an authenticated primary user with a valid token makes a POST/PUT API call to create/update claims. To enable pattern-matching on the input path, it is represented as an array instead of a string.

 

# rule to authorize incoming requests
allow {                                                         # allow is assigned true if ...
  is_token_valid                                                # is_token_valid is true AND

  input.method == "GET"                                         # method is "GET" AND
  input.path = ["v1", "claims", "enrollee", enrolleeId]         # path has the form ["v1", "claims", "enrollee", enrolleeId] ie. "/v1/claims/enrollee/<some_enrollee_id>"

 token.payload.enrolleeId == enrolleeId
}

# helper rule to check if the user’s token is valid

is_token_valid {                                                # is_token_valid is assigned true if ...

  now := time.now_ns() / 1000000000

  now < token.payload.exp                                       # current time is less than token expiration time

}

 

Wrap Up

In this two-part blog series, we explored how to integrate Apigee, Okta and OPA to set up a basic microservice environment using Apigee as the API gateway, Okta to manage end-user authentication and OPA to make authorization decisions. 

We learned how to leverage Apigee’s callout feature to query OPA and enforce OPA’s policy decision in the request path. We also saw the input Apigee provides to OPA and how OPA can use that to evaluate different kinds of security policies to provide least-privilege access.

So far we’ve seen Apigee callout to OPA on the request path and enforce the boolean decisions returned by OPA. But what if we wanted to hide certain sensitive fields in the insurance claims a user is allowed to see. For instance, if Alice can access Bob’s insurance claims, we may want to hide the procedures Bob has undergone from Alice. One way to achieve this would be to hand over the insurance claim object to OPA and let it filter the necessary fields in the object and then Apigee would return the filtered object to the end-user. 

Sound interesting? We’ll discuss data filtering in OPA and explore how we can apply it in real-world scenarios in a future blog post.  

And, if you're looking for advice on how to deploy OPA at scale, we're here to help! 

 Prev

API Authorization at the Gateway with Apigee, Okta and OPA (Part 1)

Next  

Using Open Policy Agent to safeguard Kubernetes

Subscribe

Using Open Policy Agent to safeguard Kubernetes

Open Policy Agent addresses Kubernetes..

October 6
Go

API Authorization at the Gateway with Apigee, Okta and OPA (Part 1)

API gateways have become a standard..

October 22
Go