Policy engines and authz at scale
Once authz logic is scattered across 40 services, every team reinvents it and nobody can answer "who can do what." A policy engine externalizes the decision — policy-as-code, evaluated as a sidecar — but it is a latency and consistency problem.
A compliance auditor asks what should be the simplest question in the building: “Which roles can issue a refund?” You open the billing service and find the check inlined in a controller. Then someone mentions the admin console has its own copy. Then the internal CSR tool, which calls a different endpoint, has a third copy — and that one was patched last quarter to allow team leads, but only there. Three services, three answers, none authoritative, and the rule drifts a little more with every sprint. Nobody is wrong; everyone is just enforcing authorization where they happen to handle the request. That is the scaling wall: when the decision lives inside forty services, “who can do what” stops being a fact you can look up and becomes an archaeology project you run during an incident.
By the end of this lesson you’ll know why authorization logic decays when it’s scattered across services, how a policy engine like OPA externalizes the decision into policy-as-code, and the latency and consistency tradeoffs that decision buys you.
Why scattered authorization decays
Authorization starts simple. One service, a couple of roles, an if user.role == "admin" near the top of the handler. It works, so the next service copies the pattern, and the one after that. Nothing about this is a mistake on any given day — it’s the path of least resistance, and each team is locally correct. The failure is emergent: after a year you have the same rule expressed forty subtly different ways, in four languages, against three slightly different role models, and no single place that can answer a policy question.
That decay has three concrete costs a senior feels in production. First, drift: when “managers can approve their own team’s expenses up to $5k” changes to “$10k,” you must find and edit it in every service that enforces it — and you will miss one, silently, because nothing fails when a check is merely stale rather than absent. Second, unauditability: a compliance or incident question like “who can delete a customer record” has no authoritative answer; you grep forty repos and hope. Third, inconsistent enforcement: the refund rule that allows team leads in the CSR tool but not the billing service isn’t a feature — it’s a vulnerability that nobody decided to ship. Scattered authz isn’t just messy; it is the structural cause of the broken-access-control bugs you’ve already met, multiplied by your service count.
Externalizing the decision: PDP and PEP
The fix is to separate deciding from enforcing. This is the PDP/PEP split, and it’s the mental model that makes everything else click:
- The Policy Enforcement Point (PEP) lives in your service. It’s the code at the request boundary that says “I’m about to do action X on resource Y for user Z — am I allowed?” It does not contain the rule. It asks.
- The Policy Decision Point (PDP) is the policy engine. It holds the rules as data, receives the question plus relevant context (the user’s roles, the resource’s owner, attributes), evaluates the policy, and returns
allowordeny.
Your application code shrinks to one line: gather the input, call the PDP, honor the answer. The logic — every condition, role, and exception — moves into versioned policy-as-code that lives in one repository, gets code-reviewed, gets tested in CI, and is deployed independently of the services that consume it. The win is direct: the auditor’s question now has exactly one place to look, the refund rule changes in exactly one file, and enforcement is identical everywhere because every service asks the same brain.
OPA and Rego in practice
The reference implementation of this pattern is OPA (Open Policy Agent), a CNCF-graduated, general-purpose policy engine. You don’t write OPA policies in your application language; you write them in Rego, a declarative query language built for exactly this shape of problem — “given this input, does any rule conclude allow?” A trivial Rego policy looks like this:
package authz
default allow := false
allow if {
input.action == "refund"
input.user.role in {"billing_admin", "team_lead"}
}Two things matter here. default allow := false is deny by default expressed as one line — if no rule fires, the answer is no, which is exactly the reflex access control demands. And the rule is data the engine evaluates, not control flow you maintain: you change who can refund by editing this file, not by hunting through service code. OPA isn’t only for service authz, either — the same engine and language enforce Kubernetes admission control (via Gatekeeper), Terraform plan checks, and CI gates, which is why investing in Rego pays off across the platform, not just one API.
The operational question is where the PDP runs, and this is where seniors earn their pay:
| Deployment | Decision latency | Failure blast radius | Policy freshness |
|---|---|---|---|
| Central PDP service (network hop) | ~1–10ms+ per call, on the request path | Shared SPOF: PDP down → every service blocked | Instant: one place to update |
| Sidecar / embedded OPA (local) | Sub-millisecond, in-process / loopback | Isolated: one sidecar fails → one pod | Eventually consistent: bundle pull lag |
| Embedded library (in your binary) | Lowest, no IPC at all | Coupled to the process lifecycle | Tied to your deploy/reload cadence |
The dominant production answer is the sidecar / local-daemon model: OPA runs next to each service (same pod, same host), policy is distributed as signed bundles the agent pulls periodically, and the decision is a loopback call measured in microseconds. You trade a network round-trip you can’t afford on every request for eventual consistency of policy — a changed rule takes a bundle-poll interval to reach every node. For authorization that’s usually fine; for revoking a compromised admin right now, you must understand that the propagation lag is real and design for it.
▸Why this works
Why not just put the PDP behind a network call and keep one authoritative copy? Because you’d be adding a synchronous dependency to every authorized request in the company. At 50k req/s a 5ms authz hop is 250 engine-seconds of latency added per wall-clock second, and the moment that service has a bad deploy or a GC pause, every other service stalls behind it — you’ve built a company-wide single point of failure on the hottest path there is. The sidecar model keeps the single source of truth (one policy repo) while distributing evaluation to the edge, so a failure is one pod, not the platform. The cost you accept in return is that “source of truth” and “what’s currently enforced on node N” are eventually consistent, not instantaneous.
What a senior gets wrong (and right)
The seductive mistake is treating “we adopted OPA” as the finish line. Externalizing the engine doesn’t externalize the data the policy needs: if the policy must know that user 42 owns invoice 4815, that ownership fact has to reach the PDP somehow — passed in the input, or replicated into the engine, or fetched mid-decision. Each option is a real tradeoff (input bloat vs. a stale replica vs. a decision-time dependency you were trying to avoid). And the engine is now on your critical path: you need a defined behavior when the PDP is unreachable. Fail-closed (deny on engine error) is the secure default and the right call for sensitive actions; fail-open is occasionally justified for low-risk reads where availability beats strictness — but that is a decision you make explicitly, per action, with eyes open, never a fallback you discover during an outage.
You're rolling out a policy engine (OPA) for authorization across ~40 services handling tens of thousands of req/s. Pick the deployment and failure posture a senior would choose.
In the PDP/PEP split, what lives in your application service?
You run OPA as a per-service sidecar with policy distributed as bundles. An admin is compromised and you revoke their role. Why might they still pass an authz check seconds later?
Order the lifecycle of a single externalized authz decision, from request to enforcement:
- 1 Request hits the service; the PEP intercepts at the boundary
- 2 PEP assembles the input: subject, action, resource, context
- 3 PEP queries the PDP (OPA), which evaluates the loaded Rego policy
- 4 PDP returns allow or deny (default deny if no rule fires)
- 5 Service honors the decision — proceeds or rejects
- 01Explain the PDP/PEP split and why externalizing the authz decision beats inline checks in forty services.
- 02What does OPA's sidecar deployment buy you, and what consistency and failure tradeoffs does it force you to design for?
When authorization logic lives inline in forty services, “who can do what” stops being a fact and becomes archaeology: the rules drift, can’t be audited, and get enforced inconsistently — which is just broken access control multiplied by your service count. A policy engine fixes this by separating deciding from enforcing. The Policy Enforcement Point (PEP) is thin code in each service that builds the input and asks; the Policy Decision Point (PDP) — OPA, evaluating policy written in Rego with deny-by-default — holds the rules as versioned policy-as-code in one repo. In production you run OPA as a per-service sidecar fed signed bundles, trading a synchronous network hop you can’t afford on the hot path for eventual consistency of policy: a changed or revoked rule takes a poll interval to propagate, and you fail-closed on engine errors for sensitive actions. Now when an auditor asks “which roles can issue a refund,” your first move isn’t to grep forty repos — it’s to open the one policy file that every service already obeys.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.