Explained: Open Policy Agent

Codelooru Open Policy Agent

A new engineer asks who is allowed to delete an invoice. You say you will find out, which is the first sign of trouble.

The answer lives in four places. There is a role check in the API gateway config. There is an if user.role == "admin" in the invoices service. There is a slightly different check in the nightly cleanup job, written a year later by someone who did not know about the first two. And there is a comment in the billing service saying finance can do it too, with the actual check commented out below it.

None of them agree. Nobody can tell you which one is authoritative. Changing any of them requires a code review, a build, and a deploy, which means the answer to "who can delete an invoice" is effectively frozen until the next release.

Open Policy Agent, usually just called OPA, exists to fix exactly this. It is a general-purpose policy engine, graduated in the CNCF, that takes the decision-making out of your application and puts it somewhere you can read, test, and change on its own.


The problem is not authorization. It is where authorization lives.

Every non-trivial system makes rules-based decisions constantly. Can this user read this document? Should this Pod be allowed into the cluster? Does this Terraform plan violate the tagging standard? Is this API key permitted to hit this endpoint at this rate?

Each of those questions has a correct answer, and each answer depends on facts: who is asking, what they are asking about, what groups they belong to, what time it is. Traditionally the logic that combines those facts lives inline, wherever the decision happens to be needed.

That works fine in one service. It stops working when you have thirty. The logic drifts, because a rule updated in one codebase does not propagate to the others. It becomes unauditable, because "show me every rule governing invoice deletion" means grepping four repositories in three languages. And it becomes slow to change, because every policy adjustment is a software release.

The insight behind OPA is that policy is not really application logic. It is configuration that happens to be expressed as rules. If you can pull it out into its own artifact with its own lifecycle, you get a single place to look, a single place to test, and the ability to change a rule without shipping code.


A decision, not a code path

OPA works by splitting one thing you normally think of as a unit into two.

The first half is the decision: given these facts, is this allowed? The second half is the enforcement: actually returning a 403, or rejecting the Pod, or failing the build. OPA does the first half only. It never touches your request, never sits in your data path by force, and never enforces anything.

Your service sends OPA a JSON document describing the situation. OPA evaluates it against the loaded policy and sends back a JSON document containing the answer. Your service reads the answer and does something about it. This split has formal names borrowed from older access control literature: your service is the Policy Enforcement Point (PEP) and OPA is the Policy Decision Point (PDP).

DECISION SEPARATED FROM ENFORCEMENT Your Service Enforces the answer Policy Enforcement Point OPA Produces the answer Policy Decision Point input: who, what, action decision: allow, deny, reasons Policy Rego rules Data JSON facts OPA answers the question. Your service is still the one that acts on the answer.

That separation is what makes the rest of OPA possible. Because OPA only ever answers questions about JSON, it does not care whether the question came from a Java service, an Envoy proxy, the Kubernetes API server, or a CI pipeline. The same engine serves all of them.


Three things go in, one comes out

An OPA evaluation combines exactly three inputs.

input is the request-specific document. It describes the thing being decided right now: the HTTP method, the path, the authenticated user, the Kubernetes object being created. It changes on every query and is supplied by the caller.

data is everything else OPA knows. This is ambient context loaded ahead of time: the user-to-role mapping, the list of approved container registries, the org chart, the tier limits for each customer plan. It is plain JSON and it persists between queries.

The policy is the set of rules, written in a language called Rego, that relate input and data to an answer.

THE EVALUATION MODEL input this request, right now data everything known already policy Rego rules OPA evaluation Decision arbitrary JSON A decision is not limited to true or false. It can be any JSON value the caller needs.

The output is worth pausing on. Most people assume a policy engine returns a boolean, and it often does. But OPA returns whatever the policy computes, so a decision can just as easily be a rate limit of 1000, a list of rejection reasons, a filtered set of visible rows, or an object containing all three. That flexibility is why OPA gets used for things well beyond access control.


Rego, in about five minutes

Rego is the language policies are written in. It is declarative and descends from Datalog, which means you describe what makes something true rather than writing the steps to check it. This feels strange for roughly an hour and then feels obvious.

Here is a policy allowing a user to read a document they own.

package authz

default allow := false

allow if {
    input.method == "GET"
    input.path == ["documents", input.document_id]
    input.user.id == data.documents[input.document_id].owner
}

Three things are doing the work here. default allow := false makes the policy fail closed, so if no rule matches, the answer is deny. The statements inside the braces are joined by implicit AND, so allow is true only when all three hold. And data.documents reaches into the ambient JSON that was loaded separately from the rules.

Rules can also produce sets instead of single values, which is the natural shape for validation. Rather than one boolean, you collect every reason something should be rejected.

package kubernetes.admission

deny contains msg if {
    input.request.kind.kind == "Pod"
    some container in input.request.object.spec.containers
    not startswith(container.image, "registry.internal.example.com/")
    msg := sprintf("image %v is not from the approved registry", [container.image])
}

The some container in ... iterates every container in the Pod, and each one that fails the check contributes a message to the deny set. An empty set means the Pod is fine. A non-empty set is both the verdict and the explanation, which is far more useful to the person who got rejected than a bare false.

Note that neither example imports anything. OPA 1.0 made the modern Rego syntax the default, so the import rego.v1 line you will see in older tutorials is no longer needed. If you find a blog post that includes it, the rest of the post is probably from before 2025 too.

Policies are testable in the same language, which matters more than it sounds. A rule change you cannot test is a rule change you will be nervous about deploying.

package authz_test

import data.authz

test_owner_can_read_own_document if {
    authz.allow with input as {
        "method": "GET",
        "path": ["documents", "d-42"],
        "user": {"id": "u-7"}
    } with data.documents as {"d-42": {"owner": "u-7"}}
}

Run it with opa test. The with keyword substitutes mock values for input and data, so the test is fast, hermetic, and needs no running services.


Where OPA actually runs

OPA ships as a single static Go binary with no dependencies, which gives you unusual freedom in how you deploy it. There are three common shapes, and the choice is mostly about latency versus operational surface.

EMBEDDED LIBRARY SIDECAR SHARED SERVICE one process App code OPA library function call microseconds one pod, two containers App OPA HTTP on localhost sub-millisecond svc A svc B svc C OPA network hop, shared blast radius The trade-off is always the same Closer to the app means faster and more resilient. Further away means fewer copies to operate. Most production deployments choose the sidecar. All three run identical policy. Only the call mechanism differs.

The embedded library puts OPA inside your process using the Go SDK. Evaluation is a function call, so there is no serialization and no network. This is the fastest option but restricts you to Go.

The sidecar runs OPA as a separate container beside your application, reachable over localhost. Any language can call it, the network hop is a loopback, and if OPA dies it takes only that one pod's decisions with it. This is the most common production pattern.

The shared service runs a centralized OPA cluster that many services query. It is the easiest to operate and the worst for latency and blast radius, since every service now depends on a network call to make basic decisions. It is a reasonable choice for low-volume decisions and a poor one for per-request authorization.


Getting policy in and decisions out

A policy engine that requires a restart to change policy has not solved much. OPA distributes policy through bundles, which are gzipped tarballs containing Rego files and JSON data.

Each OPA instance is configured with a bundle service URL and polls it on an interval. When a new bundle appears, OPA downloads it, compiles it, and swaps it in atomically. Nothing restarts and no in-flight request sees a half-applied policy. Bundles can be signed, so OPA will refuse to activate policy that does not carry a valid signature.

This inverts the usual deployment story. Policy lives in its own Git repository, goes through its own review, gets tested by opa test in CI, and is published as a bundle. Updating who can delete an invoice becomes a pull request against a rules repository rather than a release of four services.

Running the other direction are decision logs. OPA can ship every decision it makes, along with the full input and the result, to a remote endpoint. Because the log contains the exact facts the decision was based on, you can replay historical decisions against a new policy to see what would have changed. That is a genuinely hard thing to build when your policy is scattered through application code.


Where you will meet it

Most engineers encounter OPA sideways, through a tool built on top of it rather than through OPA directly.

Kubernetes admission control is the biggest one, usually via Gatekeeper. The API server is configured to send every create or update to a validating webhook before persisting it. The webhook evaluates Rego against the incoming object and returns a verdict.

ADMISSION CONTROL, TIME FLOWING DOWNWARD kubectl API server OPA webhook etcd create Pod AdmissionReview JSON evaluate deny rules image registry check allowed: false, with reason rejected, reason shown never reached The object is rejected before it is ever persisted, so the cluster never holds an invalid state.

The critical detail is where the check sits. Because admission happens before the write to etcd, a rejected object never exists in the cluster at all. This is prevention rather than detection, which is a meaningfully different security posture from scanning for violations after the fact.

Envoy and service meshes use OPA through the external authorization API, letting the proxy ask OPA about every request before it reaches your application. Your service code contains no authorization logic whatsoever.

Infrastructure as code checks run OPA over Terraform plans, Dockerfiles, and Kubernetes manifests in CI, usually through Conftest. Rejecting an unencrypted S3 bucket at pull request time costs a developer thirty seconds. Finding it in production costs considerably more.


Where OPA is the wrong tool

OPA evaluates against data it already holds in memory, which sets a hard boundary. If answering your question requires querying a database mid-evaluation, OPA is a poor fit. You can make HTTP calls from Rego, but doing so on a hot authorization path is a reliable way to build something slow and fragile.

This means data has to be small enough to keep resident and fresh enough to be correct. A role mapping for ten thousand users is fine. Replicating your entire orders table into OPA so it can check ownership is not.

Rego is also a real language with a real learning curve. The declarative model, the way undefined values propagate, and the fact that variables are existentially quantified all trip people up early. Teams that adopt OPA and then write Rego that reads like Python usually end up with policies that are harder to follow than the code they replaced.


Summary

The thing worth carrying away is not that OPA is a policy engine. It is that "who is allowed to do what" was never really application logic in the first place. It only looked that way because that is where it happened to be written.

Once you accept that a decision is just a pure function from facts to an answer, everything else follows. The function can live in its own file, so you can read all of it in one place. It can be unit tested, because it has no side effects. It can be versioned and shipped independently, because changing it does not change your application. And it can be logged completely, because the inputs and the output are both just JSON.

OPA is the general-purpose implementation of that idea. Rego is the syntax, bundles are the delivery mechanism, and sidecars are the usual deployment shape, but those are details. The shift is treating policy as a thing your system consults rather than a thing your system contains.


Part of the Explained series — concepts in tech, clearly.



×