DEV Community

Mukesh
Mukesh

Posted on

Beyond Roles: Modeling Multi-Tenant SaaS Permissions with Auth0 FGA

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
Enter fullscreen mode Exit fullscreen mode

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}`,
    }],
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}`,
  }],
});
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)