Chapter 25 — Distributed authorization: RBAC to ABAC to ReBAC/Zanzibar
In one sentence
As a system grows, hard-coded role checks scattered across services become impossible to reason about or change, so authorization moves to an explicit model — attributes (ABAC), relationships (ReBAC/Zanzibar), or policy (Cedar/OPA) — evaluated by a shared decision component that every service calls.
Where we are
Every service must authorize every request (Ch 3, and G1 Ch 45). This chapter is how to express and evaluate authorization so it's consistent, changeable, auditable, and fast — across a fleet.
How it works
The evolution:
HARD-CODED CHECKS if (user.role == "admin" || user.id ==
resource.owner_id) ...
scattered across N services, N languages, subtly inconsistent, no
central view of "who can do what", every policy change is a deploy.
RBAC roles -> permissions; users -> roles.
good for coarse, stable structures. Struggles with: ownership
("only the creator"), hierarchy ("folder -> doc"), sharing
("Anne shared this with Bob"), relationships ("a project's members"),
conditions ("only during business hours", "only from the corp
network"). Leads to role explosion.
ABAC (attribute-based) policy over attributes of principal,
action, resource, and context.
permit if principal.department == resource.department
and context.time in businessHours
and resource.classification <= principal.clearance
flexible; can be hard to answer "who can access X?" and to audit
("why was this allowed?"). Cedar and OPA/Rego do this well.
ReBAC (relationship-based) / ZANZIBAR authorization as a graph of
RELATIONSHIP TUPLES: object#relation@subject
doc:readme#owner@user:anne
doc:readme#viewer@group:eng#member (a "userset" -- everyone
who is a member of eng)
folder:root#viewer@user:bob
doc:readme#parent@folder:root
A SCHEMA defines relations and "rewrites":
- `viewer` of a doc = direct viewers UNION editors UNION
(viewers of its parent folder)
A CHECK("can user:bob view doc:readme?") walks the graph.
Strengths: models sharing/hierarchy/ownership/groups NATURALLY;
answers "who can view X?" (expand) and "what can Anne view?"
(list-objects) directly; the tuple store is the single source of
truth. This is how Google (Drive, etc.), GitHub, and many SaaS do
fine-grained authz.
Implementations: OpenFGA (CNCF), SpiceDB (Authzed), Ory Keto,
Warrant, Aserto.
POLICY-AS-DATA + ENGINE Cedar (AWS Verified Permissions), OPA/Rego
-- ship a policy bundle + an entity/data store to a PDP; evaluate
locally or as a service.
The PDP / PEP model (XACML terminology, still the reference):
PEP Policy Enforcement Point -- in each service; intercepts the
request, builds an authz query, calls the PDP, enforces the
decision.
PDP Policy Decision Point -- evaluates the policy + data, returns
permit/deny (+ obligations).
PIP Policy Information Point -- supplies attributes/relationships the
PDP needs (a directory, the tuple store, the request context).
PAP Policy Administration Point -- where policies/tuples are authored,
reviewed, versioned.
EMBEDDED PDP (a library / sidecar, e.g. OPA sidecar, OpenFGA SDK with a
local cache, Cedar library):
+ low latency, no network hop per check, survives PDP outage
- policy/data distribution + freshness is on you
CENTRAL PDP (a service):
+ one source of truth, easy to update, consistent
- a network hop per check (cache it), a dependency to make HA
Common answer: central store of truth (tuples/policy) + a
locally-cached embedded evaluator + a change feed for invalidation.
The hard parts (this is why authz is a project, not an afternoon):
CONSISTENCY / "new enemy" Anne removes Bob's access, then posts a
secret. A stale cache lets Bob still read it. Zanzibar solves this
with "zookies"/"zedtokens" -- a token capturing a revision, passed
from the write to subsequent checks so they're at-least-as-fresh.
LIST / FILTER "show me the docs Anne can see" must not be
"fetch all docs, check each" (N+1, and it leaks counts via timing).
Use the engine's list-objects / reverse-index.
LATENCY BUDGET a check on every request, sometimes many per
request. Cache aggressively; batch checks; co-locate the evaluator.
DATA SYNC the authz model needs relationship data
(who owns what, group membership, org hierarchy) kept in sync with
the app's source of truth -> events, not nightly jobs.
DEFENSE IN DEPTH check at the edge/gateway (coarse, fail
fast) AND in each service (authoritative, object-level) AND in the
data layer (RLS / per-tenant keys, Ch 27). One missed PEP shouldn't
be a breach.
MIGRATION moving from scattered `if`s to a model is
incremental: shadow-mode the PDP (log what it *would* decide vs the
legacy check), reconcile, then switch enforcement.
Worked example
SecureShop's "can this user view this order?" in ReBAC, and why it beats roles.
Requirements that break RBAC:
- a customer views their OWN orders
- a customer can share an order's tracking with another person
- a tenant's support agents view any order IN THAT TENANT
- a tenant admin views everything in the tenant
- SecureShop staff view nothing by default (break-glass only)
OpenFGA-style model (DSL sketch):
type user
type tenant
relations
define admin: [user]
define agent: [user] or admin
type order
relations
define tenant: [tenant]
define owner: [user]
define shared_viewer: [user]
define viewer: owner or shared_viewer
or agent from tenant
or admin from tenant
Tuples (written from app events):
order:1001#tenant@tenant:42
order:1001#owner@user:alice
tenant:42#agent@user:sam
order:1001#shared_viewer@user:bob (alice shared tracking)
Checks:
Check(user:alice, viewer, order:1001) -> permit (owner)
Check(user:bob, viewer, order:1001) -> permit (shared_viewer)
Check(user:sam, viewer, order:1001) -> permit (agent from tenant)
Check(user:eve, viewer, order:1001) -> deny
ListObjects(user:sam, viewer, order) -> every order in tenant 42
(one call, no N+1)
The PEP in orders-api: on GET /orders/1001, call
Check(subject=<sub from the token, Ch 24>, viewer, order:1001); 403 ->
return 404 (don't confirm existence, G1 Ch 45). Every service that
touches orders calls the same model -> consistent. Adding "managers see
their team's orders" is a schema + tuple change, no code deploy in five
services.
Practice (75 min)
- Run OpenFGA (or SpiceDB). Model SecureShop's order-viewing rules
above. Load tuples. Run
checkandlist-objectsfor several user/order/tenant combinations; get the deny cases right. - Add a hierarchy:
folder/collectionof orders with inheritedviewer. Verify inheritance and that removing the parent relation revokes access. - Wire a PEP into a tiny
orders-api: extractsubfrom the (Ch 24) token, callcheck, enforce, return 404 on deny. Measure the added latency; add a short-TTL cache; re-measure. - Demonstrate the "new enemy" problem: revoke
bob'sshared_viewer, then immediatelycheckagainst a stale cache — show the leak; then use the engine's consistency token (zedtoken/consistency) to fix it. - Keep the tuple store in sync via events: emit
order.shared/order.unshared/agent.addedevents and have a consumer write/delete tuples. Prove eventual consistency and bound the lag. - Run the same rules as a Cedar policy set and as OPA/Rego for comparison; note which model expresses "shared with" and "agent from tenant" most naturally.
Build it in Go (45 min) — a Zanzibar-style check in about 120 lines
The essence of ReBAC fits in one function: permissions are computed from relationship tuples plus a few rewrite rules (an editor is also a viewer, and a folder's viewers can view its documents), and the check returns the chain of tuples that proves the answer.
// rebac: relationship-based access control, Zanzibar style, in ~120 lines.
//
// Permissions are derived from stored RELATIONSHIP TUPLES
//
// object#relation@subject e.g. doc:q3-plan#editor@user:bob
//
// plus a small schema of REWRITE RULES:
//
// viewer = direct viewers + editors (every editor can view)
// editor = direct editors + owners
// on docs, viewer also = viewers of the PARENT folder
//
// Subjects can be users or USERSETS: group:eng#member ("every member of eng").
//
// go run ./rebac
package main
import (
"fmt"
"slices"
"strings"
)
type tuple struct{ object, relation, subject string }
type store struct{ tuples []tuple }
// parse splits "doc:q3-plan#editor@user:bob" (the subject may itself contain '#').
func parse(t string) tuple {
obj, rest, _ := strings.Cut(t, "#")
rel, sub, _ := strings.Cut(rest, "@")
return tuple{obj, rel, sub}
}
func (s *store) write(t string) { s.tuples = append(s.tuples, parse(t)) }
// delete revokes access: no ACL scan, no cache to invalidate by hand.
func (s *store) delete(t string) {
s.tuples = slices.DeleteFunc(s.tuples, func(x tuple) bool { return x == parse(t) })
}
// schema: which other relations on the same object imply this one, and which
// relation on a related object ("tupleset") passes through.
var implied = map[string][]string{
"viewer": {"editor"},
"editor": {"owner"},
}
var inherited = map[string][2]string{ // relation -> {link relation, relation on the linked object}
"viewer": {"parent", "viewer"}, // viewer of a doc's parent folder can view the doc
"editor": {"parent", "editor"},
}
// check answers "does subject have relation on object?" and returns the
// chain of tuples that proves it (or nil). depth bounds recursion on cycles.
func (s *store) check(object, relation, subject string, depth int) []string {
if depth > 10 {
return nil
}
for _, t := range s.tuples {
if t.object != object || t.relation != relation {
continue
}
if t.subject == subject { // direct grant
return []string{fmt.Sprintf("%s#%s@%s", t.object, t.relation, t.subject)}
}
if grp, rel, ok := strings.Cut(t.subject, "#"); ok { // userset: group:eng#member
if why := s.check(grp, rel, subject, depth+1); why != nil {
return append([]string{fmt.Sprintf("%s#%s@%s", t.object, t.relation, t.subject)}, why...)
}
}
}
for _, rel := range implied[relation] { // e.g. editors are viewers
if why := s.check(object, rel, subject, depth+1); why != nil {
return append([]string{fmt.Sprintf("(%s implies %s)", rel, relation)}, why...)
}
}
if inh, ok := inherited[relation]; ok { // e.g. viewers of the parent folder
for _, t := range s.tuples {
if t.object == object && t.relation == inh[0] {
if why := s.check(t.subject, inh[1], subject, depth+1); why != nil {
return append([]string{fmt.Sprintf("%s#%s@%s", t.object, t.relation, t.subject)}, why...)
}
}
}
}
return nil
}
func main() {
s := &store{}
for _, t := range []string{
"folder:finance#owner@user:alice",
"folder:finance#viewer@group:auditors#member",
"group:auditors#member@user:carol",
"doc:q3-plan#parent@folder:finance",
"doc:q3-plan#editor@user:bob",
} {
s.write(t)
}
for _, q := range [][3]string{
{"doc:q3-plan", "viewer", "user:bob"},
{"doc:q3-plan", "editor", "user:alice"},
{"doc:q3-plan", "viewer", "user:carol"},
{"doc:q3-plan", "editor", "user:carol"},
{"doc:q3-plan", "viewer", "user:mallory"},
} {
why := s.check(q[0], q[1], q[2], 0)
verdict := "DENY "
if why != nil {
verdict = "ALLOW"
}
fmt.Printf("%s %-12s %-6s %-12s", verdict, q[2], q[1], q[0])
if why != nil {
fmt.Printf(" because %s", strings.Join(why, " -> "))
}
fmt.Println()
}
// Revocation is just deleting a tuple: remove carol from auditors.
s.delete("group:auditors#member@user:carol")
fmt.Printf("\nafter removing carol from group:auditors: carol can view q3-plan? %v\n",
s.check("doc:q3-plan", "viewer", "user:carol", 0) != nil)
}
$ go run ./rebac
ALLOW user:bob viewer doc:q3-plan because (editor implies viewer) -> doc:q3-plan#editor@user:bob
ALLOW user:alice editor doc:q3-plan because doc:q3-plan#parent@folder:finance -> (owner implies editor) -> folder:finance#owner@user:alice
ALLOW user:carol viewer doc:q3-plan because doc:q3-plan#parent@folder:finance -> folder:finance#viewer@group:auditors#member -> group:auditors#member@user:carol
DENY user:carol editor doc:q3-plan
DENY user:mallory viewer doc:q3-plan
after removing carol from group:auditors: carol can view q3-plan? false
What to notice:
- Every ALLOW comes with its proof. "Why can Carol see this?" is the question auditors and incident responders ask. A policy engine that can't answer it is hard to operate.
- Revocation is a single tuple delete. No per-document ACLs to scan, and no role table to diff.
- Alice never had a grant on the document. Ownership of the parent folder flowed down through two rewrite rules. That's also the risk: a broad tuple high in the hierarchy grants a lot, so review those tuples like IAM policies.
- What production systems (SpiceDB, OpenFGA) add: consistency tokens
("zookies") so a check never misses a revocation it should have seen, caching,
expandandlookupqueries, and a schema language instead of Go maps.
Exercises:
- Add tenant isolation as a relation: every object
#tenant@tenant:acme, and makeviewerrequire membership of the object's tenant. Prove a cross-tenant check fails even with a direct grant (Chapter 27). - Implement
lookupResources(user, relation): every document a user can view. Why is this much harder thancheck? - Add a cycle (
group:a#member@group:b#member, and the reverse) and confirm the depth limit stops it.
Common mistakes
- Authz logic duplicated per service. Inconsistent, unauditable, every change is N deploys. Centralise the model; distribute the evaluator.
- Roles for everything. Ownership, sharing, and hierarchy cause role explosion. Use relationships or attributes for those.
listimplemented as "fetch all, check each". N+1, slow, and leaks counts. Uselist-objects/ reverse indexes.- Ignoring consistency. Stale caches after a revocation = the "new enemy" leak. Use the engine's consistency tokens on security-sensitive reads.
- Relationship data synced nightly. Group membership and sharing change in real time; drive the tuple store from events.
- Only checking at the gateway. One service reachable another way, or a background job, bypasses it. PEP in every service + data-layer backstop (Ch 27).
- Big-bang migration. Shadow-mode first: log legacy-vs-model decisions, reconcile, then enforce.
Check yourself
- What kinds of requirements does plain RBAC struggle with, and what do ABAC and ReBAC each add?
- In Zanzibar, what is a relationship tuple and a "userset"? What does a Check do?
- Define PEP, PDP, PIP, PAP. What's the trade-off between an embedded and a central PDP?
- What is the "new enemy" problem and how do Zanzibar-style systems address it?
- Why must a "list what I can see" operation not be implemented as "check each"?
- Where should authorization be enforced for defense in depth?
Further reading
- Paper: "Zanzibar: Google's Consistent, Global Authorization System" (Pang et al., USENIX ATC 2019) — read it; everything modern descends from it.
- Implementations: OpenFGA (
openfga.dev, the modeling guide and the "Auth0 FGA" material), SpiceDB / Authzed (authzed.com/docs, the schema language and consistency docs), Ory Keto. - Policy engines: Cedar (
cedarpolicy.com, AWS Verified Permissions), OPA (openpolicyagent.org, "OPA for application authorization"), thestyra.com/blogapp-authz series. - Book: Microservices Security in Action — authz chapters; OWASP "Authorization Cheat Sheet".
- Reference model: XACML 3.0 (for PEP/PDP/PIP/PAP vocabulary) — you don't need to implement XACML, but the model is the lingua franca.