OPA architecture and the Rego mental model
Queries over documents, not scripts.
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.
dataThe 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:
curl -L -o opa https://openpolicyagent.org/downloads/latest/opa_linux_amd64_staticchmod +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.
package authzdefault allow := false# OR: any body that succeeds grants accessallow if input.user.role == "admin"allow if {# AND: every expression in this body must holdinput.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.
cat > data.json <<'EOF'{"teams": {"alice": ["payments", "fraud"]}}EOFcat > input.json <<'EOF'{"method": "GET","user": {"name": "alice", "role": "developer"},"resource": {"team": "payments"}}EOFopa 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.
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.
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.
opa versionprintf 'package test\nallow if input.user == "alice"\n' > /tmp/p.regoecho '{"user":"alice"}' | opa eval -f raw -I -d /tmp/p.rego 'data.test.allow'
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.
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?false, and that is the exact misconception this lesson warns about.default allow := false the result is undefined, and enforcement can fail open without a sound.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?admin shortcut in this lesson sits in its own body.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?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.