Skip to content

← Writing

engineering

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_id never 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 if statements 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::after hook — 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 parent is 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.