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
aliceedit documentreadme?". 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
| RBAC | FGA | |
|---|---|---|
| Question | "Does user U hold permission P tenant-wide?" | "Can subject S do relation R on object O?" |
| Granularity | Coarse — role → permission, tenant-scoped | Fine — a relationship to one specific object |
| Data shape | role, permission, and their assignment edges | object#relation@subject relation tuples |
| Enforcement | Effective-permission resolve (Redis-cached read model) | Check API — a bounded graph walk over tuples |
| In the token? | Yes, opt-in permissions claim | No — FGA answers are never minted into tokens |
| Reach for it when | app-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 with400 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
managerrole that grantsread:invoicesandapprove: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
substring. 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 orgReturns 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:
- Resolve live — call
GET /api/v1/users/{sub}/permissionsat your decision point. Always fresh (a revoke is immediate), no token bloat. - In the ID token — opt a client into the
permissionsclaim 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 theorg-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)
objectistype:id.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 / 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 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 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 holdsbaseand does not holdsubtract— e.g.viewer = <all viewers> BUT NOT banned(negation / revocation-by-exclusion). OpenFGA spells this operatordifference; 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
resolution — depth_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_tupleitself, 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
objectsis 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 theconsistencyTokenfrom 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}/download — NDJSON,
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:
| 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 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
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/permissions | tenant:roles.write | Create a permission (409 duplicate; 400 permission_name_reserved). |
GET | /api/v1/permissions | tenant:roles.read | List permissions (cursor). |
GET | /api/v1/permissions/{id} | tenant:roles.read | Get one (404 cross-tenant / missing). |
DELETE | /api/v1/permissions/{id} | tenant:roles.write | Delete a permission (204; cascades role edges). |
POST | /api/v1/roles | tenant:roles.write | Create a role (409 role_exists). |
GET | /api/v1/roles | tenant:roles.read | List roles (cursor). |
GET | /api/v1/roles/{id} | tenant:roles.read | Get one role (404). |
PATCH | /api/v1/roles/{id} | tenant:roles.write | Update the role description. |
DELETE | /api/v1/roles/{id} | tenant:roles.write | Delete a role (204; cascades edges + assignments). |
GET | /api/v1/roles/{id}/permissions | tenant:roles.read | List the role's permissions (cursor). |
PUT | /api/v1/roles/{id}/permissions | tenant:roles.write | Set the role's full permission set ({ permissionIds }; 404 permission_not_found). |
GET | /api/v1/roles/{id}/assignments | tenant:roles.read | Who holds this role (cursor). |
GET | /api/v1/users/{sub}/roles | tenant:roles.read | A user's assigned roles (cursor). |
POST | /api/v1/users/{sub}/roles | tenant:roles.write | Assign a role ({ roleId, organizationId? }; 409 assignment_exists). |
DELETE | /api/v1/users/{sub}/roles/{roleId} | tenant:roles.write | Unassign (204, idempotent; ?organizationId= to scope). |
GET | /api/v1/users/{sub}/permissions | tenant:roles.read | A 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
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/fga/check | tenant:fga.check or tenant:fga.read | Batch Check (≤ 50) → aligned results. The enforcement hot path. Accepts consistencyToken. |
POST | /api/v1/fga/list-objects | tenant:fga.check or tenant:fga.read | The 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/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/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 (cursor, newest-first). |
GET | /api/v1/fga/authorization-models/{id} | tenant:fga.read | Get one version, schema included (404). |
POST | /api/v1/fga/expand | tenant:fga.read | Expand 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:importRBAC 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 migrationV82.tenant:fga.read/tenant:fga.write/tenant:fga.check— FGA — granted on the same customer-plane clients by hub migrationV86.tenant:fga.checkadditionally 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
| Symptom | Cause / fix |
|---|---|
permission claim absent from the token | The 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_scope | The 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 API | The 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_reserved | A 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 / set | The role or permission id belongs to another tenant (or doesn't exist) — cross-tenant is 404, no leak. |
FGA Check returns allowed: false unexpectedly | Read 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 API | The 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_consistency | consistency must be UNSPECIFIED, MINIMIZE_LATENCY, or HIGHER_CONSISTENCY (case-insensitive). Omit the field for the default. |
| Check latency jumps after a client change | Something 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 / import | No authorization model exists yet — create one before writing tuples. |
400 invalid_tuple on write | A tuple is malformed, or references an (object type, relation) not defined in the latest model. |
403 tuple_limit_exceeded | The write / import would exceed max-tuples-per-tenant — raise the limit or prune tuples. |
| FGA relation shows in a token | It never should — FGA answers are never minted into tokens; enforce via Check at request time. |
Security notes
- Tenant isolation on every request via the
tntclaim plus tenant-scoped queries; cross-tenant reads / writes / deletes return404, never403. RBAC and FGA data are keyed bytenant_idand 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 needstenant: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 atenant:*/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.