Frontend Labs
⋯

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)

  1. Run OpenFGA (or SpiceDB). Model SecureShop's order-viewing rules above. Load tuples. Run check and list-objects for several user/order/tenant combinations; get the deny cases right.
  2. Add a hierarchy: folder/collection of orders with inherited viewer. Verify inheritance and that removing the parent relation revokes access.
  3. Wire a PEP into a tiny orders-api: extract sub from the (Ch 24) token, call check, enforce, return 404 on deny. Measure the added latency; add a short-TTL cache; re-measure.
  4. Demonstrate the "new enemy" problem: revoke bob's shared_viewer, then immediately check against a stale cache — show the leak; then use the engine's consistency token (zedtoken/consistency) to fix it.
  5. Keep the tuple store in sync via events: emit order.shared / order.unshared / agent.added events and have a consumer write/delete tuples. Prove eventual consistency and bound the lag.
  6. 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, expand and lookup queries, and a schema language instead of Go maps.

Exercises:

  1. Add tenant isolation as a relation: every object #tenant@tenant:acme, and make viewer require membership of the object's tenant. Prove a cross-tenant check fails even with a direct grant (Chapter 27).
  2. Implement lookupResources(user, relation): every document a user can view. Why is this much harder than check?
  3. 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.
  • list implemented as "fetch all, check each". N+1, slow, and leaks counts. Use list-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

  1. What kinds of requirements does plain RBAC struggle with, and what do ABAC and ReBAC each add?
  2. In Zanzibar, what is a relationship tuple and a "userset"? What does a Check do?
  3. Define PEP, PDP, PIP, PAP. What's the trade-off between an embedded and a central PDP?
  4. What is the "new enemy" problem and how do Zanzibar-style systems address it?
  5. Why must a "list what I can see" operation not be implemented as "check each"?
  6. 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"), the styra.com/blog app-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.

Generated from real-life-security-guide-v1.md · 8 Oct 2026 16:36