Product documentation
B2B Organizations
Model a tenant's own business customers as organizations with members and email invitations — the product-api Organizations management API.
B2B Organizations
A Thoryn tenant is the paying customer. A B2B organization is that tenant's own customer — a business the tenant serves — modeled as a sub-entity within the tenant. An organization is not a tenant and not an OIDC issuer; it groups members (subjects from any federation), it does not mint tokens.
Organizations are owned entirely by product-api (the customer plane), consistent with
RBAC and FGA. The authorization hub never learns "organization".
This page documents slices O1 (the data model and management API), O2 (org-scoped roles + the active org in the token — see Org-scoped roles), and the O3 additions (org-level SSO connections, DNS-TXT domain verification, email-domain routing, JIT membership, and the login-gate reconciliation). Invitations are now also emailed to the invitee (SSO-2261) — the accept link is sent in addition to the token, which is still returned; the console UI is the remaining O4 slice. The org-SSO + login-gate details live in the companion guide Organization SSO and the login gate; see ADRs
2026-07-15-b2b-organizations.mdand2026-07-16-b2b-organizations-org-sso-and-login-gate.md.
For end users
A tenant administrator (or a machine client with the right scopes) can:
- Create an organization with a name, a tenant-unique
slug, an optionalexternalId(your own id for the org), and arbitrary JSONmetadata. - Add members directly by
subject(the opaquesuba user carries in their Thoryn token, from any federation). A subject may belong to many organizations. - Invite members by email. Issuing an invitation returns a single-use token once and emails the invitee a console accept link (SSO-2261); the invited, authenticated user accepts it to become an active member.
- List and remove members, and list and revoke pending invitations.
Endpoint reference
All paths are under /api/v1; the tenant is taken from the tnt claim (never the path).
Responses are camelCase; lists use the { data, pagination } cursor envelope; errors
are RFC 9457 problem-details (application/problem+json) with a stable errorCode. Writes
accept an Idempotency-Key header. Cross-tenant reads/updates/deletes return 404, not
403 — no existence leak.
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/organizations | tenant:orgs.write | Create an org (409 organization_exists on a duplicate slug). |
GET | /api/v1/organizations | tenant:orgs.read | List orgs (cursor; filter slug, externalId). |
GET | /api/v1/organizations/{id} | tenant:orgs.read | Get one org (404 cross-tenant / missing). |
PATCH | /api/v1/organizations/{id} | tenant:orgs.write | Update name / metadata / status. |
DELETE | /api/v1/organizations/{id} | tenant:orgs.write | Delete an org (members + invitations cascade). |
GET | /api/v1/organizations/{id}/members | tenant:orgs.read | List members (cursor). |
POST | /api/v1/organizations/{id}/members | tenant:orgs.write | Add an active member ({ subject }; 409 member_exists). |
PATCH | /api/v1/organizations/{id}/members/{subject} | tenant:orgs.write | Change a member's status ({ status: "active" | "inactive" }; 404 cross-tenant / unknown; 409 directory_managed_readonly for a Directory-Sync-owned membership). |
DELETE | /api/v1/organizations/{id}/members/{subject} | tenant:orgs.write | Remove a member (204, idempotent). |
GET | /api/v1/me/organizations | tenant:orgs.read | The caller's own active organizations ({id, slug, name} each), derived from the token sub + tnt + env. Empty ⇒ 200 []. |
POST | /api/v1/organizations/{id}/invitations | tenant:orgs.write | Invite ({ email, roles? }) → the token is returned once and the invitee is emailed an accept link (SSO-2261). |
GET | /api/v1/organizations/{id}/invitations | tenant:orgs.read | List pending invitations (cursor). |
DELETE | /api/v1/organizations/{id}/invitations/{invId} | tenant:orgs.write | Revoke an invitation. |
POST | /api/v1/organization-invitations/accept | authenticated user | Accept ({ token }) → the caller becomes an active member. |
PUT | /api/v1/organizations/{id}/connections | tenant:orgs.write | Create or replace the org's SSO connection (O3; see the companion guide). |
GET | /api/v1/organizations/{id}/connections | tenant:orgs.read | Get the org's SSO connection (404 when none). |
DELETE | /api/v1/organizations/{id}/connections | tenant:orgs.write | Remove the org's SSO connection. |
POST | /api/v1/organizations/{id}/domains | tenant:orgs.write | Add an email domain (returns the DNS-TXT challenge to publish). |
GET | /api/v1/organizations/{id}/domains | tenant:orgs.read | List the org's domains and their verification state. |
POST | /api/v1/organizations/{id}/domains/{domId}/verify | tenant:orgs.write | Verify a domain via DNS-TXT. |
PATCH /api/v1/organizations/{id} also accepts the O3 per-org JIT toggles
jitProvisioningEnabled and domainAutoMembership (both default false).
The machine-readable contract is the generated OpenAPI document
(docs/api/product-api.openapi.yaml).
Invitations and tokens
Invitation tokens are DB-stored hashes, never JWTs (the same shape as password-reset
tokens): the plaintext is returned exactly once on POST .../invitations and only its
SHA-256 hash is persisted. Tokens are single-use and TTL-bounded (7 days by default). The
invitee's opaque subject is unknown at invite time, so the pending state lives on the
invitation row; the active membership is created at accept with the accepting user's
real sub. Token possession is the authorization — an invited user external to the
inviting tenant can accept (the cross-tenant B2B flow).
Email delivery (SSO-2261). In addition to returning the token, product-api emails the
invitee a console accept link built from the token. It builds the link from the
configured console base URL (oauthy.organizations.invitation.accept-base-url, a required
Helm value in deployments — never a request Host header, per the SSO-1134 email-URL rule)
and calls identity-service's internal POST /internal/tenants/{tenantId}/organization-invitations/send
render-and-send endpoint, which owns the templated, per-tenant-customizable, localized
organization_invitation email (mirroring the tenant-user invitation template). The send is
best-effort: an SMTP/identity outage never fails the invite (the token is still returned,
so the copy-link fallback always works), and a test-mode invite (mode=test) mints the
token but suppresses the real email (the sandbox never contacts a real inbox). The token
remains in the API response for now — its removal is a deliberately-deferred later slice.
Invitations are rate-limited per email (3/hour) and per IP (10/hour) in Redis, so the cap holds across replicas.
Configuration
oauthy:
organizations:
invitation:
ttl: P7D # invitation token time-to-live (ISO-8601 duration)
# SSO-2261: console origin the accept link is built on. REQUIRED in deployments
# (Helm `productApi.consoleInvitationAcceptBaseUrl`, fail-loud). Blank ⇒ the email
# is skipped and only the token is returned (local-dev / CI copy-link fallback).
accept-base-url: https://console.example.com
rate-limit:
per-email-per-hour: 3
per-ip-per-hour: 10The two scopes tenant:orgs.read / tenant:orgs.write are granted on the customer-plane
clients by the hub migration V89__add_orgs_scopes_to_customer_plane_clients.sql. The
gateway forwards /api/v1/organizations/** and /api/v1/organization-invitations/**
verbatim; scope enforcement stays in product-api.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
409 organization_exists on create | The slug is already used in this tenant (slugs are unique per tenant, free across tenants). |
400 invalid_slug | slug must be a non-empty URL-safe string ([A-Za-z0-9_-]+). |
400 invalid_metadata | metadata must be a JSON object, not a scalar or array. |
404 not_found reading/patching an org that exists | The org belongs to another tenant — cross-tenant access returns 404 by design. |
409 member_exists | The subject is already a member of the org. |
400 invalid_token on accept | The token is unknown, already used, or revoked. 410 invitation_expired means the TTL elapsed. |
429 rate_limited on invite | The per-email or per-IP invitation window was exceeded; retry later. |
Sign-in loops with invalid_scope | The client is requesting tenant:orgs.* before the hub V89 grant is deployed — deploy product-api + the hub migration first (the scope-grant-ordering rule). |
404 organization_not_found assigning a role | The organizationId names an org that does not exist in this tenant (O2). |
access_denied at /oauth2/authorize with organization | The authenticating subject is not an active member of the requested org (O2), or the value is not a valid org id. |
org / org_roles claim absent from the ID token | The client didn't opt those sources in, the subject has ≥ 2 orgs and passed no organization, or the requested org isn't an active membership (all "omit, never guess"). |
Security notes
- Tenant isolation is enforced by the
tntclaim on every request plus tenant-scoped queries; cross-tenant access is404, never403. - Invitation tokens are hashes at rest; the plaintext exists only in the create response and in the accept-link email (SSO-2261). They are single-use and expire.
- The accept-link email URL is built from the configured console base URL, never a
request
Hostheader (the SSO-1134 account-takeover bug class; enforced bycheck-no-host-header-in-email-url-builder.sh). - Members are opaque subjects, never user rows — deleting an org cascades its
memberships and never touches any user record (there are none in
product-api). - The internal read
GET /internal/tenants/{tenantId}/members/{subject}(the SSO-1496 tenant-membership axis, consumed by the hub login gate in O3) is network-isolated and not gateway-routable. - The O2 internal read
GET /internal/tenants/{tenantId}/organizations/{orgId}/members/{subject}(the org-membership oracle the hub calls to validate theorganizationparam) is likewise network-isolated and not gateway-routable. The hub owns no org data; it reads this product-api endpoint and mirrors nothing. A product-api outage on this path fails open (the login proceeds; the org claim is still omitted for a non-member), so a broker blip never blocks sign-in.
Org-scoped roles (O2)
Org-scoped roles are RBAC — not a second authorization engine. An org-scoped assignment
is a user_role row with its org_id set to an org (the column RBAC already shipped). It
reuses the tenant:roles.* scopes and the same effective-permission resolver; only the
scope of the assignment is new.
Assign a role to a user within an org by adding organizationId to the assignment body:
POST /api/v1/users/{userId}/roles
Authorization: Bearer <token with tenant:roles.write>
Content-Type: application/json
{ "roleId": "…", "organizationId": "…" } // omit organizationId for a tenant-global roleThe org must exist in the caller's tenant, else 404 organization_not_found (no existence
leak). Unassign the org-scoped row with DELETE /api/v1/users/{userId}/roles/{roleId}?organizationId=…
(omit the query param to remove the tenant-global assignment).
| Method | Path | Scope | Description |
|---|---|---|---|
POST | /api/v1/users/{userId}/roles | tenant:roles.write | Assign a role; optional organizationId scopes it to an org. |
DELETE | /api/v1/users/{userId}/roles/{roleId} | tenant:roles.write | Unassign; optional ?organizationId= scopes the removal. |
GET | /api/v1/organizations/{id}/members/{subject}/roles | tenant:roles.read | List a member's org-scoped role assignments (cursor). |
Effective permissions are org-aware. For a (tenant, subject, active-org) context the
resolver returns the union of the subject's global assignments (org_id NULL) and the
assignments scoped to the active org. A different org's assignments never apply. The
resolver's Redis cache key gains the active-org dimension; a role write still busts the whole
tenant's cache (the per-tenant version counter is unchanged). This composition is the same
one RBAC and FGA already use — you pay for one authorization model, not three.
Active org in the token (O2)
A token names one active organization (the Auth0 / WorkOS "one active org per token"
model). On ID-token issuance, a client that has opted the org / org_roles sources into
its claims-in-token configuration receives:
org— the active org's{ id, slug, name };org_roles— the org-scoped role names for the active org (not the union across the user's orgs).
Selecting the active org — the organization authorize parameter
Pass an organization (the org id) to /oauth2/authorize (or PAR). The hub validates
that the authenticating subject is an active member of that org and, if so, threads the
selection through to the token. A non-member org is refused with access_denied — it is
never silently ignored.
Resolution rules (all applied by product-api, which owns the membership data — the hub
stays product-agnostic and only forwards the opaque selection):
| Situation | Result |
|---|---|
organization names an org the subject is an active member of | that org is active |
No organization, and the subject has exactly one active membership | that org is active (implicit) |
No organization, and the subject has ≥ 2 active memberships | org / org_roles omitted — the app must select |
organization names an org the subject is not a member of | access_denied at /oauth2/authorize (and the claim is omitted) |
The claims fire on the ID token only (webhook enrichment is ID-token-only, a PII guard).
An app that needs org context in an access token reads it from the ID token or enumerates
GET /api/v1/organizations — the same fallback RBAC's permissions claim uses.
SCIM /Groups unification (SSO-1959)
The product-api organization model is the single system of record for organization
membership. identity-service's SCIM /scim/v2/Groups endpoint — a live enterprise IdP
integration surface — is preserved verbatim (same URL, schema, media type, verbs,
displayName eq filter, pagination, group id + member value shapes) and becomes a thin
facade over that SoR (ADR 2026-07-16-b2b-organizations-scim-unification.md). No IdP
re-configures its SCIM connector.
Two translations happen at the boundary:
- Subject. A SCIM member
valueis a raw identity-serviceusers.id, but the org membership subject issha256_hex(users.id)(the same hash the hubsubuses, the V28pairwise_subcolumn). On write, the facade validates the user and hashes the id; on read, it reverses each subject via thepairwise_subunique index back tousers.id. - Tenant. identity-service scopes SCIM by a
tenant_idclaim; product-api scopes bytnt. A product-api-ownedscim_tenant_aliastable mapsscim_tenant_id → tnt; the facade resolves it server-side, so identity-service never learnstnt.
Thoryn-managed-subset read semantics (a documented narrowing). A GET/list on a group
returns only the members whose subject has a pairwise_sub preimage — i.e. the users
identity-service provisioned. Members added through another federation (the org's own Okta)
or the console are omitted from the SCIM view. This is the correct SCIM semantic (an IdP
manages only the users it created) and is invisible to a client managing its own users.
Cutover is forward-only and phased. The backing store is selected by
oauthy.identity.scim.org-store (local — the default, the legacy identity-service store —
or product-api — the facade). Flipping to product-api requires the scim_tenant_alias
row for the SCIM tenant to be seeded first, and it runs a one-time idempotent runtime
backfill of existing groups/memberships into the SoR (organisations.id preserved as the
group id). The legacy organisations table is retained — it still backs
identity-service-local login policy (branding, dynamic groups, magic-link, SSO enforcement);
re-keying that onto the unified org id is a P2 follow-up.