System Design Security
Access Control Models
A contractor opens a shared folder and should see three design docs, not the payroll spreadsheet sitting two clicks away. Login already succeeded. The remaining question is which model computes that boundary on every read, share, and export.
, , , and answer the same triplet—, action, resource—with different data shapes. Pick the shape that matches how sharing actually works, then make deny the default on every enforcement path.
Authorization journey
User, group, or workload—not the IP.
Stable verbs on a resource class.
Choose the model
List, role, attributes, or graph.
No silent bypass around the PDP.
What actually happens
Every request that reads or mutates a protected object should hit a . The PEP asks a : may this perform this action on this object, given current facts? Facts come from role membership, object ACLs, attribute stores, or relation . The answer must be allow, deny, or a required extra proof—not “maybe, the UI hid the button.”
List endpoints are the usual failure: a detail page checks ownership, then GET /docs returns every tenant’s titles because the collection query never applied the same model. Search, exports, admin impersonation, and background jobs are the same class of hole.
Revocation is part of the model. Removing a role, deleting a tuple, or flipping an attribute is incomplete until every cache key that included the old is invalidated or expires within a budget you can defend in an incident review.
Naive design, and the invariant that must hold
Naive claim
“Hide the button in the frontend, store isAdmin on the user row, and add a new role whenever finance wants an exception.”
Counterexample
A mobile client still calls the refund API. Search returns other tenants’ invoices. Six months later you have 400 overlapping roles, nobody can answer “why did this grant work,” and a departed contractor’s group membership still expands into production folders.
Correctness invariant
What must never become false
Every read and mutation of a protected object is authorized with the same model, including lists, jobs, and admin tools. Deny is default. Decisions name a . Revoking a grant cannot leave an unbounded cached allow.
How to choose a model
1. How is sharing described in the product? Org charts favor . Per-document invites favor or . Compliance rules over region and clearance favor .
2. Which questions must be fast? “Can Alice read Doc D?” is a point check. “List every doc Alice can read” needs a reverse index or a carefully bounded expansion. ACL-on-object without a subject index fails that query at scale.
3. How fast must revoke land? Cached role bits in a session cookie can outlive a firing. Graph tuple deletes need cache keys that include object, relation, and snapshot.
Simple rule of thumb
Start with RBAC for coarse product surfaces. Add ReBAC when objects nest and get shared independently. Add ABAC constraints for environment and data-class rules that should not become new roles. Keep ACLs as the leaf grant when a human explicitly shares one object. Hybrids are normal; four disconnected systems are not.
The four models, side by side
Read the diagram definition
flowchart LR
subgraph ACL["ACL"]
R1[Resource] --> L[Per-object grant list]
L --> D1[Direct allow or deny]
end
subgraph RBAC["RBAC"]
U[User] --> Role
Role --> Perm[Permission bundle]
Perm --> D2[Coarse decision]
end
subgraph ABAC["ABAC"]
Attr[Subject resource environment] --> Rule[Policy rules]
Rule --> D3[Contextual decision]
end
subgraph ReBAC["ReBAC"]
T[Relation tuples] --> G[Sharing graph]
G --> D4[Reachability check]
end| Model | Stores | Shines when | Breaks when |
|---|---|---|---|
| ACL | Grants on the object | Few owners, explicit shares, POSIX-like files | “What can Alice see?” has no reverse index; lists grow huge |
| RBAC | User → role → permission | Stable job functions, auditable bundles | from per-exception roles |
| ABAC | Attributes + policy docs | Context, tenancy, geo, time, data classification | Unreadable policies, missing attributes fail open |
| ReBAC | Relation tuples + namespace config | Folders, orgs, teams, inherited viewers | Deep graphs, cycles, fan-out groups, slow list queries |
Decision path at request time
Read the diagram definition
sequenceDiagram
autonumber
participant Client
participant PEP as Enforcement point
participant Cache as Decision cache
participant PDP as Policy engine
participant Store as Roles / tuples / attributes
participant Audit
Client->>PEP: Action on resource
PEP->>Cache: Lookup principal, resource, action, policy version
alt fresh cache hit
Cache-->>PEP: Allow or deny
else miss or high-risk
PEP->>PDP: Evaluate request
PDP->>Store: Load roles, attributes, or relation tuples
Store-->>PDP: Facts
PDP-->>PEP: Decision, reason, policy version
PEP->>Cache: Store with TTL and version key
end
PEP->>Audit: Actor, action, resource, outcome, reason
alt allowed
PEP-->>Client: Forwarded result
else denied
PEP-->>Client: 403 with stable error code
endCache keys must include principal, resource, action, tenant, and . A five-minute allow cache is a five-minute revoke delay. High-risk actions—payouts, role grants, export of a whole workspace—should skip long caches or re-check live.
Missing attributes in ABAC are a design choice you must name: fail closed for writes and sensitive reads, fail closed for unknown tenants, never fail open because a catalog was empty.
RBAC in production: roles as products
Treat a role as a versioned product: name, owner, permission set, and review cadence. Prefer a handful of job-function roles plus scoped grants over “custom role per ticket.” Bind roles in a tenant: editor@acme is not global editor.
belongs here early. If one role can both submit and approve a wire, the model already lost. Encode exclusions in the PDP, not in a wiki. Time-bound elevation (just-in-time admin) is still RBAC—the role membership has a TTL and an approver, and the audit row must show both.
Sizing sketch: 50k employees × 8 roles each average is 400k memberships, trivial to store. The danger is 8,000 distinct roles with almost-identical permission bitmaps. Count distinct permission sets, not headcount, when you claim RBAC “does not scale.”
ReBAC checks: walk the graph, bound the work
Read the diagram definition
flowchart TD
Q["Check: Alice can view Doc D?"] --> P["Doc D#viewer includes Alice?"]
P -->|direct tuple| Y[Allow]
P -->|via parent| F["Folder F#viewer includes Alice?"]
F -->|group| M["Eng#member includes Alice?"]
M --> Y
F -->|no path| N[Deny]
P -->|no path| NA check asks whether a userset expansion from the object’s relation includes the subject. Namespace config defines rewrite rules:viewer = owner + parent.viewer + viewer. Direct tuples are O(1) lookups; inherited viewers require parent walks and group expansion.
Bound depth and fan-out. A group of 80k members used as folder viewer must not expand fully on every document check—store reverse indexes or check membership of the subject in candidate groups instead. Detect cycles; treat them as deny plus an ops alert, not as a hang.
List-objects is a different API from check. Interviews that only describe check will fail a drive-share product: you still need a way to page “docs Alice can open” without scanning every object in the tenant.
ABAC without becoming unreadable
Good ABAC policies are small, named, and tested. Bad ABAC is a 2,000-line document nobody can simulate. Keep attributes sourced from systems of record (HR, inventory, data catalog) with freshness SLAs. A stale “contractor=false” is an authorization bug, not an HR inconvenience.
Combine ABAC with RBAC rather than replacing it: roles select the feature; attributes constrain where and on which data class it applies. That pairing stops without forcing every button into a custom policy language.
Worked numbers
2 million docs, average ACL of 12 entries, no subject index: listing Alice’s docs scans 2e6 × 12 = 24 million grant rows. With a subject-to-object index, the same query is Alice’s grant set (say 400) plus ReBAC expansion of her groups (say 20 groups × 50 docs).
Point checks at 40k QPS with p99 5 ms: a remote PDP hop of 3 ms leaves 2 ms for local PEP work. Cache hits must dominate; measure hit ratio by risk class, not a single global number. If 1% of checks miss and each miss loads 15 tuples, budget the tuple store for ~6k complex lookups/sec at peak, not for the full 40k.
Policy rollout: dual-run new version in shadow, compare allow/deny diffs, then flip . A 0.2% unexplained allow regression on 40k QPS is 80 extra allows per second—stop the rollout.
Trade-offs and failure modes
Explainability vs expressiveness. RBAC is easy to audit; ABAC and deep ReBAC explain “why” only if you persist reason traces. Interviews should say how a support admin reconstructs a grant.
Consistency vs latency. Strongly consistent tuple writes make share-then-open work; eventual membership can briefly leak or briefly 403. Pick per action: share links may tolerate seconds; payroll must not.
Cache TTL vs revoke. Longer caches cheaper and smoother; they are also the window after a contractor is removed. Document the window per data class.
Central PDP vs local evaluation. Central is consistent and observable; local compiled policy is faster and survives PDP brownouts if you accept stale snapshots. Fail closed on snapshot expiry for writes.
UI hiding vs enforcement. The UI is not a PEP. Clients are hostile. Tests must call APIs with stolen object ids.
Still fails when…
- Search, analytics, and “export all” skip the model.
- Admin impersonation has no ticket id or TTL.
- Group expansion has no cycle or fan-out limit.
- Deny lacks a stable reason code, so product retries forever.
What to measure
- Check latency, cache hit ratio, and revoke propagation time.
- Shadow-policy disagreement rate during rollouts.
- Count of distinct roles and unused permissions.
- Authorization denials that later succeeded via a bypass route.
Choosing and explaining access control models
A complete explanation connects the mechanism to a requirement. Describe what improves when you introduce it, which new failure cases become possible, and how you would know that the trade-off is worthwhile.
Explain the core idea
Authorization is a per-request decision over principal, action, and resource, not a login flag.
ACL, RBAC, ABAC, and ReBAC store the same decision in different shapes; hybrids are common.
Enforcement must cover lists, jobs, and admin paths, and every decision should carry a policy version.
When it belongs
More than one actor can touch the same objects, especially across tenants or shared folders.
You need explainable grants, SoD, or time-bound elevation.
Revoke and “what can this user see?” are product requirements, not afterthoughts.
How it fails
Role explosion from encoding every exception as a new role.
Collection endpoints that skip object-level checks.
Cached allows that outlive membership or tuple deletes.
Unbounded ReBAC expansion or ABAC fail-open on missing attributes.
Trade-offs to weigh
RBAC simplicity versus ABAC/ReBAC expressiveness and operational cost.
Decision cache speed versus revoke delay.
Central PDP consistency versus local evaluation during brownouts.
Point-check latency versus list-objects completeness.
Common misunderstandings
Treating frontend routing as authorization.
Confusing authentication success with authorization.
Saying ReBAC is “just RBAC with groups” without check vs list-objects.
Designing only the allow path and ignoring default deny and reason codes.
Wrap-up
Choose the model that matches how people actually share, enforce it on every path, and keep grants versioned and revocable. Roles, attributes, lists, and graphs are tools—not competing religions.
Primary sources
Key takeaway
Access control is a data model plus an enforcement path. Match ACL, RBAC, ABAC, or ReBAC to the product’s sharing shape, default deny, version every decision, and never let search or jobs skip the check the detail page performs.