Skip to content

Product documentation

Guest B2B collaboration

Invite an external email into a tenant as a scoped, time-limited guest: mint a single-use magic-link token, let the invitee redeem it (token possession is the authorization), provision a cross-tenant membership, and have the recorded tenant:* scopes reach the guest's access token through the same enrichment channel PIM and entitlements use. Grounded in the shipped product-api guest surface and the hub scope-union customizer.

Guest B2B collaboration

Guest collaboration lets a tenant admin invite an external person — someone with no standing account in the tenant — into that tenant as a scoped, time-limited guest. The admin records the tenant:* scopes the guest should hold; the platform mints a single-use magic-link token; the invitee redeems it; and from then on the guest is a cross-tenant member whose recorded scopes ride their access token, until the invitation's window lapses.

It is the guest-access counterpart to B2B Organizations: organizations model a tenant's own business customers as sub-entities; guest collaboration hands a specific outside individual a bounded foothold in the tenant — the "share this workspace with an external contractor for 30 days" shape.

The model has three moving parts:

  • Invitation — a record that an external email may redeem into the tenant, carrying the tenant:* scopes to grant, a single-use token (stored only as a hash), and an expiry. Created by an admin; grants nothing until it is redeemed.
  • Redemption — an authenticated but scope-gate-free call that turns a still-PENDING invitation into an active cross-tenant membership for the redeeming subject, and stamps who redeemed it. Token possession is the authorization.
  • The token reach — a redeemed guest's recorded scopes are unioned into their access token's scope claim the next time they mint a token, so a resource server's @PreAuthorize("hasAuthority('SCOPE_tenant:…')") actually passes. When the invitation's window lapses (or the guest is never redeemed), the next token no longer carries them.

Everything below is on the customer-plane management API (/api/v1/** on product-api, behind the public api-gateway). For the admin operations the tenant is always taken from the tnt claim, never from the path; request and response bodies are camelCase; errors are RFC 9457 problem-details (application/problem+json) with a stable errorCode; the list is cursor-paginated; and cross-tenant (or cross-mode) access returns 404, never 403 — the platform's no-existence-leak invariant. The one exception to the tnt rule is redemption, which is deliberately tenant-agnostic — see Step 2.

The lifecycle at a glance

 admin (tenant:guests.invite / .manage)          invitee (any authenticated bearer)
 ──────────────────────────────────────          ───────────────────────────────────
 POST /guests/invite
   external email + tenant:* scopes ───▶ single-use magic-link token (returned ONCE)
                                            │  delivered to the invitee out of band
                                            ▼
                                          POST /guests/redeem  (token = the capability)
                                            │  provision tenant_member in the INVITING tenant
                                            ▼  PENDING ─▶ ACCEPTED  (stamps redeemed_by_subject)
                    next access-token mint: hub unions the ACCEPTED invitation's tenant:* scopes into `scope`
                                            │
   DELETE /guests/{id}  (PENDING only) ─▶ REVOKED        expiry sweep (~60s) ─▶ EXPIRED
                                            │
                    a lapsed window (expires_at) drops the elevated scopes from the next mint;
                    the tenant_member login access is governed separately

Prerequisites

  • A tenant-admin access token with the right scope for each admin step (see the table below). tenant:guests.invite gates inviting; tenant:guests.manage gates listing and revoking. These scopes ride under the tenant: namespace and are granted to your customer-plane client the same transitive "you can only delegate what you hold" way as every other tenant scope (see Authorize with roles, permissions, and relationships).
  • The invitee needs any authenticated Thoryn bearer to redeem — they do not need a scope, a membership, or a token in the inviting tenant. See the token-possession model in Step 2.
  • For a redeemed guest's recorded scopes to actually reach their token, the client they sign in through must have the guest claims-in-token source enabled (see Configuration). Without it, redemption still provisions the membership — only the scope-union is skipped.

Step 1 — Invite a guest (admin)

Inviting an external email is gated on tenant:guests.invite. The admin supplies the email and the tenant:* scopes the guest should hold once they redeem.

curl -sS -X POST https://api.stg.thoryn.org/api/v1/guests/invite \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "email": "contractor@partner.example",
        "scopes": ["tenant:reports.read", "tenant:documents.read"],
        "ttlSeconds": 604800
      }'
  • email (required) — the address the invitation is for. Must contain an @; it is trimmed and lower-cased before storage. A blank or malformed value returns 400 invalid_email.
  • scopes (optional) — the tenant:* scopes the guest holds once the invitation is redeemed, recorded verbatim on the invitation. These should be tenant:* scopes the tenant genuinely holds; the hub's namespace filter and the capability drop-gate are the backstops that keep a guest from ever carrying an operator-plane or non-entitled scope (see Step 3).
  • ttlSeconds (optional, default 604800 = 7 days) — how long the invitation is valid; it sets the invitation's expiresAt and bounds the elevated-scope grant once redeemed.

On success you get 201 with the invitation record and the single-use token:

{
  "id": "b6f1e2c0-1111-2222-3333-444455556666",
  "invitedEmail": "contractor@partner.example",
  "invitedBySubject": "user-42",
  "status": "PENDING",
  "scopes": ["tenant:documents.read", "tenant:reports.read"],
  "maxTtlSeconds": 604800,
  "expiresAt": "2026-08-10T09:41:22.170Z",
  "createdAt": "2026-08-03T09:41:22.170Z",
  "token": "9f8c…64-hex-chars…21a0"
}

The token is a 32-byte cryptographically-random value, returned exactly once here. The platform stores only its SHA-256 hash — the plaintext is never persisted and can never be retrieved again. Embed it in the magic link you send to the invitee (for example https://your-app.example/guest?token=…); your integration delivers that email, and the invitee later posts the token back to the redeem endpoint.

Step 2 — Redeem the invitation (invitee)

Redemption is authenticated but scope-gate-free — it mirrors the B2B org-invitation accept flow. There is no @PreAuthorize gate and no tnt requirement: the caller presents their own bearer (from any tenant), which supplies the redeeming sub, and the single-use token in the body is the capability.

curl -sS -X POST https://api.stg.thoryn.org/api/v1/guests/redeem \
  -H "Authorization: Bearer $INVITEE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "token": "9f8c…64-hex-chars…21a0" }'

On success you get 200 with the now-ACCEPTED invitation. Two things happen, in this order, so a failure leaves the invitation cleanly retryable rather than accepted-but-access-less:

  1. The redeeming subject is provisioned as an active tenant_member of the inviting tenant (TenantMemberService.admit, idempotent) — the membership axis the hub's login gate consults, so this is what actually grants the guest access to the tenant.
  2. The invitation transitions PENDING → ACCEPTED, stamping acceptedAt and the redeeming subject, and writing a guest.invite.accepted audit row carrying the granted scopes.

Why redemption is scope-gate-free — the token-possession model

A magic-link token is the authorization. The whole point of an emailed invitation is that it can be forwarded: the person who ultimately redeems need not control the invitedEmail address (a contractor forwards it to a colleague; a shared inbox routes it onward). So redemption deliberately does not check that the caller's identity matches invitedEmail, and does not require the caller to hold any scope — it authorizes on possession of the single-use token, and provisions the membership in the invitation's tenant regardless of which tenant the caller's own token belongs to. This is the same trust model the platform's B2B org-invitation accept uses.

The security that makes this safe lives in the token itself: it is high-entropy, single-use, stored only as a hash, and time-bounded (see Security notes).

Redemption outcomes:

  • 200 — redeemed; membership provisioned, scopes recorded against the redeeming subject.
  • 404 not_found — the token matched no row (never existed, or was already used).
  • 422 invitation_expired — the invitation was still PENDING but past its expiresAt; the row is marked EXPIRED and no membership is provisioned.
  • 409 invitation_already_terminal — the invitation is already ACCEPTED / REVOKED / EXPIRED. This is the single-use guard: a second redemption of an already-accepted invitation short-circuits here and provisions nothing.

Step 3 — How a recorded scope reaches the access token

A redeemed guest is now a tenant_member, which grants them access to the tenant — but a membership row carries no scopes, and an invitation record enforces nothing at a resource server. The recorded tenant:* scopes start granting privilege only when they reach a live access token. That bridge is the hub's ElevatedScopeCustomizer, wired into the token-mint customizer chain (see the Token lifecycle architecture page). It is the same shared wire PIM and entitlement packages ride — the design is recorded in ADR 2026-08-03-pim-entitlement-token-enrichment.md (SSO-1321 / SSO-1322), extended to the guest source in SSO-2276.

On every access-token mint — a fresh login or a refresh — for a client whose tenant enabled the guest source:

  1. The hub calls product-api's in-cluster enrichment endpoint POST /internal/claims-enrichment (HMAC-signed, off the JWT-signing thread — the same bounded, IO-dispatched transport the claims-enrichment webhook uses, per adrs/2026-07-02-token-claims-enrichment-auto-wire.md).
  2. product-api resolves the subject's in-force redeemed-guest scopes (ElevatedScopeResolverGuestService.getActiveScopes, cached in Redis for a short TTL) and returns them on the response's scopes channel — the de-duplicated union of the recorded scopes of every currently-in-force ACCEPTED invitation the subject redeemed (a guest re-invited and re-redeemed can hold more than one). The resolver filters on expires_at > now, so a lapsed invitation contributes nothing.
  3. The hub filters that set to the tenant:* namespace (ElevatedScopeExtractor — a hard security floor) and unions the survivors into the access token's scope claim.
  4. The capability-entitlement gate then runs as a backstop, still dropping any tenant:<family>.* scope whose family the tenant is not entitled to (per adrs/2026-06-17-tenant-capability-entitlement.md).

The guest's next access token now carries the recorded scopes, and the matching @PreAuthorize("hasAuthority('SCOPE_tenant:…')") checks pass.

Two consequences worth internalising:

  • The guest needs a fresh token. The scope-union is applied at mint time; a token the guest already holds does not retroactively gain the scopes. Re-authenticate or refresh after redeeming.
  • Access token only. Scopes are authorization, not identity — a guest's recorded scope is never put on the ID token or the refresh token.

Step 4 — Expiry and revocation

Guest access is designed to be time-bounded, and the two governed axes — the elevated scopes and the membership/login access — expire differently:

  • The scopes are bounded by the invitation window. GuestService.getActiveScopes filters on expires_at > now, so once an ACCEPTED invitation's window lapses its recorded scopes stop resolving into new tokens — the guest's next mint simply no longer carries them (the under-privilege direction). No sweep is required for this; it is a real-time read filter.
  • Auto-expiry of unredeemed invitations. GuestExpiryTask runs a sweep every 60 seconds, transitioning any still-PENDING invitation whose expiresAt has passed to EXPIRED and writing an audit row, so a never-redeemed invitation cannot be redeemed after its window.
  • Revocation cancels a pending invitation. DELETE /api/v1/guests/{id} (scope tenant:guests.manage) transitions a PENDING invitation to REVOKED so it can no longer be redeemed. Revoking an already-terminal invitation (ACCEPTED / REVOKED / EXPIRED) returns 409 invitation_already_terminal; an id not in the caller's tenant (or mode) returns 404.

See Limitations for what revocation does not do to an already-redeemed guest.

Endpoint reference

MethodPathScopeDescription
POST/api/v1/guests/invitetenant:guests.inviteInvite an external email as a scoped guest; returns the single-use token
POST/api/v1/guests/redeembearer, no extra scopeRedeem an invitation token (token possession authorizes)
GET/api/v1/gueststenant:guests.manageList the tenant's invitations (all statuses), cursor-paginated
DELETE/api/v1/guests/{id}tenant:guests.manageRevoke a PENDING invitation

All four are exposed through the public gateway on the full versioned path (the product-api-guests route in api-gateway/application.yml, forwarded unstripped). The generated, always-current contract for these endpoints is in the API reference: Guest.

Configuration

Enable the guest enrichment source on the client. The scope-union call only fires for a client whose tenant has turned on the guest claims-in-token source — the same per-client toggle that gates the claims channel and the pim / entitlements scope sources. Enable it through the client's claims-in-token configuration; see Claims in the token. Without it, invitations still redeem and the membership is still provisioned, but no recorded scope reaches the token.

Invitation TTL. The invite's ttlSeconds sets the invitation's lifetime and defaults to 604800 (7 days). It bounds both how long the invitation may be redeemed and — once redeemed — how long the recorded scopes resolve into new tokens.

Enrichment cache TTL. The redeemed-guest scopes are read through a short-TTL Redis cache (ElevatedScopeCache, under its own guest keyspace segment) on the access-token mint hot path, keyed on (tenant, subject, mode). A just-redeemed grant appears within that TTL; a just-lapsed one lingers at most that long. This is the same bounded cache the PIM and entitlement scope lookups use — see the hub token-claims configuration in the PIM guide.

Troubleshooting

SymptomCauseFix
400 invalid_email on POST /inviteemail is blank or has no @Supply a valid email address
401 missing_tnt on invite / list / revokeThe admin token has no valid tnt claimUse a tenant-admin token minted for the tenant
404 not_found on POST /redeemThe token matched no row — it never existed, or was already usedConfirm the exact single-use token from the invite response; a used or superseded token will not redeem
422 invitation_expired on POST /redeemThe invitation was still PENDING but past its expiresAtThe admin must send a fresh invitation
409 invitation_already_terminal on POST /redeemThe invitation is already ACCEPTED / REVOKED / EXPIREDSingle-use: re-invite if the guest needs access again
409 invitation_already_terminal on DELETE /{id}The invitation is already ACCEPTED / REVOKED / EXPIREDOnly a PENDING invitation can be revoked (see Limitations for accepted guests)
Guest redeemed OK but a resource server still returns 403The guest holds the pre-redemption token; the client's guest source is off; the cache TTL has not elapsed; the tenant is not entitled to that scope family; the invitation window has lapsed; or the token's mode (test/live) differs from the invitation'sMint a fresh token (re-login or refresh); enable the guest source; wait out the cache TTL; check the tenant's capability entitlement; confirm the invitation is still in force; match the mode
404 on redeem/revoke/list for an id or token from elsewhereThe row belongs to another tenant or another modeExpected — cross-tenant/cross-mode access is 404 by design, not 403

Security notes

  • Single-use magic-link tokens. The redemption token is a 32-byte CSPRNG value; the database stores only its SHA-256 hash, so a read of the database never yields a usable token. The plaintext is returned exactly once (on invite) and never again. Redemption is single-use — a second attempt on an already-ACCEPTED invitation 409s and provisions nothing.
  • Token-possession authorization. Redemption is authenticated but scope-gate-free by design: the bearer supplies the redeeming sub, and the token is the capability. Magic links are forwardable on purpose — the redeemer need not own invitedEmail, and the membership always lands in the invitation's tenant, never the caller's own tnt. The token's entropy, single-use nature, and expiry are what bound this — the same trust model as the B2B org-invitation accept.
  • Expiry-bounded scopes, fail-safe. The recorded scopes reach the token only while the invitation's window is in force (expires_at > now); a lapsed invitation drops the elevated scopes from the next mint (the under-privilege direction). And because the scope-union path only adds privilege, it is deliberately fail-open: any enrichment failure — timeout, product-api down, non-2xx, malformed body — issues the token without the guest scopes. A failure makes the guest under-privileged (a 403 they retry), never over-privileged, and never breaks token issuance or login.
  • Membership and scopes are separate axes. Redemption provisions a tenant_member (the login axis) and records scopes (the authorization axis). The invitation window governs the elevated scopes; the tenant_member login access is governed separately and is not dropped by invitation expiry — see Limitations.
  • Namespace floor and capability backstop at the hub. The hub filters the enrichment response to tenant:* before the union (ElevatedScopeExtractor), so even a buggy or compromised product-api cannot inject an operator-plane (admin:*) scope into a guest's access token. The capability drop-gate is a further backstop, so a guest can never carry a tenant:<family>.* scope past the tenant's own capability entitlement.
  • Tenant, mode, and environment isolation, full audit. Every admin operation is scoped by the tnt claim, the token's test/live mode, and the caller's resolved environment (the customer-plane list and revoke reads AND the invitation's environment_id — the workspace/environment model, epic SSO-2408); cross-tenant / cross-mode / cross-environment reads return 404, never 403. Every transition (invite, accept, revoke, expire) writes an audit row under the guest retention category — a test-mode invitation's trail stays in the test dataset.

Limitations

  • Revocation is PENDING-only. DELETE /api/v1/guests/{id} cancels an invitation that has not yet been redeemed; it returns 409 invitation_already_terminal for an already-ACCEPTED invitation and does not remove an already-redeemed guest's tenant_member or their in-force scopes. To time-bound an accepted guest's elevated scopes today, rely on the invitation's expires_at window (after which the scopes stop resolving into new tokens). Removing the tenant_member login access itself is a separate concern not covered by this endpoint — the SSO-1324 scope boundary; a future story may add accepted-guest revocation.

See also