Skip to content

Product documentation

Authorize with roles, permissions, and relationships (RBAC + FGA)

Two tenant-defined authorization models on product-api: coarse role→permission RBAC for tenant-wide gating, and relationship-based FGA (relation tuples + a Check API) for per-resource 'can user U do action A on resource R'.

Authorize with roles, permissions, and relationships

Thoryn ships two tenant-defined authorization models, and they answer different questions:

  • RBAC (role-based access control) answers "does user U hold permission P tenant-wide?" — coarse gating by role, e.g. "is this user a manager?". You define permissions, group them into roles, and assign roles to users. RBAC permissions can optionally ride in the ID token.
  • FGA (fine-grained / relationship-based authorization, a pragmatic subset of Google Zanzibar / OpenFGA) answers "can subject S do relation R on object O?" — a per-resource question that depends on a relationship between one subject and one specific object, e.g. "can alice edit document readme?". You write relation tuples, author an authorization model, and enforce at request time with a Check call.

They are different layers of the same product and are composable, not exclusive. A mature tenant uses RBAC for app-role gating and FGA for per-resource sharing; v1 keeps the two subsystems separate and does not force-migrate one into the other. Both are owned entirely by product-api (the customer plane) and isolated by the tnt claim; the authorization hub never learns about roles, tuples, or checks (it stays product-agnostic).

Everything below is on the customer-plane management API (/api/v1/** on product-api, behind the public api-gateway). The tenant is always taken from the tnt claim, never from the path; responses are camelCase; errors are RFC 9457 problem-details (application/problem+json) with a stable errorCode; lists are cursor-paginated; and cross-tenant access returns 404, never 403 — the platform's no-existence-leak invariant.

When to use which

RBACFGA
Question"Does user U hold permission P tenant-wide?""Can subject S do relation R on object O?"
GranularityCoarse — role → permission, tenant-scopedFine — a relationship to one specific object
Data shaperole, permission, and their assignment edgesobject#relation@subject relation tuples
EnforcementEffective-permission resolve (Redis-cached read model)Check API — a bounded graph walk over tuples
In the token?Yes, opt-in permissions claimNo — FGA answers are never minted into tokens
Reach for it whenapp-role gating ("is this user a manager?")per-resource sharing ("who can edit this doc?")

You can use both at once. They share the same ownership (product-api), the same tnt isolation, and the same Redis-cached, multi-node-safe read models — you pay for one authorization platform, not three.


RBAC — roles and permissions

The model

  • Permission — the atomic authorization unit: a tenant-namespaced string your app defines (read:invoices, approve:orders). These are distinct from OAuth scopes — scopes gate Thoryn's own management API, permissions gate your app. A permission name may not begin with a reserved platform-scope namespace (tenant:, admin:, holder:) — the create call rejects that with 400 permission_name_reserved, so RBAC can never be used to escalate into Thoryn's management-API authorization.
  • Role — a named, tenant-scoped set of permissions (e.g. a manager role that grants read:invoices and approve:orders). Roles compose permissions; roles do not nest in v1 (flat role→permission).
  • Assignment — binds a user to a role. The user is identified by their OAuth sub string. An assignment may optionally be scoped to an organization (organizationId); omit it for a tenant-global assignment. (Org-scoped roles are documented in B2B Organizations.)

All RBAC state lives in product-api Postgres (migration V46). Enforcement is authoritative in product-api, resolved per request from a Redis-cached read model keyed (tenant, subject) → effective permissions — so revoking a role takes effect immediately, without re-minting any token.

1. Create a permission

POST /api/v1/permissions
Authorization: Bearer <token with tenant:roles.write>
Content-Type: application/json
 
{ "name": "approve:orders", "description": "Approve customer orders" }

Returns 201 with { id, name, description, createdAt }. A duplicate name is 409 permission_exists; a reserved-namespace name is 400 permission_name_reserved.

2. Create a role

POST /api/v1/roles
Authorization: Bearer <token with tenant:roles.write>
Content-Type: application/json
 
{ "name": "manager", "description": "Team manager" }

Returns 201 with { id, name, description, createdAt }. A duplicate name is 409 role_exists.

3. Assign permissions to a role

Membership is set as a whole set (idempotent replace), by permission id:

PUT /api/v1/roles/{roleId}/permissions
Authorization: Bearer <token with tenant:roles.write>
Content-Type: application/json
 
{ "permissionIds": ["<perm-id-1>", "<perm-id-2>"] }

Returns the role's resulting permission set. An unknown permission id (or one from another tenant) fails the whole call with 404 permission_not_found; an unknown role is 404. Read the current membership with GET /api/v1/roles/{roleId}/permissions.

4. Assign a role to a user

The user is their OAuth sub:

POST /api/v1/users/{sub}/roles
Authorization: Bearer <token with tenant:roles.write>
Content-Type: application/json
 
{ "roleId": "<role-id>" }        // add "organizationId" to scope it to an org

Returns 201. Assigning a role the tenant does not own returns 404 role_not_found; an org-scoped assignment naming an org outside the tenant returns 404 organization_not_found; a duplicate assignment at the same scope is 409 assignment_exists. Unassign with DELETE /api/v1/users/{sub}/roles/{roleId} (idempotent 204; add ?organizationId= to scope the removal). GET /api/v1/roles/{roleId}/assignments is the reverse lookup — who holds a role.

5. Read a user's effective permissions

GET /api/v1/users/{sub}/permissions
Authorization: Bearer <token with tenant:roles.read>

Returns the distinct union of the permission names across every role the user is assigned in this tenant (empty set for an unknown user or one with no assignments), resolved from the Redis-cached read model. This is the authoritative, always-fresh view — and the fallback an app calls when it does not want permissions in the token.

Getting permissions into the access-check path

There are two ways your app consumes RBAC decisions:

  1. Resolve live — call GET /api/v1/users/{sub}/permissions at your decision point. Always fresh (a revoke is immediate), no token bloat.
  2. In the ID token — opt a client into the permissions claim so the effective set is resolved and merged into the ID token at issuance. This is configured per client on its claims-in-token sources; see Claims in the token for the toggle, the size guard, and the org-aware behaviour. Note the claim fires on the ID token only (access tokens are never webhook-enriched — a PII guard), so a service that needs permissions server-side reads them from the ID token or calls the endpoint above.

The console

Tenant admins do all of the above without the API from the Thoryn console (defining roles, mapping permissions, and assigning roles to users). The console is delivered separately (it calls this same product-api surface through its BFF); this guide documents the underlying API that the console and the thoryn CLI both drive.


FGA — relationships and the Check API

FGA is shipped (relation tuples, a tenant-authored authorization model, a bounded batch Check + ListObjects APIs, and an OpenFGA bulk-import path). The console model editor / tuple browser / Check playground is a later slice; drive FGA through the API described here today.

The model

Relation tuple — the atomic fact object#relation@subject, read "subject has relation on object". On the wire a tuple is its three parts (object, relation, subject):

document:readme # viewer @ user:alice          alice is a direct viewer 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.
  • 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 / relation names are lowercase [a-z0-9_-] (1–64 chars); ids are opaque tenant strings (1–128 chars). user:* (the type-bound wildcard, "everyone of this type") is supported.

Authorization model — a tenant-authored JSON document (OpenFGA-schema-1.1-compatible) defining the object types, their relations, and the rewrite rules that make relations imply one another. It is immutable once created — you create new versions, never mutate — and the Check API interprets the latest version by default. A relation's members are derived by a userset-rewrite tree: three leaf rules combined by the three Zanzibar set operators.

Leaves — the terminals of the tree:

  • this — subjects assigned 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 that combine child rewrites, and may nest arbitrarily (bounded by the walk's depth cap):

  • union { child: [...] } — OR. A subject is a member if any child holds. (This is the default composition and the only operator needed for pure inheritance models.)
  • intersection { child: [...] } — AND. A subject is a member only if every child holds — e.g. viewer = member AND active (conjunction).
  • exclusion { base, subtract }base ∧ ¬subtract, i.e. "base BUT NOT subtract". A subject is a member if it holds base and does not hold subtract — e.g. viewer = <all viewers> BUT NOT banned (negation / revocation-by-exclusion). OpenFGA spells this operator difference; both spellings import.

Intersection and exclusion take the engine to the full Zanzibar rewrite algebra (ADR 2026-08-13-fga-userset-rewrite-intersection-exclusion.md, epic SSO-2575). A model that mixes them — viewer = (member AND active) BUT NOT banned — validates, imports, and evaluates end-to-end. 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. FGA state lives in product-api Postgres (migration V48); tuples are model-independent (a tuple is data; the model is the schema that interprets it).

The set operators — worked examples

viewer = member AND active (intersection). A document is visible only to a subject who is both a member and active:

"viewer": {
  "intersection": { "child": [
    { "computedUserset": { "relation": "member" } },
    { "computedUserset": { "relation": "active" } }
  ] }
}

Check(document:readme#viewer@user:alice) is allowed only when both member and active hold for alice; either one missing → denied.

viewer = <direct viewers> BUT NOT banned (exclusion). A direct grant that a ban revokes:

"viewer": {
  "exclusion": {
    "base":     { "this": {} },
    "subtract": { "computedUserset": { "relation": "banned" } }
  }
}

alice (a direct viewer, not banned) → allowed; mallory (a direct viewer who is also banned) → denied. Exclusion is evaluated as base ∧ ¬subtract: base is checked first and short-circuits to denied when it does not hold, so subtract can never grant access.

Fail-closed budget note. The Check walk's node budget folds across all operands of an intersection / exclusion (each operand issues its own sub-walk). A deep conjunction can reach the budget sooner and (correctly) fail closed with budget_exceeded — a denied verdict, never a mis-grant.

1. Create an authorization model

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 { id, createdAt } (the new immutable version). An invalid model is 400 invalid_model with the specific validation errors in detail. List versions with GET /api/v1/fga/authorization-models (newest-first) and fetch one full version (schema included) with GET /api/v1/fga/authorization-models/{id}.

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 undefined is rejected):

POST /api/v1/fga/tuples
Authorization: Bearer <token with tenant:fga.write>
Content-Type: application/json
Idempotency-Key: 0f3c…            # optional; replayed verbatim within 24h
 
{
  "writes": [
    { "object": "document:readme", "relation": "editor", "subject": "user:alice" },
    { "object": "document:readme", "relation": "parent", "subject": "folder:root" }
  ],
  "deletes": []
}

Returns { written, deleted, consistencyToken }. Keep that consistencyToken — it is how a later read proves it reflects this write. See Consistency — reading your own writes. Writing a tuple before any model exists is 400 no_model; a malformed tuple or an undefined (object type, relation) is 400 invalid_tuple; exceeding the per-tenant tuple limit is 403 tuple_limit_exceeded. List/filter tuples with GET /api/v1/fga/tuples (filters: objectType, objectId, relation, subjectType, subjectId; cursor-paginated).

3. Check — the enforcement hot path

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 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" }
  ]
}

Returns an aligned results array, one entry per submitted check:

{ "results": [ { "allowed": true, "resolution": "allowed" } ] }

The walk resolves the relation's rewrite rules against the tuple store (expanding usersets and hierarchy edges), short-circuiting on the first path that reaches the subject, and is served through a version-invalidated Redis result cache (a tuple write busts the tenant's cache, giving read-after-write consistency within a tenant). Each check may pin a specific model version with authorizationModelId; omit it for the tenant's latest.

The walk is bounded and fail-closed: it caps recursion depth, total expansion steps, and per-step fan-out, and on cap exhaustion returns allowed: false with a distinguishable resolutiondepth_exceeded or budget_exceeded — never a silent false-deny that looks like a legitimate denied. An undefined relation resolves to denied. (POST /api/v1/fga/expand expands an object#relation into its userset tree for debugging; it requires tenant:fga.read.)

The expand tree surfaces the set operators faithfully: an intersection / exclusion node renders with "operator": "intersection" | "exclusion" and an ordered operands array (for an exclusion, operands[0] is the base and operands[1] the subtract branch) rather than being flattened into a union-shaped tree. A plain relation / union node keeps the v1 shape (subjects + children, no operator). This is what lets a console playground draw an AND / BUT NOT node instead of a misleading flat union.

4. List objects — the reverse of Check

Check answers "can alice read this document?". To render a list page you need the reverse: "which documents can alice read?". That is list-objects:

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" }
{ "objects": ["document:budget", "document:readme"], "truncated": false }

subject is the canonical field; user is accepted as an OpenFGA-parity alias. authorizationModelId pins a model version, and limit caps a single page (clamped to max-results, 100 by default). To read beyond one page, follow the continuationToken (below).

How it stays trustworthy. The reverse lookup walks outward from the subject over a Postgres reverse index on the tuple table to build a set of candidate objects, then confirms every candidate with the ordinary forward Check before returning it. Two properties follow, and both matter:

  • The index is a b-tree index on relation_tuple itself, not a separate materialized projection, so it cannot lag the tuples: a revoked tuple stops granting access on the very next call, with the same read-after-write consistency Check has. Nothing here is a cached view of an authorization decision.
  • Nothing reaches you without a live forward evaluation. An object in objects is an object Check allows — at exactly the freshness Check itself offers, no more and no less. The confirming Check may be served from the verdict cache, so if you have just changed a tuple and need the listing to reflect it, pass the consistencyToken from that write (below).

truncated is true when a bound was hit — your limit, or one of the traversal caps (oauthy.fga.list-objects.*). It means the list may be short, never that a listed object is wrong. Results come back in ascending object_id order, so a truncated answer is a stable prefix.

Paging with continuationToken. When more of the answer remains, the response carries a continuationToken. Pass it back — with the identical type / relation / subject (and authorizationModelId, if you pinned one) — to fetch the next page. Page 1 returns a token:

{ "objects": ["document:a", "document:z"], "truncated": true, "continuationToken": "eyJ2MSI…" }

Page 2 repeats the same query plus that token, and returns no token on the last page:

{ "objects": ["document:z2"], "truncated": false }

It is an opaque keyset cursor over object_id — you never build or parse it. No object is returned on two pages. The cursor is bound to your tenant, sandbox mode and exact query: one minted for another tenant, mode, or query is rejected 400 invalid_continuation_token. Pagination is not a snapshot: between pages, a newly-granted object above the cursor appears on a later page, one below the cursor is missed this pass, and a revoked object never appears (every object is live-Checked). For a stable enumeration, pin authorizationModelId and pass the consistencyToken from your last write — it composes with the cursor. A continuationToken: null response that is still truncated: true means you have paged everything the bounded reverse traversal could gather, yet a traversal cap (not the page size) still clipped it — narrow by type / relation, or use the exhaustive export job below to enumerate the full set offline.

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), 400 invalid_continuation_token (a cursor not valid for this tenant / mode / query), 404 model_not_found (no model authored yet).

Exhaustive export — enumerate everything, offline

The interactive list-objects above is bounded by the reverse traversal's max-candidates reach (a DoS bound): a subject reachable to more objects than that stays truncated: true. When you need the full set — a bulk access review, a 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).

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", "truncated": false, "objectCount": null }

Poll GET /api/v1/fga/list-objects/exports/{exportId} until status is READY (or FAILED), then stream the result from GET /api/v1/fga/list-objects/exports/{exportId}/downloadNDJSON, one {"object":"type:id"} per line, repeatable until the result expires (24h by default). Only one export runs per environment at a time (a second submit for that environment is 409 export_in_progress; each sandbox plus the production plane has its own export slot — SSO-2453). 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. Cross-tenant or cross-mode ids return 404, never 403.

5. Consistency — reading your own writes

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 (a tuple write, delete, or model create bumps the tenant's cache generation before the call returns, so the next Check recomputes). For almost every caller that read-after-write default is the end of the story.

Two opt-in controls let a caller ask for more when it matters most — the moment you change access and immediately read it back, where a verdict computed before your change must not be served afterwards. They are independent and compose; reach for the smallest 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 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 list-objects, the confirming forward Check each candidate goes through as well as the traversal. An unrecognised value is rejected with 400 invalid_consistency; it never quietly falls back to the cached default.

HIGHER_CONSISTENCY skips the cache in both directions: it does not read a cached verdict, and it does not store the one it computes. Otherwise a single strongly-consistent call would re-seed the cache it asked to be excluded from, and the next default caller could not tell the difference. It is the right tool when you want maximum freshness but have no specific write to point at — reconciling tuples from an external system, verifying a migration, a support workflow that needs a provably uncached answer.

consistencyToken — "at least as fresh as my write" (the zookie)

consistency gives you maximum freshness. Often you want something narrower and cheaper: freshness relative to a specific write you just made. Every FGA write returns a consistencyToken — an opaque string standing for the revision that write committed:

{ "written": 0, "deleted": 1, "consistencyToken": "ZmdhMTo3ZjRk…" }

Pass it back on a later check, list-objects or expand to say "evaluate at least as fresh as this":

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…"
}

A cached verdict older than that revision is discarded and the check is re-evaluated against the database; a verdict already at or past it is served straight from the cache. So unlike HIGHER_CONSISTENCY, 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. (Sending both is allowed and harmless: HIGHER_CONSISTENCY already bypasses the cache, so the token adds nothing on top.)

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 — which is 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; the cache exists for a reason.
  • Don't try to 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.

The revision behind the token is written in the same database transaction as the tuples, so it can never disagree with them: if the write is visible, so is the revision, and vice versa. That is also why a token-bearing read stays correct even when the cache's invalidation signal goes missing.

Why this is enough here — and the one condition it rests on

Google's Zanzibar needs zookies because it deliberately serves checks from stale snapshots for global latency. Thoryn's authoritative store is a single PostgreSQL database whose primary reads are strongly consistent, and the Check walk and the list-objects traversal both read it directly. All staleness in FGA is therefore self-inflicted by the result cache, so both "give me a strongly consistent answer" and "evaluate at least as fresh as revision N" reduce to "read the primary" — no snapshot, no wait. That holds only while FGA reads go to the primary. Introducing a read replica for FGA reads would make both controls insufficient on their own and require real snapshot / wait handling; see ADR 2026-07-23 fga-read-consistency-zookie-equivalent, which stages the consistency parameter (SSO-2124) ahead of the token (SSO-2125).

Enforcing from your own backend (M2M)

tenant:fga.check is a first-class machine-to-machine scope and the primary enforcement channel: your backend calls POST /api/v1/fga/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 / subdomain (not the client row), so a backend can only check its own tenant's tuples. tenant:fga.check is grantable to a tenant's own registered client under the transitive-grant model (a tenant admin holding tenant:fga.check can grant it to their client; admin:* remains structurally ungrantable). Model and tuple management (tenant:fga.read / tenant:fga.write) stay admin-token-driven via the console / CLI.

Importing from OpenFGA / Auth0 FGA

A prospect already on OpenFGA or Auth0 FGA can bring an existing tuple export straight in via the bulk-import path — no rewriting:

POST /api/v1/fga/tuples:import
Authorization: Bearer <token with tenant:fga.write>
Content-Type: application/json
 
{
  "tuples": [
    { "user": "user:alice", "relation": "viewer", "object": "document:readme" }
  ]
}

It accepts OpenFGA's tuple shape (subject named user; the Read-response { key: {…} } wrapper is also accepted; timestamp / condition are ignored), streams the rows into the store in chunks under the write-batch cap, and is idempotent (a re-import is a no-op). It reports per-row rejects rather than silently dropping them: { total, imported, rejected: [ { index, code, reason, tuple } ] }. Import the authorization model with the standard model-create endpoint above; the OpenFGA model DSL is schema-1.1-compatible.

The model import accepts a real OpenFGA / Auth0-FGA authorization-model JSON directly (the output of fga model transform --output json, or a WriteAuthorizationModel body): the snake_case envelope keys schema_version / type_definitions are aliases of schemaVersion / typeDefinitions; a tupleToUserset's tupleset / computedUserset may be either a bare relation string or OpenFGA's ObjectRelation object ({ "object": "", "relation": "<r>" }); per-type metadata / directly_related_user_types are accepted and ignored; and OpenFGA intersection / difference (exclusion) rewrites import to the typed tree — a model using the full union / intersection / exclusion algebra (including nested operators) migrates without edits. Only a non-empty ABAC conditions block is flagged with a legible error rather than silently dropped.

Project organization membership into FGA (opt-in)

B2B organization membership can project into FGA relation tuples of the form organization:{orgId}#member@user:{sub}, so Check reasons over org membership natively — e.g. a model whose document#viewer unions in organization:{orgId}#member grants every member of an org access without per-user tuples. It is an explicit per-tenant opt-in, disabled by default, so tenants that never use FGA are unaffected.

Enable it from the console (Authorization → Org membership) or the API:

PUT /api/v1/fga/org-membership-projection   { "enabled": true }
GET /api/v1/fga/org-membership-projection   # → { enabled, enabledAt, enabledBy }

Enabling requires your authorization model to declare an organization type with a member relation — FGA rejects tuples for undeclared types, so otherwise it answers 409 model_missing_organization_member. On enable, existing active memberships are backfilled into tuples (409 tuple_limit_exceeded if that would exceed your per-tenant tuple cap). Thereafter every membership add / remove keeps the tuple in sync, and deleting an org removes its member tuples. Disabling stops new projection but leaves existing tuples in place — a toggle flip never silently drops authorization data.

FGA in tokens — deliberately never

Unlike RBAC's permissions claim, an FGA Check result is never surfaced as a token claim. The set of (object, relation) a subject satisfies is unbounded and per-object; it would bloat the token without limit and go stale the instant a tuple changes. FGA is enforced at request time via Check, full stop.


Endpoint reference

All paths are under /api/v1 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 access is 404. The machine-readable contract is the generated OpenAPI document (docs/api/product-api.openapi.yaml).

RBAC

MethodPathScopeDescription
POST/api/v1/permissionstenant:roles.writeCreate a permission (409 duplicate; 400 permission_name_reserved).
GET/api/v1/permissionstenant:roles.readList permissions (cursor).
GET/api/v1/permissions/{id}tenant:roles.readGet one (404 cross-tenant / missing).
DELETE/api/v1/permissions/{id}tenant:roles.writeDelete a permission (204; cascades role edges).
POST/api/v1/rolestenant:roles.writeCreate a role (409 role_exists).
GET/api/v1/rolestenant:roles.readList roles (cursor).
GET/api/v1/roles/{id}tenant:roles.readGet one role (404).
PATCH/api/v1/roles/{id}tenant:roles.writeUpdate the role description.
DELETE/api/v1/roles/{id}tenant:roles.writeDelete a role (204; cascades edges + assignments).
GET/api/v1/roles/{id}/permissionstenant:roles.readList the role's permissions (cursor).
PUT/api/v1/roles/{id}/permissionstenant:roles.writeSet the role's full permission set ({ permissionIds }; 404 permission_not_found).
GET/api/v1/roles/{id}/assignmentstenant:roles.readWho holds this role (cursor).
GET/api/v1/users/{sub}/rolestenant:roles.readA user's assigned roles (cursor).
POST/api/v1/users/{sub}/rolestenant:roles.writeAssign a role ({ roleId, organizationId? }; 409 assignment_exists).
DELETE/api/v1/users/{sub}/roles/{roleId}tenant:roles.writeUnassign (204, idempotent; ?organizationId= to scope).
GET/api/v1/users/{sub}/permissionstenant:roles.readA user's effective permission set (Redis-cached).

{sub} is the target user's OAuth sub. The two scopes are granted on the customer-plane clients by hub migration V82.

FGA

MethodPathScopeDescription
POST/api/v1/fga/checktenant:fga.check or tenant:fga.readBatch Check (≤ 50) → aligned results. The enforcement hot path. Accepts consistencyToken.
POST/api/v1/fga/list-objectstenant:fga.check or tenant:fga.readThe reverse of Check: which objects of a type a subject can access (truncated flag; keyset continuationToken pages beyond one limit). Accepts consistencyToken.
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/authorization-modelstenant:fga.writeCreate a new immutable model version (400 invalid_model). Returns consistencyToken.
GET/api/v1/fga/authorization-modelstenant:fga.readList model versions (cursor, newest-first).
GET/api/v1/fga/authorization-models/{id}tenant:fga.readGet one version, schema included (404).
POST/api/v1/fga/expandtenant:fga.readExpand object#relation to its userset tree (debug). Accepts consistencyToken (always live, so it is validated only).

The three scopes are granted on the customer-plane clients by hub migration V86. check, list-objects, and expand additionally accept the optional consistency field described above (400 invalid_consistency on an unrecognised value).

An invalid consistencyToken — malformed, another tenant's, the other mode's, or claiming a revision the tenant has not reached — is 400 invalid_consistency_token on all three read endpoints.

Configuration

FGA's caps and per-tenant limits are operator configuration under oauthy.fga.* on product-api (defaults shown):

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:                    # the reverse lookup's bounds (completeness only,
      max-depth: 10                  #   never 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`
    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

RBAC has no such caps (roles/permissions are small tenant-config sets). Both models require Redis for their multi-node-safe read/result caches. There is no per-tenant on/off feature flag for either model — availability is entirely a matter of whether the calling client carries the relevant scope (below).

Enabling access — the scope grants

Both models are gated purely by scope. A caller reaches them only if its token carries the scope, and a client can request a scope only if the scope is registered on the client:

  • tenant:roles.read / tenant:roles.write — RBAC — granted on the customer-plane clients (self-service-bff, thoryn-cli, customer-plane-demo) by hub migration V82.
  • tenant:fga.read / tenant:fga.write / tenant:fga.check — FGA — granted on the same customer-plane clients by hub migration V86. tenant:fga.check additionally flows to a tenant's own registered backend client through the transitive scope-grant model.

Ordering matters. The grant migration must be deployed before a client requests the scope. Spring Authorization Server mints only requested ∩ registered, so requesting an ungranted scope loops sign-in with invalid_scope. If you self-manage and see that loop, confirm V82 / V86 have run against the hub database.

Troubleshooting

SymptomCause / fix
permission claim absent from the tokenThe client hasn't opted the permissions source into its claims-in-token config; the subject has no role assignments; or you're reading an access token — the claim fires on the ID token only.
Sign-in loops with invalid_scopeThe client is requesting tenant:roles.* / tenant:fga.* before the hub grant (V82 / V86) is deployed — deploy product-api + the hub migration first.
403 on POST /fga/check or the RBAC APIThe token lacks the required scope (tenant:fga.check/.read for Check; tenant:roles.* for RBAC). A tenant admin can grant a client only scopes they themselves hold.
400 permission_name_reservedA permission name begins with tenant: / admin: / holder: — those namespaces are reserved for platform scopes; rename it.
404 role_not_found / 404 permission_not_found on assign / setThe role or permission id belongs to another tenant (or doesn't exist) — cross-tenant is 404, no leak.
FGA Check returns allowed: false unexpectedlyRead the resolution: denied (no path — check your tuples and the model's rewrites); depth_exceeded / budget_exceeded (the walk hit a cap — the model is too deep / fans out too wide, or a tuple cycle exists). An undefined (object type, relation) in the model also resolves to denied.
A Check misses a change made outside the product APIThe result cache is busted by product-API writes only. Re-issue the call with "consistency": "HIGHER_CONSISTENCY" to evaluate against the database, and fix the out-of-band write path — do not set the flag permanently.
400 invalid_consistencyconsistency must be UNSPECIFIED, MINIMIZE_LATENCY, or HIGHER_CONSISTENCY (case-insensitive). Omit the field for the default.
Check latency jumps after a client changeSomething is sending consistency: HIGHER_CONSISTENCY on the hot path — every such call bypasses the cache and pays the full walk. Watch fga.check.cache_bypass_total against fga.check.cache_hit_total.
400 no_model on a tuple write / importNo authorization model exists yet — create one before writing tuples.
400 invalid_tuple on writeA tuple is malformed, or references an (object type, relation) not defined in the latest model.
403 tuple_limit_exceededThe write / import would exceed max-tuples-per-tenant — raise the limit or prune tuples.
FGA relation shows in a tokenIt never should — FGA answers are never minted into tokens; enforce via Check at request time.

Security notes

  • Tenant isolation on every request via the tnt claim plus tenant-scoped queries; cross-tenant reads / writes / deletes return 404, never 403. RBAC and FGA data are keyed by tenant_id and never cross the boundary — a Check on one tenant's object never traverses another tenant's tuples.
  • Scopes are the access gate. RBAC needs tenant:roles.*; FGA needs tenant:fga.*. A tenant admin can grant a client only scopes they themselves hold (the transitive model); admin:* is structurally ungrantable to a tenant client.
  • Neither model can escalate into platform authorization. RBAC permission names may not use a reserved platform-scope namespace (tenant: / admin: / holder:); FGA object types, relations, and ids are opaque tenant strings — a Check result is a boolean your app interprets, never convertible to a tenant:* / admin:* / holder:* OAuth scope.
  • The hub stays product-agnostic. All roles, permissions, tuples, and models live in product-api; the hub stores none of them. The only hub-side artifact is the scope-grant migration that lets customer-plane clients request the scopes.
  • Enforcement is live, not token-trusted. RBAC effective permissions resolve from a Redis-cached read model (revoking a role is immediate); FGA Check evaluates against the current tuple store (a write busts the tenant's check cache). The FGA cache fails open-to-recompute, never open-to-allow — a Redis blip costs latency, not a bypass. A caller may additionally demand a cache-free evaluation per request with consistency: HIGHER_CONSISTENCY; that only ever makes an answer fresher, never more permissive — the bounded walk and every cap are identical on both paths.
  • FGA Check is bounded and fail-closed. Depth / node-budget / fan-out caps bound every check regardless of tuple-graph shape; exhausting a cap denies (with a distinguishable resolution), and a cyclic tuple graph is short-circuited by a cycle guard.