📦 GitHub: https://github.com/geekchow/micro-service-auth
OPA is the Policy Decision Point: it receives authorization input from Kong and returns allow or deny.
What OPA Is
OPA stands for Open Policy Agent.
In this project, OPA is the PDP (Policy Decision Point) — see 01 — Concepts for the full IdP / PEP / PDP glossary.
OPA does one job:
- receive a structured input document
- evaluate it against Rego policy rules
- return a decision: allow or deny
OPA does not authenticate users. That is Keycloak’s role. OPA does not enforce the decision at the edge. That is Kong’s role. OPA only decides.
Why This Project Needs OPA
Authorization logic could have been written directly inside:
- Kong plugin code
- banking-api-service Java code
But that would couple policy to enforcement or business logic.
Using OPA as a dedicated PDP gives three concrete benefits:
In this PoC:
- Keycloak proves who the user is.
- Kong enforces at the edge (PEP).
- OPA decides whether the action is allowed (PDP).
- banking-api-service adds defense in depth (resource server).
Where OPA Sits In The Architecture
#mermaid-svg-LMsYcDIJ651tAplh{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LMsYcDIJ651tAplh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LMsYcDIJ651tAplh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LMsYcDIJ651tAplh .error-icon{fill:#552222;}#mermaid-svg-LMsYcDIJ651tAplh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LMsYcDIJ651tAplh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LMsYcDIJ651tAplh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LMsYcDIJ651tAplh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LMsYcDIJ651tAplh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LMsYcDIJ651tAplh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LMsYcDIJ651tAplh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LMsYcDIJ651tAplh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LMsYcDIJ651tAplh .marker.cross{stroke:#333333;}#mermaid-svg-LMsYcDIJ651tAplh svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LMsYcDIJ651tAplh p{margin:0;}#mermaid-svg-LMsYcDIJ651tAplh .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-LMsYcDIJ651tAplh .cluster-label text{fill:#333;}#mermaid-svg-LMsYcDIJ651tAplh .cluster-label span{color:#333;}#mermaid-svg-LMsYcDIJ651tAplh .cluster-label span p{background-color:transparent;}#mermaid-svg-LMsYcDIJ651tAplh .label text,#mermaid-svg-LMsYcDIJ651tAplh span{fill:#333;color:#333;}#mermaid-svg-LMsYcDIJ651tAplh .node rect,#mermaid-svg-LMsYcDIJ651tAplh .node circle,#mermaid-svg-LMsYcDIJ651tAplh .node ellipse,#mermaid-svg-LMsYcDIJ651tAplh .node polygon,#mermaid-svg-LMsYcDIJ651tAplh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LMsYcDIJ651tAplh .rough-node .label text,#mermaid-svg-LMsYcDIJ651tAplh .node .label text,#mermaid-svg-LMsYcDIJ651tAplh .image-shape .label,#mermaid-svg-LMsYcDIJ651tAplh .icon-shape .label{text-anchor:middle;}#mermaid-svg-LMsYcDIJ651tAplh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LMsYcDIJ651tAplh .rough-node .label,#mermaid-svg-LMsYcDIJ651tAplh .node .label,#mermaid-svg-LMsYcDIJ651tAplh .image-shape .label,#mermaid-svg-LMsYcDIJ651tAplh .icon-shape .label{text-align:center;}#mermaid-svg-LMsYcDIJ651tAplh .node.clickable{cursor:pointer;}#mermaid-svg-LMsYcDIJ651tAplh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LMsYcDIJ651tAplh .arrowheadPath{fill:#333333;}#mermaid-svg-LMsYcDIJ651tAplh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LMsYcDIJ651tAplh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LMsYcDIJ651tAplh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LMsYcDIJ651tAplh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LMsYcDIJ651tAplh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LMsYcDIJ651tAplh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LMsYcDIJ651tAplh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LMsYcDIJ651tAplh .cluster text{fill:#333;}#mermaid-svg-LMsYcDIJ651tAplh .cluster span{color:#333;}#mermaid-svg-LMsYcDIJ651tAplh div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LMsYcDIJ651tAplh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LMsYcDIJ651tAplh rect.text{fill:none;stroke-width:0;}#mermaid-svg-LMsYcDIJ651tAplh .icon-shape,#mermaid-svg-LMsYcDIJ651tAplh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LMsYcDIJ651tAplh .icon-shape p,#mermaid-svg-LMsYcDIJ651tAplh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LMsYcDIJ651tAplh .icon-shape .label rect,#mermaid-svg-LMsYcDIJ651tAplh .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LMsYcDIJ651tAplh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LMsYcDIJ651tAplh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LMsYcDIJ651tAplh :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
identity and token activity
allow or deny
business response
Client
Kong
Keycloak
OPA
banking-api-service
OPA sits between Kong and the upstream banking-api-service. Kong calls OPA synchronously on every request, waits for the decision, then either forwards the request or returns 403.
The Input → Policy → Result Model
OPA is a general-purpose engine. It does not know what a bank account is by itself. It only knows:
#mermaid-svg-ysHpr1pg5gALjfSP{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ysHpr1pg5gALjfSP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ysHpr1pg5gALjfSP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ysHpr1pg5gALjfSP .error-icon{fill:#552222;}#mermaid-svg-ysHpr1pg5gALjfSP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ysHpr1pg5gALjfSP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ysHpr1pg5gALjfSP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ysHpr1pg5gALjfSP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ysHpr1pg5gALjfSP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ysHpr1pg5gALjfSP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ysHpr1pg5gALjfSP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ysHpr1pg5gALjfSP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ysHpr1pg5gALjfSP .marker.cross{stroke:#333333;}#mermaid-svg-ysHpr1pg5gALjfSP svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ysHpr1pg5gALjfSP p{margin:0;}#mermaid-svg-ysHpr1pg5gALjfSP .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-ysHpr1pg5gALjfSP .cluster-label text{fill:#333;}#mermaid-svg-ysHpr1pg5gALjfSP .cluster-label span{color:#333;}#mermaid-svg-ysHpr1pg5gALjfSP .cluster-label span p{background-color:transparent;}#mermaid-svg-ysHpr1pg5gALjfSP .label text,#mermaid-svg-ysHpr1pg5gALjfSP span{fill:#333;color:#333;}#mermaid-svg-ysHpr1pg5gALjfSP .node rect,#mermaid-svg-ysHpr1pg5gALjfSP .node circle,#mermaid-svg-ysHpr1pg5gALjfSP .node ellipse,#mermaid-svg-ysHpr1pg5gALjfSP .node polygon,#mermaid-svg-ysHpr1pg5gALjfSP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ysHpr1pg5gALjfSP .rough-node .label text,#mermaid-svg-ysHpr1pg5gALjfSP .node .label text,#mermaid-svg-ysHpr1pg5gALjfSP .image-shape .label,#mermaid-svg-ysHpr1pg5gALjfSP .icon-shape .label{text-anchor:middle;}#mermaid-svg-ysHpr1pg5gALjfSP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ysHpr1pg5gALjfSP .rough-node .label,#mermaid-svg-ysHpr1pg5gALjfSP .node .label,#mermaid-svg-ysHpr1pg5gALjfSP .image-shape .label,#mermaid-svg-ysHpr1pg5gALjfSP .icon-shape .label{text-align:center;}#mermaid-svg-ysHpr1pg5gALjfSP .node.clickable{cursor:pointer;}#mermaid-svg-ysHpr1pg5gALjfSP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ysHpr1pg5gALjfSP .arrowheadPath{fill:#333333;}#mermaid-svg-ysHpr1pg5gALjfSP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ysHpr1pg5gALjfSP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ysHpr1pg5gALjfSP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ysHpr1pg5gALjfSP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ysHpr1pg5gALjfSP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ysHpr1pg5gALjfSP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ysHpr1pg5gALjfSP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ysHpr1pg5gALjfSP .cluster text{fill:#333;}#mermaid-svg-ysHpr1pg5gALjfSP .cluster span{color:#333;}#mermaid-svg-ysHpr1pg5gALjfSP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ysHpr1pg5gALjfSP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ysHpr1pg5gALjfSP rect.text{fill:none;stroke-width:0;}#mermaid-svg-ysHpr1pg5gALjfSP .icon-shape,#mermaid-svg-ysHpr1pg5gALjfSP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ysHpr1pg5gALjfSP .icon-shape p,#mermaid-svg-ysHpr1pg5gALjfSP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ysHpr1pg5gALjfSP .icon-shape .label rect,#mermaid-svg-ysHpr1pg5gALjfSP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ysHpr1pg5gALjfSP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ysHpr1pg5gALjfSP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ysHpr1pg5gALjfSP :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
Input JSON
Rego Policy
result: true or false
In this repo:
- Kong constructs and sends the input
- banking_authz.rego defines the rules
- OPA returns result: true or result: false
Rego Basics
OPA policies are written in Rego, a declarative language.
You describe what must be true for access to be granted, rather than writing step-by-step imperative logic.
In practice the rules in this repo read as:
- allow if the route is a read route and role is ops-admin
- allow if the route is a read route, role is customer, and the requested account is in the token’s account_ids
- deny everything else
Deny By Default
The most important line in the policy is:
default allow := false
Unless one of the allow rules matches, the answer is deny. This is safer than enumerating deny rules — a missing deny rule can never accidentally open access.
The Actual Policy In This Repo
File: infra/opa/policies/banking_authz.rego
package banking_authz
default allow := false
allow {
read_only_account_request
input.role == "ops-admin"
}
allow {
read_only_account_request
input.role == "customer"
input.customer_id != ""
account_ids := object.get(input, "account_ids", [])
account_ids[_] == input.account_id
}
read_only_account_request {
input.method == "GET"
regex.match("^/api/accounts/[^/]+(?:/transactions)?$", input.path)
}
Policy Walkthrough
package banking_authz
Places the rules in the banking_authz package. Kong queries OPA at:
http://opa:8181/v1/data/banking_authz/allow
So banking_authz is the package and allow is the decision being queried.
default allow := false
Everything is denied unless one of the allow rules below matches.
First allow Rule — ops-admin
allow {
read_only_account_request
input.role == "ops-admin"
}
Meaning:
- the request must be a valid read route (via helper)
- the caller must have role ops-admin
- no account ownership check — ops-admin may read any account
Second allow Rule — customer
allow {
read_only_account_request
input.role == "customer"
input.customer_id != ""
account_ids := object.get(input, "account_ids", [])
account_ids[_] == input.account_id
}
Meaning:
- the request must be a valid read route
- the caller must have role customer
- the token must carry a non-empty customer_id
- the requested account_id must appear in the token’s account_ids list
This is the core customer ownership check.
Helper Rule: read_only_account_request
read_only_account_request {
input.method == "GET"
regex.match("^/api/accounts/[^/]+(?:/transactions)?$", input.path)
}
Meaning:
- only GET requests pass this gate
- only two path shapes are permitted:
- /api/accounts/{accountId}
- /api/accounts/{accountId}/transactions
This prevents future non-read routes from being accidentally allowed by the same allow rules.
What Input OPA Receives
OPA does not read HTTP requests directly. Kong constructs a structured input document and POSTs it as JSON.
The fields OPA actually consumes in the policy are:
| method | string | read_only_account_request |
| path | string | read_only_account_request |
| role | string | both allow rules |
| customer_id | string | customer allow rule |
| account_ids | array of strings | customer allow rule |
| account_id | string | customer allow rule |
username is included in the input but not consumed by policy rules in this version.
See 14 — Request & Response Details for the full claim catalog.
Example input document for alice:
{
"input": {
"method": "GET",
"path": "/api/accounts/A-1001",
"account_id": "A-1001",
"customer_id": "C-1001",
"account_ids": ["A-1001"],
"role": "customer",
"username": "alice"
}
}
How Kong Sends Input To OPA
Kong calls the OPA REST API via the opa-authz plugin. The URL comes from infra/kong/kong.yml:
plugins:
– name: opa–authz
config:
opa_url: http://opa:8181/v1/data/banking_authz/allow
The plugin handler (infra/kong/plugins/opa-authz/handler.lua) constructs the input body:
local request_body = cjson.encode({
input = {
method = kong.request.get_method(),
path = kong.request.get_path(),
account_id = account_id,
customer_id = claim_value(claims.customer_id),
account_ids = claim_values(claims.account_ids),
role = effective_role(claims),
username = claims.preferred_username,
},
})
OPA receives a clean authorization input extracted from the validated JWT, not the raw HTTP request.
What OPA Returns
If the policy allows:
{ "result": true }
If the policy denies:
{ "result": false }
Kong maps that to behavior:
- result: true → forward request upstream to banking-api-service
- result: false → return 403 Forbidden to the client
OPA Request Flow In This PoC
banking-api-service
OPA
Keycloak
Kong
Client
banking-api-service
OPA
Keycloak
Kong
Client
#mermaid-svg-JxVcqMPRruIPue9m{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JxVcqMPRruIPue9m .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JxVcqMPRruIPue9m .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JxVcqMPRruIPue9m .error-icon{fill:#552222;}#mermaid-svg-JxVcqMPRruIPue9m .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JxVcqMPRruIPue9m .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JxVcqMPRruIPue9m .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JxVcqMPRruIPue9m .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JxVcqMPRruIPue9m .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JxVcqMPRruIPue9m .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JxVcqMPRruIPue9m .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JxVcqMPRruIPue9m .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JxVcqMPRruIPue9m .marker.cross{stroke:#333333;}#mermaid-svg-JxVcqMPRruIPue9m svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JxVcqMPRruIPue9m p{margin:0;}#mermaid-svg-JxVcqMPRruIPue9m .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JxVcqMPRruIPue9m text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-JxVcqMPRruIPue9m .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-JxVcqMPRruIPue9m .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-JxVcqMPRruIPue9m .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-JxVcqMPRruIPue9m .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-JxVcqMPRruIPue9m #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-JxVcqMPRruIPue9m .sequenceNumber{fill:white;}#mermaid-svg-JxVcqMPRruIPue9m #sequencenumber{fill:#333;}#mermaid-svg-JxVcqMPRruIPue9m #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-JxVcqMPRruIPue9m .messageText{fill:#333;stroke:none;}#mermaid-svg-JxVcqMPRruIPue9m .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JxVcqMPRruIPue9m .labelText,#mermaid-svg-JxVcqMPRruIPue9m .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-JxVcqMPRruIPue9m .loopText,#mermaid-svg-JxVcqMPRruIPue9m .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-JxVcqMPRruIPue9m .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-JxVcqMPRruIPue9m .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-JxVcqMPRruIPue9m .noteText,#mermaid-svg-JxVcqMPRruIPue9m .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-JxVcqMPRruIPue9m .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JxVcqMPRruIPue9m .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JxVcqMPRruIPue9m .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-JxVcqMPRruIPue9m .actorPopupMenu{position:absolute;}#mermaid-svg-JxVcqMPRruIPue9m .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-JxVcqMPRruIPue9m .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-JxVcqMPRruIPue9m .actor-man circle,#mermaid-svg-JxVcqMPRruIPue9m line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-JxVcqMPRruIPue9m :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
alt
[result false]
[result true]
GET /api/accounts/A-1001 + JWT
Introspect token
active true
POST policy input JSON
Evaluate Rego rules
result true or false
403 Forbidden
forward request
banking response
200 response
How OPA Is Run In Docker Compose
opa:
image: openpolicyagent/opa:0.68.0
command: ["run", "–server", "–addr=0.0.0.0:8181", "/policies"]
ports:
– "8181:8181"
volumes:
– ./infra/opa/policies:/policies:ro
- OPA runs as a standalone HTTP server on port 8181
- Policies are mounted read-only from infra/opa/policies
- OPA is not compiled into any Java service — it is a separate container
This separation means policy can be updated, tested, and reloaded independently of Kong or banking-api-service.
The Tests
File: infra/opa/policies/banking_authz_test.rego
package banking_authz_test
import data.banking_authz
Allow: ops-admin reads an account
test_ops_admin_is_allowed {
banking_authz.allow with input as {
"method": "GET",
"path": "/api/accounts/A-1001",
"role": "ops-admin",
"account_id": "A-1001",
"customer_id": "C-9999",
}
}
ops-admin may read any account endpoint regardless of customer ownership.
Allow: customer reads their own account
test_customer_can_access_owned_account {
banking_authz.allow with input as {
"method": "GET",
"path": "/api/accounts/A-1001",
"role": "customer",
"account_id": "A-1001",
"customer_id": "C-1001",
"account_ids": ["A-1001"],
}
}
alice (customer C-1001) may read account A-1001 when it appears in her account_ids.
Allow: customer reads their own transactions
test_customer_can_access_owned_account_transactions {
banking_authz.allow with input as {
"method": "GET",
"path": "/api/accounts/A-1001/transactions",
"role": "customer",
"account_id": "A-1001",
"customer_id": "C-1001",
"account_ids": ["A-1001"],
}
}
The transactions sub-resource is also permitted for an owned account.
Deny cases
The test file proves denial for all of the following:
| test_customer_cannot_access_other_account | account_ids does not contain the requested account |
| test_customer_without_claimed_account_is_denied | account_ids is empty |
| test_customer_without_customer_id_is_denied | customer_id field is absent |
| test_ops_admin_post_account_is_denied | POST fails read_only_account_request |
| test_customer_subresource_path_is_denied | /cards path not matched by regex |
| test_other_roles_are_denied | role auditor matches neither allow rule |
Negative tests matter as much as positive ones: a policy is only trustworthy if you also prove what it denies.
Practical Examples
alice Reading Her Own Account
Input:
{
"input": {
"method": "GET",
"path": "/api/accounts/A-1001",
"account_id": "A-1001",
"customer_id": "C-1001",
"account_ids": ["A-1001"],
"role": "customer",
"username": "alice"
}
}
Result: allow
Reason:
- GET + matching path → read_only_account_request passes
- role == "customer"
- customer_id is non-empty
- A-1001 is in account_ids
alice Attempting Another Customer’s Account
Input:
{
"input": {
"method": "GET",
"path": "/api/accounts/A-2001",
"account_id": "A-2001",
"customer_id": "C-1001",
"account_ids": ["A-1001"],
"role": "customer",
"username": "alice"
}
}
Result: deny
Reason:
- A-2001 is not in alice’s account_ids (["A-1001"])
ops-admin Reading Any Account
Input:
{
"input": {
"method": "GET",
"path": "/api/accounts/A-2001",
"account_id": "A-2001",
"customer_id": "C-9999",
"role": "ops-admin"
}
}
Result: allow
Reason:
- ops-admin rule does not check account ownership
POST Request (Denied For Both Roles)
Input:
{
"input": {
"method": "POST",
"path": "/api/accounts/A-1001",
"role": "ops-admin",
"account_id": "A-1001",
"customer_id": "C-9999"
}
}
Result: deny
Reason:
- read_only_account_request fails because method != "GET"
Unsupported Sub-Resource Path
Input:
{
"input": {
"method": "GET",
"path": "/api/accounts/A-1001/cards",
"role": "customer",
"account_id": "A-1001",
"customer_id": "C-1001",
"account_ids": ["A-1001"]
}
}
Result: deny
Reason:
- regex only matches /api/accounts/{id} and /api/accounts/{id}/transactions
- /cards does not match
What OPA Does Not Do
OPA is powerful, but it has clear limits in this PoC:
- does not authenticate users (that is Keycloak)
- does not issue JWTs (that is Keycloak)
- does not introspect tokens itself in this request path (that is the Kong plugin)
- does not validate JWT signatures itself here (the Kong plugin decodes the payload after introspection)
- does not serve banking data (that is banking-api-service)
OPA depends on Kong to provide trustworthy, well-formed input. If the input is wrong, the decision is wrong.
Mental Model
📚 返回专栏目录



