Enforce Fine-Grained Authorization with Open Policy Agent and Rego
Write, test, and serve a real Rego policy for role-based access control, ownership overrides, and data classification, then wire it into a Node middleware layer the way a gateway would.
What you'll build
You'll write a Rego policy that enforces role-based access control plus resource ownership and data-classification rules for a documents API, unit test that policy with opa test, then run OPA as a standalone decision service and query it from a gateway-style middleware layer the way a real API gateway would.
Prerequisites
- OPA CLI v0.70.0 or later (the examples use Rego v1 syntax, which is optional via
import rego.v1pre-1.0 and the default from OPA 1.0.0 onward). Checkopen-policy-agent/opareleases on GitHub if you want a specific version. curland, ideally,jqfor readable output.- Node.js 18+ if you want to run the middleware example (native
fetchships in 18+). - Basic REST/JSON familiarity. No Go knowledge required, OPA ships as a static binary.
- Architecture doesn't matter much here: Homebrew picks the right binary automatically on Apple Silicon or Intel Macs, and the Linux static builds ship for both
amd64andarm64.
1. Install OPA
macOS (Homebrew):
brew install opa
Linux amd64 (replace <version> with a real tag from the releases page, e.g. v0.70.0):
curl -L -o opa https://openpolicyagent.org/downloads/<version>/opa_linux_amd64_static
chmod 755 opa
sudo mv opa /usr/local/bin/opa
Linux arm64 (same version tag, different binary):
curl -L -o opa https://openpolicyagent.org/downloads/<version>/opa_linux_arm64_static
chmod 755 opa
sudo mv opa /usr/local/bin/opa
Or skip the install entirely and use the official image:
docker pull openpolicyagent/opa:latest
Confirm it works:
opa version
2. Scaffold the policy package
mkdir -p authz-demo/policy && cd authz-demo
You'll end up with two files under policy/: the policy itself and its test suite. OPA's convention is strict here, test files must end in _test.rego and test rules must be named test_* with no arguments.
3. Write the authorization policy
Create policy/authz.rego. This models three things advanced authZ setups actually need: role permissions, ownership overrides, and a data-classification guard that even permissive roles can't bypass without admin clearance.
package httpapi.authz
import rego.v1
default allow := false
role_permissions := {
"admin": {"read", "write", "delete"},
"editor": {"read", "write"},
"viewer": {"read"},
}
is_admin if "admin" in input.roles
is_owner if input.resource.owner == input.user
is_restricted if {
input.resource.classification == "confidential"
not is_admin
}
role_grants_action if {
some role in input.roles
input.action in role_permissions[role]
}
allow if {
role_grants_action
not is_restricted
}
allow if {
is_owner
input.action in {"read", "write"}
not is_restricted
}
A few things worth calling out for an advanced reader:
default allow := falsematters more than it looks. Without it, a query against a non-matching input returns undefined, which OPA's HTTP API renders as{}instead of{"result": false}. Callers that don't distinguish "no result" from "false" will fail open.- Splitting
is_admin,is_owner, andis_restrictedinto named rules isn't just style, it makes each one independently unit-testable, which you want once policies grow past toy examples. not is_restrictedrelies on Rego treating an undefined rule as absent. Ifis_restricted's body never matches (classification isn't confidential), the rule is undefined rather thanfalse, andnoton undefined evaluates totrue. That's intentional here, but it's a common source of confusion if you're new to Rego's three-valued logic, so don't skip writing the negative test case for it.role_permissionsis inlined here for clarity. In production you'd load it fromdata.jsonor a bundle so role-permission mappings can change without a policy redeploy.
4. Unit test the policy
Create policy/authz_test.rego in the same package so tests can reach the private helper rules directly:
package httpapi.authz
import rego.v1
test_editor_can_write_others_doc if {
allow with input as {
"user": "alice",
"roles": ["editor"],
"action": "write",
"resource": {"owner": "bob", "classification": "internal"},
}
}
test_viewer_cannot_write if {
not allow with input as {
"user": "carol",
"roles": ["viewer"],
"action": "write",
"resource": {"owner": "bob", "classification": "internal"},
}
}
test_owner_can_read_without_role if {
allow with input as {
"user": "dave",
"roles": [],
"action": "read",
"resource": {"owner": "dave", "classification": "internal"},
}
}
test_confidential_blocks_editor if {
not allow with input as {
"user": "erin",
"roles": ["editor"],
"action": "write",
"resource": {"owner": "frank", "classification": "confidential"},
}
}
test_confidential_allows_admin if {
allow with input as {
"user": "grace",
"roles": ["admin"],
"action": "delete",
"resource": {"owner": "frank", "classification": "confidential"},
}
}
test_is_restricted_for_confidential_non_admin if {
is_restricted with input as {
"roles": ["editor"],
"resource": {"classification": "confidential"},
}
}
The with input as {...} override is the key mechanic here: it lets each test case supply its own input document without touching global state, and without needing a separate fixture file. This is how you get real unit tests instead of end-to-end smoke tests against a running server.
5. Run the tests
opa test ./policy -v
You should see each test listed individually with a PASS:
policy/authz_test.rego:
data.httpapi.authz.test_editor_can_write_others_doc: PASS (0.11ms)
data.httpapi.authz.test_viewer_cannot_write: PASS (0.09ms)
data.httpapi.authz.test_owner_can_read_without_role: PASS (0.08ms)
data.httpapi.authz.test_confidential_blocks_editor: PASS (0.10ms)
data.httpapi.authz.test_confidential_allows_admin: PASS (0.09ms)
data.httpapi.authz.test_is_restricted_for_confidential_non_admin: PASS (0.06ms)
--------------------------------------------------------------------------------
PASS: 6/6
Wire this into CI (opa test ./policy, nonzero exit on any failure by default) before you ever let a policy change near production. Treat Rego like application code, because it is application code.
6. Serve OPA as a policy decision point
Run OPA as a server, pointing it at the policy directory. Add --ignore so the test file doesn't get loaded into the running server, it would still work harmlessly, but there's no reason to expose test_* rules as queryable data in something you're calling a security boundary:
opa run --server --addr :8181 --ignore="*_test.rego" ./policy
Query the decision the same way a gateway would, over plain HTTP:
curl -s -X POST localhost:8181/v1/data/httpapi/authz/allow \
-H 'Content-Type: application/json' \
-d '{
"input": {
"user": "alice",
"roles": ["editor"],
"action": "write",
"resource": {"owner": "bob", "classification": "internal"}
}
}' | jq
Expected output: {"result": true}. Change classification to confidential and rerun, you should get {"result": false}.
7. Call OPA from your gateway or middleware
This is the pattern most API gateways and service meshes use under the hood (Envoy's ext_authz, Kong's OPA plugin, or a hand-rolled Express/Koa middleware all boil down to this HTTP call). A minimal Express middleware:
// authz-middleware.js
const OPA_URL = process.env.OPA_URL || 'http://localhost:8181/v1/data/httpapi/authz/allow';
async function authorize(req, res, next) {
const input = {
user: req.user.sub,
roles: req.user.roles || [],
action: req.method === 'GET' ? 'read' : 'write',
resource: await loadResourceMeta(req.params.id), // { owner, classification }
};
let decision;
try {
const resp = await fetch(OPA_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
});
decision = (await resp.json()).result;
} catch (err) {
return res.status(503).json({ error: 'authz service unavailable' }); // fail closed
}
if (decision !== true) return res.status(403).json({ error: 'forbidden' });
next();
}
module.exports = authorize;
The catch block matters as much as the happy path: if OPA is unreachable, deny the request. Fail-open authorization middleware is a classic outage-turns-into-breach pattern.
Verify it works
opa test ./policy -vshowsPASS: 6/6.curl localhost:8181/healthreturns{}with HTTP 200 while the server is up.- The curl query in step 6 returns
truefor an editor writing an internal doc andfalsefor the same request against a confidential doc. - With the middleware wired to a route, a request with a viewer role attempting a
POSTreturns HTTP 403; the same request with an editor role returns your normal route handler response.
Troubleshooting
rego_parse_error: 'if' keyword is required before rule body: you're mixing v0 and v1 syntax. Either add if to every rule with a body, or run with opa test --v0-compatible ./policy if you need to stay on the old syntax temporarily.
Server returns {} with no result field: your query path doesn't resolve to a defined value, usually a typo in the URL path (/v1/data/httpapi/authz/allow must exactly match package httpapi.authz plus the rule name) or a missing default for that rule. Always set default allow := false on any rule your app treats as a security boundary.
opa test reports "no test cases found": check the filename ends in _test.rego and every test rule name starts with test_ and takes no input arguments.
Middleware gets ECONNREFUSED: OPA isn't listening where you think. Confirm with curl localhost:8181/health, and if OPA runs in a container alongside your app, make sure you're using the container's service name/port, not localhost, once they're both inside Docker Compose or Kubernetes.
Next steps
For production, distribute policies as signed OPA bundles pulled from an object store or bundle server instead of a local directory, add --set decision_logs.console=true (or ship logs to your SIEM) for an audit trail of every allow/deny, and look at partial evaluation if you need to push filtering logic down into a database query instead of evaluating per-row. If your gateway is Envoy, the opa-envoy-plugin implements the ext_authz gRPC API natively so you skip the custom middleware entirely. For Kubernetes admission control specifically, that's OPA Gatekeeper's job, not this pattern.
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 0
No comments yet
Be the first to weigh in.