CoursesPolicy-as-code at scaleOPA architecture and the Rego mental model

OPA architecture and the Rego mental model

Queries over documents, not scripts.

Advanced35 min · lesson 1 of 13

Every nightclub has one rulebook and a dozen bouncers. If each bouncer works from memory, the club's real policy is whatever each of them half-remembers tonight. Open Policy Agent (OPA) is that rulebook turned into a program. It is a *policy engine*, software whose only job is to answer questions like "may this request go ahead?" so any other system can ask before it acts. You write the rules in a small language built for the purpose, called Rego, and you keep them as plain text files. That is the whole idea behind *policy-as-code*. Your authorization and compliance rules live in Git, get reviewed like code, and give the same answer every time, instead of living in a wiki page, a ticket, and somebody's memory.

What OPA is, and what goes wrong without it

OPA (a graduated project at the Cloud Native Computing Foundation, and pronounced "oh-pa") exists to kill *policy sprawl*. Without it, one rule such as "no container runs as root" gets written five times: once in a Kubernetes admission webhook, once in a CI (continuous integration) shell script, once in a Terraform review checklist, and twice in application code. Each copy drifts on its own schedule. One of them forgets an edge case, and you have an incident. OPA splits the job in two. The policy enforcement point (PEP) is whatever actually does the blocking: your API gateway, your CI job, your microservice. The policy decision point (PDP) is OPA. The enforcement point sends OPA a JSON (JavaScript Object Notation, the plain-text data format everything on the wire already speaks) document describing what is about to happen, called the input. OPA checks it against the policy plus whatever background facts it holds (data), then hands back a JSON answer. OPA never blocks, never mutates, never enforces. It answers. That split is why one policy can serve many enforcement points without being rewritten for each one.

Architecture: it is all one document

Inside, OPA has exactly one data structure: a single JSON document tree with data at the root. One filing cabinet, where every folder has an address. Every Rego file declares a package, and its rules mount into the tree at that path, so package authz makes its rules addressable as data.authz.*. Static background facts (team rosters, image allowlists, CIDR ranges, meaning shorthand blocks of IP addresses like 10.0.0.0/8) load alongside as *base documents*. The per-request input sits apart as its own short-lived document. A query such as data.authz.allow is nothing fancier than a path into that tree, and evaluating it means working out the value at that path for the current input. You can run OPA three ways: embedded as a Go library inside your own program, as a long-running daemon or sidecar with a REST API (a plain HTTP interface where each URL names a thing you can read or write), or compiled to WebAssembly. In production, policies and data usually arrive as versioned, signed bundles that OPA pulls from a remote endpoint. How those bundles get built and promoted belongs to the policy-lifecycle lesson.

Everything is one document tree rooted at data
Base documents (static context)
Team rosters, image allowlists, CIDR ranges
shipped in signed bundles, copied in ahead of time
Addressable under data.*
the data problem: multi-GB base docs break first at scale
Policy packages (Rego rules)
package authz → data.authz.*
each file's rules mount into the tree at its package path
default allow := false
stops an undefined result from sliding through as an allow
input (per-request, ephemeral)
method, user, resource
the JSON the enforcement point (PEP) sends with each request
Separate from data
never persisted; discarded once the decision is made
A query like data.authz.allow is only a path into this one tree. Evaluation works out the value at that path for the current input, and the REST URL /v1/data/authz/allow mirrors that path exactly.

The whole engine ships as one static binary, a single file with nothing else to install. That is why the same download works as a command-line tool, as a server, and as a test runner:

shell
curl -L -o opa https://openpolicyagent.org/downloads/latest/opa_linux_amd64_static
chmod +x opa && sudo mv opa /usr/local/bin/
opa version
# Version: 1.18.2
# Go Version: go1.25.0
# Platform: linux/amd64
# WebAssembly: unavailable

The Rego mental model: queries, not programs

Rego descends from Datalog, a language for querying facts, and it is *declarative*. A rule does not run top to bottom. It states the conditions under which a name has a value. Three ideas carry almost the entire language. One: every expression inside a rule body has to hold, so a body is a logical AND. Two: writing the same rule name again with a different body means any one of them succeeding is enough, so repetition is a logical OR. Three: variables are not assigned, they are *unified*. OPA hunts for values that satisfy every expression at once, which is how you iterate without writing a loop (some c in input.spec.containers tries each element in turn). Rules are pure on top of that. Nothing gets mutated, nothing happens off to the side, so the same input and data always produce the same decision.

policy.rego
package authz
default allow := false
# OR: any body that succeeds grants access
allow if input.user.role == "admin"
allow if {
# AND: every expression in this body must hold
input.method == "GET"
input.resource.team in data.teams[input.user.name]
}

Run it from the command line. opa eval takes policies and base documents with -d, the input with -i, and the query itself as its final argument.

shell
cat > data.json <<'EOF'
{"teams": {"alice": ["payments", "fraud"]}}
EOF
cat > input.json <<'EOF'
{"method": "GET",
"user": {"name": "alice", "role": "developer"},
"resource": {"team": "payments"}}
EOF
opa eval -d policy.rego -d data.json -i input.json --format pretty 'data.authz.allow'
# true

Follow what happened. OPA tried the first allow body: role is developer, so that body fails. The second body holds. The method matches, and unification finds "payments" inside data.teams["alice"], so allow comes back true. Had every body failed (say the user is missing from data.teams, which makes that reference undefined), allow would fall back to its declared default of false. Delete that default line and something far more dangerous happens instead.

Undefined is not false
When no rule body succeeds and you have not declared a default, the result is undefined. Undefined is not false. Over the REST API it comes back as an empty object {} with no result key at all. Enforcement code written as "deny only when the result is false", or "allow unless denied", fails open the moment a path goes undefined. Misspell one field name and everything gets authorized. Declare default allow := false every time, and write every caller so that anything other than a literal true counts as a deny.

From one-shot eval to a decision service

The same binary hands out decisions over HTTP. opa run --server loads your files and exposes the document tree under /v1/data/.... The URL path mirrors the query path exactly, which is the one-tree model paying off.

shell
opa run --server --addr :8181 policy.rego data.json
# {"addr":":8181","level":"info","msg":"Initializing server.","time":"..."}
# in another terminal:
curl -s localhost:8181/v1/data/authz/allow \
-d '{"input": {"method": "GET",
"user": {"name": "alice", "role": "developer"},
"resource": {"team": "payments"}}}'
# {"result":true}

Trade-offs, hardening, and what breaks at scale

Rego's first cost is the mental switch. Engineers who live in imperative code fight unification for about a week, and clever Rego that nobody reviewed turns into write-only code. The second cost is the *data problem*. OPA evaluates against documents it already holds locally, so any context a rule needs has to be copied into the engine ahead of time, through bundles or the data API. Base documents that grow into gigabytes inflate memory and restart times, and they are usually the first thing to break once you scale up. You can reach out at decision time with the http.send built-in and skip the replication, but then your request path inherits the latency and the outages of whatever you called. Keep it as a last resort and set strict timeouts. In-memory decisions normally land well under a millisecond. On a path that runs for every single request, you measure that number. You do not assume it.

A plain opa run --server listens with no authentication at all. Anyone who can reach the port can ask it for decisions and, far worse, replace your policies and data through the same REST API. In production, start it with --authentication=token --authorization=basic plus a system authorization policy, so only the callers you intend reach the decision endpoints. Serve TLS (Transport Layer Security, the encryption behind HTTPS) with --tls-cert-file and --tls-private-key-file. Bind it to localhost when it runs as a sidecar. Switch on decision logging with --set decision_logs.console=true, or point it at a remote sink, so every allow and every deny is auditable. Verify bundle signatures as well, so a compromised bundle server cannot ship you a policy that allows everything.

Purity is the property worth carrying forward. Because a Rego rule is a deterministic function of input and data, a policy decision is the cheapest thing in your stack to test. Feed it a JSON document, assert the answer. No mocks, no cluster. The next lesson turns that into working practice: structuring packages, writing opa test suites, and measuring coverage so a refactor cannot quietly flip a production decision.

Try this

Run these in a lab or a throwaway box, so you see the real output shape rather than a screenshot from someone's blog post.

terminal
opa version
printf 'package test\nallow if input.user == "alice"\n' > /tmp/p.rego
echo '{"user":"alice"}' | opa eval -f raw -I -d /tmp/p.rego 'data.test.allow'
output
Version: …
true

Takeaway

OPA answers queries over structured documents, in Rego, not in imperative scripts. Get that model straight in your head before you scale engines out across clusters.

Next: how input, data, and packages load the world Rego evaluates.

Quick check
01An OPA authorization policy has no default allow := false, and the caller blocks a request only when the result comes back false. Someone misspells a field name in the input, so no allow body matches. What happens?
Incorrect — No. A missing or misspelled field makes the reference undefined rather than an error, and OPA replies with an empty result object, not a 400.
Incorrect — No. Undefined is explicitly not false, and that is the exact misconception this lesson warns about.
Correct — Undefined is not false, so "block only when false" authorizes everything on that path, and a single typo quietly grants access.
Incorrect — No. OPA has no implicit fail-closed behaviour. Without default allow := false the result is undefined, and enforcement can fail open without a sound.
02A teammate wants access granted only when the method is GET and the resource belongs to one of the user's teams. They write two separate rules named allow, the first with input.method == "GET" as its only expression and the second with the data.teams check as its only expression. What does that policy actually do?
Incorrect — No, that has it backwards. Merging happens inside a single body, not across bodies. Two bodies are two independent ways to reach the same answer.
Correct — Repeating a rule name is Rego's OR, and every expression inside one body is the AND. Splitting the pair across two rules widens the policy instead of narrowing it.
Incorrect — No. Later definitions do not shadow earlier ones. Both bodies are live, and any one of them succeeding is enough.
Incorrect — No. Defining the same name repeatedly is normal, intended Rego. It is precisely how you express an OR, which is why the admin shortcut in this lesson sits in its own body.
03Your OPA sidecar was started with a plain opa run --server --addr :8181. A teammate demonstrates that from another pod they can not only ask it for decisions but replace your policy through the same REST API. What do you do next?
Incorrect — No. OPA only answers, but whoever can rewrite the policy controls every answer it gives, which means they control every enforcement point asking it.
Incorrect — No. That default protects against undefined results, not against someone who replaces the whole policy file with one that allows everything.
Correct — A plain opa run --server has no authentication at all, so you gate the endpoints with token authentication and an authorization policy, encrypt the hop, and stop listening on an address other pods can dial.
Incorrect — No. Decision logs make every allow and deny auditable after the fact, which is worth doing, but they do nothing to stop the next person who reaches the port.

Related