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.0or OpenFGA Cloud), reachable over HTTP/gRPC with an API token or client-credentials configured. - Node 20+ and the
@openfga/sdkpackage 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.
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 relation — parent 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
aliceview?” 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.
Related
- Modeling Zanzibar-style relationship tuples — designing the tuple schema for documents in folders in orgs, group-to-group grants, and listing a user’s objects.
- Choosing between RBAC and ABAC — decide whether relationships, roles, or attributes should drive your authorization model.
- Designing Role-Based Access Control Systems — the flat-role baseline ReBAC extends when ownership becomes graph-shaped.
- Policy enforcement points in microservices — where to call the OpenFGA check on every request without coupling services to the model.