ReBAC: When Permissions Become a Graph
· Jerwin Arnado · 8 min read ·
Series closer. Part one left a thread hanging: RBAC asks
what kind of user, ABAC asks does this rule hold — and there’s a third school that
asks how are you two related? ReBAC — Relationship-Based Access Control — is the
model behind the most familiar permission system on earth: Google Drive. Nobody grants you
documents.read on a Drive file. You can open it because you’re an editor of the folder
that contains the folder that contains the doc. Access isn’t a role or a rule — it’s a
path through a graph.
Google described the system that answers those questions — for billions of objects, in milliseconds — in the Zanzibar paper (2019), and open-source implementations followed: OpenFGA (CNCF, from Auth0/Okta) and SpiceDB (authzed). Here’s when they’re the right tool, and when they’re résumé-driven infrastructure.
The Zanzibar idea: tuples
A ReBAC store holds exactly one kind of fact — a relationship tuple:
object # relation @ user. Read them as sentences:
folder:clients # owner @ user:maria Maria owns the clients folder
document:brief # parent @ folder:clients the brief lives in that folder
document:brief # viewer @ user:jun Jun was granted view on the doc directly
The power move is that relations compose. The authorization model — a schema over the tuples — declares that some relations imply others, including across objects:
model
schema 1.1
type user
type folder
relations
define owner: [user]
define editor: [user] or owner # owners edit, plus direct grants
type document
relations
define parent: [folder]
define editor: [user] or editor from parent # ← the graph walk, one line
define viewer: [user] or editor
editor from parent is the whole trick: anyone who is an editor of my parent folder is
an editor of me. Now check(user:maria, editor, document:brief) walks
owner-of-folder → editor-of-folder → editor-of-document and returns allowed — no role
was assigned on the document, no policy predicate written. Sharing a folder with a new
teammate is one tuple write, and ten thousand documents inside instantly follow.
The tell in your codebase
You don’t decide to need ReBAC by reading papers — your policies tell you. Recall part one’s smell: role names with resource attributes baked in meant the attributes were the real model. Same trick here. The ReBAC smell is policy methods that walk relationship chains:
public function view(User $user, Document $doc): bool
{
return $doc->folder->project->members->contains($user)
|| $doc->folder->project->workspace->admins->contains($user)
|| $doc->sharedWith->contains($user)
|| ($doc->folder->parent && /* ...recurse up the folder tree?? */ false);
}
Three eager-loads deep, a recursion you can’t express in Eloquent without a CTE, and every
new sharing feature (“share a whole project”, “guest access to one folder”) adds another
|| branch and another join. When the predicate’s real shape is “is there a path from
this user to this object through the ownership graph,” you’re hand-rolling a graph engine
in SQL. That’s the line. Attribute checks (status !== Archived, department equality,
tenant) stay happily in policies
forever — it’s the chain-walking that belongs in a ReBAC store.
Wiring it into Laravel: the policy stays the seam
The pleasant surprise: adopting ReBAC changes where the decision is computed, not where
it’s asked. Controllers still call $this->authorize('view', $doc); the policy method
becomes a thin client to the authorization service:
class DocumentPolicy
{
public function __construct(private OpenFgaClient $fga) {}
public function view(User $user, Document $doc): bool
{
// Local attribute checks stay local — cheap and not graph-shaped
if ($doc->trashed()) {
return false;
}
// The graph question goes to the store
return $this->fga->check(
user: "user:{$user->id}",
relation: 'viewer',
object: "document:{$doc->id}",
)->allowed;
}
}
Everything from part two still applies — the feature tests proving the 403 is wired don’t change at all. This is why keeping the Policy class as the single seam was worth the discipline: the entire migration hides behind it.
What it costs
ReBAC is the only model in this series that adds infrastructure, and the bill has three lines:
- A second source of truth to keep in sync. Your database says the doc is in the folder; the tuple store must say so too. Every create/move/share/delete now dual-writes, and a missed tuple write is either a lockout or a leak. Treat tuple sync like any dual-write problem — same transaction boundary discipline, an outbox if volume demands it — and reconcile on a schedule to catch drift.
- A network hop on the hot path. Every
check()is a call to another service. Zanzibar exists precisely to make that fast, and the implementations are — but “fast” is still a budget line per request that$user->id === $post->user_idnever was. Co-locate it, cache what its consistency model lets you cache. - A model to test. The DSL is code. OpenFGA ships store tests — YAML assertions like
check(user:maria, editor, document:brief) = allowed— and they belong in CI next to the policy grid, because a one-line model change can silently re-share every folder.
And one genuine gift: ReBAC fixes ABAC’s blind spot from part one. “Who can access this
document?” — the question ABAC could only answer by evaluating everyone — is a native
reverse-lookup (expand/list-users) over the graph. The auditor gets their list back.
When not to
Most apps clear none of the bars above, and the hybrid from part one — roles for the capability, policy attributes for the row — remains the right amount of machinery. Reach for ReBAC when the product is graph-shaped sharing: nested folders/projects, org hierarchies, delegation chains, guest links. Don’t reach for it because the tuples are elegant. A clinic where physicians see their assigned patients is two attribute checks in a policy; it does not need a CNCF project between the request and the row.
Caveats and best practices
- Keep policies as the seam even before ReBAC. The option to adopt it later is free if
every authorization question already flows through one class per model — and expensive
if
ifstatements are scattered through controllers. - Sync tuples with the same rigor as money. Dual-writes drift; drift here is an access bug. Transactional writes where possible, an outbox where not, and a scheduled reconciliation job comparing store to database.
- Audit both sides. Log tuple writes like
model changes, and denials like part four’s
Gate::afterhook — a permission graph you can’t explain historically fails the same compliance questions. - Don’t run two sources of truth indefinitely. A migration that “temporarily” checks both the old policy logic and the new store never ends temporarily. Set a cutover date; shadow-mode (compare, log mismatches, trust the old answer) is a phase, not a home.
- Model reviews are security reviews.
editor from parentis one line with the blast radius of a firewall rule. PR review for the model file is where that scrutiny happens.
Conclusion
RBAC → what kind of user — roles, enumerable, coarse
ABAC → does this rule hold — policies over attributes, fine-grained
ReBAC → is there a path — relationship tuples, composable, reverse-lookup
Tell → policies walking relationship chains = the graph asking to come out
Seam → the Policy class: adopt ReBAC behind it, or happily never need to
That closes the series: roles answer the auditor, attributes answer the request, and when the answers start depending on paths — folders in folders, teams in orgs, shares of shares — the graph is the model that was always underneath. Choose the lightest layer that answers your product’s actual question, test it like the boundary it is, keep the trail of what it decided — and let the Policy class hide the rest.