Product documentation
Integrate fine-grained authorization (FGA)
Author a relationship-based authorization model on product-api, write relation tuples, enforce at request time with the batch Check and reverse ListObjects APIs, read your own writes with the consistency token, export exhaustively for access reviews, and import an existing OpenFGA / Auth0 FGA graph.
Integrate fine-grained authorization (FGA)
Fine-grained authorization (FGA) answers a per-resource question — "can subject S do
relation R on object O?" — the kind of check that depends on a relationship between one
subject and one specific resource: "can alice edit document readme?", "who can view
this folder?". It is a pragmatic subset of Google Zanzibar / OpenFGA: you write relation
tuples, author an authorization model, and enforce at request time with a Check call.
FGA is shipped. It lives entirely on the customer-plane management API
(/api/v1/fga/** on product-api, behind the public api-gateway), is owned by
product-api, and is isolated by the tnt claim — the authorization hub never learns about
tuples, models, or checks. This guide is the HTTP integration reference: it is what your
backend, the thoryn CLI, and any tooling drive.
This is the deep FGA integration reference. For how FGA relates to RBAC (role-based access control) and when to reach for each, read the companion Authorize with roles, permissions, and relationships guide first. FGA and RBAC are composable layers of the same product, not alternatives.
Everything below is on product-api. Across the whole surface: the tenant is always the
tnt claim, never the path; request and response fields are camelCase; errors are
RFC 9457 problem-details (application/problem+json) with a stable errorCode; lists are
cursor-paginated; and cross-tenant (or cross-mode) access returns 404, never 403 —
the platform's no-existence-leak invariant.
Concepts
Relation tuples
A relation tuple is the atomic fact object#relation@subject, read "subject has
relation on object". On the wire it is its three parts — object, relation, and
subject:
document:readme # editor @ user:alice alice is a direct editor of the readme
document:readme # viewer @ group:eng#member every member of group:eng is a viewer (a userset)
document:readme # parent @ folder:root the readme's parent is folder:root (a hierarchy edge)
objectistype:id(e.g.document:readme).subjectis either a direct subjecttype:id(e.g.user:alice) or a usersettype:id#relation(e.g.group:eng#member, "every subject that hasmemberongroup:eng"). Usersets are what make this relationship-based rather than list-based.- Type and relation names are lowercase
[a-z0-9_-], 1–64 characters. Ids are opaque tenant strings, 1–128 characters, and may not contain:,#,@, or whitespace. user:*— the reserved type-bound wildcard, "everyone of this type" — is a valid subject id, but only on a direct subject (a wildcard may not carry a subject relation).
A tuple is data; the model is the schema that interprets it. Tuples are stored independently of any model version.
The authorization model
An authorization model is a tenant-authored JSON document (OpenFGA-schema-1.1-compatible)
that declares the object types, their relations, and the rewrite rules that make one
relation imply another. A model is immutable once created: you create new versions, you
never mutate one. The Check engine interprets the tenant's latest version by default, and any
call may pin an older version with authorizationModelId.
A relation's members are derived by a userset-rewrite tree: three leaf rules combined by
the three Zanzibar set operators (the full rewrite algebra —
ADR 2026-08-13-fga-userset-rewrite-intersection-exclusion.md).
Leaves:
this— subjects granted by a direct tuple (including userset subjects).computedUserset { relation }— "having this relation also comes from havingrelationon the same object" (e.g. everyeditoris aviewer).tupleToUserset { tupleset, computedUserset }— "having this relation comes from havingcomputedUserseton the object reached via thetuplesettuple" (e.g. a document'sviewerincludes theviewerof itsparentfolder). This is the hierarchy / inheritance primitive.
Set operators (interior nodes, nestable arbitrarily):
union { child: [...] }— OR: member if any child holds.intersection { child: [...] }— AND: member only if every child holds (viewer = member AND active).exclusion { base, subtract }—base ∧ ¬subtract, "base BUT NOT subtract" (viewer = <all viewers> BUT NOT banned). OpenFGA spells thisdifference; both import.
Conditional / ABAC (conditions) tuples and modular models remain out of scope — a model
that uses them fails validation with a clear error rather than mis-evaluating.
Check, ListObjects, Expand, and Export
Four read operations sit on top of one model + tuple store:
- Check (
POST /api/v1/fga/check) — the enforcement hot path. "Can S do R on O?" → a boolean per check, batched up to 50 per call. This is what your application calls at its own decision points. - ListObjects (
POST /api/v1/fga/list-objects) — the reverse of Check. "Which objects of type T can S access via relation R?" → a bounded list for rendering a page. Every returned object was confirmed by a forward Check, so the reverse index never decides on its own. - Expand (
POST /api/v1/fga/expand) — a debugging aid. Expands anobject#relationinto its userset tree. It always reads the tuple store live (never the verdict cache). - Export (
POST /api/v1/fga/list-objects/exports) — the exhaustive, offline version of ListObjects. An asynchronous job (submit → poll → download NDJSON) that enumerates the full reverse-reachable object set for an access review, beyond the interactive endpoint's candidate reach.
The consistency token
Check verdicts are memoised in a Redis result cache, so a page render costs one round-trip rather than a graph walk per object. That cache is the only place FGA can be stale — every write through the product API busts it. For almost every caller that read-after-write default is the whole story.
When you change access and immediately read it back and must not be handed a verdict
computed before your change, every FGA write returns a consistencyToken — an opaque
string standing for the revision that write committed. Pass it back on a later Check /
ListObjects / Expand to demand "evaluate at least as fresh as this". It is a monotonic
freshness floor, not a snapshot — see Consistency
for exactly what it does and does not guarantee.
Before you start — tokens and scopes
FGA is gated purely by OAuth scope; there is no per-tenant on/off flag. A caller reaches an
endpoint only if its access token carries the scope, and a client can request a scope only if
the scope is registered on the client. Three scopes, all under the tenant: namespace:
| Scope | Grants |
|---|---|
tenant:fga.write | Author models, write / delete / import tuples |
tenant:fga.read | Read models and tuples; run Check, ListObjects, Expand, and Export |
tenant:fga.check | Run Check and ListObjects (the machine-to-machine enforcement channel) |
Management (tenant:fga.read / tenant:fga.write) is admin-token driven — the console and
the thoryn CLI hold these. tenant:fga.check is a first-class machine-to-machine scope
and the primary enforcement channel: your backend calls Check server-to-server with a
client_credentials bearer at its own decision points. The tnt on that machine token is
derived from the request host, so a backend can only check its own tenant's tuples.
Under the transitive-grant model a tenant admin who holds tenant:fga.check can grant it to
their own registered client; admin:* is structurally ungrantable to a tenant client.
The three scopes are registered on the customer-plane clients by hub migration
V86__add_fga_scopes_to_customer_plane_clients.sql. Ordering matters: the hub grant must
be deployed before a client requests the scope, because Spring Authorization Server mints
only requested ∩ registered — request an ungranted scope and sign-in loops with
invalid_scope. If you self-manage and see that loop, confirm V86 has run against the hub
database.
For end users — a full integration walkthrough
The examples use document / folder / group / user types. Substitute your own.
1. Define an authorization model
A model version is immutable; create one before writing any tuple. The schema is the
OpenFGA 1.1 type-definition document:
POST /api/v1/fga/authorization-models
Authorization: Bearer <token with tenant:fga.write>
Content-Type: application/json
{
"schema": {
"schemaVersion": "1.1",
"typeDefinitions": [
{ "type": "user" },
{ "type": "group", "relations": { "member": { "this": {} } } },
{ "type": "folder", "relations": { "viewer": { "this": {} } } },
{
"type": "document",
"relations": {
"parent": { "this": {} },
"editor": { "this": {} },
"viewer": {
"union": { "child": [
{ "this": {} },
{ "computedUserset": { "relation": "editor" } },
{ "tupleToUserset": { "tupleset": "parent", "computedUserset": "viewer" } }
] }
}
}
}
]
}
}Returns 201 with the new immutable version — { id, createdAt, consistencyToken }. (A
model create can change verdicts, so it is a write and mints a token like a tuple write does.)
An invalid model is 400 invalid_model with the specific validation errors in detail. List
versions newest-first with GET /api/v1/fga/authorization-models (cursor-paginated); fetch
one full version, schema included, with GET /api/v1/fga/authorization-models/{id}.
Already running on OpenFGA or Auth0 FGA? Their model DSL is schema-1.1-compatible — post it to the same endpoint. See Migrating from OpenFGA / Auth0 FGA.
2. Write relation tuples
Writes and deletes go in one call. Each tuple is validated against the latest model — a tuple
whose (object type, relation) is not defined is rejected and the whole batch fails:
POST /api/v1/fga/tuples
Authorization: Bearer <token with tenant:fga.write>
Content-Type: application/json
Idempotency-Key: 0f3c8e1a-... # optional; the response is replayed verbatim within 24h
{
"writes": [
{ "object": "document:readme", "relation": "editor", "subject": "user:alice" },
{ "object": "document:readme", "relation": "parent", "subject": "folder:root" },
{ "object": "group:eng", "relation": "member", "subject": "user:bob" }
],
"deletes": []
}Returns { written, deleted, consistencyToken }. Keep that consistencyToken — it is how
a later read proves it reflects this write (see Consistency).
A batch may carry up to 100 writes + deletes. Read and filter tuples with
GET /api/v1/fga/tuples — optional objectType, objectId, relation, subjectType,
subjectId filters, cursor-paginated.
3. Run a Check — enforce at request time
This is how your application enforces FGA at its own decision points. Check is batch-capable (up to 50 checks per call — one round-trip for a whole page render):
POST /api/v1/fga/check
Authorization: Bearer <token with tenant:fga.check or tenant:fga.read>
Content-Type: application/json
{
"checks": [
{ "object": "document:readme", "relation": "viewer", "subject": "user:alice" },
{ "object": "document:readme", "relation": "editor", "subject": "user:bob" }
]
}The response is an aligned results array — one entry per submitted check, in order:
{
"results": [
{ "allowed": true, "resolution": "allowed" },
{ "allowed": false, "resolution": "denied" }
]
}The walk resolves the relation's rewrite rules against the tuple store (expanding usersets and
hierarchy edges) and short-circuits on the first path that reaches the subject. Each check may
pin a model version with authorizationModelId; omit it for the tenant's latest.
Read the resolution, not just allowed — it is one of:
resolution | Meaning |
|---|---|
allowed | A path reached the subject. |
denied | No path — including when the (object type, relation) is undefined in the model. |
depth_exceeded | The walk hit the recursion-depth cap — the model is too deep, or a tuple cycle exists. |
budget_exceeded | The walk hit the node / fan-out budget — the graph fans out too wide. |
The walk is bounded and fail-closed: on cap exhaustion it returns allowed: false with a
distinguishable resolution (depth_exceeded / budget_exceeded) rather than a silent
denied that looks like a legitimate deny. Treat those two as an operational signal, not a
business "no".
4. List the objects a subject can access
Check answers "can alice read this document?". To render a list page you need the reverse — "which documents can alice read?". That is ListObjects:
POST /api/v1/fga/list-objects
Authorization: Bearer <token with tenant:fga.check or tenant:fga.read>
Content-Type: application/json
{ "type": "document", "relation": "viewer", "subject": "user:alice", "limit": 50 }{ "objects": ["document:budget", "document:readme"], "truncated": false }subject is the canonical field; user is accepted as an OpenFGA-parity alias (read only
when subject is absent). authorizationModelId pins a model version; limit caps the
answer and is itself clamped to max-results (100 by default).
How it stays trustworthy. The reverse lookup walks outward from the subject over a
Postgres b-tree index on the tuple table to build a set of candidate objects, then
confirms every candidate with an ordinary forward Check before returning it. The index is on
relation_tuple itself, maintained inside the same transaction as the tuple write — it cannot
lag the tuples — and nothing reaches you without a live forward evaluation. So a listed object
is exactly an object Check allows, at exactly the freshness Check offers.
truncated: true means a bound was hit — your limit, or one of the traversal caps
(oauthy.fga.list-objects.*). The list may be short; it is never wrong. Results come back in
a deterministic order, so a truncated answer is a stable prefix. For a subject whose reachable
set is larger than the traversal's max-candidates reach, or to enumerate everything for
an access review, use the export job below.
Errors: 400 invalid_list_objects (missing or ungrammatical type / relation / subject),
400 undefined_relation (your model does not define that relation on that type — nothing could
ever be allowed), 404 model_not_found (no model authored yet).
5. Export exhaustively for reporting
The interactive ListObjects is bounded by the traversal's max-candidates reach. When you
need the full set — a bulk access review, a compliance report — submit an asynchronous
export job. It scans the tenant's entire candidate object population of the type and
confirms every candidate with the same forward Check, so it enumerates beyond the traversal's
reach without ever over-reporting. It requires tenant:fga.read (an offline reporting
operation, not request-time enforcement).
Submit — returns 202 Accepted with a PENDING job:
POST /api/v1/fga/list-objects/exports
Authorization: Bearer <token with tenant:fga.read>
Content-Type: application/json
{ "type": "document", "relation": "viewer", "subject": "user:alice" }{ "exportId": "...", "status": "PENDING", "objectCount": null, "truncated": false }Poll GET /api/v1/fga/list-objects/exports/{exportId} until status is READY (or
FAILED). The lifecycle is PENDING → RUNNING → READY | FAILED, and a READY job later
becomes EXPIRED once its download TTL passes.
Download GET /api/v1/fga/list-objects/exports/{exportId}/download — streams
NDJSON (application/x-ndjson), one {"object":"type:id"} per line, repeatable until the
result expires (24h by default). Two response headers carry the summary: X-Export-Object-Count
and X-Export-Truncated.
Only one export runs per environment at a time — a second submit while one is in flight for that
environment is 409 export_in_progress (each sandbox plus the production plane gets its own export
slot; SSO-2453). Download states: 409 not_ready (still running), 410 gone
(expired), 404 not_found, 500 export_failed. The result is a best-effort object_id-ordered
listing: each object is Check-confirmed at the instant it is scanned (a revoked tuple never
appears), but it is not a point-in-time snapshot of the whole store. An export larger than
oauthy.fga.list-objects-export.max-objects (50 000 by default) completes truncated: true —
a bounded, object_id-ordered prefix.
Endpoint reference
All paths are under /api/v1/fga on product-api (behind api-gateway). The tenant is the
tnt claim, never the path. Errors are RFC 9457 problem-details with a stable errorCode;
cross-tenant / cross-mode access is 404. The machine-readable contract is the generated
OpenAPI document (docs/api/product-api.openapi.yaml).
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/fga/authorization-models | tenant:fga.write | Create a new immutable model version (400 invalid_model). Returns consistencyToken. |
GET | /api/v1/fga/authorization-models | tenant:fga.read | List model versions, newest-first (cursor). |
GET | /api/v1/fga/authorization-models/{id} | tenant:fga.read | Get one version, schema included (404). |
POST | /api/v1/fga/tuples | tenant:fga.write | Write / delete tuples (Idempotency-Key; validated against the model). Returns consistencyToken. |
GET | /api/v1/fga/tuples | tenant:fga.read | List / filter tuples (cursor). |
POST | /api/v1/fga/tuples:import | tenant:fga.write | Bulk OpenFGA-shaped import (idempotent; per-row rejects). Returns consistencyToken. |
POST | /api/v1/fga/check | tenant:fga.check or tenant:fga.read | Batch Check (≤ 50) → aligned results. The enforcement hot path. Accepts consistency + consistencyToken. |
POST | /api/v1/fga/list-objects | tenant:fga.check or tenant:fga.read | The reverse of Check — objects of a type a subject can access (truncated flag). Accepts consistency + consistencyToken. |
POST | /api/v1/fga/expand | tenant:fga.read | Expand object#relation into its userset tree (debug). Reads live; consistency fields validated only. |
POST | /api/v1/fga/list-objects/exports | tenant:fga.read | Submit an exhaustive export job → 202 (409 export_in_progress). |
GET | /api/v1/fga/list-objects/exports/{exportId} | tenant:fga.read | Poll job status (PENDING/RUNNING/READY/FAILED/EXPIRED; 404). |
GET | /api/v1/fga/list-objects/exports/{exportId}/download | tenant:fga.read | Download the NDJSON result (409 not_ready, 410 gone, 404). |
Consistency — reading your own writes
Every write through the product API busts the tenant's Check cache before the call returns, so the next Check recomputes: a tenant gets read-after-write consistency by default and needs nothing extra. Two opt-in controls let a caller ask for more at the moment it matters most — when you change access and immediately read it back. They are independent and compose; reach for the smaller one that fits.
consistency — "give me a strongly consistent answer" (OpenFGA parity)
check, list-objects, and expand accept an optional consistency field. The values are
OpenFGA's ConsistencyPreference, so a caller porting from OpenFGA or Auth0 FGA sends the same
field with the same values:
| Value | Meaning |
|---|---|
UNSPECIFIED | The default when the field is omitted. Same behaviour as MINIMIZE_LATENCY. |
MINIMIZE_LATENCY | Serve from the result cache when a fresh-enough entry exists. |
HIGHER_CONSISTENCY | Skip the result cache entirely — read and write — and evaluate against the database. |
POST /api/v1/fga/check
Content-Type: application/json
{
"consistency": "HIGHER_CONSISTENCY",
"checks": [
{ "object": "document:readme", "relation": "viewer", "subject": "user:alice" }
]
}The preference applies to the whole request — every item in a Check batch, and, for
ListObjects, the confirming forward Check each candidate goes through. An unrecognised value is
400 invalid_consistency; it never quietly falls back to the cached default. HIGHER_CONSISTENCY
is for the moment you want maximum freshness with no specific write to point at — reconciling
tuples from an external system, verifying a migration, a support workflow that needs a provably
uncached answer. It is not the hot path: a caller that sets it on every call bypasses the
cache on every call and pays the full bounded walk each time.
consistencyToken — "at least as fresh as my write" (the zookie)
consistency gives you maximum freshness. Usually you want something narrower and cheaper:
freshness relative to a specific write you just made. Every FGA write — a tuple write, a
tuple import, or a model create — returns a consistencyToken, an opaque string standing
for the per-tenant revision that write committed:
{ "written": 3, "deleted": 0, "consistencyToken": "ZmdhMTo3ZjRk..." }Pass it back on a later check, list-objects, or expand:
POST /api/v1/fga/check
Authorization: Bearer <token with tenant:fga.check>
Content-Type: application/json
{
"checks": [ { "object": "document:readme", "relation": "viewer", "subject": "user:alice" } ],
"consistencyToken": "ZmdhMTo3ZjRk..."
}What it guarantees — a freshness floor, not a snapshot. The token names a revision. A cached verdict computed at an older revision is discarded and the check is re-evaluated against Postgres (strongly consistent on the primary); a verdict already at or past that revision is served straight from the cache. So a token only pays the full walk when the cache is genuinely too stale for your write — it is the precise control, not the blunt one. The revision is monotonic and is committed in the same database transaction as the tuples, so it can never disagree with them: if the write is visible, so is the revision.
The token does not pin the read to a point-in-time snapshot of the whole store. It raises a lower bound — "evaluate at least as fresh as revision N" — and nothing more; a write that lands after the one your token names may be partly visible to the evaluation. That is the correct and sufficient guarantee for read-your-own-writes, and it is why this is far cheaper here than a Zanzibar zookie: the authoritative store is a single Postgres read from the primary, so "evaluate at or after revision N" collapses to "skip the cache and read the primary" — no snapshot machinery, no wait protocol.
Sending both controls is allowed and harmless: HIGHER_CONSISTENCY already bypasses the
cache, so a token on top adds nothing.
You must persist the token — this is the part integrations get wrong
Store the token next to the resource its tuples protect — a fga_consistency_token column
on your document row, an entry in your resource cache — and pass it back on the next
authorization read of that resource. Overwrite it each time a write returns a newer one.
A token you throw away buys you nothing: the mechanism works because the token travels with the
resource, from the write that changed its permissions to the read that enforces them. Without
that, you are back to hoping the cache happens to be current — exactly the coarse
consistency-only behaviour, minus the OpenFGA-parity field name.
Rules of thumb:
- Do capture the token on every tuple write, tuple import, and model create.
- Do pass it on the read that immediately follows a permission change — the "you removed Bob, now re-render the sharing dialog" path.
- Don't pass a token on every call as a blanket freshness setting; when the cache is stale it bypasses and pays a full evaluation, and the cache exists for a reason.
- Don't parse or construct one. It is opaque, unsigned, and validated server-side against your own tenant and mode; a hand-made token is simply rejected.
A token is bound to the tenant and the sandbox mode (live / test) it was minted in.
Presenting one that belongs to another tenant, to the other mode, or that claims a revision
your tenant has not reached returns 400 invalid_consistency_token — the same response in
every case, so the error cannot be used to probe for other tenants.
One condition this rests on: it holds only while FGA reads go to the Postgres primary. Introducing a read replica for FGA reads would reintroduce genuine snapshot semantics and require real snapshot / wait handling — see ADR 2026-07-23 fga-read-consistency-zookie-equivalent.
Migrating from OpenFGA / Auth0 FGA
A prospect already on OpenFGA or Auth0 FGA can bring an existing graph across without rewriting it, in two steps:
1. Import the model. OpenFGA's model DSL is schema-1.1-compatible, so post your existing
type-definition document to POST /api/v1/fga/authorization-models unchanged (step 1 above).
Models using the full union / intersection / difference (exclusion) algebra — including
nested operators — import directly. Only conditions (ABAC) or modular models are out of
scope: the create returns 400 invalid_model naming what is unsupported, rather than silently
mis-evaluating.
2. Import the tuples. The bulk-import endpoint accepts OpenFGA's tuple-export shape directly:
POST /api/v1/fga/tuples:import
Authorization: Bearer <token with tenant:fga.write>
Content-Type: application/json
Idempotency-Key: import-batch-1 # optional
{
"tuples": [
{ "user": "user:alice", "relation": "viewer", "object": "document:readme" },
{ "key": { "user": "user:bob", "relation": "member", "object": "group:eng" } }
]
}- OpenFGA field names. The subject is named
user(OpenFGA's name), and both the flat{ user, relation, object }shape and theRead-response wrapper{ "key": { user, relation, object } }are accepted. An OpenFGAtimestamp/conditionis ignored. - Idempotent. Import is idempotent on the tuple natural key — a re-import is a no-op — and
additionally honours the standard
Idempotency-Keyreplay. - Per-row rejects, never silent drops. The response is
{ total, imported, rejected: [ { index, code, reason, tuple } ] }. A tuple whose(object type, relation)is undefined in the model (code: undefined_relation) or that is malformed (code: malformed) is rejected with its 0-based rowindexand areason, so you can locate and fix it.importedcounts already-present tuples (idempotent), and the response carries aconsistencyTokenunless every row was rejected. - Batch size. A single import may carry up to 5 000 tuples (
400 import_too_largeabove that); split a larger export across several idempotent requests.400 no_modelmeans you have not created a model yet — do step 1 first.
Because the model DSL and the tuple shape are OpenFGA-compatible, the enforcement calls your
application makes afterwards (check, list-objects, and the consistency parameter) also
match OpenFGA's names, so an SDK ported from OpenFGA changes its base URL and auth, not its
call shapes.
Optional — project organization membership into FGA
If you use Thoryn B2B organizations, membership can project into FGA tuples of the form
organization:{orgId}#member@user:{sub}, so Check reasons over org membership natively — a
model whose document#viewer unions in organization:{orgId}#member then grants every member
of an org access without per-user tuples. It is an explicit per-tenant opt-in, disabled by
default. Enabling requires your model to declare an organization type with a member
relation. See the B2B Organizations guide for the toggle and the
back-fill behaviour.
Configuration
FGA's caps and per-tenant limits are operator configuration under oauthy.fga.* on
product-api (defaults shown). They bound the graph walk and per-tenant storage; none of them
change an authorization outcome — a cap only ever makes an answer deny (Check) or shorter
(ListObjects / Export), never more permissive.
oauthy:
fga:
model:
max-object-types: 100 # object types per model
max-relations-per-type: 50 # relations per object type
max-model-bytes: 131072 # serialized model size cap (128 KiB)
check:
max-depth: 10 # rewrite / userset / hierarchy recursion depth
max-nodes: 1000 # total tuple-expansion steps per check (DoS budget)
max-fanout: 100 # rows read per single object#relation expansion
max-batch-size: 50 # checks per POST /fga/check
cache:
ttl-seconds: 60 # Redis result-cache TTL backstop
list-objects: # reverse-lookup bounds (completeness only, never
max-depth: 10 # correctness — hitting one sets `truncated`)
max-nodes: 1000 # reverse-lookup steps per request
max-fanout: 100 # rows read per single reverse lookup
max-candidates: 500 # candidate objects collected before confirmation
max-results: 100 # objects returned, and the ceiling on `limit`
list-objects-export:
max-objects: 50000 # candidate objects scanned per export (then truncated)
scan-batch-size: 1000 # keyset-scan page size
ttl-hours: 24 # how long a completed export stays downloadable
stale-after-minutes: 60 # when an abandoned in-flight job may be reclaimed
limits:
max-tuples-per-tenant: 1000000 # per-tenant tuple ceiling
max-writes-per-request: 100 # writes + deletes per POST /fga/tuples
max-import-tuples-per-request: 5000 # tuples per POST /fga/tuples:importFGA requires Redis for its multi-node-safe result cache and write-idempotency slots. There is no per-tenant on/off feature flag — availability is entirely a matter of whether the calling client carries the relevant scope.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
Sign-in loops with invalid_scope | The client requests tenant:fga.* before the hub grant (V86) is deployed. Deploy product-api + the hub migration first. |
403 on any /fga call | The token lacks the required scope (tenant:fga.check/.read for Check and ListObjects; tenant:fga.read for Expand and Export; tenant:fga.write to author). A tenant admin can grant a client only scopes they themselves hold. |
400 no_model on a tuple write or import | No authorization model exists yet — create one first. |
400 invalid_tuple on write | A tuple is malformed, or references an (object type, relation) not defined in the latest model. Check names are lowercase [a-z0-9_-] (≤ 64); ids are ≤ 128 chars with no : # @ or whitespace. |
400 batch_too_large | More than max-writes-per-request (100) tuples in one POST /fga/tuples, or more than max-batch-size (50) in one POST /fga/check. |
403 tuple_limit_exceeded | The write / import would exceed max-tuples-per-tenant — raise the limit or prune tuples. |
Check returns allowed: false unexpectedly | Read the resolution: denied (no path — verify your tuples and the model's rewrites; an undefined (object type, relation) also resolves to denied); depth_exceeded / budget_exceeded (the walk hit a cap — the model is too deep / fans out too wide, or a tuple cycle exists). |
| A Check misses a change made outside the product API | The result cache is busted by product-API writes only. Re-issue with "consistency": "HIGHER_CONSISTENCY" to evaluate against the database, and fix the out-of-band write path — do not set the flag permanently. |
| A Check misses a change you just made | You did not pass the write's consistencyToken back on the read, or you threw the token away. Persist it next to the resource and pass it on the following read. |
400 invalid_consistency | consistency must be UNSPECIFIED, MINIMIZE_LATENCY, or HIGHER_CONSISTENCY (case-insensitive). Omit the field for the default. |
400 invalid_consistency_token | The token is malformed, belongs to another tenant, was minted in the other mode (live / test), or claims a revision your tenant has not reached. Do not hand-craft tokens — use only what a write returned. |
400 undefined_relation on ListObjects / Export | Your model does not define that relation on that object type — nothing could be allowed. Fix the type / relation, or author the relation. |
409 export_in_progress | An export for this workspace is already running. Wait for it to finish (the message names the in-flight exportId). |
410 gone on export download | The export result's download TTL (24h default) has passed. Submit a new export. |
404 on a model, tuple, or export id | The id belongs to another tenant (or does not exist). Cross-tenant is 404, never 403 — no existence leak. |
| An FGA relation shows up in a token | It never should — FGA answers are never minted into tokens. Enforce via Check at request time. |
Security notes
- Tenant + mode isolation on every request. Every model, tuple, check, and export is keyed
by
tenant_id(from thetntclaim) and alivemodeflag (from the hub-mintedmodeclaim; absent ⇒ live, fail-safe). A Check in one tenant or one mode never traverses another's tuples. Cross-tenant and cross-mode reads / writes return404, never403— the no-existence-leak invariant. - Check is the authority; there is no stale oracle. ListObjects and Export nominate candidates over a Postgres index maintained inside the tuple write's own transaction, then confirm every candidate with the authoritative forward Check. Nothing is a cached view of an authorization decision that could grant access on its own — the worst an over-broad candidate set costs is an extra Check. A revoked tuple stops granting access on the very next call.
- The result cache fails open-to-recompute, never open-to-allow. The only place FGA can be
stale is the Redis verdict cache, and a Redis blip costs latency (the check recomputes from
Postgres), never a bypass.
consistency: HIGHER_CONSISTENCYand theconsistencyTokenonly ever make an answer fresher — the bounded walk and every cap are identical on the cached and uncached paths. - The consistency token is a bounded hint, never trusted input. It is unsigned by design: every field is re-checked against the caller's own authenticated tenant, mode, and current revision before it can influence a read, and the worst a forged token can do is force an extra cache bypass (more work, never an authorization bypass). It is deliberately not the shape used for credentials the platform must revoke — those stay hashed and DB-stored.
- Check is bounded and fail-closed. Depth / node-budget / fan-out caps bound every check
regardless of the tuple-graph shape; exhausting a cap denies with a distinguishable
resolution, and a cyclic tuple graph is short-circuited by a cycle guard. Authorization defaults to deny. - FGA cannot escalate into platform authorization. Object types, relations, and ids are
opaque tenant strings — a Check result is a boolean your app interprets, never convertible to
a
tenant:*/admin:*OAuth scope. All FGA state lives inproduct-api; the hub stores none of it and stays product-agnostic. The only hub-side artifact is the scope-grant migration that lets customer-plane clients request thetenant:fga.*scopes.