Role-based access control is the first authorization model almost every team reaches for. Assign users to roles, assign permissions to roles, check roles at the gate. It maps cleanly onto org charts, it's easy to explain to non-engineers, and it works well for dozens of permission combinations. The problem arrives when your permission combinations number in the thousands, when context matters as much as identity, or when the same user needs different access to the same resource depending on runtime conditions. That's when you discover RBAC's fundamental limitation: roles are a proxy for the conditions you actually care about, and eventually that proxy breaks down.
This post walks through the RBAC role-explosion problem, how attribute-based access control (ABAC) addresses it with policy expressions, and how relationship-based access control (ReBAC) — exemplified by Google's Zanzibar paper — synthesizes the two into something that scales to billions of objects.
The role enumeration problem
Start with a simple SaaS product. You have three roles: viewer, editor, and admin. A month later, enterprise customers want department-scoped editors. Now you have editor:engineering, editor:finance, and editor:hr. Six months later, a customer wants editors who can edit but not delete, and viewers who can export. You're now maintaining 15 roles and the combinations are multiplying faster than you can document them.
This is role explosion: the number of roles grows combinatorially as you try to express fine-grained, context-sensitive policies through a flat role hierarchy. A useful diagnostic: if your access check reads user.roles.includes('editor_engineering_no_delete'), you've already left RBAC and are encoding policy in role names.
What ABAC actually is
Attribute-based access control evaluates a policy expression over attributes of four entities: the subject (user), the action, the resource, and the environment (time, IP, device posture). A policy engine takes these attributes and returns allow or deny.
# A simple ABAC policy evaluator
from dataclasses import dataclass
from typing import Any
@dataclass
class Subject:
user_id: str
department: str
clearance_level: int
is_contractor: bool
@dataclass
class Resource:
resource_id: str
owner_department: str
sensitivity: str # 'low', 'medium', 'high', 'restricted'
@dataclass
class Environment:
ip_address: str
is_corporate_network: bool
hour_of_day: int
def can_read_document(subject: Subject, resource: Resource, env: Environment) -> bool:
# Contractors can never read high-sensitivity documents
if subject.is_contractor and resource.sensitivity in ('high', 'restricted'):
return False
# Restricted documents require matching department AND corporate network
if resource.sensitivity == 'restricted':
return (
subject.department == resource.owner_department
and env.is_corporate_network
and subject.clearance_level >= 3
)
# High sensitivity: same department or clearance >= 2
if resource.sensitivity == 'high':
return (
subject.department == resource.owner_department
or subject.clearance_level >= 2
)
# Low/medium: any authenticated user
return True
The power here is that policies are composable expressions, not lists of roles. Adding a new condition — say, "no access after 6pm for contractors" — is a one-line change to the policy function, not a new role to create, assign, and maintain across your database.
Policy as code vs policy in the database
There are two implementation strategies for ABAC: code-level policies (as above) and externalized policy engines like Open Policy Agent (OPA). Code-level policies are simpler to start with but harder to audit and change without a deployment. OPA and similar engines let you write policies in a declarative language (Rego for OPA) and push updates without redeploying your application.
# Calling OPA's REST API from Python
import requests
def check_policy(subject: dict, resource: dict, action: str) -> bool:
response = requests.post(
"http://opa:8181/v1/data/app/authz/allow",
json={
"input": {
"subject": subject,
"resource": resource,
"action": action,
}
},
timeout=0.1, # authorization must be fast
)
result = response.json()
return result.get("result", False)
OPA evaluates your Rego policy bundle and returns a decision. The policy bundle can be updated at runtime — no redeploy required. This is essential for enterprise customers who need to express their own access policies without waiting for you to ship code.
The shortcoming ABAC inherits
ABAC solves role explosion but introduces a new problem: it requires all relevant attributes to be present at evaluation time. If the policy needs resource.sensitivity and subject.department, those values must be fetched before the check runs. For simple flat resources this is fine, but for graph-structured resources — "can this user read this comment on this document in this workspace they're a member of?" — you need to traverse a relationship graph at query time. ABAC doesn't have a native model for this.
ReBAC: relationships as first-class citizens
Relationship-based access control treats the graph of relationships between entities as the primary data model for authorization. Rather than "user has role X which implies permission Y on resource Z," ReBAC says "user is a member of group G, group G is a viewer of folder F, document D is contained in folder F, therefore user can view document D."
The 2019 Google Zanzibar paper describes the authorization system that powers Google Drive, Docs, YouTube, and most other Google products at a scale of trillions of ACLs and millions of authorization checks per second. The core abstraction is the relation tuple:
// Zanzibar-style relation tuples
// Format: namespace:object#relation@subject
// Alice is an owner of document:123
document:123#owner@user:alice
// Editors of document:123 include members of group:engineering
document:123#editor@group:engineering#member
// Bob is a member of group:engineering
group:engineering#member@user:bob
// Implicit: Bob can edit document:123 because:
// Bob -> group:engineering#member -> document:123#editor -> edit permission
The authorization check becomes a graph reachability problem: can we traverse from user:bob to document:123#editor via any combination of stored tuples and userset rewrites?
Implementing a minimal Zanzibar-style check
// Simplified ReBAC check — production systems use BFS/DFS with cycle detection
interface Tuple {
namespace: string;
objectId: string;
relation: string;
subjectNamespace: string;
subjectId: string;
subjectRelation?: string; // for usersets like group:eng#member
}
async function check(
db: TupleStore,
namespace: string,
objectId: string,
relation: string,
userId: string,
depth = 0
): Promise {
if (depth > 10) return false; // cycle guard
// Direct match: is user directly assigned to this relation?
const direct = await db.tuples.find({
namespace,
objectId,
relation,
subjectNamespace: 'user',
subjectId: userId,
});
if (direct) return true;
// Userset expansion: find group assignments and recurse
const usersets = await db.tuples.findUsersets({ namespace, objectId, relation });
for (const us of usersets) {
const inGroup = await check(
db,
us.subjectNamespace,
us.subjectId,
us.subjectRelation!,
userId,
depth + 1
);
if (inGroup) return true;
}
return false;
}
This recursive check works for small graphs. Production Zanzibar implementations add memoization, parallel evaluation of independent branches, and a "zookie" consistency token that lets clients trade consistency for latency when they know their most recent write has already propagated.
When to use which model
These models are not mutually exclusive — most mature systems use all three in combination:
- RBAC for coarse-grained product-level access: is this user on a paid plan, are they in the admin group, can they access this feature at all?
- ABAC for policy expressions over resource metadata: sensitivity classification, data residency requirements, time-of-day restrictions, device trust level.
- ReBAC for ownership and sharing hierarchies: folder inheritance, org-level permissions, shared workspaces, delegated access.
The decision tree is straightforward: if your access rules depend primarily on who the user is (their role), use RBAC. If they depend on properties of the resource or environment, add ABAC policies. If they depend on the relationship between the user and the resource through intermediate objects, you need ReBAC.
For teams starting a new SaaS product, a practical path is: ship RBAC for v1, model shared ownership with a simple relationship table (user_id, resource_id, role) as a lightweight ReBAC layer, and introduce OPA-backed ABAC policies when enterprise customers start asking for attribute-driven rules. The migration from simple relationships to a full Zanzibar-style tuple store is real work, but it's a migration you can execute incrementally — the tuple model is a superset of what a simple relationship table can express.