Relationship-Based Access Control With OpenFGA

When a document can be shared with a user directly, inherited from a folder, granted to an entire team, or owned through an organization three levels up, role tables and attribute policies start to collapse under their own joins. Relationship-Based Access Control (ReBAC) models authorization as a graph of relationships instead of a matrix of roles, and OpenFGA is the open-source implementation of Google’s Zanzibar paper that makes this practical. This guide is part of the Advanced Access Control & Authorization collection, and it shows how to model, write, and evaluate relationship tuples in production with @openfga/sdk in TypeScript.

Prerequisites

Before you model anything, make sure you have:

  • A running OpenFGA server (Docker openfga/openfga:v1.5.0 or OpenFGA Cloud), reachable over HTTP/gRPC with an API token or client-credentials configured.
  • Node 20+ and the @openfga/sdk package installed.
  • A clear inventory of your object types (document, folder, organization, team) and the relations between them (owner, editor, viewer, parent, member).
  • A baseline understanding of why coarse-grained roles are insufficient — read the comparison in choosing between RBAC and ABAC first if you are still deciding on a model.
  • A plan for where authorization is enforced. ReBAC answers “can this user do this?” but you still need policy enforcement points in microservices to call the check on every request.

The problem: authorization as a graph, not a matrix

RBAC answers “what roles does this user have?” and ABAC answers “do this user’s attributes satisfy a policy?”. Neither answers “is this user reachable from this object through a chain of relationships?” efficiently. Consider Google Drive: alice can view doc:roadmap because she is a member of team:eng, which was granted editor on folder:planning, which contains doc:roadmap. Expressing that with roles requires denormalizing every inherited grant into per-user rows and recomputing them whenever any edge changes. The sharing graph is the real source of truth; ReBAC stores the edges and computes reachability at query time.

Google’s Zanzibar paper formalized this. The unit of storage is a relationship tuple written object#relation@user — for example document:roadmap#viewer@user:alice means “alice is a viewer of the roadmap document”. Tuples are tiny, append-only facts. The authorization model (type definitions) declares which relations exist and how indirect relations are computed from direct ones. A check then walks the graph: given a tuple set and a model, is there a path from object to user for the requested relation?

The relationship graph

The diagram below shows the sharing graph for a single document. Direct tuples are solid edges; computed access (the dashed path) is derived by the model, never stored.

OpenFGA relationship graph for a shared document Alice is a member of the engineering team, which is an editor of the planning folder, which is the parent of the roadmap document. Alice therefore computes as a viewer of the document. user:alice subject team:eng userset folder:planning object document:roadmap object member editor parent computed viewer
OpenFGA relationship graph for a shared document

Step 1: Define the authorization model

The model is the schema. In OpenFGA’s DSL it declares each type, its relations, and how each relation is satisfied — by a direct assignment, a computed relation on the same object, or a tupleset relation that walks to a related object (the from keyword). The model below covers organizations, folders, and documents with inheritance.

model
  schema 1.1

type user

type organization
  relations
    define member: [user]

type folder
  relations
    define owner: [user]
    define parent: [folder]
    define org: [organization]
    define editor: [user, team#member] or owner or editor from parent
    define viewer: [user, team#member] or editor or member from org

type team
  relations
    define member: [user]

type document
  relations
    define parent: [folder]
    define owner: [user]
    define editor: [user, team#member] or owner or editor from parent
    define viewer: [user, team#member] or editor or viewer from parent

Read viewer from parent as: “a user is a viewer of this document if they are a viewer of the folder named in this document’s parent tuple”. This is a tupleset relationparent is the tupleset, viewer is the computed relation walked on the referenced object. editor or owner is a computed relation: any owner is automatically an editor, no separate tuple needed. The [user, team#member] annotation declares the allowed user types, including usersets (team#member means “every member of the team”), which is how group-to-group grants work.

Publish the model with the SDK and capture the returned authorization_model_id — checks are evaluated against a specific, immutable model version.

import { OpenFgaClient } from "@openfga/sdk";

const fga = new OpenFgaClient({
  apiUrl: process.env.FGA_API_URL!,
  storeId: process.env.FGA_STORE_ID!,
});

// model is the parsed JSON form of the DSL above (use `fga model transform` or the API)
const { authorization_model_id } = await fga.writeAuthorizationModel(model);
// Persist authorization_model_id; pin every check to it for consistency.

Step 2: Write relationship tuples

Tuples are the data. Each is an object#relation@user fact. The user field may be a single subject (user:alice) or a userset (team:eng#member) to grant access to a whole group at once. Write them as your application’s sharing actions happen — when a folder is created, a document is moved, or a user is invited to a team.

await fga.write({
  writes: [
    // Alice is a direct member of the engineering team.
    { user: "user:alice", relation: "member", object: "team:eng" },
    // The engineering team (every member) is an editor of the planning folder.
    { user: "team:eng#member", relation: "editor", object: "folder:planning" },
    // The roadmap document lives inside the planning folder.
    { user: "folder:planning", relation: "parent", object: "document:roadmap" },
  ],
});

No tuple grants Alice access to document:roadmap directly. Her viewer access is computed: viewer from parent walks to folder:planning, where editor from ... resolves through team:eng#member, where she is a member. The store holds three small facts; the model derives the rest.

Step 3: Run a check

The Check API answers a single boolean: does user have relation on object? It walks the graph using the pinned model and returns allowed. Call it on the hot path of every protected request.

const { allowed } = await fga.check({
  user: "user:alice",
  relation: "viewer",
  object: "document:roadmap",
  // Pin the model so a half-deployed model change can't flip the answer.
  authorizationModelId: process.env.FGA_MODEL_ID!,
  // contextualTuples can inject request-time facts not yet persisted.
});

if (!allowed) {
  return new Response("Forbidden", { status: 403 });
}

Two other read APIs round out the surface:

  • Expand returns the full userset tree for an object#relation — invaluable for debugging why a check passed and for building a “who has access” UI.
  • ListObjects answers the inverse of check: “which documents can alice view?” It is the right call for rendering a filtered list page without N separate checks. Designing tuples so ListObjects stays cheap is covered in modeling Zanzibar-style relationship tuples.
const { objects } = await fga.listObjects({
  user: "user:alice",
  relation: "viewer",
  type: "document",
  authorizationModelId: process.env.FGA_MODEL_ID!,
});
// objects: ["document:roadmap", ...] — drive the UI list from this, not a DB scan.

Validation & testing

OpenFGA models are testable as code. Write a .fga.yaml assertion file and run it in CI so a model edit that accidentally widens access fails the build before it ships.

name: document-access-tests
model_file: ./model.fga
tuples:
  - user: user:alice
    relation: member
    object: team:eng
  - user: team:eng#member
    relation: editor
    object: folder:planning
  - user: folder:planning
    relation: parent
    object: document:roadmap
tests:
  - name: alice inherits viewer via team and folder
    check:
      - user: user:alice
        object: document:roadmap
        assertions:
          viewer: true
          editor: true
      - user: user:bob
        object: document:roadmap
        assertions:
          viewer: false

Run fga model test --tests document-access.fga.yaml. For ad-hoc verification, fga query check user:alice viewer document:roadmap from the CLI and inspect fga query expand document:roadmap viewer to see the resolved tree.

Common misconfigurations

Misconfiguration Symptom Fix
Overly permissive computed relation (e.g. viewer: editor or member from org) Every org member can read every document; access far wider than intended Scope computed relations narrowly; grant org-wide access only through an explicit org tupleset on resources that truly need it, not a blanket union.
Missing tuple cleanup on delete/unshare Revoked users still pass checks; orphaned tuples reference deleted objects Make every “delete object” and “remove share” path also write a deletes entry. Run periodic reconciliation against your primary DB.
Unpinned model in checks Access answers flip mid-deploy as a new model rolls out Always pass authorizationModelId; treat model IDs like immutable schema migrations.
Tupleset relation pointing at the wrong type Checks silently return false; inheritance never resolves Validate that X from Y has Y as a tupleset relation whose allowed type defines X. The model validator catches most of these.
Relying on ListObjects without consistency control Stale or partial lists right after a write Use the HIGHER_CONSISTENCY option for read-after-write UIs; accept eventual consistency only where a brief stale read is safe.

The “new enemy” problem and consistency

Zanzibar’s hardest correctness issue is the new enemy problem: a user revoked from a resource must not read content that was written after their revocation, even if the revocation tuple has not yet propagated to the replica serving the check. OpenFGA addresses this with consistency tuning — you can request HIGHER_CONSISTENCY on a check to bypass cache and read the latest committed state, at higher latency. The default MINIMIZE_LATENCY may serve a slightly stale graph. Choose per call site: enforce high consistency on the operation that writes sensitive content immediately after a revocation; tolerate the fast path for read-heavy list views.

Security implications

ReBAC concentrates all authorization logic into one externalized service, which is both its strength and its risk surface. The model is your policy: a single over-broad union like or member from org is the equivalent of OWASP A01:2021 Broken Access Control at scale, granting thousands of users access in one line. Keep the deny-by-default posture OpenFGA gives you (no tuple, no access) and never add a wildcard user:* direct relation unless a resource is genuinely public. Because tuples are append-only facts, treat tuple writes as privileged operations — an attacker who can write arbitrary tuples can grant themselves any access, so the tuple-write path must itself be authorized and audited. Log every write and check with the resolved model ID and decision, and alert on tuple writes that grant high-privilege relations (owner, org member) outside your normal provisioning flows.

When ReBAC beats the alternatives: choose it when ownership is deeply nested (documents in folders in orgs), when access flows through sharing graphs (per-resource invites, group-to-group grants), or when you need to answer “list everything this user can see” cheaply. Stay with RBAC when permissions are flat and tied to job function, and reach for ABAC when decisions depend on runtime attributes (time of day, IP, resource sensitivity) rather than relationships. Many production systems combine them: ReBAC for “who is related to this object” and ABAC contextual tuples for the situational guardrails.

Tuples Instead of Roles

A relationship tuple and the question it answers Each tuple states that a user has a relation to an object, and a check walks those relations transitively to answer whether a specific user may perform a specific action on a specific object. user:amara — editor — document:q3-report subject user or a group relation owner · editor · viewer object document · folder · org
Three fields replace the role table. The power comes from relations that reference other relations, so folder membership implies document access without a second tuple per document.

Consistency and the Read-After-Write Problem

Sharing a document and immediately checking access Without a consistency token the check may run against a replica that has not yet seen the new tuple, so the user who was just granted access is told they do not have it. Write the tuple share the document Immediate check hits a stale replica Pass the token read at least as fresh "I shared it and they still cannot open it" is a consistency bug, not a permissions bug.
Carry the write token through to the next check and the sharing flow stops producing confusing support tickets.

Designing the Authorization Model

The model is the schema for your permission graph, and getting it right is most of the work. Start from the questions the product must answer — “who can view this document?”, “can this user comment?” — and write the relations those questions imply rather than transcribing your existing role table.

Define relations that reference other relations wherever access is inherited. A document’s viewer relation should include the parent folder’s viewer, so sharing a folder grants access to everything inside it without writing a tuple per document. That single technique is what keeps the tuple count proportional to real sharing events instead of to the size of your data.

Keep computed relations shallow. Each level of indirection is another hop in the traversal, and a model with deep chains turns a cheap check into an expensive one. Two or three levels — organisation, folder, document — covers most products; if you find yourself at five, the hierarchy probably has a level that exists for organisational tidiness rather than for access.

Model group membership once, and let every resource type reference it. Duplicating “member of team” logic per resource is how two resources end up disagreeing about who is in a team, and reconciling that afterwards means rewriting tuples you no longer trust.

Finally, write the model’s tests as concrete assertions — this user, this relation, this object, expected true or false — and run them on every change. Because relations compose, a small change to one relation can widen access several levels away, and only assertions at the leaves catch that.

Operating a Tuple Store

Two operational habits matter more than anything else. First, make writes idempotent and traceable: a share action should record who granted the relation and when, in your own audit log, because the tuple store records the fact but not the intent. Second, plan for deletion. Tuples accumulate as objects are removed, and a store full of relations pointing at deleted documents both slows traversals and produces confusing answers — schedule cleanup alongside object deletion rather than as an afterthought.

Frequently Asked Questions

When is a tuple store worth adopting?

When your hardest access question involves transitive relationships — a document in a folder shared with a group that contains another group that contains the user. Expressing that with roles or attribute conditions produces queries that are either wrong or unbearably slow. If your permissions are essentially “role within tenant”, a tuple store is a lot of machinery for no gain.

How do I keep the number of tuples manageable?

Use relations that reference other relations rather than writing a tuple per object. A document’s viewer relation should include its folder’s viewer, so sharing a folder is one tuple regardless of how many documents it contains. Writing per-object tuples for inherited access is what turns a tuple store into an unmaintainable table.

Why does a freshly shared document appear inaccessible?

Because the check read a replica that has not yet seen the new tuple. Carry the consistency token returned by the write into the subsequent check so the read is at least as fresh as the write. Without it, sharing flows produce intermittent “they still cannot open it” reports that look like permission bugs and are really replication lag.

Should I list objects with the store or with my database?

Use the store’s list operation for permission-driven listings and paginate it, rather than fetching a page from your database and checking each row. Checking per row is a chatty pattern that gets slower as pages grow, and it produces short pages when many rows are filtered out. Where a listing is dominated by other filters, fetch candidates first and batch the checks.

What happens to tuples when an object is deleted?

Nothing, unless you delete them — which is why cleanup belongs in the same transaction or workflow as the object deletion. Orphaned tuples slow traversals and produce confusing answers when an identifier is later reused. Schedule a reconciliation job as a backstop, and treat a growing orphan count as a signal that a deletion path is missing its cleanup step.

Model changes deserve the same care as schema changes, because a relation edit can widen access several levels away from where it was made.