Skip to content

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.md and 2026-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:

  1. Create an organization with a name, a tenant-unique slug, an optional externalId (your own id for the org), and arbitrary JSON metadata.
  2. Add members directly by subject (the opaque sub a user carries in their Thoryn token, from any federation). A subject may belong to many organizations.
  3. 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.
  4. 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.

MethodPathScopeDescription
POST/api/v1/organizationstenant:orgs.writeCreate an org (409 organization_exists on a duplicate slug).
GET/api/v1/organizationstenant:orgs.readList orgs (cursor; filter slug, externalId).
GET/api/v1/organizations/{id}tenant:orgs.readGet one org (404 cross-tenant / missing).
PATCH/api/v1/organizations/{id}tenant:orgs.writeUpdate name / metadata / status.
DELETE/api/v1/organizations/{id}tenant:orgs.writeDelete an org (members + invitations cascade).
GET/api/v1/organizations/{id}/memberstenant:orgs.readList members (cursor).
POST/api/v1/organizations/{id}/memberstenant:orgs.writeAdd an active member ({ subject }; 409 member_exists).
PATCH/api/v1/organizations/{id}/members/{subject}tenant:orgs.writeChange 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.writeRemove a member (204, idempotent).
GET/api/v1/me/organizationstenant:orgs.readThe caller's own active organizations ({id, slug, name} each), derived from the token sub + tnt + env. Empty ⇒ 200 [].
POST/api/v1/organizations/{id}/invitationstenant:orgs.writeInvite ({ email, roles? }) → the token is returned once and the invitee is emailed an accept link (SSO-2261).
GET/api/v1/organizations/{id}/invitationstenant:orgs.readList pending invitations (cursor).
DELETE/api/v1/organizations/{id}/invitations/{invId}tenant:orgs.writeRevoke an invitation.
POST/api/v1/organization-invitations/acceptauthenticated userAccept ({ token }) → the caller becomes an active member.
PUT/api/v1/organizations/{id}/connectionstenant:orgs.writeCreate or replace the org's SSO connection (O3; see the companion guide).
GET/api/v1/organizations/{id}/connectionstenant:orgs.readGet the org's SSO connection (404 when none).
DELETE/api/v1/organizations/{id}/connectionstenant:orgs.writeRemove the org's SSO connection.
POST/api/v1/organizations/{id}/domainstenant:orgs.writeAdd an email domain (returns the DNS-TXT challenge to publish).
GET/api/v1/organizations/{id}/domainstenant:orgs.readList the org's domains and their verification state.
POST/api/v1/organizations/{id}/domains/{domId}/verifytenant:orgs.writeVerify 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: 10

The 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

SymptomCause / fix
409 organization_exists on createThe slug is already used in this tenant (slugs are unique per tenant, free across tenants).
400 invalid_slugslug must be a non-empty URL-safe string ([A-Za-z0-9_-]+).
400 invalid_metadatametadata must be a JSON object, not a scalar or array.
404 not_found reading/patching an org that existsThe org belongs to another tenant — cross-tenant access returns 404 by design.
409 member_existsThe subject is already a member of the org.
400 invalid_token on acceptThe token is unknown, already used, or revoked. 410 invitation_expired means the TTL elapsed.
429 rate_limited on inviteThe per-email or per-IP invitation window was exceeded; retry later.
Sign-in loops with invalid_scopeThe 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 roleThe organizationId names an org that does not exist in this tenant (O2).
access_denied at /oauth2/authorize with organizationThe 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 tokenThe 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 tnt claim on every request plus tenant-scoped queries; cross-tenant access is 404, never 403.
  • 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 Host header (the SSO-1134 account-takeover bug class; enforced by check-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 the organization param) 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 role

The 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).

MethodPathScopeDescription
POST/api/v1/users/{userId}/rolestenant:roles.writeAssign a role; optional organizationId scopes it to an org.
DELETE/api/v1/users/{userId}/roles/{roleId}tenant:roles.writeUnassign; optional ?organizationId= scopes the removal.
GET/api/v1/organizations/{id}/members/{subject}/rolestenant:roles.readList 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):

SituationResult
organization names an org the subject is an active member ofthat org is active
No organization, and the subject has exactly one active membershipthat org is active (implicit)
No organization, and the subject has ≥ 2 active membershipsorg / org_roles omitted — the app must select
organization names an org the subject is not a member ofaccess_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 value is a raw identity-service users.id, but the org membership subject is sha256_hex(users.id) (the same hash the hub sub uses, the V28 pairwise_sub column). On write, the facade validates the user and hashes the id; on read, it reverses each subject via the pairwise_sub unique index back to users.id.
  • Tenant. identity-service scopes SCIM by a tenant_id claim; product-api scopes by tnt. A product-api-owned scim_tenant_alias table maps scim_tenant_id → tnt; the facade resolves it server-side, so identity-service never learns tnt.

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.