Relationship-Based Access Control (ReBAC) is an authorization model in which access decisions are derived from the graph of relationships between entities — users, groups, resources, and organizational structures. Rather than asking "what role does this user have?" or "what attributes does this resource carry?", ReBAC asks: "does a path exist in the relationship graph between this user and this resource that satisfies the required permission?"
The key insight of ReBAC is that many real-world authorization requirements are fundamentally relational. Access to a resource is often not a direct property of the user or the resource in isolation — it emerges from how users, groups, teams, organizations, and resources are connected to each other.
ReBAC was formalized and popularized by Google's Zanzibar paper (2019), which described the authorization system that powers Google Drive, Docs, Calendar, Maps, and YouTube. Zanzibar introduced the terminology of tuples, userset rewrites, and check calls that has influenced most modern ReBAC implementations (including SpiceDB, OpenFGA, Permify, and Ory Keto).
Consider a collaborative SaaS platform:
- A user is a member of a team.
- That team is a collaborator on a project.
- The project contains a set of documents.
- Each document inherits the permissions of its parent project.
The question "can Alice read this document?" has no answer in isolation. The answer depends on whether Alice is in a team, whether that team has a relationship with the project, and whether the project's relationships propagate to its documents. This is a graph traversal problem, not a role lookup or an attribute evaluation.
Standard RBAC would require a role per project per team — leading to role explosion. ACL-based FGAC would require individual ACEs for every user-document pair — leading to millions of entries. ABAC would require attributes that encode the full organizational membership context — an awkward fit.
ReBAC solves this by making relationships first-class data and answering access questions by traversing the relationship graph.
| Simpler model | Limitation addressed by ReBAC |
|---|---|
| RBAC | Cannot express per-resource collaborative access without role explosion |
| FGAC (ACL) | Millions of ACEs needed to represent team/org membership and inheritance |
| ABAC | Organizational membership encoded as attributes is brittle and impractical at scale |
| Hardcoded ownership | Can express only direct ownership, not delegated, inherited, or group-derived access |
An object is any identifiable entity in the system that can be the target of an authorization check — a resource, a group, a team, an organization, a project, a document. In ReBAC, objects are not just passive resources; they can also be subjects of relationships (e.g., a group object that has members).
Objects are identified by a type and an ID:
document:doc-789
project:proj-20
team:team-engineering
organization:org-acme
user:alice
Example: document:doc-789 is the object representing a specific document.
A relation is a named, directed edge between two objects in the relationship graph. Relations describe how entities are connected. A relation has a name that carries semantic meaning in the authorization model.
Example relations:
| From | Relation | To |
|---|---|---|
user:alice |
member |
team:engineering |
team:engineering |
collaborator |
project:proj-20 |
document:doc-789 |
parent |
project:proj-20 |
user:bob |
owner |
document:doc-789 |
A tuple is the atomic unit of the relationship graph. It is a stored record that states: "object X has relation R to object Y." Tuples are the data that the authorization system reads to answer permission questions.
A tuple takes the form:
( object, relation, subject )
Where subject can be either a direct user identity or a userset (a set of users defined by their relationship to another object).
Example tuples:
(team:engineering, member, user:alice)
(project:proj-20, collaborator, team:engineering#member)
(document:doc-789, parent, project:proj-20)
(document:doc-789, owner, user:bob)
The notation team:engineering#member means "users who have the member relation to team:engineering" — this is a userset reference, allowing groups and organizational hierarchies to be expressed without enumerating individual members.
A userset is a set of users defined by a relationship to an object, rather than by explicit enumeration. Usersets allow hierarchical and group-based access to be expressed in a single tuple rather than one tuple per user.
Example: Instead of creating one tuple per member of the engineering team to grant them collaborator access to a project, a single tuple is created:
(project:proj-20, collaborator, team:engineering#member)
This means: "the collaborator relation on project:proj-20 is held by all users who are members of team:engineering." When team membership changes, no project tuple changes are needed.
A permission is a derived, computed access right. Permissions are not stored as tuples — they are defined in the schema as logical expressions over relations. A permission is satisfied when the defined expression evaluates to true given the current state of the relationship graph.
Example schema definition:
permission read = owner OR collaborator OR viewer OR parent->reader
permission write = owner OR collaborator
permission delete = owner
Here, parent->reader means: "the user has the reader permission on the parent object" — enabling permission inheritance through the hierarchy.
The schema defines the valid object types, the relations between them, and how permissions are computed from those relations. It is the authorization model definition.
Example schema (pseudocode):
type user {}
type team {
relation member: user
}
type project {
relation owner: user
relation collaborator: user | team#member
relation viewer: user | team#member
permission read = owner OR collaborator OR viewer
permission write = owner OR collaborator
permission admin = owner
}
type document {
relation owner: user
relation parent: project
permission read = owner OR parent->read
permission write = owner OR parent->write
permission delete = owner
}
This schema expresses that a document's read permission is granted either to its direct owner or to anyone who has read permission on its parent project — which in turn is granted to project owners, collaborators, and viewers, which can include team members.
A check is an authorization query: "does subject S have permission P on object O?" The ReBAC system answers this by traversing the relationship graph according to the schema's permission definitions and returning allowed or denied.
Example check:
check: user:alice | read | document:doc-789
The system traverses:
- Does
user:alicehave a direct tuple(document:doc-789, read, user:alice)? No. - Is
read = owner OR parent->read? Checkowner: No. Checkparent->read: - Does
document:doc-789have relationparentto any project? Yes:project:proj-20. - Does
user:alicehavereadpermission onproject:proj-20? - Is
read = owner OR collaborator OR viewer? Checkcollaborator: - Does
project:proj-20have relationcollaboratortouser:aliceor a userset containinguser:alice? - Yes:
(project:proj-20, collaborator, team:engineering#member). Isuser:alicea member ofteam:engineering? - Check:
(team:engineering, member, user:alice)— YES. - Path found → ALLOWED.
Expand is a complementary operation to check: given an object and a permission, expand returns the complete set of users who hold that permission. It is used for debugging ("who can access this document?"), for building "shared with me" views, and for access reviews.
The tuple store is the database that stores all relationship tuples. It is the authoritative source of the relationship graph. Every permission check reads from the tuple store. The tuple store must be optimized for the specific query patterns of ReBAC: look up all subjects related to an object, and look up all objects related to a subject.
ReBAC evaluates permission questions by recursively traversing the relationship graph until a path between the subject and the object is found (or all paths are exhausted).
flowchart TD
A([Incoming Request\nuser:alice | action:read | document:doc-789]) --> PEP
PEP[Policy Enforcement Point\nTranslates request to check call] -->|check user:alice, read, document:doc-789| Engine
subgraph ReBAC Authorization Engine
Engine[Check Handler\nLook up permission definition in schema] --> Q1{Direct tuple exists?\ndocument:doc-789, read, user:alice}
Q1 -- Yes --> Allow([✅ ALLOWED])
Q1 -- No --> Expand[Expand permission expression\nread = owner OR parent → read]
Expand --> Q2{owner tuple\nmatches alice?}
Q2 -- Yes --> Allow
Q2 -- No --> Q3{parent object found?\ndocument:doc-789 → parent → project:proj-20}
Q3 -- No --> Deny([❌ DENIED])
Q3 -- Yes --> Recurse[Recursive check:\nuser:alice | read | project:proj-20]
Recurse --> Q4{collaborator userset\nincludes alice via team?}
Q4 -- Yes --> Allow
Q4 -- No --> Deny
end
Engine -->|allowed / denied| PEP
PEP -->|ALLOW| BL([Business Logic])
PEP -->|DENY| Err([403 Forbidden])
Engine -.->|Tuple lookups| TS[(Tuple Store)]
Engine -.->|Schema lookups| Schema[(Schema / Namespace Config)]
Step-by-step:
- Request intercepted — The PEP receives a request and translates it to a check call:
(user, permission, object). - Schema lookup — The engine loads the permission definition from the schema to understand how to evaluate the given permission.
- Direct tuple check — The engine queries the tuple store for a direct tuple matching the subject, relation, and object.
- Userset expansion — If the permission definition references other relations or computed permissions (e.g.,
parent->read), the engine expands each userset recursively. - Recursive traversal — For each referenced object (e.g., the parent project), the engine performs another check call. This continues until a path is found or all paths are exhausted.
- Decision — If any traversal path finds a matching tuple, the check returns ALLOWED. If no path is found, the check returns DENIED.
- Enforcement — The PEP enforces the decision.
Object (any entity in the system)
├── type: string -- e.g. "user", "document", "project", "team", "organization"
└── id: string -- unique within type, e.g. "doc-789", "alice", "eng-team"
Relation Tuple (the core data unit)
├── objectType: string -- the object type (e.g. "document")
├── objectId: string -- the object id (e.g. "doc-789")
├── relation: string -- the relation name (e.g. "owner", "parent", "member")
├── subjectType: string -- the subject type (e.g. "user", "team")
├── subjectId: string -- the subject id (e.g. "alice", "eng-team")
├── subjectRelation: string? -- if set, this is a userset reference (e.g. "member")
│ meaning "all subjects with `member` relation to this subject object"
├── createdAt: timestamp
└── createdBy: string -- audit: who established this relationship
Schema (static configuration, not per-request data)
├── ObjectTypeDefinition[]
│ ├── typeName: string
│ ├── relations: RelationDefinition[]
│ │ ├── name: string
│ │ └── allowedSubjectTypes: string[] -- type constraints
│ └── permissions: PermissionDefinition[]
│ ├── name: string
│ └── expression: PermissionExpression -- union/intersection/exclusion of relations
│
└── PermissionExpression (recursive type)
├── union: PermissionExpression[] -- OR
├── intersection: PermissionExpression[] -- AND
├── exclusion: { base, subtract } -- base MINUS subtract
├── computedUserset: relation name -- refers to another relation on this object
└── tupleToUserset: { tupleset, computedUserset }
-- e.g. "parent->read": find the parent object, then check `read` on it
Key design points:
- The tuple store is append-oriented with logical deletes (tuples are marked inactive rather than deleted, for audit purposes).
- Tuples are indexed on both
(objectType, objectId, relation)and(subjectType, subjectId)to support both check (forward traversal) and list (reverse traversal) operations. - The schema is static and versioned — schema changes require migration planning.
| Input | Description |
|---|---|
subject |
The entity requesting access: user:alice |
permission |
The permission being checked: read |
object |
The resource being accessed: document:doc-789 |
function check(subject, permission, object):
// 1. Look up the permission expression in the schema
expression = schema.getPermission(object.type, permission)
if expression is a UNION:
// Permission is satisfied if ANY sub-expression is satisfied
for each subExpr in expression.operands:
if check(subject, subExpr, object) == ALLOWED:
return ALLOWED
return DENIED
if expression is an INTERSECTION:
// Permission is satisfied only if ALL sub-expressions are satisfied
for each subExpr in expression.operands:
if check(subject, subExpr, object) == DENIED:
return DENIED
return ALLOWED
if expression is a COMPUTED_USERSET (direct relation reference):
// Check if a direct tuple exists: (object, relation, subject)
// or if subject is in a userset reachable from object via this relation
subjects = expandUserset(object, expression.relation)
if subject IN subjects: return ALLOWED
return DENIED
if expression is a TUPLE_TO_USERSET (e.g. parent->read):
// Find all objects related to the current object via the tupleset relation
relatedObjects = getTuples(object, expression.tupleset)
for each relatedObject in relatedObjects:
if check(subject, expression.computedUserset, relatedObject) == ALLOWED:
return ALLOWED
return DENIED
function expandUserset(object, relation):
tuples = tupleStore.getTuples(object, relation)
result = Set()
for each tuple in tuples:
if tuple.subjectRelation is null:
result.add(tuple.subject) // direct user
else:
// Expand the userset: all members of tuple.subject with tuple.subjectRelation
result.addAll(expandUserset(tuple.subject, tuple.subjectRelation))
return result
A path exists in the relationship graph, following the schema's permission expression tree, that connects the subject to the object via the required permission.
No path exists. All branches of the permission expression tree are exhausted without finding a match between the subject and the object.
Some ReBAC systems support exclusion in permission expressions: read = (owner OR collaborator) MINUS banned. A user in the banned relation is excluded from the read permission even if they would otherwise qualify via another path.
Default-deny. If no path connecting the subject to the object via the required permission exists, access is denied.
- Cycles: The schema must be designed to avoid infinite loops in the relationship graph. Well-designed schemas are acyclic; implementations add depth limits as a guard.
- Consistency: In distributed tuple stores, there is a brief window after a tuple write where the change may not be visible to all check operations. Systems like Zanzibar address this with zookies (consistency tokens) that allow callers to request a check that is consistent with a specific point in time.
Schema (abbreviated):
type user {}
type team {
relation member: user
}
type organization {
relation member: user | team#member
relation admin: user
}
type project {
relation owner: user
relation collaborator: user | team#member
relation viewer: user | team#member | organization#member
permission read = owner OR collaborator OR viewer
permission write = owner OR collaborator
permission admin = owner
}
type document {
relation owner: user
relation parent: project
permission read = owner OR parent->read
permission write = owner OR parent->write
permission delete = owner
}
Tuples:
(team:engineering, member, user:alice)
(team:engineering, member, user:carol)
(organization:acme, member, team:engineering#member)
(project:proj-20, owner, user:bob)
(project:proj-20, collaborator, team:engineering#member)
(document:doc-789, owner, user:bob)
(document:doc-789, parent, project:proj-20)
(document:doc-790, parent, project:proj-20)
Users:
| User | Direct Roles | Group Memberships |
|---|---|---|
| alice | — | team:engineering |
| bob | owner of proj-20, owner of doc-789 |
— |
| carol | — | team:engineering |
| dave | — | — |
Allowed request — group-inherited access:
Alice checks
readondocument:doc-789.
Traversal:
read = owner OR parent->readondocument:doc-789.owner: Is there(doc-789, owner, alice)? No.parent->read: Find parent:(doc-789, parent, project:proj-20).- Check
readonproject:proj-20foruser:alice. read = owner OR collaborator OR viewer.collaborator: Is there(proj-20, collaborator, alice)or via userset? Check(proj-20, collaborator, team:engineering#member). Isalicea member ofteam:engineering? YES.- Path found → ALLOWED.
Denied request — no relationship path:
Dave checks
readondocument:doc-789.
Traversal:
owner: No(doc-789, owner, dave).parent->read→ checkreadonproject:proj-20foruser:dave.owner: No.collaborator: No direct tuple; usersetteam:engineering#member— isdavea member? No.viewer: No tuples matchingdave.- All paths exhausted → DENIED.
Allowed request — direct ownership:
Bob checks
deleteondocument:doc-789.
delete = owner.- Is there
(doc-789, owner, bob)? YES. - Path found → ALLOWED (direct tuple, no traversal needed).
Allowed request — inherited via organization:
Add a new tuple: (project:proj-20, viewer, organization:acme#member)
Now user:dave is added to organization:acme:
(organization:acme, member, user:dave)
Dave checks
readondocument:doc-789.
parent->read→ checkreadonproject:proj-20fordave.viewer: Check(proj-20, viewer, organization:acme#member). Isdavea member oforganization:acme? YES.- ALLOWED.
// --- Types ---
interface Tuple {
objectType: string;
objectId: string;
relation: string;
subjectType: string;
subjectId: string;
subjectRelation?: string; // if set, this is a userset reference
}
type PermissionExpr =
| { type: "union"; operands: PermissionExpr[] }
| { type: "intersection"; operands: PermissionExpr[] }
| { type: "relation"; relation: string } // direct relation check
| { type: "ttu"; tupleset: string; computedUserset: string }; // tuple-to-userset
interface Schema {
getPermissionExpression(objectType: string, permission: string): PermissionExpr;
}
// --- Tuple store (in-memory for illustration) ---
const tuples: Tuple[] = [
{ objectType: "team", objectId: "engineering", relation: "member", subjectType: "user", subjectId: "alice" },
{ objectType: "team", objectId: "engineering", relation: "member", subjectType: "user", subjectId: "carol" },
{ objectType: "project", objectId: "proj-20", relation: "owner", subjectType: "user", subjectId: "bob" },
{ objectType: "project", objectId: "proj-20", relation: "collaborator", subjectType: "team", subjectId: "engineering", subjectRelation: "member" },
{ objectType: "document", objectId: "doc-789", relation: "owner", subjectType: "user", subjectId: "bob" },
{ objectType: "document", objectId: "doc-789", relation: "parent", subjectType: "project", subjectId: "proj-20" },
];
function getTuples(objectType: string, objectId: string, relation: string): Tuple[] {
return tuples.filter(
t => t.objectType === objectType && t.objectId === objectId && t.relation === relation
);
}
// --- Schema (simplified) ---
const schema: Schema = {
getPermissionExpression(objectType, permission): PermissionExpr {
const definitions: Record<string, Record<string, PermissionExpr>> = {
project: {
read: {
type: "union",
operands: [
{ type: "relation", relation: "owner" },
{ type: "relation", relation: "collaborator" },
{ type: "relation", relation: "viewer" },
],
},
write: {
type: "union",
operands: [
{ type: "relation", relation: "owner" },
{ type: "relation", relation: "collaborator" },
],
},
},
document: {
read: {
type: "union",
operands: [
{ type: "relation", relation: "owner" },
{ type: "ttu", tupleset: "parent", computedUserset: "read" },
],
},
delete: { type: "relation", relation: "owner" },
},
};
return definitions[objectType]?.[permission]
?? (() => { throw new Error(`No permission '${permission}' on type '${objectType}'`); })();
},
};
// --- Check engine ---
const MAX_DEPTH = 10;
function check(
subjectType: string, subjectId: string,
permission: string,
objectType: string, objectId: string,
depth = 0
): boolean {
if (depth > MAX_DEPTH) return false; // cycle / depth guard
const expr = schema.getPermissionExpression(objectType, permission);
return evalExpr(expr, subjectType, subjectId, objectType, objectId, depth);
}
function evalExpr(
expr: PermissionExpr,
subjectType: string, subjectId: string,
objectType: string, objectId: string,
depth: number
): boolean {
switch (expr.type) {
case "union":
return expr.operands.some(op =>
evalExpr(op, subjectType, subjectId, objectType, objectId, depth)
);
case "intersection":
return expr.operands.every(op =>
evalExpr(op, subjectType, subjectId, objectType, objectId, depth)
);
case "relation": {
// Check if subject is directly in this relation, or reachable via a userset
const related = getTuples(objectType, objectId, expr.relation);
return related.some(t => {
if (t.subjectRelation == null) {
// Direct subject match
return t.subjectType === subjectType && t.subjectId === subjectId;
} else {
// Userset: recurse — check if subjectId has t.subjectRelation on t.subjectId
return check(subjectType, subjectId, t.subjectRelation, t.subjectType, t.subjectId, depth + 1);
}
});
}
case "ttu": {
// Tuple-to-userset: find the related object via tupleset, then check computedUserset on it
const parents = getTuples(objectType, objectId, expr.tupleset);
return parents.some(t =>
check(subjectType, subjectId, expr.computedUserset, t.subjectType, t.subjectId, depth + 1)
);
}
}
}
// --- Usage ---
// Alice reads doc-789 (via team:engineering → project:proj-20 → document:doc-789)
console.log(check("user", "alice", "read", "document", "doc-789")); // true
// Dave reads doc-789 (no path)
console.log(check("user", "dave", "read", "document", "doc-789")); // false
// Bob deletes doc-789 (direct owner)
console.log(check("user", "bob", "delete", "document", "doc-789")); // true
// Alice deletes doc-789 (not owner)
console.log(check("user", "alice", "delete", "document", "doc-789")); // falseTeam membership, organizational hierarchy, project collaboration, and resource inheritance map directly and intuitively onto tuples and permission expressions. The authorization model mirrors the mental model users and developers already have.
Because access is derived from graph relationships rather than explicit role assignments, there is no need to create a project-A-member role for every project. A single collaborator relation on a project object, combined with a team membership tuple, expresses the same access for all current and future members of that team.
Tuple storage scales horizontally. Adding a new document to a project requires only a single (document:new-doc, parent, project:proj-20) tuple — not one ACE per user. Changes to team membership automatically propagate to all downstream resource access without additional writes.
Because relationships are stored as first-class data, the system can answer queries like:
- "What documents can alice access?" (reverse lookup)
- "Who can access this document?" (expand)
- "List all projects this team has collaborator access to." (graph traversal)
These queries are the foundation of "shared with me" views, access reviews, and security dashboards.
Google's Zanzibar introduced consistency tokens (zookies) that allow callers to ensure a permission check reflects a specific version of the relationship graph. This prevents scenarios where a user's access is revoked but a stale check still returns ALLOWED during the propagation window.
Permissions are defined as logical expressions over relations. Complex access patterns — "can edit if owner OR team collaborator, but not if in the blocked list" — are expressed cleanly using union, intersection, and exclusion operators in the schema.
Designing a correct ReBAC schema is non-trivial. Poorly designed schemas produce unexpected access (permission leakage via an unanticipated traversal path) or unexpectedly block access (a path exists but the schema doesn't traverse it). The schema is a critical security artifact that requires careful design and testing.
For deep relationship hierarchies or wide usersets, a check call may require many recursive tuple lookups. Without caching and careful index design, check latency can grow with the depth of the graph. The Zanzibar paper describes sophisticated strategies (parallel evaluation, leopard indexing) to keep latency bounded at Google scale — most smaller systems do not need that level of optimization but must still think carefully about traversal depth.
ReBAC expresses access through relationships, not attribute conditions. A requirement like "only allow access during business hours" or "block access to documents classified as confidential from external networks" is not naturally expressible in a pure ReBAC model. These conditions require layering ABAC or PBAC on top.
The tuple store is queried on every authorization check. It must be high-availability, low-latency, and consistently up-to-date. It is not a secondary system — it is as critical as the application database.
Tracing why a user does or does not have access requires traversing the full relationship graph. The path may go through several intermediate objects and usersets. Good tooling (expand operations, check trace output) is essential for debugging.
Changing the schema — adding a new relation type, modifying a permission expression — can have broad effects on existing access decisions. Schema changes must be backward-compatible or involve a careful migration. Unlike RBAC where adding a role is isolated, schema changes in ReBAC affect all objects of the changed type.
Running a production ReBAC system requires expertise in tuple store operations, schema versioning, consistency management, and check performance profiling. Teams without prior experience will encounter a steeper learning curve than with RBAC or FGAC.
ReBAC excels when:
- Access is determined by organizational relationships. Team membership, department hierarchy, project participation, and org-level roles are all relationships.
- Resources are hierarchically organized. Documents within projects, projects within workspaces, workspaces within organizations — inheritance through parent relationships is a natural ReBAC pattern.
- Per-resource collaboration is a core feature. "Invite this team to this project," "share this document with this user," and "make this resource public" are all tuple write operations.
- The system needs to support "list" or "search" operations scoped to user access. "Show me all documents I can read" requires reverse relationship traversal.
- The authorization model is relationship-heavy rather than role-heavy or attribute-heavy.
Realistic scenarios:
| Scenario | Why ReBAC fits |
|---|---|
| Collaborative SaaS (Notion, Figma, Confluence style) | Workspace/team/resource hierarchy; per-item sharing |
| Source code hosting (GitHub/GitLab style) | Org → team → repository → branch permission inheritance |
| Project management tools | Team members inherit project and task access |
| Cloud infrastructure IAM | Account → project → resource relationship hierarchies |
| Social platforms | Follow, friend, group membership as authorization relationships |
| Multi-tenant B2B SaaS | Organization → department → user hierarchies with resource inheritance |
- Access is driven by data classification and environmental context. "Only allow access to confidential documents from VPN during business hours" is an attribute condition, not a relationship. Use ABAC or PBAC.
- The authorization model is flat and role-based. If users fall into a small number of job function roles with uniform permissions across all resources of a type, RBAC is simpler and sufficient.
- The team lacks expertise to design and maintain a schema. A poorly designed ReBAC schema is a significant security liability. If the team is not ready to invest in schema design and ongoing maintenance, a well-disciplined RBAC model is safer.
- Real-time conditions must influence decisions. Relationship graphs are updated asynchronously; they do not capture ephemeral environmental context. For time-based or network-based restrictions, combine ReBAC with ABAC.
- The relationship graph is extremely dense. If nearly every user has a relationship to every resource (e.g., a public resource sharing model where all users can read everything), the relationship graph does not provide selectivity and RBAC or attribute-based open access is more efficient.
| Model | Main Idea | Decision Inputs | Complexity | Best For |
|---|---|---|---|---|
| ReBAC | Access derived from graph relationships between entities | Graph traversal from subject to object via schema-defined permission expressions | Medium–High | Collaborative tools; hierarchical resources; team/org-based access; "shared with me" features |
| RBAC | Permissions assigned to roles; users assigned to roles | User's role memberships | Low–Medium | Organizations with clear job functions; class-level resource access |
| FGAC | Per-instance ACLs; explicit grants per subject/resource | Subject identity, resource instance ACL | Medium | Ownership-driven sharing; explicit per-user grants |
| ABAC | Policies evaluated against attributes of subject, resource, and environment | Attribute sets + policy rules | High | Context-sensitive access; data classification; heterogeneous resource types |
| PBAC | Externalized, centralized declarative policies with formal governance | Attributes + centralized policy set | Very High | Enterprise-wide consistent enforcement; compliance-driven multi-service architectures |
ReBAC and FGAC overlap in use cases involving per-resource access control. The key distinction is that FGAC stores explicit per-user ACEs, while ReBAC stores group/team membership and hierarchy relationships and derives individual access through graph traversal. ReBAC is more efficient when access is group- or org-driven; FGAC is simpler when access is primarily individual-user-driven.
The schema determines what traversal paths exist in the relationship graph. An incorrect permission expression can create an unanticipated path that grants access to unintended subjects. The schema must be reviewed by security engineers, not only by product engineers. Changes to permission expressions in the schema require security sign-off.
If an attacker can insert arbitrary tuples into the tuple store, they can create relationships that grant unauthorized access. Tuple write operations must be protected by their own authorization checks (who is allowed to create which relations on which object types). This is a recursive security requirement: use the ReBAC system itself to govern who can write tuples.
In multi-tenant systems, the objectId namespace must be globally unique and tenant-scoped. A tuple that connects user:alice to document:123 in tenant A must not match a document:123 in tenant B. Use namespaced IDs: document:tenantA/doc-123 or include tenant IDs in all lookups. Tenant boundary enforcement must be validated explicitly in tests.
The absence of a path in the relationship graph is a denial. Ensure the system never silently falls through to an implicit allow when a check fails due to a store error or timeout. Check failures must be treated as denials, not as unknown.
Deleting or modifying existing tuples should be treated as a privileged, auditable operation. In high-security systems, implement logical deletes (mark as inactive with a timestamp) rather than physical deletes, to preserve audit trail completeness.
Between the moment a tuple is written (e.g., revoking a user's access) and the moment the check propagates to all read replicas, there is a window where the revoked access may still be reported as allowed. For high-security operations (revoking access to sensitive resources), use consistency tokens to enforce check freshness, or synchronously wait for propagation before confirming the revocation.
Caching check results speeds up frequently repeated queries but creates a window where revoked access remains effective. Use short TTLs for cached check results. For sensitive permissions (delete, admin), disable caching or invalidate on tuple change.
Log every check call with: subject, permission, object, decision, traversal trace, and timestamp. The traversal trace is essential for debugging unexpected decisions and for compliance audits that ask "why did this user have access to this resource at this time?"
The two primary query patterns are:
- Forward lookup — Given
(objectType, objectId, relation), return all matching subject tuples. Supports check and expand operations. - Reverse lookup — Given
(subjectType, subjectId), return all objects where this subject appears. Supports "list all resources I can access" queries.
Both queries must be supported by database indexes. A composite index on (objectType, objectId, relation) and a second index on (subjectType, subjectId, subjectRelation) cover the most common patterns.
Cache the result of check(subject, permission, object) keyed by (subjectId, permission, objectId). Check results are safe to cache for short periods (seconds to minutes) because they change only when a relevant tuple is added or removed.
Invalidate cached results selectively when a tuple change affects an object or a subject that is part of a cached key.
Set a maximum traversal depth (e.g., 15 hops) to prevent unbounded recursion on pathological graphs. Log a warning when the depth limit is reached rather than silently returning DENIED — hitting the depth limit may indicate a schema design problem.
When a permission expression is a union of multiple branches, evaluate the branches in parallel rather than sequentially. Return ALLOWED as soon as the first branch resolves to ALLOWED without waiting for the others. This reduces average latency when the first branch is the most commonly satisfied.
"List all documents user:alice can read" requires traversing the relationship graph in reverse. Maintain a reverse index that maps from (subjectId, permission) to objectId sets. This index must be updated on every tuple write and is more expensive to maintain but essential for scalable list operations.
When rendering a list of resources, avoid making one check call per resource item. Use the reverse index to fetch all accessible resource IDs for the user in a single query, then load only those resources from the application database.
Test: Direct owner relation
Given: tuple (document:doc-789, owner, user:bob) exists
When: check("user", "bob", "delete", "document", "doc-789")
Then: ALLOWED
Test: No direct tuple → denied
Given: no tuple connects user:dave to document:doc-789
When: check("user", "dave", "read", "document", "doc-789")
Then: DENIED
Test: Access via group membership
Given: (team:engineering, member, user:alice)
(project:proj-20, collaborator, team:engineering#member)
When: check("user", "alice", "read", "project", "proj-20")
Then: ALLOWED (alice → team:engineering → proj-20 collaborator → read)
Test: Non-member of group denied
Given: user:dave is NOT a member of team:engineering
When: check("user", "dave", "read", "project", "proj-20")
Then: DENIED
Test: Document inherits permissions from parent project
Given: (document:doc-789, parent, project:proj-20)
alice has read permission on project:proj-20
When: check("user", "alice", "read", "document", "doc-789")
Then: ALLOWED (via parent->read path)
Test: Removing team membership revokes inherited document access
Given: alice is removed from team:engineering
When: check("user", "alice", "read", "document", "doc-789")
Then: DENIED (path through team no longer exists)
Test: Viewer cannot write
Given: alice has read permission on project:proj-20 via viewer relation only
When: check("user", "alice", "write", "project", "proj-20")
Then: DENIED (write requires owner OR collaborator, not viewer)
Test: Non-owner cannot delete
Given: alice has write permission but is not owner
When: check("user", "alice", "delete", "document", "doc-789")
Then: DENIED
Test: User cannot write tuples without authorization
Given: user:dave has no write-tuple permission
When: dave attempts to insert (project:proj-20, owner, user:dave)
Then: tuple write is rejected (authorization check on tuple write fails)
Test: Userset cannot reference out-of-scope objects
Given: schema defines that `document` parent must be of type `project`
When: a tuple (document:doc-789, parent, user:alice) is attempted
Then: tuple is rejected (schema type constraint violation)
Test: User from tenant A cannot access resources in tenant B
Given: alice belongs to tenant "acme"; document:globex/doc-456 belongs to tenant "globex"
When: check("user", "alice", "read", "document", "globex/doc-456")
Then: DENIED (no path connects alice to globex-scoped objects)
Test: New team member immediately gains inherited access
Given: user:eve is added to team:engineering via a new tuple
When: check("user", "eve", "read", "document", "doc-789")
Then: ALLOWED (new tuple propagated correctly)
Test: Access revocation propagates correctly
Given: (team:engineering, member, user:alice) tuple is deleted
When: check("user", "alice", "read", "document", "doc-789")
Then: DENIED (cache invalidated, revocation effective)
Test: Expand returns all users with read access to a document
Given: alice (via team), bob (direct owner)
When: expand("read", "document", "doc-789")
Then: result set contains user:alice AND user:bob
ReBAC requires a dedicated tuple store and check engine alongside the application's existing data stores.
flowchart TD
Client([Client App]) -->|HTTPS + JWT| GW[API Gateway]
GW --> Auth[Authentication Service\nValidate JWT\nExtract user:alice identity]
Auth --> PEP[PEP Middleware\nTranslates request to check call]
PEP -->|check user:alice, read, document:doc-789| CheckAPI[ReBAC Check API]
subgraph ReBAC Service - High Availability
CheckAPI --> Engine[Check Engine\nSchema-driven recursive traversal]
Engine --> TS[(Tuple Store\nRelationship data\nIndexed for forward + reverse lookup)]
Engine --> Cache[(Check Cache\nShort TTL\nInvalidated on tuple change)]
end
Engine -->|allowed / denied| PEP
PEP -->|ALLOW| SVC[Business Logic Service]
PEP -->|DENY| Err([403 Forbidden])
SVC --> DB[(Application Database\nDomain data)]
DB --> Resp([Response])
subgraph Tuple Lifecycle
App[Application Layer] -->|Write tuple on\nshare / add member / create resource| TupleWriteAPI[Tuple Write API]
TupleWriteAPI --> TS
TupleWriteAPI --> Cache
end
subgraph Observability
Engine -.->|Check trace| AuditLog[(Immutable Audit Log)]
TupleWriteAPI -.->|Tuple change log| AuditLog
end
Tuple writes are tightly coupled to application domain events:
- Create a resource → write
(resource, parent, parentObject)and(resource, owner, user) - Add team member → write
(team, member, user) - Share with user → write
(resource, viewer, user) - Remove collaborator → delete
(project, collaborator, user)
These tuple write operations should be performed in the same transaction (or with strong consistency guarantees) as the application's domain changes. A resource created without its ownership tuple is inaccessible to everyone including its creator.
For multi-service architectures, the tuple store and check engine run as a standalone, high-availability service. All application services call the same check API. This ensures consistency and centralizes the relationship graph.
Open-source implementations like OpenFGA, SpiceDB, and Permify provide production-ready ReBAC services that can be self-hosted or used as managed cloud services, implementing the Zanzibar paper's approach.
For requirements that combine relationship-based access with attribute conditions (e.g., "team members can read internal documents but not confidential ones"), layer ReBAC with ABAC:
- ReBAC determines whether the user has a relationship path to the resource (coarse filter).
- ABAC evaluates attribute conditions on the resource and environment (fine filter).
Both checks must pass for access to be allowed.
Problem: Instead of writing a (document, parent, project) tuple when the document is created, the application derives the parent relationship from the document record at check time by loading the document from the database.
Why it is dangerous: This bypasses the ReBAC system. The authorization engine does not know about the parent relationship. Permissions that depend on parent->read fail silently, denying access to legitimate users. Or worse, the application adds a special case in code to load the document before the authorization check, defeating the purpose of the authorization layer.
Correct approach: Every entity relationship that is used in authorization must be represented as a tuple in the tuple store, written at the time the relationship is established in the domain.
Problem: The schema does not restrict which object types can appear as subjects of a given relation. A tuple (document:doc-789, parent, user:alice) is accepted even though the schema intends parent to reference project objects only.
Why it is dangerous: Unexpected tuple shapes can create unintended traversal paths that grant unauthorized access. A user who appears as the parent of a document gains any permissions that traverse parent->*.
Correct approach: Define explicit type constraints in the schema for every relation. Validate tuple writes against the schema before persisting them. Reject tuples that violate type constraints.
Problem: A read permission expression is written as owner OR parent->read without considering that the parent relation could point to an object type with a very permissive read expression (e.g., a type where all authenticated users have read).
Why it is dangerous: All users end up with read access to all documents via the parent's open read expression — a permission leakage through an unanticipated traversal path.
Correct approach: Trace every possible traversal path in the schema before deploying a new permission expression. Use the expand operation to verify that the effective subject set for a permission is what you intend. Test with "who can access this resource?" queries against representative data.
Problem: When a document is deleted, its tuples (parent, owner, viewer) remain in the tuple store.
Why it is dangerous: The tuple store accumulates stale data. More dangerously, if the resource ID is reused (with non-UUID IDs), the new resource silently inherits the old resource's relationship graph, including potentially unauthorized viewers.
Correct approach: Delete all tuples referencing a resource when the resource is deleted. Implement this as a transactional operation alongside the resource deletion. Use UUID resource IDs to prevent ID reuse.
Problem: The check engine has no depth limit. A schema that creates a cycle (object A is the parent of object B, and object B is the parent of object A) causes infinite recursion.
Why it is dangerous: Denial-of-service via a crafted relationship graph. Even without a cycle, deeply nested hierarchies (workspace → org → division → team → subteam → project → folder → document) can cause many recursive calls per check.
Correct approach: Implement a maximum traversal depth (e.g., 15–20 hops). Return DENIED and log a warning when the limit is exceeded. Design schemas to avoid deep nesting where possible.
Problem: The authorization system returns DENIED for a user who believes they should have access. The engineering team has no way to trace why the check failed — which path was evaluated, where it stopped, which tuples were missing.
Why it is dangerous: Access issues are time-consuming to debug. Users lose trust. In the worst case, a bug is misdiagnosed as a policy decision and never fixed, or a legitimate access gap is never identified.
Correct approach: Implement a check trace mode that returns the full evaluation path alongside the decision. Expose an expand endpoint that returns all subjects with a given permission on an object. These tools are essential for both debugging and access reviews.
What it is: Relationship-Based Access Control is an authorization model that derives access decisions from a graph of typed relationships between entities. Users, groups, teams, organizations, and resources are nodes; relations are edges. A permission check is a graph traversal question: does a path exist between this user and this resource via the schema's permission expression?
How it works: Relationships are stored as tuples in a tuple store. A schema defines which relations are valid between which object types and how permissions are computed from those relations using union, intersection, and exclusion expressions. On each authorization check, the engine traverses the graph recursively — following relation edges and userset expansions — until it finds a path (ALLOW) or exhausts all paths (DENY).
Strengths:
- Natural, intuitive expression of organizational access patterns
- Eliminates role explosion for team- and org-driven access control
- Scales efficiently: one team membership tuple grants access to all team-accessible resources
- Enables powerful reverse lookups for "list resources I can access" and "who can access this resource" queries
- Clean composable permission expressions via union, intersection, and exclusion
Limitations:
- Schema design is a security-critical artifact requiring careful design and testing
- Recursive traversal performance requires index design and caching discipline
- No native attribute-based conditions — combine with ABAC for context-sensitive decisions
- Tuple store is a critical dependency requiring high availability
- Schema evolution is costly compared to modifying RBAC role definitions
When to use it: ReBAC is the right model when authorization requirements are fundamentally relational — when access to a resource is derived from team membership, organizational hierarchy, project participation, or other first-class relationships between entities. It excels in collaborative SaaS platforms, hierarchical resource systems, and multi-tenant B2B products where users share and collaborate on specific resources. For purely role-driven access with no per-resource sharing, RBAC is simpler. For attribute-driven, context-sensitive policies, combine ReBAC with ABAC.