Modeling Zanzibar-Style Relationship Tuples
You have a working OpenFGA store, but every new sharing feature forces another awkward tuple and your viewer from parent inheritance keeps returning the wrong answer. The fix is almost always the tuple schema, not the check call. This page is part of the Relationship-Based Access Control With OpenFGA guide and drills into how to design tuples — objects, relations, usersets, group-to-group grants, and parent-child inheritance — so that “documents in folders in orgs” resolves correctly and listing a user’s objects stays cheap.
Why the tuple schema is where mistakes live
A relationship tuple is the atomic fact object#relation@user, for example document:roadmap#viewer@user:alice. Three fields, each constrained by the authorization model. Most modeling bugs come from confusing the three kinds of right-hand side the user field can hold:
- A subject —
user:alice. One concrete principal. - A userset —
team:eng#member. “Every user who satisfiesmemberonteam:eng.” This is the building block for group-based grants. - A wildcard —
user:*. “Every user of this type.” Use it only for genuinely public access; it is the most common cause of accidental over-exposure.
The model’s type restrictions decide which of these is legal for each relation. Declaring define viewer: [user, team#member] permits both a direct subject and a team userset; declaring define member: [user] permits only subjects. Getting these restrictions tight is the first line of defense.
Objects, relations, and usersets
Pick object types that mirror your real resources, and relations that mirror real verbs. A relation is satisfied three ways: a direct tuple, a computed relation on the same object, or a tupleset relation that walks to another object. Here is the documents-in-folders-in-orgs model with each kind annotated.
model
schema 1.1
type user
type organization
relations
define admin: [user]
define member: [user] or admin # computed: admins are members
type team
relations
define member: [user, team#member] # userset: teams can nest other teams
type folder
relations
define org: [organization]
define owner: [user]
define parent: [folder]
# direct, computed (owner), and tupleset (editor from parent) combined:
define editor: [user, team#member] or owner or editor from parent
define viewer: [user, team#member] or editor or viewer from parent
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
The two folder/document define viewer lines are doing the heavy lifting. viewer from parent is a tupleset relation: parent names the tupleset (the related folder), and viewer is the relation walked on that folder. Inheritance is recursive — a document’s viewer resolves to its folder’s viewer, which resolves to that folder’s parent’s viewer, all the way up.
Group-to-group grants
The reason usersets exist is to avoid writing one tuple per user. Grant a whole team editor access with a single tuple whose user is a userset:
await fga.write({
writes: [
// Every member of team:eng becomes an editor of the folder — one tuple, N users.
{ user: "team:eng#member", relation: "editor", object: "folder:planning" },
],
});
Because team.member is itself [user, team#member], teams nest: making team:platform#member a member of team:eng means platform engineers inherit everything team:eng can reach. This is group-to-group delegation, and it composes without touching any document tuple. When you later add a user to team:platform, every downstream grant updates automatically — no recomputation, no denormalized rows to backfill.
Parent-child inheritance: documents in folders in orgs
The canonical Zanzibar example is a file hierarchy, and the tuples that wire it up are pure structure:
await fga.write({
writes: [
{ user: "organization:acme", relation: "org", object: "folder:planning" },
{ user: "folder:planning", relation: "parent", object: "folder:q3" },
{ user: "folder:q3", relation: "parent", object: "document:roadmap" },
],
});
A check for viewer on document:roadmap now walks document → folder:q3 → folder:planning → organization:acme, evaluating each relation’s union along the way. The diagram shows the concrete graph these tuples build.
Tuple lifecycle and cleanup
Tuples are append-only facts, so the dangerous moments are removals. Every operation that revokes access or deletes an object must write a corresponding deletes entry, or you leak access through stale facts.
// Unshare: remove a direct grant AND the structural edges of a deleted document.
await fga.write({
deletes: [
{ user: "user:dana", relation: "viewer", object: "document:roadmap" },
{ user: "folder:q3", relation: "parent", object: "document:roadmap" },
],
});
Guard rails that keep the tuple store honest:
- Couple writes to your domain events. The same transaction that deletes a document row should enqueue its tuple deletions. Treat a missed deletion as a security incident, not a cosmetic bug.
- Reconcile periodically. Run a job that lists tuples for each type via the Read API and cross-checks object IDs against your primary database; delete tuples whose objects no longer exist.
- Never orphan a parent edge. Deleting a folder without deleting its
parenttuples leaves children pointing at a ghost, which silently breaks (or, worse, preserves) inherited access.
Listing a user’s objects
Rendering “documents Dana can see” with one check per document is O(n) round trips. Use ListObjects instead — it walks the graph from the user side and returns every object of a type they have the relation on.
const { objects } = await fga.listObjects({
user: "user:dana",
relation: "viewer",
type: "document",
authorizationModelId: process.env.FGA_MODEL_ID!,
});
// objects: ["document:roadmap", "document:launch-plan", ...]
ListObjects performance is a function of model shape: deep from parent chains and wide unions make it traverse more of the graph. Keep inheritance chains shallow where you can, and if a list view is latency-critical, denormalize a single direct viewer tuple at share time so ListObjects resolves it without walking the whole hierarchy. Pin the authorizationModelId so the list is computed against the same model your checks use.
Security implications
The tuple schema is the security boundary. A single over-broad type restriction — define viewer: [user:*] where you meant [user] — turns a private resource public, and a missing deletion leaves a revoked user with standing access. Because tuples grant authority, the write path must itself be an authorized, audited operation: an attacker who can inject document:secret#owner@user:attacker owns the document. Validate that user-supplied IDs in tuple writes match the resource the caller is actually allowed to share, log every write with the granted relation, and alert on grants of high-privilege relations (owner, organization admin) issued outside your provisioning flow.
Prevention and monitoring hooks
- Assert in CI. Keep a
.fga.yamltest that pins expectedviewer/editoroutcomes for representative users; fail the build if a model edit flips any assertion. - Log resolved decisions. Emit
{ user, relation, object, allowed, model_id }for every check so you can replay why access was granted. - Alert on tuple write spikes. A sudden burst of
owner/adminwrites is a strong signal of a misbehaving provisioning job or an attack. - Reconcile on a schedule. Track the count of tuples whose objects no longer exist; a rising number means a cleanup path is missing.
Frequently Asked Questions
What is the difference between a userset and a computed relation?
A userset appears in the user field of a tuple — team:eng#member means “every member of team:eng” and is how you grant access to a group. A computed relation appears in the model, like viewer: editor or ..., and means one relation is automatically satisfied whenever another is, without storing a tuple. Usersets are data; computed relations are schema.
How do I model nested groups, like a team that contains other teams?
Declare the membership relation to allow its own userset: define member: [user, team#member]. Then write a tuple such as team:platform#member is member of team:eng. Checks recurse through the nesting automatically, so adding a user to the inner team grants every access the outer team holds.
When should I denormalize a direct tuple instead of relying on inheritance?
When a ListObjects or check is latency-critical and the inheritance chain is deep or fans out widely. Writing an explicit document:x#viewer@user:y at share time lets the graph resolve in one hop. The trade-off is more tuples to keep consistent — you must delete the denormalized tuple when the underlying grant is revoked.
How do I revoke a single user without removing a whole group's access?
If access came through a userset (group membership), remove the user from the group with a deletes on team:eng#member@user:dana rather than touching the resource tuple. If you must keep the user in the group but block one resource, OpenFGA does not support per-tuple deny — model an explicit blocked relation and subtract it in the model with but not blocked.
Will deleting a folder automatically remove access to its documents?
No. Tuples are independent facts. Deleting the folder object in your database does not delete the parent tuples that point children at it, nor any direct grants on the folder. Your delete handler must write the corresponding deletes for every edge, and a reconciliation job should sweep up any that were missed.
Related
- Relationship-Based Access Control With OpenFGA — the full ReBAC model, check/expand/list-objects APIs, and the new-enemy consistency problem.
- Choosing between RBAC and ABAC — when relationships beat roles or attributes as the basis for authorization.
- Designing Role-Based Access Control Systems — the flat-role schema that relationship tuples extend for graph-shaped ownership.