Skip to content

Identity and IdP bindings — OIDC, claim mapping, API tokens

This document is the authoritative bounded-context reference for the per-Domain IdP binding, User, UserSession, ServiceIdentity, and APIToken aggregates that live under internal/identity/{idp,users,services,tokens,authn,events}. It covers the operator onboarding runbook, the claim-mapping rules the OIDC adapter applies to every id_token, worked acr / amr step-up examples, the API-token format / rotation / revocation contract, and the invariant-to-test matrix that ties every requirement to at least one automated test.

For the bounded-context siblings see tenancy.md (Domain, Project, Resource, Node aggregates under internal/identity/tenancy) and ../../contributing/layout.md (the repository-layout context map). For the persistent schema the identity aggregates write through see the Identity schema section of the internal/platform/db reference. For the OIDC adapter's refresh policy, unknown-kid behaviour, and metrics see internal/identity/idp/oidc/doc.go.

Runbook

This runbook walks an operator through registering, verifying, rotating, and retiring a per-Domain IdP binding. Every step emits either an audit event (identity.IdPBinding* in plexsphere.outbox_events) or a log line carrying the identity feature tag so the procedure is fully traceable.

1. Provision the upstream client

The operator provisions an OIDC code client on the upstream IdP (Entra ID, Okta, Keycloak, Dex, Google Workspace, …) with:

  • Redirect URI: https://<tenant-host>/v1/auth/callback.
  • Response type: code with PKCE (S256).
  • Required scopes: openid profile email groups.
  • ID-token claims: sub, email, email_verified, groups, and acr / amr if the IdP asserts them (Entra acr values, Okta authenticator policies, Keycloak LoA templates).

The upstream client secret is stored in OpenBao (or the equivalent secret-store adapter) under a stable path; only the opaque reference (ClientSecretRef) ever enters the plexsphere.idp_bindings row. The aggregate rejects a binding that carries an empty or whitespace-only secret reference so a misconfigured OpenBao mount never silently produces a credentials-less binding.

2. Register the binding

Call POST /v1/admin/idp with the binding body. The handler calls idp.NewIdPBinding which enforces:

  • DomainID non-zero.
  • Issuer and DiscoveryURL are absolute http / https URLs.
  • ClientID and ClientSecretRef are trimmed, non-empty.
  • ClaimMappings keys and values are non-empty; an empty map is legal and means "use the default claim names".
  • RequiredACRValues and RequiredAMRValues are trimmed and deduplicated.
  • JITPolicy ∈ {allow, deny}, defaulting to allow.
  • Status ∈ {active, inactive, degraded}, defaulting to active.

The partial unique index idp_bindings_active_domain_issuer_uq serialises the (DomainID, Issuer) pair at the SQL layer — a concurrent second active binding for the same pair returns 409 Conflict . Successful registration appends identity.IdPBindingRegistered to the outbox in the same transaction.

3. Verify the binding

After registration the adapter issues a discovery probe against DiscoveryURL and a JWKS probe against the published jwks_uri. The outcome is visible in two places:

  • plexsphere.idp_bindings.status — transitions to degraded when the cold-cache refresh fails (operator alert surface).
  • plexsphere_oidc_jwks_refresh_total{outcome=…} counter — success / failure / unknown_kid labels (dashboard surface).

A degraded binding emits identity.IdPDiscoveryStale into the outbox so the audit log records every operator-visible failure. Re-running the probe by calling PATCH /v1/admin/idp/{id}/status with status=active after the upstream is reachable resets the cache on the next sign-in attempt.

4. Sign a test user in

Drive POST /v1/auth/sign-in followed by GET /v1/auth/callback with a test account. The happy path writes three rows in one transaction:

  1. plexsphere.users — the JIT-provisioned User (when JITPolicy=allow and the subject is new) or an updated last_sign_in_at on the existing row.
  2. plexsphere.user_sessions — the server-side SHA-256 of the session cookie plus the asserted acr / amr.
  3. plexsphere.outbox_events — one of UserProvisioned + UserSignedIn (first sign-in) or just UserSignedIn (repeat).

If JITPolicy=deny and the subject is unknown the handler returns 401 without touching plexsphere.users — a pre-sync is required.

JIT acceptance fork

When the OIDC callback has resolved the verified id_token onto a users.User via claims.Map and persisted the row through Users.UpsertWithBinding, the handler asks the InvitationConsumer (internal/identity/invitations/consumer/) whether a pending Invitation exists for the (DomainID, ExternalSubject) pair the upsert just keyed on. The fork runs at the point marked by the // DECISION: block that wires the InvitationConsumer in internal/transport/http/v1/auth/callback.go, strictly between the User upsert and RecordSignIn, and follows the behaviour matrix below:

  • Pending invitation, unexpired. The consumer calls InvitationRepo.AcceptDuringSignIn which (1) runs the CAS UPDATE plexsphere.invitations SET status='accepted', accepted_at=$now, accepted_user_id=$uid WHERE id=$id AND status='pending' on the row and (2) appends a single InvitationAccepted outbox event whose payload carries the denormalised tuple_objects array (one entry per InitialTuple carried by the aggregate) — all inside the SAME repo transaction. The downstream relation-tuples projector consumes that same InvitationAccepted event, reads tuple_objects off the payload, and writes one SpiceDB tuple per entry, so the projector observes the status flip and the seed tuples as a single logical batch. There is no per-tuple RelationTupleEnqueued outbox event — the InvitationAccepted row is the projector's input. The User upsert ran in its own earlier transaction (the auth callback's Users.UpsertWithBinding opens and commits its own tx); the consumer threads the now-known AcceptedUserID into the CAS predicate so the row records the acceptor without the literal shared-tx refactor that is out of scope at this level (mirrors the review-driven DECISION on the groups syncer).
  • Pending invitation, already elapsed under the lock. The consumer treats the row as terminal and returns Accepted=false with a nil error so the callback silently falls through to RecordSignIn. The eager flip to status=expired is intentionally deferred to the sweeper — see the deferred-flip DECISION block on consumer.AcceptIfPending for the full rationale (the sweeper is the single source of truth for "row has been observed as expired", and consumer.AcceptIfPending is the single source of truth for "this sign-in did not consume an invitation"). The User upsert still persists; the audit trail records the rejected attempt through the failure-leg audit row when the rejection is service- layer (see invitations.md for the full state machine).
  • Already-accepted / already-revoked sentinel. Same fall-through posture as the elapsed-pending arm — Accepted=false, nil error, callback continues with the plain sign-in path. The terminal-state sentinels are absorbed silently because the row's lifecycle has already been decided by an earlier operator action.
  • No pending row. GetPendingByDomainSubject returns invrepo.ErrNotFound; the consumer short-circuits to Accepted=false and the callback proceeds with the existing plain sign-in path unchanged.

A non-sentinel repo error from the consumer surfaces as 500 invitation-accept so a flaky persistence backend never hides behind a successful sign-in. The fork composes the *invitations/repo.InvitationRepo directly rather than routing through the application service (InvitationService.AcceptDuringSignIn) so the InvitationAccepted audit emission is not bound to the literal "unknown" subject fallback at the OIDC bootstrap moment — see the consumer DECISION block. The InvitationAccepted outbox event already carries accepted_user_id and the timestamp transactionally, so the audit pipeline reconstructs the operator context downstream from there. The full state machine (pending → accepted | revoked | expired), the per-Domain default TTL, the partial-unique-index contract, and the InitialTuples object-scope rule (domain:<this-id> / project:<uuid> / group:<uuid>) live in the bounded-context reference at invitations.md.

5. Rotate the upstream client secret

Rotation is a three-step procedure so the rotation never carries downtime (the "rotate a binding without downtime" scenario):

  1. Register a replacement binding with the new ClientSecretRef and a distinct Issuer query parameter (for example a new Entra app registration). The old binding stays active so in-flight sign-ins complete.
  2. Cut traffic over on the IdP's end; watch plexsphere_oidc_jwks_refresh_total{outcome="success"} against the new binding rise.
  3. Call PATCH /v1/admin/idp/{id}/status with status=inactive on the old binding. The partial unique index now lets a future registration reuse the original (domain_id, issuer) tuple without tripping 409 Conflict — deactivated rows are preserved for audit history but excluded from the uniqueness check.

6. Retire a binding

DELETE /v1/admin/idp/{id} is only valid when the binding is already inactive or degraded and no User has referenced it in the session ledger within the retention window. The handler emits identity.IdPBindingDeactivated into the outbox. The row itself is only dropped after audit retention elapses; immediate deletion is rejected to preserve sign-in forensics.

Claim Mapping

Claim mapping is the pure function idp/claims.Map that translates a verified OIDC id_token into a users.User aggregate. Determinism is a sign-in correctness property — the same (Binding, IDTokenClaims) pair always produces the same User fields (the User's freshly-minted UUIDv7 ID is unavoidably per-call).

Lookup order

For each local User field the adapter consults, in order:

  1. The binding's ClaimMappings table. Keys are the local field name (email, email_verified, groups, sub, acr, amr); values name the upstream id_token claim.
  2. If no override exists, the default upstream claim name from claims/claims.go (subsub, emailemail, and so on).
  3. When the override does exist, the typed field is ignored and the raw decoded id_token payload (IDTokenClaims.Raw) is consulted. Non-string / non-bool / non-slice values fall back to the typed field's zero value so a misconfigured binding cannot erase a perfectly good claim.

Worked example — Entra ID with custom group claim

yaml
# binding.claim_mappings
groups: "wids"              # Entra emits group object-IDs under the
                            # wids claim for application-role mapping
email: "preferred_username" # Entra's email is under preferred_username
                            # on consumer tenants

Given the id_token payload

json
{
  "sub": "ea60…-b5",
  "preferred_username": "ada@contoso.com",
  "email_verified": true,
  "wids": ["62e90394-…", "f28a1f50-…"],
  "acr": "phr",
  "amr": ["pwd", "mfa"]
}

claims.Map produces a users.User with ExternalSubject = "ea60…-b5", Email = "ada@contoso.com", EmailVerified = true, and Groups = ["62e90394-…", "f28a1f50-…"]. The aggregate trims, deduplicates, and rejects whitespace-only entries before the row lands in plexsphere.users.groups.

Error modes

  • MissingClaimError{Claim:"sub"} — whitespace-only or absent subject; sign-in handler returns 401, no row is written.
  • StepUpRequiredError{…} — see the next section. Emits identity.UserAuthenticationStepUpRequired to drive the frontend challenge.
  • users.ErrInvariant — a downstream invariant (e.g. malformed email) rejected by users.NewUser; surfaced unchanged so the original trace is preserved.

acr/amr Examples

RequiredACRValues and RequiredAMRValues on an IdPBinding apply set semantics: if the list is non-empty, at least one of its entries must appear in the id_token's presented values. Both lists are independent — an ACR-only policy does not imply an AMR check, and vice versa.

Example 1 — ACR-only policy, passing

yaml
binding.required_acr_values: ["phr", "phrh"]   # Entra "phishing-resistant"
binding.required_amr_values: []
id_token.acr: "phrh"
id_token.amr: ["pwd", "hwk"]                   # hardware-bound key

Set intersection on ACR is non-empty (phrh) → no step-up. AMR is not checked because the binding list is empty. UserSignedIn fires with acr="phrh" and amr=["pwd","hwk"].

Example 2 — ACR mismatch, step-up required

yaml
binding.required_acr_values: ["http://schemas.openid.net/pape/policies/2007/06/multi-factor"]
binding.required_amr_values: []
id_token.acr: "urn:oasis:names:tc:SAML:2.0:ac:classes:Password"
id_token.amr: ["pwd"]

No intersection → StepUpRequiredError with RequiredACR = ["http://schemas.openid.net/pape/policies/2007/06/multi-factor"] and PresentedACR = ["urn:oasis:names:tc:SAML:2.0:ac:classes:Password"]. The handler emits identity.UserAuthenticationStepUpRequired and returns 401 Unauthorized with the RFC 9470 §3 step-up challenge WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="<required-space-joined>" so an OIDC client can drive a correct re-authentication request. AMR-only policies emit the bare Bearer error="insufficient_user_authentication" challenge because RFC 9470 does not define an amr_values parameter. No row is written to plexsphere.users or plexsphere.user_sessions.

Example 3 — AMR-only policy, hardware-bound key

yaml
binding.required_acr_values: []
binding.required_amr_values: ["hwk", "swk"]   # hardware or software key
id_token.acr: "phr"
id_token.amr: ["pwd", "mfa"]                   # password + OTP, no key

No intersection on AMR → step-up required. The FIDO2 / WebAuthn challenge is driven out-of-band by the frontend; the handler will accept the subsequent sign-in attempt once the IdP re-issues an id_token carrying amr{hwk} or {swk}.

Example 4 — multivalued ACR in a single string

Some IdPs (notably Google Workspace) deliver acr as a single space-separated string. The claims adapter accepts either shape: []string, []any, or a single string. In the last case the string is wrapped into a one-element slice before the set intersection check runs, so acr: "urn:mace:incommon:iap:silver" is treated identically to acr: ["urn:mace:incommon:iap:silver"].

Dashboard step-up handling

An elevated, ACR-gated action (today: acknowledging an integrity violation) answers an insufficient session with 401 carrying the required acr_values. The dashboard surfaces this through the auth useStepUp hook (web/src/features/auth/useStepUp.tsx):

  • acr_values is an untyped RFC 9457 extension on the application/problem+json body — neither the OpenAPI Problem schema nor SignInRequest declares it — so the client parses it defensively off the raw error object and the feature's toProblem normaliser preserves unknown Problem members rather than dropping them.
  • challenge(problem, retry) inspects the result. A 401 carrying acr_values opens a re-auth confirmation Dialog; a bare 401 (no acr_values) is a normal auth challenge and is ignored.
  • On confirm the hook begins a step-up POST /v1/auth/sign-in carrying the captured acr_values (sent as an additive body field at the call boundary), redirects the browser to the returned authorization_url, and replays the original operation exactly once after the round-trip.

API Tokens

Plexsphere-issued API tokens (psk_…) are the machine-to-machine credential the dashboard, plexd CLI, and third-party automation use. They are owned by exactly one of {User, ServiceIdentity} — enforced at the aggregate boundary by IdentityRefForUser / IdentityRefForService and at the SQL layer by the api_tokens_exactly_one_owner CHECK.

Plaintext format

text
psk_<env>_<id-base32>_<random-base32>
  • Constant prefix psk_ so credential scanners grep for a single literal.
  • <env> — lowercase ASCII letter run ^[a-z]+$ (for example prod, staging, dev). The operator picks the value at mint time.
  • <id-base32> — the owning APIToken's UUIDv7, RFC 4648 base32 (no padding) case-folded to lowercase.
  • <random-base32> — 16 cryptographically random bytes (128 bits of entropy, well above the 96-bit floor the security baseline requires) base32-encoded.

The canonical regex ^psk_[a-z]+_[a-z2-7]+_[a-z2-7]{20,}$ is applied on every ValidatePlaintext call so operator CLI tooling and the authn middleware share one source of truth. FormatPlaintext is the single mint path — callers never concatenate the segments by hand.

Hashing and verification

The plaintext is hashed immediately via Argon2id (HashAlgo = "argon2id" is the only accepted value; the SQL CHECK enforces it) using the parameters in tokens/argon2.go. Only the PHC-encoded digest ever reaches plexsphere.api_tokens.token_hash; the plaintext is returned to the caller exactly once in the POST /v1/auth/tokens response and discarded. Every subsequent introspection round-trips through VerifyHash(plaintext, stored) — constant-time by Argon2id's construction.

TTL and rotation window

  • MaxTTL = 90 days. An ExpiresAt more than 90 days after CreatedAt is rejected at Build time.
  • RotationOverlap = 48 hours. APIToken.BeginRotation(now) sets RotationStartedAt = now and SunsetAt = now + 48h atomically; setting one without the other is rejected so the Sunset header is always computable.
  • Inside the rotation window both the old and the new tokens authenticate. POST /v1/auth/tokens/{id}/rotate responds with the new plaintext and sets the response header Sunset: <RFC 1123 timestamp> on the old token's next use so HTTP clients can surface a deprecation warning (the "rotation creates overlap and sets Sunset header" scenario).
  • After SunsetAt the old token stops being accepted by IsAcceptable(now); subsequent calls fail with 401 Unauthorized and an identity.APITokenRotated event already in the outbox to correlate the traffic shift.

Revocation

DELETE /v1/auth/tokens/{id} is immediate: the handler stamps RevokedAt = now, appends identity.APITokenRevoked to the outbox in the same transaction, and the authn middleware rejects every subsequent presentation within one pool round-trip (the "revocation is immediate" scenario). Revoking an already-revoked token is a no-op — APIToken.Revoke returns the aggregate unchanged rather than resetting the timestamp, so the audit trail remains linear.

Invariant Matrix

Every invariant the identity context enforces is backed by at least one automated test. When a row lists multiple enforcement layers, the later layers are belt-and-braces — the earlier one is authoritative.

Invariant (REQ-id)Enforced atTest
IdPBinding: non-empty Issuer/DiscoveryURL, ClientID/SecretRef trimmed, ClaimMappings well-formed, JITPolicy ∈ {allow,deny}, Status ∈idp.NewIdPBinding + CHECK constraints on plexsphere.idp_bindingsinternal/identity/idp/binding_test.go
Only one active IdPBinding per (DomainID, Issuer)Partial unique index idp_bindings_active_domain_issuer_uq on plexsphere.idp_bindingsinternal/identity/idp/repo/repos_test.go + tests/integration/identity_idp_oidc_test.go
Discovery/JWKS refresh: TTL-driven, unknown-kid triggers exactly one refresh, outcome counter exposedinternal/identity/idp/oidc/adapter.go + jwks.go + metrics.gointernal/identity/idp/oidc/adapter_test.go, jwks_test.go, discovery_test.go
Unreachable DiscoveryURL transitions binding to degraded and emits IdPDiscoveryStaleOIDC adapter failure path + repo's DeactivateBinding helperinternal/identity/idp/oidc/adapter_test.go + tests/integration/identity_idp_oidc_test.go
Claim-map: missing/empty sub is rejected, overrides route through Raw, email downgrade on email_verified=falseidp/claims.Mapinternal/identity/idp/claims/claims_test.go
ACR/AMR set-intersection step-up policyidp/claims.enforceStepUp + StepUpRequiredErrorinternal/identity/idp/claims/claims_test.go + tests/integration/identity_idp_oidc_test.go
User: DomainID non-zero, ExternalSubject trimmed/non-empty, Email well-formed when present, Groups deduplicatedusers.NewUser + UNIQUE (domain_id, external_subject) on plexsphere.usersinternal/identity/users/user_test.go + internal/identity/users/repo/repos_test.go
JIT provisioning idempotent on repeat sign-in; JITPolicy=deny refuses unknown subjectusers/repo UpsertUser + RecordUserSignedIntests/integration/identity_idp_oidc_test.go + tests/e2e/identity-idp/chainsaw-test.yaml
Session hash storage: only SHA-256 of the cookie value persistedauthn middleware + plexsphere.user_sessions.session_hash bytea UNIQUEinternal/identity/authn/middleware/middleware_test.go + internal/identity/users/repo/repos_test.go
Per-route ACR/AMR enforcement via authn.RequireACR / RequireAMR decoratorsinternal/identity/authn/middlewareinternal/identity/authn/middleware/decorators_test.go
Device-code flow (RFC 8628): authorization_pending, slow_down, expired_token taxonomyinternal/transport/http/v1/auth/device_code.go + device_token.gotests/integration/identity_device_code_test.go
Device-flow approval surface: server-hosted GET /v1/device page -> cookie-authenticated, CSRF-guarded POST /v1/auth/device/approve flips DeviceSession.Status=approved, pins UserID, and emits a DeviceApproved outbox row in the same transactioninternal/transport/http/v1/auth/device_page.go + device_approve.gointernal/transport/http/v1/auth/device_page_test.go + device_approve_test.go + tests/integration/auth_device_page_test.go + tests/integration/auth_device_approve_test.go + tests/e2e/identity/signin-roundtrip/chainsaw-test.yaml
ServiceIdentity FederationKind pin (oidc_cc, spiffe_svid, api_token); mismatched credential rejected without inspectionservices.NewServiceIdentity + CHECK on plexsphere.service_identities.federation_kindinternal/identity/services/service_test.go + tests/integration/identity_service_token_test.go
SPIFFE bundle resolution: per-binding cache keyed on IdPBinding.ID(), JWKS-shaped trust bundle fetched from binding.SPIFFEBundleURL, signature + standard claims (exp/iat/nbf/optional iss/aud) verified with 60s leeway, unknown-kid retries refresh exactly onceinternal/identity/spiffe.Adapter + internal/transport/http/v1/auth/service_token.go (serviceTokenJWTBearer)internal/identity/spiffe/adapter_test.go + internal/transport/http/v1/auth/service_token_test.go (TestServiceToken_SPIFFEJWTBearer_*) + tests/integration/identity_service_token_test.go
APIToken plaintext format psk_<env>_<id>_<rand> enforced at issuance and verifytokens.FormatPlaintext + ValidatePlaintext + ParsePlaintextinternal/identity/tokens/token_test.go
APIToken XOR ownership (User XOR ServiceIdentity)IdentityRefForUser / IdentityRefForService + api_tokens_exactly_one_owner CHECKinternal/identity/tokens/token_test.go + tests/integration/identity_api_token_test.go
APIToken 90-day MaxTTL captokens.buildAPITokeninternal/identity/tokens/token_test.go
APIToken 48-hour rotation overlap with Sunset headerAPIToken.BeginRotation + issuer.Rotate + handler sets Sunset response headerinternal/identity/tokens/token_test.go + tests/integration/identity_api_token_test.go
APIToken immediate revocationAPIToken.Revoke + issuer.Revoke + authn middleware checkinternal/identity/tokens/token_test.go + tests/integration/identity_api_token_test.go
Every aggregate mutation appends exactly one matching outbox event in the same transactionidp/repo, users/repo, services/repo, tokens/repointernal/identity/{idp,users,services,tokens}/repo/repos_test.go + tests/e2e/identity-idp/chainsaw-test.yaml
Identity aggregate packages free of pgx, sqlcgen, go-jose, net/http imports outside */repo/** and */middleware/**depguard + tests/workspace/identity_persistence_isolation_test.gotests/workspace
All invariant errors carry the (REQ-xxx, PX-0008) suffixidp.errInvariant, users.errInvariant, services.errInvariant, tokens.errInvariant, oidc.errUnknownKidf, claims.*ErrorEvery *_test.go in internal/identity/** asserts the suffix
Identity schema migration up/down idempotent; five tables + FKs to plexsphere.domains0003_identity.sql + tests/integration/db_migrations_test.goTestIdentityMigrations_UpDownIdempotent
JIT acceptance: pending invitation consumed atomically with User upsert in OIDC callbackinternal/transport/http/v1/auth/callback.go + internal/identity/invitations/consumer/internal/transport/http/v1/auth/callback_test.go + internal/identity/invitations/consumer/consumer_test.go + tests/e2e/identity/invitations/chainsaw-test.yaml

Cross-references

  • tenancy.md — sibling bounded-context reference for Domain, Project, Resource, Node aggregates under internal/identity/tenancy.
  • groups.md — sibling bounded-context reference for the Group, GroupMembership, and GroupParent aggregates under internal/identity/groups. The IdP-synced Group path reuses the IdPBinding defined here to map OIDC groups claims to memberships on every sign-in.
  • invitations.md — bounded-context reference for the Invitation aggregate (pending → accepted | revoked | expired), the per-Domain default TTL, the partial-unique-index contract, and the InitialTuples object-scope rule. The JIT acceptance fork wired into the OIDC callback above is documented here; invitations.md covers the full state machine plus the expiry sweeper, the audit relations (invitation.create, invitation.list, invitation.read, invitation.revoke, invitation.accept, invitation.expire), and the HTTP surface (POST/GET/DELETE /v1/domains/{id}/invitations(/{invitationId})) the operator drives Create / Get / List / Revoke through .
  • rebac.md — sibling bounded-context reference for the SpiceDB-backed authorisation layer. The authn.Principal an IdPBinding resolves is translated into a SpiceDB subject by the middleware.PrincipalMapper documented there; the acr / amr worked examples above feed the requires_assurance caveat evaluated on every Check.
  • ../../contributing/layout.md — bounded-context map placing internal/identity inside the repo.
  • ../../reference/platform/db.md — pgx pool, goose migrations, sqlc workflow, and the identity schema row the identity context writes through.
  • ../../contributing/openapi.md — the OpenAPI spec that hosts /v1/auth/** and /v1/admin/idp/**.
  • internal/identity/idp/oidc/doc.go — godoc for the JWKS cache, unknown-kid policy, and refresh metrics.
  • internal/identity/doc.go — bounded-context package doc for internal/identity.
  • internal/platform/db/migrations/0003_identity.sql — canonical schema for the five identity tables.
  • tests/e2e/identity-idp/chainsaw-test.yaml — end-to-end suite that registers a binding, signs a user in, and rotates + revokes an API token.