Edge Authorization using OPA and ForgeRock

Posted September 28, 2021 by Jatinder Singh ‐ 9 min read

Using a general-purpose Open Policy Agent (OPA) bundled with the ForgeRock platform, the post demos how to provide comprehensive API security at Edge.

Edge AuthZ using OPA and ForgeRock
Edge AuthZ using OPA and ForgeRock

Authorization in the Identity & Access Management (IAM) domain focuses on access control. In a nutshell, it governs who can and cannot access what resources. While it may sound like an easy task, it is a tough cookie to crack, and it only gets challenging with today’s complex authorization requirements. And the question that often emerges is - where to implement authorization. To understand the authorization lexicon and various challenges, I suggest reading this article by Sam Scott.

The three pieces that make up the authorization are - enforcement, decision-making, and model. This blog post will primarily focus on the enforcement and decision-making elements. It goes in-depth and demonstrates how to bring the decision-making close to the enforcement body and break away from the traditional centralized approach where the decision-making and enforcement components typically sit in the opposite direction of the spectrum. This architecture can eliminate network hops and latency and shorten the critical path to access a protected resource.

I will extend my previous post on API Security but replace the traditional XACML-based policy engine with Open Policy Agent (OPA) to make authorization an edge component.

A quick summary of what we will uncover:

What is Open Policy Agent (OPA)?

The Open Policy Agent (OPA, pronounced “oh-pa”) is an open-source, general-purpose policy engine that unifies policy enforcement across the stack. OPA provides a high-level declarative language that lets you specify policy as code and simple APIs to offload policy decision-making from your software. You can use OPA to enforce policies in microservices, Kubernetes, CI/CD pipelines, API gateways, and more. OPA decouples policy decision-making from policy enforcement. When your software needs to make policy decisions it queries OPA and supplies structured data (e.g., JSON) as input. OPA accepts arbitrary structured data as input.

Or in layman terms, the key takeaways are:

  • It is a cloud-native general-purpose policy engine designed from the ground up to serve various stacks;
  • Comes with a high-level declarative language called REGO to specify policy as code;
  • Allows separation of concerns by separating policy decision-making from the software;
  • It can accept any arbitrary structured data as input.

The below image shows how OPA’s architecture decouples the policy decision-making from policy enforcement.

OPA Policy Decoupling
OPA Policy Decoupling

OPA goes beyond the traditional XACML-based policy engine and solves today’s complex authorization requirements. Out of the box, it supports features including - declarative language for writing policies, performance, scalability, high availability, policy testing and benchmarking, tooling, extensibility, monitoring, security, and last but not least, accessibility (REST). To further understand the differences between OPA and XACML, I suggest reading this documentation.

Demo

Architecture

The majority of the below architecture should look familiar if you have read my previous post on API Security. The only change is the introduction of OPA as an edge component. The diagram, if you look closely, groups both IG and OPA. OPA is responsible for providing authorization decisions to IG, and IG enforces those decisions to extend security to the MaDOC API. This grouping shows we can implement OPA as a sidecar to IG.

MaDOC API Architecture with OPA and ForgeRock
MaDOC API Architecture with OPA and ForgeRock

Since most of the architecture is similar to the previous post, the demo will only focus on OPA to keep it relevant to the main point and avoid duplication of content.

With that, let’s first set the stage with our security requirements.

Security Requirements

The decision-making authority relies on two pieces to compile authorization decisions - logic and user data. We will provide logic using the declarative REGO language, and the data will come as a JWT token.

As for security requirements for this post, we have to meet the following authorization matrix to secure access to the MaDOC API.

RequestAdminDoctorPatient
AppointmentCRU-CR
HealthRecord-CRU-
UserCRU--

C = CREATE, R = READ, U = UPDATE

OPA Policies

The first task we need to do is to understand what kind of data we need to protect. For example, is it REST data, is it IoT data, or something else. Once we have the answer to that question, we are ready to write our first OPA policy.

Our MaDOC API is essentially a REST-based API and provides access to resources, including Appointments, Users, and Health Records. And since all of these are HTTP resources, our OPA policies will ingest HTTP Request data to compile an authorization decision.

Since access to resources can vary and to separate access logic between resources, I have the following set of OPA policies:

  • policy-oauth2-tokens.rego - The policy is responsible for extracting and decoding the JWT token provided in the input data and assigns it to a variable for later use.
package oauth2.tokens

ac := io.jwt.decode(input.access_token)[1]
  • policy-user.rego - The policy protects the /users endpoints. It provides access control logic to state “who” can perform “what action” on the /users endpoints. For example, the below policy defines a default deny; only users with ROLE_admin have access.
package user

default allow = false

allow {
    some i
    input.request.method == "GET"
    input.request.path == "/users"
    data.oauth2.tokens.ac.roles[i] == "ROLE_admin"
}

allow {
    some i
    input.request.method == "GET"
    regex.match("^/users/[a-zA-Z0-9]+$", input.request.path)
    data.oauth2.tokens.ac.roles[i] == "ROLE_admin"
}

allow {
    some i
    input.request.method == "POST"
    input.request.path == "/users"
    data.oauth2.tokens.ac.roles[i] == "ROLE_admin"
}

allow {
    some i
    input.request.method == "PUT"
    regex.match("^/users/[a-zA-Z0-9]+$", input.request.path)
    data.oauth2.tokens.ac.roles[i] == "ROLE_admin"
}
  • policy-appointment.rego - Similar to the above policy, the appointment policy protects access to the /appointments endpoints. In this policy, an Admin can perform CRU, a Patient is only allowed to perform CR, and a Doctor is forbidden access.
package appointment

default allow = false

allow {
    some i,j
    roles := ["ROLE_admin", "ROLE_patient"]
    input.request.method == "GET"
    input.request.path == "/appointments"
    data.oauth2.tokens.ac.roles[i] == roles[j]
}

allow {
    some i,j
    roles := ["ROLE_admin", "ROLE_patient"]
    input.request.method == "GET"
    regex.match("^/appointments/[0-9]+$", input.request.path)
    data.oauth2.tokens.ac.roles[i] == roles[j]
}

allow {
    some i,j
    roles := ["ROLE_admin", "ROLE_patient"]
    input.request.method == "POST"
    input.request.path == "/appointments"
    data.oauth2.tokens.ac.roles[i] == roles[j]
}

allow {
    some i,j
    roles := ["ROLE_admin"]
    input.request.method == "PUT"
    regex.match("^/appointments/[0-9]+$", input.request.path)
    data.oauth2.tokens.ac.roles[i] == roles[j]
}
  • policy-healthrecord.rego - By now, you must be getting the hang of an OPA-based policy. The below policy provides logic to protect /healthrecords endpoints. Only a doctor is allowed access, and everybody else is forbidden access.
package healthrecord

default allow = false

allow {
    some i,j
    roles := ["ROLE_doctor"]
    input.request.method == "GET"
    input.request.path == "/healthrecords"
    data.oauth2.tokens.ac.roles[i] == roles[j]
}

allow {
    some i
    roles := ["ROLE_doctor"]
    input.request.method == "POST"
    input.request.path == "/healthrecords"
    data.oauth2.tokens.ac.roles[i] == roles[j]
}

allow {
    some i
    roles := ["ROLE_doctor"]
    input.request.method == "PUT"
    regex.match("^/healthrecords/[0-9]+$", input.request.path)
    data.oauth2.tokens.ac.roles[i] == roles[j]
}
  • policy-oauth2-authz.rego - This policy puts everything together and is the main policy that IG will query to enforce authorization decisions. Since all policies above define a default deny, meaning by default the access is denied, this policy does the following:

    • Provides a default deny response with an error message;
    • Queries available policies to see if “this” action is allowed anywhere;
    • Provides authorization decision as response object.
package oauth2

default authorize = {
    "allow": false,
    "code": 403,
    "message": "Authorization failed!"
}

allow {
  data.appointment.allow
}
allow {
  data.user.allow
}
allow {
  data.healthrecord.allow
}

authorize = response {
  allow
  response := {
    "allow": true,
    "code": 200,
  }
}

Once you have the policies in place, start the policy server referencing these policies in the start command.

opa run --server ./*.rego

The above will ingest all files ending with *.rego and start a web server listening at port 8181.

You can query the policy engine to assert if the above policies are available. For example, the below command will output all available policies in JSON format.

curl localhost:8181/v1/policies

Enforce Authorization Decisions (IG)

The architecture above shows that IG will intercept the incoming client request and run it through a set of filters and scripts to enforce authorization decisions. All routes are the same as in my previous post. The only change is a new ScriptableFilter called OPAEdgeAuthorizeFilter that queries against a locally deployed OPA policy engine for authorization decisions. Once an authorization decision is available, it enforces that decision and returns either a protected resource or a 401 or a 403 forbidden response. Below is a groovy script for this filter:

bearerToken = contexts.oauth2.accessToken.token

if(bearerToken?.trim()) {
  Request isAuthorized = new Request()

  isAuthorized.uri = "http://127.0.0.1:8181/v1/data/oauth2/authorize"
  isAuthorized.method = "POST"
  isAuthorized.headers.put("Content-Type", "application/json")
  isAuthorized.entity.json = [ input: [ access_token: bearerToken, request: [ path: request.uri.path, method: request.method ]]]
  return http.send(context, isAuthorized)
    .thenAsync({ authZResponse ->
        data = authZResponse.entity.json
        logger.info("OPA Authorization Response: ${authZResponse.entity.json}, Status: ${authZResponse.status}")

        if(Status.OK == Status.valueOf(data.result.code) && data.result.allow == true) {
          return next.handle(context, request)
        } else { // 403
          Response res = new Response(Status.FORBIDDEN)
          res.entity.json = [message: "Access Denied!"]
          return newResponsePromise(res)
        }
    } as AsyncFunction)
} else { // bearer token not available == 401
    Response res = new Response(Status.UNAUTHORIZED)
    res.entity.json = [message: "Access Denied!"]
    return newResponsePromise(res)
}

Once IG retrieves a response from OPA, and if it’s not a happy path, IG logs the original OPA response and returns a new 403 forbidden response.

Let’s Test

To perform end-to-end testing of our above security requirements, I have configured the following components:

  • ForgeRock Platform - AM, Config DS, CTS DS User DS, IG (Edge);
  • OPA Policy Engine;
  • MaDOC API Server.

The complete source code for this post is available in my Github repository. Please see the below Github section for details.

To demo the heart of this post, I will only show two of the test cases. For the complete list of test cases, you can follow my postman collection.

Test Case 1:

Prerequisites: Using postman collection, authenticate, authorize and get access token as Patient user.

Description: Check patients cannot access health records.

Result: 403 Forbidden

Request

curl --location --request GET 'https://ig1.sqoopdata.local:17193/healthrecords?username=patient' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIodXNyIXBhdGllbnQpIiwiY3RzIjoiT0FVVEgyX1NUQVRFTEVTU19HUkFOVCIsImF1dGhfbGV2ZWwiOjAsImF1ZGl0VHJhY2tpbmdJZCI6Ijk4ODY2ZGZhLTAzODctNGIzNS04NDEyLTk4MDYyNjQzNDgzOS04NTA1Iiwic3VibmFtZSI6InBhdGllbnQiLCJpc3MiOiJodHRwczovL2lkZW50aXR5MS5zcW9vcGRhdGEubG9jYWw6MTcxNDMvb3BlbmFtL29hdXRoMi9yZWFsbXMvcm9vdC9yZWFsbXMvZW1yIiwidG9rZW5OYW1lIjoiYWNjZXNzX3Rva2VuIiwidG9rZW5fdHlwZSI6IkJlYXJlciIsImF1dGhHcmFudElkIjoiNEo4R3J3QnVuenBiZl94cThoblZiWHB2TXNVIiwiYXVkIjoiY2xpZW50LWFwcGxpY2F0aW9uIiwibmJmIjoxNjMyNzcxMzE5LCJncmFudF90eXBlIjoiYXV0aG9yaXphdGlvbl9jb2RlIiwic2NvcGUiOlsiaGVhbHRocmVjb3JkcyIsImFwcG9pbnRtZW50cyIsIm9wZW5pZCIsInVzZXJzIl0sImF1dGhfdGltZSI6MTYzMjc3MTMwOCwicmVhbG0iOiIvZW1yIiwiZXhwIjoxNjMyNzc0OTE5LCJpYXQiOjE2MzI3NzEzMTksImV4cGlyZXNfaW4iOjM2MDAsImp0aSI6ImpQNm1Sb3FvRm5FcEJCajlFOXFpUko2V2JYRSIsInJvbGVzIjpbIlJPTEVfcGF0aWVudCJdfQ.zdAfbgpZI3aHI7JbnwLNlGJfZezl3AdMoHsvihCDpPQ'

Response

{
    "message": "Access Denied!"
}

Test Case 2:

Prerequisites: Using postman collection, authenticate, authorize and get access token as Doctor user.

Description: Check doctor can create health record for patient;

Result: 201 Created

Request

curl --location --request POST 'https://ig1.sqoopdata.local:17193/healthrecords' \
--header 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIodXNyIWRvY3RvcikiLCJjdHMiOiJPQVVUSDJfU1RBVEVMRVNTX0dSQU5UIiwiYXV0aF9sZXZlbCI6MCwiYXVkaXRUcmFja2luZ0lkIjoiOTg4NjZkZmEtMDM4Ny00YjM1LTg0MTItOTgwNjI2NDM0ODM5LTg1NDgiLCJzdWJuYW1lIjoiZG9jdG9yIiwiaXNzIjoiaHR0cHM6Ly9pZGVudGl0eTEuc3Fvb3BkYXRhLmxvY2FsOjE3MTQzL29wZW5hbS9vYXV0aDIvcmVhbG1zL3Jvb3QvcmVhbG1zL2VtciIsInRva2VuTmFtZSI6ImFjY2Vzc190b2tlbiIsInRva2VuX3R5cGUiOiJCZWFyZXIiLCJhdXRoR3JhbnRJZCI6IkNDOFQwbGVrS01ZVF9NVVVKZUgtQkJLazJBOCIsImF1ZCI6ImNsaWVudC1hcHBsaWNhdGlvbiIsIm5iZiI6MTYzMjc3MTQ4MiwiZ3JhbnRfdHlwZSI6ImF1dGhvcml6YXRpb25fY29kZSIsInNjb3BlIjpbImhlYWx0aHJlY29yZHMiLCJhcHBvaW50bWVudHMiLCJvcGVuaWQiLCJ1c2VycyJdLCJhdXRoX3RpbWUiOjE2MzI3NzE0NzUsInJlYWxtIjoiL2VtciIsImV4cCI6MTYzMjc3NTA4MiwiaWF0IjoxNjMyNzcxNDgyLCJleHBpcmVzX2luIjozNjAwLCJqdGkiOiJJd0xNZHR0aWpPVG80cmYycm1jcXVTV0hmMGciLCJyb2xlcyI6WyJST0xFX2RvY3RvciJdfQ.i0zjOAgi2T9f03GMxF6xlBTt5MrHsGp92r090RLPR-Q' \
--header 'Content-Type: text/plain' \
--data-raw '{
    "description": "Patient reported of migrane. Prescribed medication for migrane.",
    "patient": "patient",
    "createdBy": "doctor",
    "apptId": 3
}'

Response

{
    "healthRecordId": 24,
    "apptId": 3,
    "patient": "patient",
    "description": "Patient reported of migrane. Prescribed medication for migrane.",
    "createdBy": "doctor",
    "created": "2021-09-27T19:38:12.681416Z"
}

Github

The companion source code for this blog post includes the following:

You can find the source code at our repository hosted at Github here.

You can find the source code for the MaDOC API at our Github here.

Summary

Phew. We did what we set out to do. With the above test cases, we successfully demo all pieces to provide comprehensive API security on edge.

Now, this doesn’t end here, and there’s more. We have not answered questions like - Where do we manage OPA policies? How do we deploy OPA policies on edge? How do we harden the OPA server? And many more similar questions. If you are wondering about these questions, I would be thrilled to discuss this post further with you. You can always reach out to me at contact@sqoopdata.com.

At Sqoop Data, we understand and preach the importance of authorization. If you have an authorization problem and require professional feedback, we would be thrilled to speak with you. Please don’t hesitate to reach us at contact@sqoopdata.com.

References

Open Policy Agent (OPA)

ForgeRock OAuth2 Guide