The Point Where RBAC Stops Scaling
Most SaaS products start with a role column on a memberships table: owner, editor, viewer. It works fine until product wants per-resource sharing — a viewer on the org who's also an editor on exactly one project, or a contractor who can see a document only because someone shared it with them, not because of any role. At that point every permission check turns into a pile of OR clauses and JOINs, and every new sharing feature means touching authorization logic across the codebase.
This is the problem relationship-based access control (ReBAC) solves, and Auth0 FGA (built on the open-source OpenFGA/Zanzibar model) gives you a purpose-built store for it instead of reinventing a graph database inside Postgres. This walkthrough builds a real permission model for a multi-tenant SaaS — organizations, projects, documents — using the actual FGA SDK, not pseudocode.
The Core Idea: Tuples, Not Rows
FGA stores authorization as relationship tuples: user, relation, object. Instead of asking "what role does this user have," you ask "is there a relation, direct or derived, connecting this user to this object." The authorization model defines which relations exist and how they compose.
Here's a model for our SaaS, written in FGA's DSL:
model
schema 1.1
type user
type organization
relations
define admin: [user]
define member: [user] or admin
type project
relations
define org: [organization]
define editor: [user] or admin from org
define viewer: [user] or editor or member from org
type document
relations
define project: [project]
define editor: [user] or editor from project
define viewer: [user] or editor or viewer from project
Three things matter here. First, admin from org and viewer from project are relation inheritance — a project editor doesn't need an explicit tuple to view its documents; the model derives it. Second, [user] restricts who can hold that relation directly, which prevents accidentally allowing an organization object to be assigned as a document editor. Third, this hierarchy means an org admin automatically has document-editor rights on every document in every project under that org, with zero application code enforcing it.
Writing Relationships
Granting access is a tuple write, not a row update:
import { OpenFgaClient } from '@openfga/sdk';
const fgaClient = new OpenFgaClient({
apiUrl: process.env.FGA_API_URL,
storeId: process.env.FGA_STORE_ID,
credentials: {
method: 'client_credentials',
config: {
apiTokenIssuer: process.env.FGA_TOKEN_ISSUER,
apiAudience: process.env.FGA_API_AUDIENCE,
clientId: process.env.FGA_CLIENT_ID,
clientSecret: process.env.FGA_CLIENT_SECRET,
},
},
});
async function shareDocument(userId, documentId, relation = 'viewer') {
await fgaClient.write({
writes: [{
user: `user:${userId}`,
relation,
object: `document:${documentId}`,
}],
});
}
async function attachProjectToOrg(projectId, orgId) {
await fgaClient.write({
writes: [{
user: `organization:${orgId}`,
relation: 'org',
object: `project:${projectId}`,
}],
});
}
Notice shareDocument never checks the caller's role — it just writes a fact. Whether that fact is allowed to be written is a separate authorization check on the write path itself (does the requester have editor on the document), which you enforce the same way as any read check, shown next.
Checking Access
async function canView(userId, documentId) {
const { allowed } = await fgaClient.check({
user: `user:${userId}`,
relation: 'viewer',
object: `document:${documentId}`,
});
return allowed;
}
This single call replaces what would otherwise be a query joining memberships, project roles, and document shares. The traversal through org admin → project editor → document viewer happens inside FGA, not in your application.
Filtering Lists: The Part RBAC Handles Badly
The harder problem is usually not "can this user open this document" but "which documents should I show this user in their dashboard." Doing this with SQL roles means a query with as many JOINs as you have inheritance levels. FGA's listObjects does it in one call:
async function visibleDocuments(userId) {
const { objects } = await fgaClient.listObjects({
user: `user:${userId}`,
relation: 'viewer',
type: 'document',
});
return objects.map(o => o.split(':')[1]); // document IDs
}
Use this to get the ID set, then fetch the actual rows from your primary database with a WHERE id IN (...). FGA isn't your document store — it's the permission index sitting alongside it.
Contextual Tuples for Temporary Access
A common requirement — a support engineer needs read access to a customer's document for the duration of one ticket — doesn't need a persisted tuple you'll forget to revoke. FGA supports contextual tuples passed only at check time:
const { allowed } = await fgaClient.check({
user: `user:support-agent-42`,
relation: 'viewer',
object: `document:${documentId}`,
contextualTuples: [{
user: 'user:support-agent-42',
relation: 'viewer',
object: `document:${documentId}`,
}],
});
The grant exists only within that request's evaluation. Nothing is written to the store, so there's nothing to clean up when the ticket closes.
The Consistency Gotcha
FGA writes are eventually consistent by default — a check immediately following a write can occasionally miss it, because the read path may hit a replica that hasn't caught up. If your flow is "share document, then immediately redirect the recipient to it," that race is visible to users as a confusing "access denied" on a page they were just granted. Pass consistency: 'HIGHER_CONSISTENCY' on the check call for these read-your-own-write paths:
await fgaClient.check({
user: `user:${userId}`,
relation: 'viewer',
object: `document:${documentId}`,
}, { consistency: 'HIGHER_CONSISTENCY' });
Don't set it globally — it trades latency for consistency, and most checks (page loads, list filtering) don't need it.
Testing the Model Before It Ships
FGA model files support inline assertions, which you should run in CI the same way you'd run unit tests:
tests:
- name: org admin can edit nested document
tuples:
- user: user:alice
relation: admin
object: organization:acme
- user: organization:acme
relation: org
object: project:p1
- user: project:p1
relation: project
object: document:d1
assertions:
- user: user:alice
relation: editor
object: document:d1
expectation: true
This catches modeling regressions — like accidentally breaking inheritance when someone edits the DSL — before they reach production, the same way you'd catch a broken SQL migration.
Migrating Without a Big-Bang Cutover
You don't need to rip out your role column on day one. A pragmatic path: keep existing role checks as the source of truth, but write corresponding FGA tuples on every role change as a shadow write. Run FGA checks in parallel with the old logic and log mismatches without acting on them. Once mismatches are consistently zero for the resource types you care about, flip the read path to FGA and let the shadow write become the only write. This gives you weeks of production traffic validating the model before it's load-bearing, instead of trusting a migration script for that role column.
Top comments (0)