Animated Architecture Patterns

Policy-Based Authorization

Services ask a central policy engine for allow/deny decisions (RBAC, ABAC, ReBAC).

Also known as Externalized authorization, Fine-grained authorization, PDP/PEP

Ana manager · Sales Bo employee · Sales Cara manager · Marketing Dev employee · Finance approve e-42 if (u.isManager() && amt <= 5000) PEP policy enforcement point checks every operation asks the local PDP local PDP v12 · < 1 ms local PDP not reachable Expenses API Java service e-42 · Bo · $1,800 · Sales approval limit $5,000 edit, test, redeploy ✓ e-42 approved Ana’s list to approve: 1 query dept = Sales and amount ≤ 5000 e-42 $1,800 · e-51 $640 approve e-42: denied, fail closed if ("manager" in roles and amt <= 2000): Reports Python service approvals report approval limit $2,000 edit, test, redeploy if u.Dept == "Finance" || u.IsManager { Payouts Go service releases payments any manager, no limit edit, test, redeploy policy v12 1 view: own expenses 2 approve: manager, same dept, up to $5,000 3 approve: Finance, any amount Policy repo Git · the PAP ✓ reviewed ✓ tests pass Attributes PIP directory ana: manager, Sales expense data e-42: $1,800, Sales relationships bo#manager@ana PDP policy decision point authorize? subject action resource context ana approve e-42 09:41, office allow · matched rule 2 the same rule, written as RBACthe role decides ABACattributes decide ReBACrelationships decide Control plane builds bundle v12 from the policy repo and attribute data, ships it to every local PDP no call per request bundle Who may approve what? Nobody can say: three services, three different rules. Change the limit? Find every copy, then edit, test and redeploy each service. Decision log every decision, with its inputs, kept for audit time subject action resource decision reason and inputs 09:41:07 ana approve e-42 allow rule 2 · policy v12 · ana: manager, Sales · e-42: $1,800, Sales 09:58:12 ana list expense filter dept = Sales and amount ≤ 5000 · policy v12 · local PDP, 0.2 ms 09:58:14 ana approve e-42 deny no decision: local PDP not reachable, so the PEP failed closed RBAC role-based allow if "manager" in roles Ana has the manager role allow a role can’t see department or amount, so Cara (Marketing) would pass too ABAC attribute-based manager · same department · up to $5,000 Ana · e-42 · $1,800 · Sales Ana · e-43 · $7,000 · Sales Cara · Marketing · e-42 allow deny deny ReBAC relationship-based approver: manager from owner ana bo e-42 manages owns Ana manages Bo, who owns e-42 allow v12 v12

Step 1 · Rules scattered in code. Each service checks permissions with its own if-statements, in its own language, and they disagree: the Expenses API lets a manager approve up to $5,000, Reports assumes $2,000, and Payouts lets anyone in Finance or any manager release a payment of any size. Changing the limit means finding every copy, then editing, testing and redeploying each service, and nobody can answer who may approve what without reading three codebases.

Step 2 · Ask a decision point. The Expenses API's policy enforcement point (PEP) asks one question: may subject ana perform approve on resource e-42, in this context? The policy decision point (PDP) evaluates policy v12 against Ana's attributes (manager, Sales) and those of e-42 ($1,800, Sales), answers allow with the rule that matched, the PEP enforces the answer, and the decision is logged. The policy itself lives in Git, where it is reviewed and tested like code before a new version is shipped to the PDP.

Step 3 · RBAC, ABAC or ReBAC. RBAC grants by role: Ana is a manager, so she may approve, but so would Cara from Marketing, at any amount. ABAC also weighs attributes of the user and the expense (same department, up to $5,000), so Ana may approve e-42 but not the $7,000 e-43, and Cara may not approve e-42. ReBAC follows stored relationships, as Google's Zanzibar does: Ana manages Bo, Bo owns e-42, so Ana may approve it.

Step 4 · Fast, safe, auditable. The engine runs next to the service, as a sidecar or an embedded library, and a control plane ships it versioned bundles of policy and data, so a check typically takes well under a millisecond and needs no network call. Ana's list of expenses to approve comes from one filter query (dept = Sales and amount ≤ 5000), not from one check per row. When the PDP cannot be reached, the PEP denies (fails closed), and every decision is logged with its inputs for audit.

The problem

Authentication and authorization answer different questions. Authentication establishes who is calling: the user signs in at an identity provider and the application receives proof of it in a token or an assertion (OpenID Connect, SAML 2.0 Single Sign-On). Authorization decides what that caller may do, and sign-in protocols leave that to the application. So every service writes its own checks, usually as if statements in its handlers, and the result looks like step 1:

  • The copies drift. The Expenses API lets a manager approve up to $5,000, Reports was written when the limit was $2,000, and Payouts never had a limit at all. Each copy was right when someone wrote it. Broken access control, which includes missing and inconsistent checks, heads the OWASP Top 10:2025.
  • A rule change becomes a release train. Raising the limit means finding every copy, in three codebases and three languages, then editing, testing and redeploying each service, and hoping none was missed.
  • Nobody can say who may do what. An auditor who asks who can approve a $4,000 Sales expense gets an answer only after someone has read all three codebases, and nothing records which decisions were made or why.

Roles and scopes in tokens don't close the gap. A token can say that the caller is a manager, or that the client may call expenses:approve, and JWT Validation checks such coarse-grained claims on every request. Whether Ana may approve this expense depends on the expense: who submitted it, which department it belongs to, how large it is. That fine-grained, per-object decision needs data the token doesn't carry, and it is the decision this pattern takes out of the code.

How it works

Four parts

The job is split into four parts. The names come from XACML 3.0, an OASIS Standard from January 2013, and NIST SP 800-162, the guide to attribute-based access control (2014, last updated 2019), describes the same four:

Part What it does In the diagram
PEP, policy enforcement point Sits in the request path: describes the request to the PDP, waits for the answer and enforces it. Middleware, or an SDK call, in front of every operation of the Expenses API
PDP, policy decision point Evaluates the policies that apply to the request and returns a decision. The policy engine
PAP, policy administration point Where policies are written, managed and tested, and from where they are published. The policy repository in Git, with review and tests
PIP, policy information point Supplies the attribute values the PDP needs. The directory, the expense data and the relationship store

XACML adds a context handler that translates between the application's request format and the PDP's and collects attributes from the PIPs. NIST treats it as optional and notes that it can fetch or cache attributes ahead of a request. NIST also stresses that these are logical functions: the PEP and the PDP may be one central service or spread across the estate, which is the choice step 4 makes.

One question, one answer

The PEP asks a question in four parts:

  • Subject: who is asking (ana), with attributes such as role and department.
  • Action: what they want to do (approve).
  • Resource: what they want to do it to (e-42), with attributes such as owner, department and amount.
  • Context: anything else the policy may weigh: time, network, device, how the user signed in.

The PDP answers allow or deny, often naming the rule that decided. The service never sees the rule, only the answer, so every service that asks the same question gets the same answer. The OpenID Foundation's AuthZEN Authorization API 1.0, a Final Specification since January 2026, standardises this exchange as JSON over HTTPS:

POST /access/v1/evaluation
Content-Type: application/json

{
  "subject":  { "type": "user", "id": "ana" },
  "action":   { "name": "approve" },
  "resource": { "type": "expense", "id": "e-42" },
  "context":  { "time": "2026-10-04T09:41:07Z" }
}
{ "decision": true }

A response can add a context object with reasons or obligations. The specification also defines a batch endpoint (/access/v1/evaluations), search endpoints that return the subjects, resources or actions a request would be allowed for, and discovery metadata at /.well-known/authzen-configuration, so a PEP written against it can work with any PDP that implements it.

Policy as code

The policy becomes a file in a repository instead of if statements spread over services. Here is the whole expense policy in Rego, the language of Open Policy Agent (OPA):

package expenses

default allow := false

# Everyone may view their own expenses.
allow if {
	input.action == "view"
	input.resource.owner == input.subject.id
}

# A manager may approve expenses from their own department, up to $5,000.
allow if {
	input.action == "approve"
	input.subject.role == "manager"
	input.subject.department == input.resource.department
	input.resource.amount <= 5000
}

# Finance may approve any amount.
allow if {
	input.action == "approve"
	input.subject.department == "Finance"
}

Changing the limit is now a one-line pull request. Reviewers see exactly what changes, the policy's unit tests (opa test) run in CI, a new version (v12 in the diagram) is built and shipped to the PDPs, and every decision records the version that made it.

Three ways to write the rule

Role-based access control (RBAC) attaches permissions to roles and gives roles to users. David Ferraiolo and Rick Kuhn formalised it at NIST in 1992; the unified NIST model, written with Ravi Sandhu in 2000, became the standard ANSI/INCITS 359-2004, revised as INCITS 359-2012. Its reference model has four components: core RBAC, role hierarchies, and static and dynamic separation of duty. RBAC is easy to administer and to review (list who holds the role), and it is the right starting point for most applications. Its limit shows in step 3: a role knows nothing about the expense, so "manager" would let Cara approve Sales expenses, and anyone holding it approve any amount. Folding department and limit into role names (sales-manager-5k) leads to what is often called role explosion, one of the reasons NIST SP 800-162 gives for ABAC.

Attribute-based access control (ABAC), as NIST SP 800-162 defines it, decides from attributes of the subject, attributes of the object, conditions of the environment, and policies written in terms of them. One rule ("a manager may approve expenses from their own department, up to $5,000") covers every department and every amount: Ana may approve e-42 but not the $7,000 e-43, and Cara may not approve e-42. Rule 3 needs no role at all: Dev, in Finance, may approve e-43. The price is that a decision is only as good as its attributes, which must be trustworthy and current, and that "who may approve e-42?" is no longer a lookup: the policy has to be evaluated against everyone's attributes.

Relationship-based access control (ReBAC) derives permissions from relationships between objects: Ana manages Bo, Bo owns e-42, so Ana may approve e-42. Relationships are stored as tuples, and a check walks the graph they form. Google described its system for this in the Zanzibar paper (Pang et al., USENIX ATC 2019): one authorization service behind Calendar, Cloud, Drive, Maps, Photos, YouTube and many other products, which held more than two trillion relation tuples, served millions of checks per second with a 95th-percentile latency under 10 ms, and stayed above 99.999% availability over three years. Its tuples read object#relation@user, so the diagram's two facts are expense:e-42#owner@user:bo and user:bo#manager@user:ana (abbreviated in the diagram). ReBAC suits sharing and hierarchies, such as folders, projects, teams and organisations, where access follows structure. In OpenFGA's modelling language the approval rule is one line, approver: manager from owner:

model
  schema 1.1

type user
  relations
    define manager: [user]

type expense
  relations
    define owner: [user]
    define approver: manager from owner
    define viewer: owner or approver

Consistency is the hard part of a relationship store, and the Zanzibar paper calls the failure the "new enemy" problem. Suppose Alice removes Bob from a folder and then has new documents moved into it. A check that sees the second change but not the first lets Bob read documents he was never meant to see. Zanzibar keeps its tuples in Spanner, whose TrueTime clock gives every write a timestamp that respects causal order, and evaluates each check at a single snapshot. When an application saves new content it asks for a zookie, an opaque token that encodes a timestamp, stores it with the content and passes it on later checks, so they are evaluated against data at least that fresh. SpiceDB calls the same token a ZedToken and lets each request choose between speed (minimize_latency) and freshness (at_least_as_fresh, at_exact_snapshot, fully_consistent).

The models combine. A role is just one more attribute to an ABAC policy, relationship models express roles as relationships (member of a group), OpenFGA adds conditions and SpiceDB caveats (CEL expressions over attributes) to relationship checks, and Cedar is designed to cover all three styles.

Engines and languages

As of October 2026:

  • Open Policy Agent (OPA) is a general-purpose engine with its own language, Rego, used well beyond applications, for example for Kubernetes admission control. It runs as a daemon or sidecar behind a REST API, embeds in Go programs as a library, or compiles policies to WebAssembly. Since OPA 1.0 (December 2024), Rego requires the if keyword before a rule body. OPA has been a graduated CNCF project since January 2021. In August 2025 its creators and several engineers from Styra, the company that had backed it, joined Apple; the project's governance and licence did not change, and Styra's tools, among them the OPA Control Plane and the Regal linter, moved into the OPA project.

  • Cedar is a policy language and engine created at AWS, written in Rust, with key properties of its design proven in the Lean proof assistant. A policy either permits or forbids; nothing is allowed unless a permit matches, any matching forbid wins, and the answer lists the policies that decided it. A schema lets policies be validated before they are deployed. Cedar joined the CNCF Sandbox in October 2025. Amazon Verified Permissions is a managed Cedar PDP: IsAuthorized evaluates a request, and IsAuthorizedWithToken takes the principal straight from an ID or access token issued by the policy store's identity source, an Amazon Cognito user pool or another OpenID Connect provider; both have batch versions. The approval rule in Cedar:

    permit (
      principal,
      action == Action::"approve",
      resource
    )
    when
    {
      principal.role == "manager" &&
      principal.department == resource.department &&
      resource.amount <= 5000
    };
    
  • OpenFGA is a relationship store inspired by Zanzibar and an incubating CNCF project since October 2025. It answers Check, BatchCheck, ListObjects and ListUsers.

  • SpiceDB, open source (Apache 2.0) from AuthZed, is a database modelled closely on Zanzibar, with CheckPermission, LookupResources and LookupSubjects.

  • Casbin is a library rather than a service. A small configuration file built on its PERM metamodel (policy, effect, request, matchers) selects ACL, RBAC, ABAC or a mix, and the policies live in a file or a database. The Go library entered the Apache Incubator in February 2026; ports exist for Java, Node.js, PHP, Python, .NET, C++ and Rust.

When to use it

  • Several services, teams or languages enforce the same business rules, and they have to agree.
  • Decisions depend on the object (owner, department, amount, who it was shared with), not only on a role in a token.
  • Rules change more often than the services that enforce them, or they belong to someone other than the service teams: security, compliance, a product owner.
  • Auditors or customers ask who can do what, and why a particular request was allowed.
  • Users share things with each other (documents, projects, workspaces). That is a relationship model, and a ReBAC store does the graph walking for you.

When not to. One service with a handful of roles doesn't need a policy engine. Keep the checks in code, but in one place: a single authorization module that every handler calls, that denies by default and that has its own tests. That module is a PEP and a PDP in miniature, and it can later be pointed at a real PDP without touching the handlers.

Trade-offs

  • A dependency on every request. If the PEP gets no answer, the operation fails, by design. The PDP must be as available as the most critical service that calls it. Running it next to each service (step 4) takes the network out of the path, but not the dependency.
  • Latency. A central PDP adds a network round trip to every check, and a page that checks many things pays it many times. A local engine is much faster: OPA's documentation treats about a millisecond as the budget for API authorization, and the benchmarks in the Cedar paper measured median evaluations of 4 to 11 µs for Cedar and of roughly 76 to 750 µs for Rego and OpenFGA, with tails beyond a millisecond as the data grew. Measure with your own policies and data.
  • Freshness. A local PDP decides with the copy of policy and data it holds. When Ana stops managing Bo, or an expense moves to another department, the decision changes only when the new data arrives, a trade-off between cached attributes and security that NIST SP 800-162 spells out. Attributes taken from a token stay as they were until the token expires.
  • The same facts in two places. Relationship stores and data bundles copy what the application already knows (who owns e-42), and the copies must be kept in step, typically by writing tuples in the same workflow as the application data or by streaming its changes.
  • Another language to learn. Rego, Cedar and relationship models are small, but they are code: they need tests, review, and people who can read them during an incident.
  • "Who can do what?" is still work. Under ABAC the question means evaluating the policy against many subjects; relationship stores answer it directly (ListUsers, LookupSubjects), one reason products combine the models.

Implementation notes

  • Where the PDP runs.
    • Central service: one deployment to update and observe, but a network hop for every check and one shared dependency for every caller. Managed PDPs such as Amazon Verified Permissions work this way.
    • Sidecar or host daemon: the engine runs beside each service and is called over localhost (see Sidecar), while a control plane distributes policy and data. OPA polls a bundle server (long polling is supported), can verify signed bundles, can keep the last bundle on disk so it starts even when the server is down, and its health endpoint with the bundles option reports healthy only after every bundle has loaded, which makes a good readiness probe. The OPA Control Plane builds bundles from Git repositories and data sources and publishes them to object storage.
    • Embedded library: Cedar in process, or OPA as a Go library or WebAssembly module. There is no hop at all, but upgrades to the engine ship with the application.
  • Where attributes come from. Subject attributes can travel in the token: roles, groups or department as claims in an OpenID Connect ID token or access token, or in a SAML assertion from the identity provider (see Federated Identity). Claims cost nothing to read but keep the values they had when the token was issued; Amazon Verified Permissions, for one, notes that a token stays usable until it expires even if it has been revoked. Resource attributes usually come from the service, which has just loaded e-42 and can pass its owner, department and amount in the request. Anything else the PDP looks up at decision time from a PIP, which is fresher but adds latency and one more dependency.
  • Lists and filters. Don't load a thousand rows and check each one; ask for a filter or a list:
    • Partial evaluation. OPA evaluates the policy with the subject known and the resource unknown and returns the conditions that remain, for Ana department = "Sales" and amount <= 5000. Its Compile API can return them as a SQL WHERE clause, or as UCAST for an ORM, so the database does the filtering.
    • List queries. OpenFGA's ListObjects, SpiceDB's LookupResources and AuthZEN's resource search return the objects a subject may act on. OpenFGA caps ListObjects at 1,000 results and 3 seconds by default, and for large collections recommends searching first and checking each page with BatchCheck.
  • Policy as code. Keep policies in Git next to their tests (opa test, Cedar's validator with a schema, fga model test), review every change, build versioned and signed bundles, roll them out in stages, and record the version in every decision so that an answer can be explained later. GitOps, with the policy repository as the source of truth that deployments reconcile to, fits naturally.
  • Decision logs. Log every decision with an ID, the inputs, the result, the rule that matched and the policy version, and ship the logs to Centralized Logging. OPA's decision logs record a decision ID, the input, the result and the bundle revision, can mask or drop sensitive inputs before they leave the host, and are uploaded in compressed batches. With the inputs recorded, "why was this allowed?" has an answer, and recorded requests make good test cases for the next policy change.
  • Fail closed. Treat a timeout, an error or an unknown answer as deny. Envoy's external authorization filter does so by default (failure_mode_allow is false), and the OWASP Authorization Cheat Sheet recommends denying by default.
  • Cache with care. If you cache decisions, the key must include everything that influenced them, including the policy version; entries should expire quickly, and a new bundle should flush them. Caching the inputs (attributes, tuples) is usually safer than caching the answers.
  • Coarse at the edge, fine in the service. Let the API Gateway reject requests without a valid token or without the right scope: it is cheap and stops most bad traffic before it reaches a service. The decision about one expense belongs in the service's PEP, which has the object at hand. A proxy in front of the service (Envoy calling OPA, for example) sees the method, the path and the token, but not who owns e-42.
  • Zero trust, one level down. Zero Trust Access uses the same PDP and PEP split to decide whether a user on a device may reach an application at all. Policy-based authorization applies the idea inside the application, to individual objects and actions, and the two usually share the identity provider and the decision logs.
  • Test the denials. The tests that matter most prove that Cara cannot approve e-42 and that Ana cannot approve e-43. Real policies also add separation of duty, such as never approving your own expense; with the policy in one place, that is one more condition instead of a change to three services.