Skip to content

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)
  • object is type:id (e.g. document:readme).
  • subject is either a direct subject type:id (e.g. user:alice) or a userset type:id#relation (e.g. group:eng#member, "every subject that has member on group: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 having relation on the same object" (e.g. every editor is a viewer).
  • tupleToUserset { tupleset, computedUserset } — "having this relation comes from having computedUserset on the object reached via the tupleset tuple" (e.g. a document's viewer includes the viewer of its parent folder). 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 this difference; 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 an object#relation into 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:

ScopeGrants
tenant:fga.writeAuthor models, write / delete / import tuples
tenant:fga.readRead models and tuples; run Check, ListObjects, Expand, and Export
tenant:fga.checkRun 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:

resolutionMeaning
allowedA path reached the subject.
deniedNo path — including when the (object type, relation) is undefined in the model.
depth_exceededThe walk hit the recursion-depth cap — the model is too deep, or a tuple cycle exists.
budget_exceededThe 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).

MethodPathScopeDescription
POST/api/v1/fga/authorization-modelstenant:fga.writeCreate a new immutable model version (400 invalid_model). Returns consistencyToken.
GET/api/v1/fga/authorization-modelstenant:fga.readList model versions, newest-first (cursor).
GET/api/v1/fga/authorization-models/{id}tenant:fga.readGet one version, schema included (404).
POST/api/v1/fga/tuplestenant:fga.writeWrite / delete tuples (Idempotency-Key; validated against the model). Returns consistencyToken.
GET/api/v1/fga/tuplestenant:fga.readList / filter tuples (cursor).
POST/api/v1/fga/tuples:importtenant:fga.writeBulk OpenFGA-shaped import (idempotent; per-row rejects). Returns consistencyToken.
POST/api/v1/fga/checktenant:fga.check or tenant:fga.readBatch Check (≤ 50) → aligned results. The enforcement hot path. Accepts consistency + consistencyToken.
POST/api/v1/fga/list-objectstenant:fga.check or tenant:fga.readThe reverse of Check — objects of a type a subject can access (truncated flag). Accepts consistency + consistencyToken.
POST/api/v1/fga/expandtenant:fga.readExpand object#relation into its userset tree (debug). Reads live; consistency fields validated only.
POST/api/v1/fga/list-objects/exportstenant:fga.readSubmit an exhaustive export job → 202 (409 export_in_progress).
GET/api/v1/fga/list-objects/exports/{exportId}tenant:fga.readPoll job status (PENDING/RUNNING/READY/FAILED/EXPIRED; 404).
GET/api/v1/fga/list-objects/exports/{exportId}/downloadtenant:fga.readDownload 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:

ValueMeaning
UNSPECIFIEDThe default when the field is omitted. Same behaviour as MINIMIZE_LATENCY.
MINIMIZE_LATENCYServe from the result cache when a fresh-enough entry exists.
HIGHER_CONSISTENCYSkip 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 the Read-response wrapper { "key": { user, relation, object } } are accepted. An OpenFGA timestamp / condition is ignored.
  • Idempotent. Import is idempotent on the tuple natural key — a re-import is a no-op — and additionally honours the standard Idempotency-Key replay.
  • 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 row index and a reason, so you can locate and fix it. imported counts already-present tuples (idempotent), and the response carries a consistencyToken unless every row was rejected.
  • Batch size. A single import may carry up to 5 000 tuples (400 import_too_large above that); split a larger export across several idempotent requests. 400 no_model means 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:import

FGA 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

SymptomCause / fix
Sign-in loops with invalid_scopeThe client requests tenant:fga.* before the hub grant (V86) is deployed. Deploy product-api + the hub migration first.
403 on any /fga callThe 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 importNo authorization model exists yet — create one first.
400 invalid_tuple on writeA 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_largeMore 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_exceededThe write / import would exceed max-tuples-per-tenant — raise the limit or prune tuples.
Check returns allowed: false unexpectedlyRead 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 APIThe 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 madeYou 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_consistencyconsistency must be UNSPECIFIED, MINIMIZE_LATENCY, or HIGHER_CONSISTENCY (case-insensitive). Omit the field for the default.
400 invalid_consistency_tokenThe 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 / ExportYour 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_progressAn export for this workspace is already running. Wait for it to finish (the message names the in-flight exportId).
410 gone on export downloadThe export result's download TTL (24h default) has passed. Submit a new export.
404 on a model, tuple, or export idThe 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 tokenIt 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 the tnt claim) and a livemode flag (from the hub-minted mode claim; absent ⇒ live, fail-safe). A Check in one tenant or one mode never traverses another's tuples. Cross-tenant and cross-mode reads / writes return 404, never 403 — 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_CONSISTENCY and the consistencyToken only 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 in product-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 the tenant:fga.* scopes.