API Authorization using Open Policy Agent and Kong

API Authorization using Open Policy Agent and Kong

tutorials

Applying fine-grained authorization to APIs is a prevalent need, and there are several ways to implement it. This article will highlight several components in a separation of concerns architecture to create a full end-to-end approach. Each ingredient used in this architecture is either open-source or free to use as a community edition software.

A helpful overview of this setup is outlined in the Introducing the Neo-Security Architecture article. The Entitlement Management System article also describes the role a system like Open Policy Agent (OPA) plays in the overall architecture.

The scenario outlined in this article is leveraging an open-source plugin described in the Integrating with Kong Open Source article. This plugin is specialized in that it only handles the Phantom Token approach, which is very straightforward to configure. As such, the scenario below will use this pattern.

OPA will be used to externalize the authorization of access to the API using a policy-based approach. Kong will call OPA with an access request, and OPA will evaluate that request against its policy. A decision, typically something like {"allow": <true/false>} will be returned by OPA, and Kong will use that information to allow or deny the call to the upstream API.

OPA uses claims in its policy to make that decision. The Curity Identity Server issues the claims as part of providing a token. Scopes and Claims are fully configurable in the Curity Identity Server, and the claims data can come from anywhere.

This article will describe the use of the above components and a combination of Scopes and Claims to achieve a multi-tier API authorization model where a coarse-grained authorization decision is made. If it passes, a fine-grained authorization decision is handled by OPA and its policy.

Prerequisites

All components used will be running in Docker using a docker compose file. The only prerequisite is to have Docker Desktop installed.

High-Level Architecture

Architecture

  1. A client needs access to data exposed by an API. This client could be any client, but for testing purposes, OAuth.tools could be used. The first step is to obtain an Access Token from the Curity Identity Server.

  2. The opaque Access Token is passed in the Authorize header to the API endpoint exposed by the Kong Gateway.

  3. Kong receives the opaque Access Token, and

    a) sends it to the Introspection endpoint of the Curity Identity Server.

    b) Then, the Curity Identity Server issues a JWT.

  4. Using the Phantom Token plugin, Kong will perform a coarse-grained authorization check using the scopes received in the JWT. If scopes configured as required in the plugin are not available, access will be denied, and a HTTP 403 returned to the client.

    a) If the coarse-grained check is approved, the Kong OPA plugin will take over and pass the decoded and verified JWT to OPA together with the request method and requested API path. OPA holds a policy that will be evaluated against these input attributes and will make an authorization decision.

    b) The decision is returned to Kong.

  5. If authorization is allowed, Kong will call the upstream API and add the JWT in the Authorization header. Otherwise, an HTTP 403 is returned to the client.

Quickstart

The full environment is available as a GitHub repository and can be started very easily.

  1. Pull down the git repo git clone https://github.com/curityio/curity-kong-opa-demo.
  2. Build the environment docker compose build.
  3. Start the environment docker compose up.
  4. When the environment has started, go to https://localhost:6749/admin and log in with the user admin and password defined in docker-compose.yml. Go through the basic wizard and make sure to enable SSL (Use Existing SSL Key and selecting default-admin-ssl-key works, or choose your own). Upload a valid license and upload the example policy, curity/curity-opa-kong-config.xml. This policy can be merged but requires the wizard to be completed and committed first.
  5. With the system configured, a client can obtain a token using the www client. Make sure to request the openid and records scope. There are no users pre-populated in the environment. As part of the authentication process, create a user. The default OPA policy checks that user==owner so authorization will fail if there is a mismatch. The owners (patient) of the records are detailed in api/server/records.json. Either create a user that matches or make changes to records.json.
  6. Use the Access Token and perform a GET request to the API exposed by Kong.
curl -Ss -X GET \
http://localhost:8000/records/0 \
-H 'Authorization: Bearer b37b14c7-a23b-4c4b-b59a-4f4bac9ba9af'

Alice owns record 0 and access will be granted. However, she does not own record 1. Therefore, access to this record will be denied.

Detailed Environment Configuration

The entire environment is comprised of four docker containers.

1. The API

This is a simple Golang application exposing an API that serves up records. Naturally, this is not required, but using the provided API with its default data will match the configuration provided for Curity and Kong as well as the authorization policy enforced by OPA.

The data exposed by the API can be modified in api/server/records.json. The data is read at every request to the API and is mapped via a volume mapping in docker-compose.yml. With this mapping, the data can be changed on-the-fly without rebuilding or even restarting the container.

It would also be possible to change the data model of the records. In that case, api/server/data-model.go must be modified and the API image rebuilt.

The API image is built as part of the docker compose build command.

2. The Curity Identity Server

In this architecture, the Curity Identity Server operates as the token service responsible for issuing opaque tokens after a successful user authentication. The opaque token is then introspected by the Kong API Gateway using the phantom token plugin to effectively obtain a JWT to replace the opaque token.

The provided configuration is set to use an HTML Form Authenticator that uses an internally provided database as the credential store. This could be changed, and any supported Authenticator could be used. The configuration has two scopes configured: openid and records. This is to showcase that the Phantom Token plugin can be used for coarse-grained Authorization directly in the Kong Gateway and thus avoiding unnecessary calls to both OPA and the upstream API if the configured scopes are not available.

When OPA receives the Authorization request from the Kong Gateway, it will contain the JWT in the payload. The OPA policy includes several elements that use claims in the JWT itself. More details on this in the next section that describes the policy.

3. Open Policy Agent

The policy is the full policy in use by OPA when the environment starts. It’s loaded from opa/policies/ at the start by the OPA container. opa/policies only contains one policy file, records.rego. However, it would be possible to add additional policy files to this directory that would be loaded when the container starts.

Calling http://localhost:8181/v1/policies would show what policies are in place.

It is also possible to change the policy in place in the OPA runtime environment by making a PUT request to http://localhost:8181/v1/policies/records/records.rego.

OPA policies are written in rego, an easy-to-understand declarative policy language. Policies can be very complex and powerful, and this example is only touching on the surface of what’s possible. However, it should offer an idea of what’s possible related to protecting data serviced by an API.

This policy is expressed in such a way that exemplifies several different features of the policy language.

First, a default decision is set with default allow = false. This is not explicitly needed but clarifies that the default response means access is not allowed.

The allow {} rule has pointers to other rules in the policy. All of these rules are going to result in a true or false result. Everything inside of the allow {} rule is AND:ed, meaning they all have to be true for allow to be true. If they are not all true, the decision will be false since that is the default value for allow {}.

is_issuer checks if the iss claim in the token in the request (input.token.payload.iss) to OPA contains an allowed value. The permitted value is defined in the issuers variable in the policy.

With that in mind, several of the other rules are self-explanatory. The is_get rule checks if the API call is a GET request, and match_claims checks that the scopes in the token are openid records (the Phantom Token plugin in Kong also does this, so this should never be false).

One complex rule that applies a more fine-grained authorization approach is the is_owner rule. The simplified version of this rule would be user==owner. It checks that the record requested access for is owned by the user requesting it. In this case, the user is identified by a claim in the token, input.token.payload.sub. The value is normalized to be all lower case using the lower() function of rego. Then the owner of the record is determined. This is done by first parsing out the record_id from the end of the path from the authorization request. For http://localhost:8000/records/0, the record_id is 0. The record_id is then passed to a defined function in the policy that calls the upstream API, gets the full content of that record, and parses out the patient (e.g., the owner). That value is then also normalized to lower case. If the two values user and patient match, the result of this rule is true.

Policy

package records

default allow = false

allow { 
  is_issuer
  is_get
  is_records
  is_aud
  match_claims
  is_owner
}

## Allowed issuer(s)
issuers = {"https://idsvr:8443/oauth/v2/oauth-anonymous"}

is_issuer {
  input.token.payload.iss == issuers[issuer]
} 

is_get {
  input.method == "GET"
}

is_records {
  startswith(input.path, "/api/records")
}

is_aud {
  input.token.payload.aud = "www"
}

match_claims {
  input.token.payload.scope = "openid records"
}

## Calls record_owner method to verify if the request record is owned by the requesting user (sub)
is_owner{
  record_id := trim_left(input.path, "/api/records/") ##trim the path to get record id
  lower(input.token.payload.sub) == lower(record_owner(record_id).patient)
}

## Method takes a record_id as input to resolve record data
record_owner(record_id) = http.send({
  "url": concat("", ["http://api:8080/api/records/", record_id]),
  "method": "GET",
  "force_cache": true,
  "force_cache_duration_seconds": 86400 # Cache response for 24 hours
}).body

4. Kong Gateway

The Kong API Gateway sits as a proxy in front of the API. In this environment, the Kong Gateway has two main responsibilities. The first is to execute the Phantom Token flow using the phantom token plugin and with that obtain a JWT. The second is to pass that JWT to the Open Policy Agent (OPA) for further authorization. If the response from OPA is successful, e.g., an allow=true is returned in the result, Kong will continue the flow and call the upstream API to return the data.

With this flow in the Kong Gateway, the plugins must execute in the correct order. The Phantom Token plugin has a priority set to 1000 and OPA 899 by default. The higher the number, the earlier they are executed. So, in this case, the Phantom Token plugin will obtain a JWT first so that OPA can consume it.

The Kong Gateway image used is built as part of the docker compose build command. This enables the two plugins to be bundled inside of the image. In addition to that, the db-less mode of Kong is used, and a declarative configuration kong.yml is made available through a volume in docker-compose.yml. The kong.yml configuration sets up the service for the API and configures the two plugins accordingly. This includes configuring the introspection endpoint, client and secret, and the policy path needed for the policy evaluation by OPA.

Conclusion

The Curity Identity Server, Open Policy Agent, and Kong Gateway all play their part in a robust architecture to provide fine-grained authorization for APIs. An identity server that leverages standards like OpenID Connect and OAuth2 is well suited to provide solid authentication and issue tokens with scopes and claims. An API Gateway like Kong can then consume this for coarse-grained authorization and further exchange of tokens to provide richer data that can hold PII information leveraged later in the chain. The API Gateway can pass that data on to an external authorization such as OPA that holds a fine-grained authorization policy capable of making an extremely detailed authorization decision. It makes a decision based on claims or attributes and shares the result with the API gateway, allowing or denying the upstream API to be called.

These components are great at what they do and can be configured to work together to provide strong security for APIs.

Resources

Let’s Stay in Touch!

Get the latest on identity management, API Security and authentication straight to your inbox.

Keep up with our latest articles and how-tos using RSS feeds