Skip to content
Security Advanced Tutorial

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.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Jul 12, 2026 · 11 min read
Enforce Fine-Grained Authorization with Open Policy Agent and Rego

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.v1 pre-1.0 and the default from OPA 1.0.0 onward). Check open-policy-agent/opa releases on GitHub if you want a specific version.
  • curl and, ideally, jq for readable output.
  • Node.js 18+ if you want to run the middleware example (native fetch ships 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 amd64 and arm64.

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 := false matters 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, and is_restricted into named rules isn't just style, it makes each one independently unit-testable, which you want once policies grow past toy examples.
  • not is_restricted relies on Rego treating an undefined rule as absent. If is_restricted's body never matches (classification isn't confidential), the rule is undefined rather than false, and not on undefined evaluates to true. 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_permissions is inlined here for clarity. In production you'd load it from data.json or 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

  1. opa test ./policy -v shows PASS: 6/6.
  2. curl localhost:8181/health returns {} with HTTP 200 while the server is up.
  3. The curl query in step 6 returns true for an editor writing an internal doc and false for the same request against a confidential doc.
  4. With the middleware wired to a route, a request with a viewer role attempting a POST returns 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 Choi
Written by
Ji-ho Choi · Security & Cloud Editor

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading