01Lab Metadata
| Field | Value |
|---|---|
| Lab ID | IB-SIA-14 |
| Track | Identity Bytes, Senior IAM Architect Track |
| Phase | Phase 3, Modern Authorization |
| Difficulty | Advanced |
| Estimated Time | 4.5 to 5.5 hours |
| Core Technologies | Open Policy Agent 0.64+, Rego, opa test, Python 3.11, Flask 3.0, jwcrypto 1.5, Keycloak 24.x, Docker, curl, jq |
| Builds On | Lab 09 (token verification, reused inside the enforcement point), Lab 13 (the desk and approval_limit attributes, and the relational gap this lab closes) |
| Feeds Into | Lab 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
| Lab | Why it is required |
|---|---|
| IB-SIA-09, JWT, JWS and JWE Deep Dive | The 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 ABAC | Provides 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 Availability | Tokens are issued and the JWKS is served through ib-lb as in every Phase 2 and 3 lab. |
System Requirements
| Resource | Minimum |
|---|---|
| OS | Ubuntu 22.04 LTS, macOS 13+, or Windows 11 with WSL2 |
| RAM | 7 GB free (OPA itself uses well under 100 MB; the Keycloak cluster dominates) |
| Disk | 2 GB free |
| CPU | 2 cores sufficient |
| Network | Access to the running Keycloak cluster on ib-lab-net; outbound HTTPS to Docker Hub and PyPI |
Required Tools
| Tool | Exact Version |
|---|---|
| Docker Engine | 25.0 or later |
| OPA | 0.64 or later (run as the official container image; the CLI binary optional for local testing) |
| Python | 3.11.x |
| Flask | 3.0.3 |
| requests | 2.32.3 |
| jwcrypto | 1.5.6 |
| curl | 8.x |
| jq | 1.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.
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 Learned | Real-World Enterprise Application |
|---|---|
| Writing Rego policies with relational comparisons, helper rules and deny reasons | Authoring 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 deployment | Putting 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 OPA | The standard integration pattern: authenticate with the IdP, decide with the policy engine, enforce in the service or gateway |
| Returning and logging deny reasons | Explainable 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 engines | The architectural selection judgement this phase of the track is building, completed by Lab 15's ReBAC comparison |
06Architecture Overview
Component Breakdown
| Component | Purpose | Technology | Deployment | Ports | Key Configuration |
|---|---|---|---|---|---|
| OPA (PDP) | Evaluates the Rego policy over the decision input and returns allow plus deny reasons | OPA 0.64+, Rego (rego.v1) | Docker container ib-opa on ib-lab-net, policies bind-mounted read-only | 8181 | Started 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 enforces | Python 3.11, Flask 3.0, jwcrypto | Runs locally in the lab virtual environment | 8099 | Never forwards caller-asserted attributes; subject comes only from the verified token |
| Keycloak | Authenticates users and issues tokens carrying the desk and approval_limit claims from Lab 13 | Keycloak 24.x | Existing HA cluster from Lab 06, via ib-lb | 8443 | desk mapper from Lab 13; an equivalent approval_limit mapper added in this lab |
| Policy repository | Holds the Rego policy and its unit tests as versioned code | Git, opa test | The ./policies directory, committed alongside the lab code | N/A | Tests must pass before the policy directory is mounted into OPA |
Data Flow
- 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. - 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. - 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. - 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
| Concern | Lab Approach |
|---|---|
| Attribute integrity | Subject 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 deny | The Rego policy sets default allow := false; a request matching no rule is refused. |
| Fail closed | If OPA is unreachable or errors, the enforcement point denies, mirroring the fail-closed stance established in Lab 08. |
| Policy integrity | Policies 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
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}
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
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
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.
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
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
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
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'
{"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
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
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.
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
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
Step 3.3: The payoff, a new desk with zero policy changes
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
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.
08Testing and Validation
End-to-End Test Scenarios
| Scenario | Steps | Expected Result |
|---|---|---|
| Unit suite green | Run opa test /policies -v | All seven tests PASS, including the desk-e generalisation test |
| Matching adviser | asmith reads pf-1001 in market hours | HTTP 200 with the portfolio |
| Cross-desk deny with reasons | jpatel reads pf-1001 | HTTP 403 listing both the desk and limit reasons |
| New desk, no policy change | lokafor (desk-e) reads pf-5001 | HTTP 200; desk-e appears nowhere in the Rego |
Negative Tests
| Test | Expected Result |
|---|---|
Stop ib-opa and call the PEP | HTTP 503, fail closed; restart OPA afterwards |
| Call the PEP with no token or a tampered token | HTTP 401 before any policy evaluation occurs |
| Call outside market hours | HTTP 403 with the time reason |
| Temporarily break a test (change 16:30 to 16:00 in the test input) and run opa test | The suite fails, demonstrating the CI gate; revert afterwards |
Common Failure Modes
| Symptom | Likely Cause | Resolution |
|---|---|---|
Parse errors mentioning if or contains | OPA image predates rego.v1 | Pull a current openpolicyagent/opa image |
| Approval-limit rule always denies | The claim arrives as a string and was compared without to_number | Keep the to_number conversion in the policy as written |
| Every live decision denies on desk | The desk claim is missing from the token | Confirm the Lab 13 desk mapper is on the portfolio-api client, the same client issuing these tokens |
| PEP cannot reach OPA | Container-versus-host networking mismatch in OPA_URL | Use ib-opa:8181 when both are containers on ib-lab-net, localhost:8181 when the PEP runs on the host |
| Policy edits have no effect | OPA loaded the policies at start and the volume is read-only | Re-run the tests, then docker restart ib-opa to load the reviewed change |
09Security Analysis
What Makes This Implementation Secure
- The decision input is built exclusively from signature-verified token claims and the application's own resource data; caller-asserted attributes never reach the policy.
- The policy defaults to deny and the enforcement point fails closed when OPA is unreachable, so every failure mode refuses access rather than granting it.
- Policies are mounted read-only and change only through the tested, version-controlled repository, giving authorization the same integrity controls as application code.
- Every deny carries machine-readable reasons, making decisions explainable for audit and incident response rather than opaque booleans.
- The identity server no longer runs decision code; each component does one job, removing the script-policy escape hatch and its attack surface.
What Is Intentionally Simplified for the Lab
- Policies are bind-mounted from disk; production OPA deployments distribute signed policy bundles from a bundle server, with OPA verifying bundle signatures.
- The PEP-to-OPA hop runs over plain HTTP on the lab network; across any real network boundary this hop needs TLS, and ideally mTLS.
- Decision logs go to the console; production ships OPA's decision log feed to the SIEM for the observability work in Lab 35.
- The direct grant obtains tokens for convenience; production uses the authorization code with PKCE flow from Lab 04.
- Market hours use the PEP's local clock and a string comparison; production would pass a timezone-aware timestamp and handle half-days and holidays as data, not code.
Production Hardening Recommendations
| Area | Recommendation |
|---|---|
| Policy distribution | Serve policies as signed bundles from a bundle server, with OPA verifying signatures, so a compromised host cannot inject policy |
| CI gating | Require opa test and opa check --strict to pass in CI before any policy merge, with coverage reporting via opa test --coverage |
| Decision logging | Enable OPA's decision log API and ship every decision, with its input and reasons, to the SIEM used in Lab 35 |
| Deployment model | Run 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 freshness | Where 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"]'
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
- Open Policy Agent documentation, Policy Language (Rego), openpolicyagent.org
- OPA documentation, rego.v1 and the OPA 1.0 syntax transition, openpolicyagent.org
- OPA documentation, Policy Testing with opa test, openpolicyagent.org
- OPA documentation, Management APIs: bundles and decision logs, openpolicyagent.org
- NIST SP 800-162, ABAC and the PEP/PDP/PIP architecture, NIST
- CNCF, Open Policy Agent graduated project page, cncf.io
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?