ADR-087: Subject-identity delegation — JWT shape, `act` claim, lifetimes, revocation, issuance
Context
ADR-076 D4 + ADR-081 D3 establish the auth direction: signed JWTs with RFC 8693 act claim for delegation; identity is forgery-resistant by construction (callers cannot supply identity fields directly). ADR-039 commits the substrate (Supabase Auth, JWKS-local validation, per-user revocation via the core.users mirror).
The specifics — token format, act claim shape, lifetimes, revocation propagation, issuance flow — are what this ADR settles.
Three caller modes need support (per ADR-081 D3): operator session, customer session, and operator acting on behalf of customer (the delegation case). The first two use standard Supabase Auth flows already covered by ADR-039. This ADR pins the specifics for delegation tokens and the cross-cutting claim conventions used across all three modes.
Decision
D1 — JWT format
All authenticated requests carry a JWT validated via JWKS-local (per ADR-039 D4a). Tokens carry the following claims:
Standard claims (per RFC 7519):
sub— the token holder’s user identifier. For customer tokens: the authenticated customer user. For operator tokens (including delegation tokens): the operator’s user identifier.iss— the issuer, split by token source: Supabase-issued session tokens carry the Supabase Auth project URL (per ADR-039); Spectral-minted delegation tokens carry Spectral’s own delegation issuer (urn:spectral:auth), which the delegation verifier pins.aud— the audience the token is valid for, split by token source:- Supabase-issued session tokens (customer sessions and standalone operator sessions) carry
aud: authenticated— the Supabase Auth default. The custom access-token hook (per D5’s note on Supabase issuance) cannot modify the registeredaudclaim, so session tokens keep the provider default; the verifier pinsauthenticatedon the session-token path. - Spectral-minted delegation tokens (D5) carry
aud: urn:spectral:api— a URI-form, host-independent audience the internal mint controls; the verifier pins it on the delegation path.
- Supabase-issued session tokens (customer sessions and standalone operator sessions) carry
exp— expiration timestamp.iat— issuance timestamp.jti— unique token identifier (for audit log correlation).
Application claims:
org_id— the(org_id)the token authorizes operations against. For customer tokens: the customer’s home org. For operator delegation tokens: the target customer org the operator is acting against.domain_id— the(domain_id)withinorg_idthe token authorizes. May be absent for org-level routes; required for domain-scoped routes. For operator delegation tokens: the target domain.scopes— array of granted scopes per ADR-006 D4 (resource names aligned per ADR-086 D4:read:domain,write:domain,admin:domain,admin:org, etc.). The operations capability is carried byapp_metadata.organization_role(valueoperations), not by anoperationsentry inscopes— a top-levelroleis reserved by the auth provider for the database role, and the operations capability “is everything implicitly” per the access-control model rather than scope-enumerated.app_metadata.organization_role— the org-level role (owner/operations) when present, injected by the Supabase custom access-token hook from the persisted org-level role store. Operators are gated onapp_metadata.organization_role = operations— the same claim path the Operations Start middleware and Codex Pages Function already use (ADR-039, ADR-046 D9), so all three verifiers stay at parity.act— optional nested object; present only on delegation tokens (D2).
Session tokens are signed by Supabase Auth and validated JWKS-local against Supabase’s JWKS at every request entry (per ADR-039 D4a). Delegation tokens are a self-contained Spectral-issued token class (SPEC-555): the Spectral mint signs them with a Spectral-owned ES256 keypair — independent of Supabase’s signing key (managed Supabase never exposes that key’s private half, so signing delegation tokens with it is infeasible in production, and importing a Spectral key into Supabase’s JWKS would entangle it with Supabase’s own session-signing rotation) — and a separate verifier instance validates them against the Spectral public key, pinned to the delegation audience (urn:spectral:api) + issuer (urn:spectral:auth) + ES256. The session and delegation trust domains never cross (distinct key, issuer, audience, and verifier). The Spectral keypair is provisioned as an env secret via provision.sh --rotate (SPEC-720); local dev uses the generated supabase/signing_keys.json file.
D2 — act claim shape
The act claim is present only on operator-acting-on-behalf-of-customer delegation tokens. Its presence signals delegation; its absence means the token holder is operating in their own identity (customer session, or operator session against no specific customer).
Spectral’s act claim payload (RFC 8693 §4.1-aligned with Spectral-specific extensions):
"act": { "sub": "<operator-user-id>", "role": "operations", "actor_org_id": "<operator-home-org>", "iat": <delegation-mint-timestamp>}Conventions:
act.subequals the outersubclaim. Spectral’s convention is that the token holder is the actor; theactclaim explicitly marks “this token holder is acting in a delegation context.” The outer claims (org_id,domain_id) name the target context the operator is acting against.act.rolenames the operator’s role at delegation-mint time (typicallyoperationsper ADR-086 D5 org-level role).act.actor_org_idnames the operator’s home org (Spectral-internal org for Spectral staff). Distinct from the outerorg_id, which is the target customer org.act.iatrecords when the delegation token was minted, distinct from the outeriat. For initial mints these are equal; for any future re-minting flows this captures the original delegation timestamp.
Recursive act chains (one delegation token chained to mint another) are supported by RFC 8693 §4.1 but not implemented at v0. Spectral’s act claim is single-level; nested act.act is a structural feature reserved for future use without implementation in v0.
Audit attribution captures both the outer sub (the actor) and the outer org_id + domain_id (the target context); per-decision audit records (per ADR-077 D3 + ADR-080 D4) include both identities.
D3 — Token lifetimes
| Token type | Access TTL | Refresh model |
|---|---|---|
| Customer session token | Supabase Auth default (≈1 hour) | Refresh-token rotation per Supabase Auth defaults |
| Operator (non-delegation) session token | Supabase Auth default (≈1 hour) | Refresh-token rotation per Supabase Auth defaults |
Operator delegation token (act claim present) | 15 minutes | No refresh; operator re-mints via D5 if continued delegation needed |
Short delegation token lifetimes bound the blast radius if a delegation token leaks. Per-session operator workflows that exceed 15 minutes re-mint via the delegation endpoint (D5); the friction is intentional — every continued delegation is a fresh auditable mint operation.
The 15-minute figure is a v0 default; operational tuning (shorter or longer based on operator workflow telemetry) is an implementation epic concern and does not require an ADR amendment to change.
D4 — Revocation propagation
Per ADR-039 D4b, every authenticated request checks the core.users mirror’s status column for the sub user identifier. Under delegation, the per-request check extends to both identity surfaces:
- Actor check — the outer
sub(operator’s user_id) is checked againstcore.usersfor revocation. Operator revocation takes effect on the operator’s next request (subject to the mirror’s replication lag, which ADR-039 already bounds). - Target check — for delegation tokens, the outer
org_idis checked against ancore.orgs(formerlycore.accounts) status mirror — if the target org is disabled/revoked, the request is rejected regardless of the operator’s status. (Thecore.orgsmirror is implementation work in Phase 4; ADR-039’s mirror pattern is the reference model.)
Either check failing → 401 with structured error per ADR-006 D3 + RFC 9457. The request never reaches application code with a revoked identity on either side.
Token TTL (D3) is the additional bound — no token outlives its exp, regardless of revocation status of either party.
D5 — Issuance flow
Customer tokens and standalone operator tokens: standard Supabase Auth flow per ADR-039. No new minting infrastructure.
Operator delegation tokens: minted via a Spectral-internal token-exchange endpoint. The endpoint sits at the auth surface (e.g., POST /api/auth/delegate; URL is illustrative per ADR-077 D-note on placeholder paths).
Request shape (illustrative):
{ "target_org_id": "<customer-org>", "target_domain_id": "<target-domain or omitted for org-level>", "purpose": "<short free-text or enum: 'support', 'audit', 'config-review', etc.>"}Auth gate on the endpoint:
- Caller must present a valid (non-delegation) operator session token with the
operationsscope (per ADR-086 D5 org-level role). - Operator’s
core.usersstatus must be active. - Target
org_idmust exist and be active.
Backend mints a new short-lived JWT per D1 + D2:
- Outer
sub= operator’s user_id - Outer
org_id= target_org_id from request - Outer
domain_id= target_domain_id from request (if present) - Outer
scopes= scopes appropriate to operator-on-behalf-of role (typically derived fromoperationsscope set, constrained to the target context) actclaim populated per D2exp=iat + 15 minutesper D3
Audit log entry on every successful mint: operator identity, target context, purpose, timestamp, mint endpoint trace. The mint is itself a tracked operation; a high mint rate against a specific target may surface in noisy-tenant operator telemetry.
Failed mints (auth gate failures, invalid target, etc.) also audit — denied-access patterns are part of the operator-side investigation surface.
Cross-IdP federation (a customer’s own identity provider issuing tokens Spectral consumes) is reserved for v1+ regulated-customer requirements. The v0 model assumes Spectral-issued tokens for all caller modes.
Alternatives considered
Strict RFC 8693 token-exchange flow (full OAuth dance with grant_type=urn:ietf:params:oauth:grant-type:token-exchange and the standard request shape). Rejected for v0. Adds OAuth-server complexity (request format, requested_token_type, subject_token vs actor_token semantics) without buying a v0-customer-visible benefit beyond what the Spectral-specific endpoint provides. RFC 8693 alignment in spirit (via act claim per D2) is preserved; the wire format diverges. Migration to strict OAuth token-exchange at v1+ is a straightforward endpoint addition.
Long-lived delegation tokens (matching customer session TTL). Rejected. Operator-acting-on-behalf-of credentials are highly sensitive — short TTL is the primary defense against leaked tokens. The friction of re-minting bounds the blast radius and forces operator workflows to be auditable per-session.
Operator impersonates a specific customer user (no act claim). Rejected. Loses audit clarity (the audit chain shows the impersonated user as the actor when it was actually the operator). Also breaks ADR-080 D4’s build-provenance audit chain expectation that the responsible party is identifiable on every decision.
Refresh-token flow for delegation tokens. Rejected for v0. Refresh tokens for delegation would amplify the blast-radius problem they’re designed to solve (a leaked refresh token can mint indefinite access tokens). The 15-minute fresh-mint pattern keeps every delegation an auditable event.
Token sub = on-behalf-of customer identity (operator impersonates the org’s owner user). Rejected. Spectral operators use their operations scope to access customer data, not the customer’s permissions. The token’s sub should reflect the actor (operator); the act claim’s presence signals the delegation context.
Skip the per-request target-org status check (D4 target check). Rejected. Without it, a revoked customer org could still have decisions made against its data via outstanding operator delegation tokens until token expiry. The per-request mirror check on the target keeps revocation effective immediately.
Customer-side IdP federation at v0 (customers using their own auth provider, Spectral validating their tokens). Rejected for v0. Adds OIDC discovery + multi-issuer JWKS-local complexity without v0 customer demand. Reservable for v1+ regulated-customer scenarios.
Consequences
- Auth middleware (per ADR-039) extends to parse the
actclaim, distinguish delegation tokens from non-delegation tokens, and apply the dual-mirror check from D4. - A new endpoint lands in
apps/apiat the auth surface:POST /api/auth/delegate(URL illustrative). Gated by theoperationsscope; mints short-lived JWTs withactclaims per D2. - The
core.usersmirror per ADR-039 D4b is the actor-revocation source. A newcore.orgsmirror (or extension of an existing org-level status table) is the target-revocation source for D4 — implementation work in Phase 4 alongside the org/domain rename from ADR-086 D8. - Audit chain (per ADR-077 D3 + ADR-080 D4) records both the outer
suband the targetorg_id+domain_idon every delegated request; queries against the audit chain can filter by either dimension. - Token-exchange endpoint surface (request shape, auth gate, mint logic, audit logging) lands as a Phase 4 implementation epic alongside the auth-middleware extension. Specifics of the
purposefield’s enumeration vs free-text disposition is a Phase 4 implementation decision; the ADR commits to “purpose recorded” not to the specific format. - Recursive
actchains are structurally reserved but not implemented at v0. A future ADR introduces them if regulated-customer scenarios require multi-level delegation (e.g., a Spectral operator delegating through a third-party support vendor). - Cross-IdP federation, refresh-token flows for delegation, full RFC 8693 OAuth token-exchange compliance — all reserved for v1+; v0 model does not preclude any of these, just doesn’t implement them.
- Audience is per token source (D1): Supabase session tokens carry
aud: authenticated(provider default; the access-token hook cannot rewrite the registered claim), Spectral-minted delegation tokens carryaud: urn:spectral:api. Verifiers pin the audience appropriate to the path they guard. The operations capability is carried byapp_metadata.organization_role, injected by the access-token hook from the org-level role store, and gates the operator surface at parity with the existing Operations Start / Codex verifiers. The standalone-operator session token therefore has noactclaim, no customer tenancy claims, andapp_metadata.organization_role: operations— the clean seam the delegation mint builds on. - The subject-identity delegation specifics are settled by this ADR.