Appearance
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:
codewith PKCE (S256). - Required scopes:
openid profile email groups. - ID-token claims:
sub,email,email_verified,groups, andacr/amrif the IdP asserts them (Entraacrvalues, Okta authenticator policies, KeycloakLoAtemplates).
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:
DomainIDnon-zero.IssuerandDiscoveryURLare absolutehttp/httpsURLs.ClientIDandClientSecretRefare trimmed, non-empty.ClaimMappingskeys and values are non-empty; an empty map is legal and means "use the default claim names".RequiredACRValuesandRequiredAMRValuesare trimmed and deduplicated.JITPolicy ∈ {allow, deny}, defaulting toallow.Status ∈ {active, inactive, degraded}, defaulting toactive.
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 todegradedwhen the cold-cache refresh fails (operator alert surface).plexsphere_oidc_jwks_refresh_total{outcome=…}counter —success/failure/unknown_kidlabels (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:
plexsphere.users— the JIT-provisioned User (whenJITPolicy=allowand the subject is new) or an updatedlast_sign_in_aton the existing row.plexsphere.user_sessions— the server-side SHA-256 of the session cookie plus the assertedacr/amr.plexsphere.outbox_events— one ofUserProvisioned+UserSignedIn(first sign-in) or justUserSignedIn(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.AcceptDuringSignInwhich (1) runs the CASUPDATE 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 singleInvitationAcceptedoutbox event whose payload carries the denormalisedtuple_objectsarray (one entry perInitialTuplecarried by the aggregate) — all inside the SAME repo transaction. The downstream relation-tuples projector consumes that sameInvitationAcceptedevent, readstuple_objectsoff 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-tupleRelationTupleEnqueuedoutbox event — theInvitationAcceptedrow is the projector's input. TheUserupsert ran in its own earlier transaction (the auth callback'sUsers.UpsertWithBindingopens and commits its own tx); the consumer threads the now-knownAcceptedUserIDinto 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=falsewith a nil error so the callback silently falls through toRecordSignIn. The eager flip tostatus=expiredis intentionally deferred to the sweeper — see the deferred-flip DECISION block onconsumer.AcceptIfPendingfor the full rationale (the sweeper is the single source of truth for "row has been observed as expired", andconsumer.AcceptIfPendingis 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 (seeinvitations.mdfor 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.
GetPendingByDomainSubjectreturnsinvrepo.ErrNotFound; the consumer short-circuits toAccepted=falseand 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):
- Register a replacement binding with the new
ClientSecretRefand a distinctIssuerquery parameter (for example a new Entra app registration). The old binding staysactiveso in-flight sign-ins complete. - Cut traffic over on the IdP's end; watch
plexsphere_oidc_jwks_refresh_total{outcome="success"}against the new binding rise. - Call
PATCH /v1/admin/idp/{id}/statuswithstatus=inactiveon the old binding. The partial unique index now lets a future registration reuse the original(domain_id, issuer)tuple without tripping409 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:
- The binding's
ClaimMappingstable. Keys are the local field name (email,email_verified,groups,sub,acr,amr); values name the upstream id_token claim. - If no override exists, the default upstream claim name from
claims/claims.go(sub→sub,email→email, and so on). - 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 tenantsGiven 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 returns401, no row is written.StepUpRequiredError{…}— see the next section. Emitsidentity.UserAuthenticationStepUpRequiredto drive the frontend challenge.users.ErrInvariant— a downstream invariant (e.g. malformed email) rejected byusers.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 keySet 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 keyNo 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_valuesis an untyped RFC 9457 extension on theapplication/problem+jsonbody — neither the OpenAPIProblemschema norSignInRequestdeclares it — so the client parses it defensively off the raw error object and the feature'stoProblemnormaliser preserves unknown Problem members rather than dropping them.challenge(problem, retry)inspects the result. A401carryingacr_valuesopens a re-auth confirmation Dialog; a bare401(noacr_values) is a normal auth challenge and is ignored.- On confirm the hook begins a step-up
POST /v1/auth/sign-incarrying the capturedacr_values(sent as an additive body field at the call boundary), redirects the browser to the returnedauthorization_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 exampleprod,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. AnExpiresAtmore than 90 days afterCreatedAtis rejected at Build time.RotationOverlap = 48 hours.APIToken.BeginRotation(now)setsRotationStartedAt = nowandSunsetAt = now + 48hatomically; 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}/rotateresponds with the new plaintext and sets the response headerSunset: <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
SunsetAtthe old token stops being accepted byIsAcceptable(now); subsequent calls fail with401 Unauthorizedand anidentity.APITokenRotatedevent 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 at | Test |
|---|---|---|
| IdPBinding: non-empty Issuer/DiscoveryURL, ClientID/SecretRef trimmed, ClaimMappings well-formed, JITPolicy ∈ {allow,deny}, Status ∈ | idp.NewIdPBinding + CHECK constraints on plexsphere.idp_bindings | internal/identity/idp/binding_test.go |
Only one active IdPBinding per (DomainID, Issuer) | Partial unique index idp_bindings_active_domain_issuer_uq on plexsphere.idp_bindings | internal/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 exposed | internal/identity/idp/oidc/adapter.go + jwks.go + metrics.go | internal/identity/idp/oidc/adapter_test.go, jwks_test.go, discovery_test.go |
Unreachable DiscoveryURL transitions binding to degraded and emits IdPDiscoveryStale | OIDC adapter failure path + repo's DeactivateBinding helper | internal/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=false | idp/claims.Map | internal/identity/idp/claims/claims_test.go |
| ACR/AMR set-intersection step-up policy | idp/claims.enforceStepUp + StepUpRequiredError | internal/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 deduplicated | users.NewUser + UNIQUE (domain_id, external_subject) on plexsphere.users | internal/identity/users/user_test.go + internal/identity/users/repo/repos_test.go |
JIT provisioning idempotent on repeat sign-in; JITPolicy=deny refuses unknown subject | users/repo UpsertUser + RecordUserSignedIn | tests/integration/identity_idp_oidc_test.go + tests/e2e/identity-idp/chainsaw-test.yaml |
| Session hash storage: only SHA-256 of the cookie value persisted | authn middleware + plexsphere.user_sessions.session_hash bytea UNIQUE | internal/identity/authn/middleware/middleware_test.go + internal/identity/users/repo/repos_test.go |
Per-route ACR/AMR enforcement via authn.RequireACR / RequireAMR decorators | internal/identity/authn/middleware | internal/identity/authn/middleware/decorators_test.go |
Device-code flow (RFC 8628): authorization_pending, slow_down, expired_token taxonomy | internal/transport/http/v1/auth/device_code.go + device_token.go | tests/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 transaction | internal/transport/http/v1/auth/device_page.go + device_approve.go | internal/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 inspection | services.NewServiceIdentity + CHECK on plexsphere.service_identities.federation_kind | internal/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 once | internal/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 verify | tokens.FormatPlaintext + ValidatePlaintext + ParsePlaintext | internal/identity/tokens/token_test.go |
| APIToken XOR ownership (User XOR ServiceIdentity) | IdentityRefForUser / IdentityRefForService + api_tokens_exactly_one_owner CHECK | internal/identity/tokens/token_test.go + tests/integration/identity_api_token_test.go |
| APIToken 90-day MaxTTL cap | tokens.buildAPIToken | internal/identity/tokens/token_test.go |
| APIToken 48-hour rotation overlap with Sunset header | APIToken.BeginRotation + issuer.Rotate + handler sets Sunset response header | internal/identity/tokens/token_test.go + tests/integration/identity_api_token_test.go |
| APIToken immediate revocation | APIToken.Revoke + issuer.Revoke + authn middleware check | internal/identity/tokens/token_test.go + tests/integration/identity_api_token_test.go |
| Every aggregate mutation appends exactly one matching outbox event in the same transaction | idp/repo, users/repo, services/repo, tokens/repo | internal/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.go | tests/workspace |
All invariant errors carry the (REQ-xxx, PX-0008) suffix | idp.errInvariant, users.errInvariant, services.errInvariant, tokens.errInvariant, oidc.errUnknownKidf, claims.*Error | Every *_test.go in internal/identity/** asserts the suffix |
Identity schema migration up/down idempotent; five tables + FKs to plexsphere.domains | 0003_identity.sql + tests/integration/db_migrations_test.go | TestIdentityMigrations_UpDownIdempotent |
| JIT acceptance: pending invitation consumed atomically with User upsert in OIDC callback | internal/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 underinternal/identity/tenancy.groups.md— sibling bounded-context reference for the Group, GroupMembership, and GroupParent aggregates underinternal/identity/groups. The IdP-synced Group path reuses the IdPBinding defined here to map OIDCgroupsclaims 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 theInitialTuplesobject-scope rule. The JIT acceptance fork wired into the OIDC callback above is documented here;invitations.mdcovers 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. Theauthn.Principalan IdPBinding resolves is translated into a SpiceDB subject by themiddleware.PrincipalMapperdocumented there; theacr/amrworked examples above feed therequires_assurancecaveat evaluated on every Check.../../contributing/layout.md— bounded-context map placinginternal/identityinside 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 forinternal/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.