IB-SIA-14 Advanced Est. 4.5 to 5.5 hours
Identity Bytes // Senior IAM Architect Track

Externalised Policy with OPA and Rego

Write the rule Lab 13 could not: subject.desk equals resource.desk, evaluated dynamically in Rego, served by Open Policy Agent as a dedicated decision point, validated against Keycloak tokens, and covered by unit tests that run before any policy ships.

01Lab Metadata

FieldValue
Lab IDIB-SIA-14
TrackIdentity Bytes, Senior IAM Architect Track
PhasePhase 3, Modern Authorization
DifficultyAdvanced
Estimated Time4.5 to 5.5 hours
Core TechnologiesOpen Policy Agent 0.64+, Rego, opa test, Python 3.11, Flask 3.0, jwcrypto 1.5, Keycloak 24.x, Docker, curl, jq
Builds OnLab 09 (token verification, reused inside the enforcement point), Lab 13 (the desk and approval_limit attributes, and the relational gap this lab closes)
Feeds IntoLab 15 (ReBAC/OpenFGA, when relationships outgrow attributes), Lab 16 (externalised authorization at the edge with Envoy ext_authz)

02Lab Title and Description

Externalised Policy with OPA and Rego

Lab 13 ended at an honest boundary. Keycloak's built-in policies expressed "the user's desk claim matches the fixed pattern desk-a", but they could not express the rule Northgate actually wanted: "the subject's desk equals the resource's desk", compared dynamically at decision time, one rule covering every desk that exists now or ever will. Keycloak's escape hatch, JavaScript policies, means running uploaded code inside the identity server, which is disabled by default for a sound security reason. The architectural answer is to stop asking the identity server to also be the policy engine.

Open Policy Agent (OPA) is a general-purpose policy engine, a CNCF graduated project, that does exactly one job: given a JSON input describing a request and a set of policies written in its language Rego, return a decision. It runs beside your services as a sidecar or a standalone decision point, answers in microseconds from memory, and keeps policy as code: versioned in Git, unit tested with opa test, and reviewed like any other change. The identity server keeps doing what it is good at, authenticating users and issuing verifiable attribute claims, and OPA consumes those claims to decide.

In this lab you write the Northgate portfolio policy in Rego, including the relational desk comparison, the market-hours window and the approval-limit threshold, all in one readable rule set with deny reasons. You cover it with unit tests before it ever serves a live decision. Then you run OPA as a container beside a small Flask enforcement point that verifies the Keycloak token (exactly as in Lab 09), builds the decision input from the verified claims and the resource's own data, and enforces OPA's answer. Finally you prove the payoff Lab 13 promised: adding a brand new desk requires no policy change at all.

Estimated completion time: 4.5 to 5.5 hours, including Rego authoring, unit testing, and live enforcement.

03Prerequisites

Completed Prior Labs

LabWhy it is required
IB-SIA-09, JWT, JWS and JWE Deep DiveThe enforcement point verifies the Keycloak access token against the realm JWKS with algorithm pinning before trusting any claim. That verification code is lifted directly from Lab 09's pattern.
IB-SIA-13, RBAC to ABACProvides the desk and approval_limit user attributes, the desk token claim mapper, and the precise statement of the relational gap this lab closes. Without Lab 13's context, the motivation for an external engine is abstract.
IB-SIA-06, Keycloak High AvailabilityTokens are issued and the JWKS is served through ib-lb as in every Phase 2 and 3 lab.

System Requirements

ResourceMinimum
OSUbuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2
RAM7 GB free (OPA itself uses well under 100 MB; the Keycloak cluster dominates)
Disk2 GB free
CPU2 cores sufficient
NetworkAccess to the running Keycloak cluster on ib-lab-net; outbound HTTPS to Docker Hub and PyPI

Required Tools

ToolExact Version
Docker Engine25.0 or later
OPA0.64 or later (run as the official container image; the CLI binary optional for local testing)
Python3.11.x
Flask3.0.3
requests2.32.3
jwcrypto1.5.6
curl8.x
jq1.7
Install and verify: Ubuntu/Debian
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3-pip jq curl

mkdir -p ~/ib-labs/ib-opa/policies
cd ~/ib-labs/ib-opa
python3.11 -m venv .venv
source .venv/bin/activate
pip install flask==3.0.3 requests==2.32.3 jwcrypto==1.5.6

# Pull the OPA image; the CLI inside it also runs the unit tests.
docker pull openpolicyagent/opa:latest

python3.11 -c "import flask, requests, jwcrypto; print('libs ok')"
docker run --rm openpolicyagent/opa:latest version   # Expect: Version 0.6x or later
jq --version
Install and verify: macOS
brew install python@3.11 jq
mkdir -p ~/ib-labs/ib-opa/policies && cd ~/ib-labs/ib-opa
python3.11 -m venv .venv && source .venv/bin/activate
pip install flask==3.0.3 requests==2.32.3 jwcrypto==1.5.6
docker pull openpolicyagent/opa:latest
docker run --rm openpolicyagent/opa:latest version
Install and verify: Windows 11 (WSL2)
# Run inside your WSL2 Ubuntu distribution, not PowerShell
wsl --install -d Ubuntu-22.04
# Then follow the Ubuntu/Debian instructions above.
INFO Rego syntax has evolved. From OPA 0.59 onward the recommended style imports rego.v1, which requires the if and contains keywords and becomes the default behaviour in OPA 1.0. This lab writes all policies in the rego.v1 style so they remain valid across the transition. If you run a much older OPA, the keywords differ; upgrade rather than adapt backwards.

04Real World Problem Statement

Authorization logic scattered through application code drifts, contradicts itself and cannot be audited as one thing. Embedding it in the identity server hits the limits Lab 13 exposed. A dedicated policy engine puts every rule in one tested, versioned, reviewable place, and answers decisions fast enough to sit in the request path.

Risk

When each service implements its own slice of the portfolio rule, one service's stale copy becomes the breach path. A single policy source, deployed identically everywhere OPA runs, removes the drift that attackers hunt for.

Compliance

Policy as code gives auditors what they actually ask for: the exact rule text, its change history in Git, the review that approved it, and the tests that passed. That evidence chain is close to impossible to assemble from authorization logic buried in application code.

Productivity

A rule change becomes a pull request against a Rego file, tested by opa test in CI, not a coordinated code change across every service that touches portfolios. Adding Northgate's fifth desk requires no change at all.

Security Posture

The identity server stops being asked to run arbitrary decision code. Keycloak authenticates and attests attributes; OPA decides. Each component does one job, which is the separation the JavaScript-policy escape hatch was violating.

Concrete scenario: Northgate opens a fifth wealth desk, desk-e, absorbing new Harborview advisers. Under Lab 13's model this means new resources and permissions per desk; under the naive RBAC model it meant a batch of new roles. In this lab the rule is written once, "permit read when the subject's desk equals the resource's desk, during market hours, within the approval limit", and when desk-e appears in this lab's final step, the decision is correct with zero policy changes. That is the payoff a Senior IAM Architect is buying when they introduce a policy engine.

05Skills Mapped to Production Solutions

Skill LearnedReal-World Enterprise Application
Writing Rego policies with relational comparisons, helper rules and deny reasonsAuthoring authorization for microservices, API gateways, Kubernetes admission control and CI/CD gates, all of which speak OPA
Structuring the decision input contract (subject, action, resource, environment)Designing the PEP-to-PDP interface that every service in an estate will share, one of the most consequential API designs in an authorization programme
Unit testing policies with opa test before deploymentPutting authorization changes behind the same CI discipline as application code, a maturity marker in policy-as-code programmes
Building an enforcement point that verifies tokens then queries OPAThe standard integration pattern: authenticate with the IdP, decide with the policy engine, enforce in the service or gateway
Returning and logging deny reasonsExplainable authorization for support, audit and incident response, answering "why was this denied" without reverse-engineering code
Choosing between IdP-embedded policy, OPA, and relationship-based enginesThe architectural selection judgement this phase of the track is building, completed by Lab 15's ReBAC comparison

06Architecture Overview

CLIENT asmith's browser/app bearer token KEYCLOAK (ib-lb) authenticates, issues token with desk + approval_limit claims; serves JWKS ENFORCEMENT POINT (PEP) ib-portfolio-app (Flask, 8099) 1 verify token vs JWKS (Lab 09) 2 build input: subject, action, resource, environment 3 POST to OPA, enforce answer OPA (PDP) ib-opa container, 8181 policies loaded from ./policies (Rego, rego.v1) answers allow + deny reasons in-memory, microseconds POLICY AS CODE portfolio.rego + tests in Git opa test gates every change reviewed like application code THE DECISION INPUT CONTRACT (one JSON document per decision) subject: { username, desk, approval_limit } from the VERIFIED token, never from the caller action: "read" resource: { id, desk, value } from the application's own data environment: { time } from the enforcement point's clock the relational rule reads BOTH sides: input.subject.desk == input.resource.desk request + token JWKS input JSON allow / reasons

Component Breakdown

ComponentPurposeTechnologyDeploymentPortsKey Configuration
OPA (PDP)Evaluates the Rego policy over the decision input and returns allow plus deny reasonsOPA 0.64+, Rego (rego.v1)Docker container ib-opa on ib-lab-net, policies bind-mounted read-only8181Started with run --server loading ./policies; decision logs to console
Enforcement point (PEP)Verifies the Keycloak token, assembles the decision input from verified claims and resource data, queries OPA and enforcesPython 3.11, Flask 3.0, jwcryptoRuns locally in the lab virtual environment8099Never forwards caller-asserted attributes; subject comes only from the verified token
KeycloakAuthenticates users and issues tokens carrying the desk and approval_limit claims from Lab 13Keycloak 24.xExisting HA cluster from Lab 06, via ib-lb8443desk mapper from Lab 13; an equivalent approval_limit mapper added in this lab
Policy repositoryHolds the Rego policy and its unit tests as versioned codeGit, opa testThe ./policies directory, committed alongside the lab codeN/ATests must pass before the policy directory is mounted into OPA

Data Flow

  1. The client calls the enforcement point with a Keycloak bearer token, requesting to read a specific portfolio.
    Why: authentication remains entirely the identity server's job; nothing about introducing OPA changes how users log in or what tokens look like.
  2. The enforcement point verifies the token against the realm JWKS with the algorithm pinned, exactly as built in Lab 09, and extracts the desk and approval_limit claims.
    Why: the policy input must be built only from verified claims. An enforcement point that copies attributes from request parameters instead of the verified token lets any caller grant themselves any desk.
  3. The enforcement point assembles one JSON input document, subject from the token, resource attributes from its own portfolio data, action from the request, time from its clock, and POSTs it to OPA's data API.
    Why: the input contract is the interface every service will share; keeping it explicit and versioned is what lets one policy serve many enforcement points.
  4. OPA evaluates the Rego policy in memory and returns the decision with reasons; the enforcement point permits or refuses accordingly, logging the reasons on deny.
    Why: in-memory evaluation keeps the decision off the critical path's network budget, and returned reasons make every deny explainable to support, auditors and Lab 33's incident responders.

Security Considerations

ConcernLab Approach
Attribute integritySubject attributes enter the decision only from the signature-verified token; resource attributes come from the application's own store; nothing is accepted from the caller's request body.
Default denyThe Rego policy sets default allow := false; a request matching no rule is refused.
Fail closedIf OPA is unreachable or errors, the enforcement point denies, mirroring the fail-closed stance established in Lab 08.
Policy integrityPolicies are mounted read-only into the OPA container and changed only through the tested, reviewed repository, not edited live.

07Step by Step Implementation

Phase 1: Write and Test the Policy Before Anything Runs

Step 1.1: Write the portfolio policy in Rego

Purpose: express the full Northgate rule, including the relational desk comparison, in one readable policy Context: this is the rule Keycloak's built-in policies could not state
policies/portfolio.rego
package northgate.portfolio

import rego.v1

# The rule Lab 13 could not express: permit a read when the subject's desk
# equals the RESOURCE's desk, dynamically, plus the market-hours window and
# the approval-limit threshold. One policy, every desk, present and future.

default allow := false

allow if {
    input.action == "read"
    desk_matches
    within_market_hours
    within_approval_limit
}

desk_matches if {
    input.subject.desk == input.resource.desk
}

within_market_hours if {
    # environment.time is supplied as "HH:MM" 24-hour UK time by the PEP.
    # String comparison works for zero-padded HH:MM values.
    input.environment.time >= "08:00"
    input.environment.time <= "16:30"
}

within_approval_limit if {
    to_number(input.subject.approval_limit) >= input.resource.value
}

# Deny reasons: each rule contributes a human-readable reason when its
# condition fails, so every deny is explainable.
deny_reasons contains reason if {
    not desk_matches
    reason := sprintf("subject desk %q does not match resource desk %q",
        [input.subject.desk, input.resource.desk])
}

deny_reasons contains reason if {
    not within_market_hours
    reason := sprintf("time %q is outside market hours 08:00 to 16:30",
        [input.environment.time])
}

deny_reasons contains reason if {
    not within_approval_limit
    reason := sprintf("approval limit %v is below resource value %v",
        [input.subject.approval_limit, input.resource.value])
}

decision := {"allow": allow, "deny_reasons": deny_reasons}
INFO Rego rules are declarative: a rule is true when every expression in its body is true, and multiple contains rules for the same set union their results. There is no execution order to reason about, which is what makes policies short and reviewable, and also what makes unit tests essential, since the behaviour is the sum of all rules rather than a single code path.

Step 1.2: Write the unit tests and run them

Purpose: prove every permit and deny path before the policy serves live traffic Context: opa test is the CI gate; a failing test blocks the policy from shipping
policies/portfolio_test.rego
package northgate.portfolio_test

import rego.v1

import data.northgate.portfolio

base_input := {
    "action": "read",
    "subject": {"username": "asmith", "desk": "desk-a", "approval_limit": "500000"},
    "resource": {"id": "pf-1001", "desk": "desk-a", "value": 250000},
    "environment": {"time": "10:30"},
}

test_matching_desk_in_hours_within_limit_allows if {
    portfolio.allow with input as base_input
}

test_wrong_desk_denies if {
    not portfolio.allow with input as
        json.patch(base_input, [{"op": "replace", "path": "/subject/desk", "value": "desk-b"}])
}

test_wrong_desk_gives_reason if {
    reasons := portfolio.deny_reasons with input as
        json.patch(base_input, [{"op": "replace", "path": "/subject/desk", "value": "desk-b"}])
    count(reasons) == 1
}

test_outside_hours_denies if {
    not portfolio.allow with input as
        json.patch(base_input, [{"op": "replace", "path": "/environment/time", "value": "19:00"}])
}

test_over_limit_denies if {
    not portfolio.allow with input as
        json.patch(base_input, [{"op": "replace", "path": "/resource/value", "value": 900000}])
}

test_new_desk_e_allows_with_no_policy_change if {
    # The payoff test: a desk that never existed when the policy was written.
    portfolio.allow with input as json.patch(base_input, [
        {"op": "replace", "path": "/subject/desk", "value": "desk-e"},
        {"op": "replace", "path": "/resource/desk", "value": "desk-e"},
    ])
}

test_write_action_denies if {
    not portfolio.allow with input as
        json.patch(base_input, [{"op": "replace", "path": "/action", "value": "write"}])
}
Run the test suite via the OPA container
cd ~/ib-labs/ib-opa
docker run --rm -v "$(pwd)/policies:/policies:ro" \
  openpolicyagent/opa:latest test /policies -v
VERIFICATION All seven tests should report PASS, including test_new_desk_e_allows_with_no_policy_change, which proves the relational rule covers a desk that did not exist when the policy was written. If a test fails with a parse error mentioning if, your OPA image predates rego.v1 support; pull a current image. If test_over_limit_denies fails, check the to_number conversion: the approval_limit claim arrives as a string from Keycloak.
PRODUCTION CONSIDERATION Wire opa test into CI so no policy change can merge with failing tests, and add opa check --strict for static analysis. Teams that treat policy changes with less rigour than code changes end up debugging authorization in production, which is the most expensive place to find a logic error.

Phase 2: Run OPA and Query It Directly

Step 2.1: Start OPA as a server with the tested policies

Purpose: bring up the decision point with the policies mounted read-only Context: joins ib-lab-net so the PEP and, later, Lab 16's gateway can reach it
docker-compose.yml addition and start
  ib-opa:
    image: openpolicyagent/opa:latest
    container_name: ib-opa
    command: ["run", "--server", "--addr", "0.0.0.0:8181",
              "--log-level", "info", "/policies"]
    volumes:
      - ./ib-opa/policies:/policies:ro
    networks:
      - ib-lab-net
    ports:
      - "8181:8181"
docker compose up -d ib-opa
VERIFICATION Confirm OPA is healthy and the policy loaded: curl -s http://localhost:8181/health | jq returns {} with HTTP 200, and curl -s http://localhost:8181/v1/policies | jq '.result[].id' lists the portfolio policy. If the policy list is empty, the volume path in the compose entry does not match where portfolio.rego lives on disk.

Step 2.2: Query the decision API directly

Purpose: exercise the exact API the enforcement point will call Context: proves the policy behaves identically served live as under test
A permit and a deny, straight to OPA
# Permit: matching desk, in hours, within limit.
curl -s -X POST http://localhost:8181/v1/data/northgate/portfolio/decision \
  -H "Content-Type: application/json" \
  -d '{"input": {
        "action": "read",
        "subject": {"username": "asmith", "desk": "desk-a", "approval_limit": "500000"},
        "resource": {"id": "pf-1001", "desk": "desk-a", "value": 250000},
        "environment": {"time": "10:30"}
      }}' | jq '.result'

# Deny: wrong desk. Note the explainable reason.
curl -s -X POST http://localhost:8181/v1/data/northgate/portfolio/decision \
  -H "Content-Type: application/json" \
  -d '{"input": {
        "action": "read",
        "subject": {"username": "jpatel", "desk": "desk-b", "approval_limit": "100000"},
        "resource": {"id": "pf-1001", "desk": "desk-a", "value": 250000},
        "environment": {"time": "10:30"}
      }}' | jq '.result'
VERIFICATION The first call returns {"allow": true, "deny_reasons": []}. The second returns allow: false with two reasons for jpatel: the desk mismatch and, because his limit is 100000 against a value of 250000, the approval limit. Every deny arrives with its explanation attached, which is the operational property that separates this from a bare boolean.

Phase 3: Enforce Live with Verified Keycloak Tokens

Step 3.1: Map the approval_limit claim and build the enforcement point

Purpose: a PEP that trusts only the verified token, never the caller's assertions Context: reuses Lab 09's verification pattern and Lab 13's attributes
Add the approval_limit token mapper (desk mapper exists from Lab 13)
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh create \
  clients/RESOURCE_CLIENT_UUID/protocol-mappers/models -r northgate \
  -s name=approval-limit-mapper \
  -s protocol=openid-connect \
  -s protocolMapper=oidc-usermodel-attribute-mapper \
  -s 'config."user.attribute"=approval_limit' \
  -s 'config."claim.name"=approval_limit' \
  -s 'config."access.token.claim"=true' \
  -s 'config."jsonType.label"=String'
pep.py, the enforcement point
# pep.py
# The Policy Enforcement Point. Verifies the Keycloak token (Lab 09 pattern),
# builds the decision input ONLY from verified claims plus its own resource
# data, asks OPA, and enforces. Fails closed if OPA is unreachable.

import json
import datetime
import requests
from flask import Flask, request, jsonify
from jwcrypto import jwt, jwk

app = Flask(__name__)
ISSUER = "https://ib-lb:8443/realms/northgate"
JWKS_URL = f"{ISSUER}/protocol/openid-connect/certs"
OPA_URL = "http://ib-opa:8181/v1/data/northgate/portfolio/decision"
requests.packages.urllib3.disable_warnings()
_jwks = jwk.JWKSet.from_json(requests.get(JWKS_URL, verify=False).text)

# The application's own resource data. In production this is the portfolio
# database; the desk and value are RESOURCE attributes the app owns.
PORTFOLIOS = {
    "pf-1001": {"desk": "desk-a", "value": 250000},
    "pf-2001": {"desk": "desk-b", "value": 80000},
    "pf-5001": {"desk": "desk-e", "value": 40000},   # the new desk
}


def _verified_claims(token):
    verified = jwt.JWT(jwt=token, key=_jwks, algs=["RS256"])
    return json.loads(verified.claims)


@app.get("/portfolio/<pf_id>")
def read_portfolio(pf_id):
    auth = request.headers.get("Authorization", "")
    _, _, token = auth.partition(" ")
    try:
        claims = _verified_claims(token)
    except Exception as e:
        return jsonify({"error": f"invalid token: {e}"}), 401

    resource = PORTFOLIOS.get(pf_id)
    if resource is None:
        return jsonify({"error": "no such portfolio"}), 404

    # Build the decision input. Subject attributes come ONLY from the
    # verified token; resource attributes from our own data; time from
    # our clock. Nothing from the caller's request body.
    decision_input = {
        "input": {
            "action": "read",
            "subject": {
                "username": claims.get("preferred_username"),
                "desk": claims.get("desk"),
                "approval_limit": claims.get("approval_limit"),
            },
            "resource": {"id": pf_id, **resource},
            "environment": {
                "time": datetime.datetime.now().strftime("%H:%M"),
            },
        }
    }

    try:
        r = requests.post(OPA_URL, json=decision_input, timeout=2)
        r.raise_for_status()
        result = r.json()["result"]
    except Exception as e:
        # Fail closed: no decision means no access.
        return jsonify({"error": f"policy engine unavailable: {e}"}), 503

    if not result["allow"]:
        return jsonify({"denied": True, "reasons": sorted(result["deny_reasons"])}), 403

    return jsonify({"portfolio": pf_id, "desk": resource["desk"],
                    "value": resource["value"], "viewer": claims.get("preferred_username")})


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8099, ssl_context="adhoc")
Run the enforcement point
python3.11 pep.py   # listens on https://localhost:8099
VERIFICATION The PEP starts and reports listening on 8099. If the JWKS fetch fails at startup, confirm the Keycloak cluster is reachable at ib-lb:8443. If the PEP runs on the host while OPA runs in Docker, change OPA_URL to http://localhost:8181/... to match your topology.
SECURITY WARNING The single most important line in pep.py is what is absent: no subject attribute is ever read from the request. A PEP that lets the caller supply their own desk in a header or body parameter has moved the authorization decision into the attacker's hands, regardless of how correct the Rego is. The policy is only as trustworthy as its input.

Step 3.2: Exercise the live chain end to end

Purpose: real tokens, real verification, real decisions with reasons Context: the same three-outcome demonstration as Lab 13, now through OPA
Permit, deny with reasons, and the limit rule
# asmith (desk-a, limit 500000) reads a desk-a portfolio: permit.
ASMITH_TOKEN=$(curl -sk -X POST \
  "https://localhost:8443/realms/northgate/protocol/openid-connect/token" \
  -d "client_id=portfolio-api" -d "client_secret=REPLACE_WITH_PORTFOLIO_SECRET" \
  -d "grant_type=password" -d "username=asmith" \
  -d "password=REPLACE_WITH_ASMITH_PASSWORD" -d "scope=openid" | jq -r '.access_token')

curl -sk -H "Authorization: Bearer $ASMITH_TOKEN" \
  https://localhost:8099/portfolio/pf-1001 | jq

# jpatel (desk-b, limit 100000) reads the same desk-a portfolio: deny,
# with BOTH reasons in the response.
JPATEL_TOKEN=$(curl -sk -X POST \
  "https://localhost:8443/realms/northgate/protocol/openid-connect/token" \
  -d "client_id=portfolio-api" -d "client_secret=REPLACE_WITH_PORTFOLIO_SECRET" \
  -d "grant_type=password" -d "username=jpatel" \
  -d "password=REPLACE_WITH_JPATEL_PASSWORD" -d "scope=openid" | jq -r '.access_token')

curl -sk -H "Authorization: Bearer $JPATEL_TOKEN" \
  https://localhost:8099/portfolio/pf-1001 | jq

# jpatel reads his OWN desk's portfolio pf-2001 (value 80000, under his
# 100000 limit): permit. Same user, different resource, different answer.
curl -sk -H "Authorization: Bearer $JPATEL_TOKEN" \
  https://localhost:8099/portfolio/pf-2001 | jq
VERIFICATION Three outcomes, run during market hours: asmith permitted on pf-1001; jpatel denied on pf-1001 with two listed reasons (desk mismatch and approval limit); jpatel permitted on pf-2001. If jpatel's second call is denied on the limit, confirm the approval_limit mapper from Step 3.1 is on the portfolio-api client and the claim appears in his decoded token.

Step 3.3: The payoff, a new desk with zero policy changes

Purpose: prove the relational rule generalises, closing the loop opened in Lab 13 Context: desk-e never appears anywhere in portfolio.rego
Create a desk-e adviser and read a desk-e portfolio
# Give lokafor the new desk-e and a modest limit.
LID=$(docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh get users \
  -r northgate -q username=lokafor --fields id | jq -r '.[0].id')
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update users/$LID \
  -r northgate -s 'attributes.desk=["desk-e"]' \
  -s 'attributes.approval_limit=["150000"]'

LOKAFOR_TOKEN=$(curl -sk -X POST \
  "https://localhost:8443/realms/northgate/protocol/openid-connect/token" \
  -d "client_id=portfolio-api" -d "client_secret=REPLACE_WITH_PORTFOLIO_SECRET" \
  -d "grant_type=password" -d "username=lokafor" \
  -d "password=REPLACE_WITH_LOKAFOR_PASSWORD" -d "scope=openid" | jq -r '.access_token')

# pf-5001 belongs to desk-e. The policy has never heard of desk-e.
curl -sk -H "Authorization: Bearer $LOKAFOR_TOKEN" \
  https://localhost:8099/portfolio/pf-5001 | jq
VERIFICATION lokafor is permitted on pf-5001. Search portfolio.rego for the string desk-e: it appears nowhere. The rule generalised because it compares two sides of the input rather than matching a fixed pattern, which is exactly the capability that motivated leaving the built-in policy engine. Under Lab 13's model this step would have required a new resource, permission and policy; under Lab 01's original RBAC it would have required new roles and assignments.
What just happened? You moved the authorization decision out of the identity server and into a component built for nothing else. The rule Lab 13 could not state, subject desk equals resource desk, took four lines of Rego, sat beside the market-hours and approval-limit conditions in one readable policy, and every deny came back with its reasons attached. Crucially, the policy was unit tested before it ever served a request, and the enforcement point built its input only from the signature-verified token, because a policy engine fed attacker-supplied attributes decides nothing at all. The closing move was the one that justifies the whole architecture: a brand new desk worked correctly with zero policy changes, because the rule describes a relationship, not an enumeration.

08Testing and Validation

End-to-End Test Scenarios

ScenarioStepsExpected Result
Unit suite greenRun opa test /policies -vAll seven tests PASS, including the desk-e generalisation test
Matching adviserasmith reads pf-1001 in market hoursHTTP 200 with the portfolio
Cross-desk deny with reasonsjpatel reads pf-1001HTTP 403 listing both the desk and limit reasons
New desk, no policy changelokafor (desk-e) reads pf-5001HTTP 200; desk-e appears nowhere in the Rego

Negative Tests

TestExpected Result
Stop ib-opa and call the PEPHTTP 503, fail closed; restart OPA afterwards
Call the PEP with no token or a tampered tokenHTTP 401 before any policy evaluation occurs
Call outside market hoursHTTP 403 with the time reason
Temporarily break a test (change 16:30 to 16:00 in the test input) and run opa testThe suite fails, demonstrating the CI gate; revert afterwards

Common Failure Modes

SymptomLikely CauseResolution
Parse errors mentioning if or containsOPA image predates rego.v1Pull a current openpolicyagent/opa image
Approval-limit rule always deniesThe claim arrives as a string and was compared without to_numberKeep the to_number conversion in the policy as written
Every live decision denies on deskThe desk claim is missing from the tokenConfirm the Lab 13 desk mapper is on the portfolio-api client, the same client issuing these tokens
PEP cannot reach OPAContainer-versus-host networking mismatch in OPA_URLUse ib-opa:8181 when both are containers on ib-lab-net, localhost:8181 when the PEP runs on the host
Policy edits have no effectOPA loaded the policies at start and the volume is read-onlyRe-run the tests, then docker restart ib-opa to load the reviewed change

09Security Analysis

What Makes This Implementation Secure

What Is Intentionally Simplified for the Lab

Production Hardening Recommendations

AreaRecommendation
Policy distributionServe policies as signed bundles from a bundle server, with OPA verifying signatures, so a compromised host cannot inject policy
CI gatingRequire opa test and opa check --strict to pass in CI before any policy merge, with coverage reporting via opa test --coverage
Decision loggingEnable OPA's decision log API and ship every decision, with its input and reasons, to the SIEM used in Lab 35
Deployment modelRun OPA as a sidecar per service or a node-local daemon to keep decisions off the network; a central OPA cluster trades latency for simpler management and should be a deliberate choice
Data freshnessWhere policies need external data (holiday calendars, desk registries), push it into OPA as data documents on a defined refresh cadence rather than calling out mid-decision

10Cleanup

Stop the PEP and remove the OPA container
cd ~/ib-labs/ib-opa
# Stop the pep.py terminal with Ctrl+C, then:
docker compose stop ib-opa
docker compose rm -f ib-opa
deactivate 2>/dev/null || true
Optionally revert lokafor's desk-e attributes
# Keep the policies directory in Git; it is reused as the decision layer in
# Lab 16. Revert lokafor only if you want the directory state back to Lab 13.
docker exec -it ib-keycloak-1 /opt/keycloak/bin/kcadm.sh update users/$LID \
  -r northgate -s 'attributes.desk=["hr"]' -s 'attributes.approval_limit=["0"]'
VERIFICATION Confirm docker ps no longer lists ib-opa and the PEP process is stopped, while the Keycloak cluster, OpenLDAP and PostgreSQL remain healthy. Keep policies/ committed; Lab 16 mounts the same policy at the gateway.

11Recommended Learning Links

12Portfolio Publishing Guide

Sanitise Before Publishing

This lab handles client secrets and user tokens. Confirm none reach your repository.

Sanitisation checklist commands
grep -R "REPLACE_WITH" . --include="*.py" --include="*.sh" --include="*.md" || echo "Clean"

cat >> .gitignore <<'EOF'
*.token
.env
.venv/
EOF

git status

README for the Repository

README.md skeleton
# IB-SIA-14: Externalised Policy with OPA and Rego

A unit-tested Rego policy served by Open Policy Agent, enforced by a Flask
PEP that verifies Keycloak tokens and builds the decision input only from
verified claims. Expresses the relational subject.desk == resource.desk
rule that IdP-embedded policies could not, with deny reasons on every
refusal.

## Stack
OPA 0.64+ (rego.v1), Python 3.11, Flask 3.0, jwcrypto 1.5, Keycloak 24.x

## What it demonstrates
- Relational attribute comparison in four lines of Rego
- opa test as the CI gate: seven tests cover every path
- Fail-closed enforcement with explainable deny reasons
- A new desk works with zero policy changes

## Part of the Identity Bytes Senior IAM Architect Track
Lab 14 of 36. See identity-bytes.com for the full curriculum.

Git Commands

Commit and push
git add policies/portfolio.rego policies/portfolio_test.rego pep.py README.md .gitignore
git commit -m "IB-SIA-14: OPA/Rego externalised policy with relational rules and unit tests"
git push origin main

Track Index Line

Add the following line to your master portfolio index:

IB-SIA-14 | Externalised Policy with OPA and Rego | Advanced | Policy as code, relational ABAC, opa test, fail-closed PEP

LinkedIn Draft

The most dangerous authorization rule in your estate is the one that exists in four services as four slightly different implementations.

Last week I hit the honest limit of identity-server policies: they could match a user's desk against a fixed pattern, but they could not say "the user's desk equals the resource's desk", compared dynamically, one rule for every desk that will ever exist. The escape hatch was running JavaScript inside the identity server, which is disabled by default for exactly the reason you would hope.

So this week the decision moved out. Open Policy Agent runs beside the service and does one job: take a JSON description of the request, evaluate the Rego policy, answer in microseconds. The relational rule took four lines. The market-hours and approval-limit conditions sat beside it in the same readable file. And before that policy ever served a live request, seven unit tests covered every permit and deny path, because authorization deserves the same CI discipline as code.

Two details matter more than the engine. First, the enforcement point builds the decision input only from the signature-verified token, never from anything the caller asserts, because a policy engine fed attacker-supplied attributes decides nothing. Second, every deny returns its reasons, so "why was this refused" is a log line, not an archaeology project.

The test that sold it: I added a brand new desk, with a new adviser and a new portfolio, and changed nothing in the policy. It was already correct, because the rule describes a relationship, not a list.

How many places in your architecture would you have to change to add one new department today?

Next: IB-SIA-15, ReBAC with OpenFGA
Attributes answer "does the subject's property match the resource's property". Lab 15 tackles the questions attributes handle poorly, ownership chains, sharing and group nesting, with relationship-based access control on OpenFGA.