M1-36: identity: identity provider port and provider management (ADR 0019)¶
Status: ready | Depends on: M1-31, M1-35 | Milestone: M1
Goal¶
After this ticket the identity module knows its sign-in drivers through one port: the tables
identity_providers and user_identities, the ExternalIdentity that every driver produces, the
mapping of an external identity to a person only through (issuer, subject), linking and
unlinking, and the owner's management of providers with step-up. OIDC (M1-41), the trusted proxy
header (M1-43), local accounts (M1-39) and enrollment (M1-37) plug into this port instead of
inventing their own account mapping, which closes the account takeover through e-mail matching
(T-ID-01, R-ID-02).
Read first¶
docs/spec/08-identity.md: "2.1 Port", "2.2 OIDC" (what the OIDC driver will need from the config), "2.3 Example: Microsoft Entra ID" (tidallow list,subas subject), "2.4 Trusted proxy header" (login header), "2.5 Local accounts", "3.1 Mapping an external identity", "3.3 Linking and unlinking another identity", "3.4 Groups from claims", "15. Tests" (ID-T04), "16. Decisions of this chapter" (R-ID-02).docs/spec/03-data-model.md: "1. Principles" (items 1, 3, 6, 8, 9, 12, 13), "4.2" tablesidentity_providers,user_identities,auth_sessions(identity_id),auth_login_flows(FK to providers),users,devices.docs/spec/05-actions.md: "4.2" (identity_provider.read,identity_provider.manage), "4.3" (identity.read,identity.link,identity.unlink), "2.4", "2.5".docs/spec/07-permissions.md: "4.1", "4.2", "4.3 Visibility", "SR5".docs/spec/06-api.md: "3.2 Login flows", "5. Errors", "7. Idempotency" (DELETE rules), "16. Endpoints" (Admin, Me, Users).docs/spec/02-architecture.md: "4.1 Rules", "5. Layers within a module", "8. Transactions and Unit of Work", "9.2 404 instead of 403".docs/spec/19-security.md: "6.1" (SEC-IN-1, SEC-IN-2, SEC-IN-4), "10. Secrets" (OIDC client access only assecret_ref).- ADR 0019 (
docs/adr/0019-identity-providers-oidc-proxy-header-local-passkey-accounts.md), ADR 0011, ADR 0017. docs/api/openapi.yaml: operationslistIdentityProviders,createIdentityProvider,getIdentityProvider,updateIdentityProvider,deleteIdentityProvider,getMyCredentials,getUserCredentials,unlinkIdentity; schemasIdentityProvider,IdentityProviderCreate,IdentityProviderUpdate,IdentityProviderList,IdentityProviderKind,AuthProviderInfo,Identity,Passkey,CredentialOverview,Problem.docs/security/threat-model.md: T-ID-01, T-ID-02.- Merged code of M1-09 (secret reference syntax of
internal/platform/secrets), M1-20 (Idempotency-Keymiddleware), M1-26 (principals, users, display-text check), M1-31 (people, owners,identityHTTP adapter), M1-33 (devices), M1-34 (auth_sessions), M1-35 (step-up), M1-24 (audit), M1-28 (authorize). services/server/internal/identity/README.md,docs/quality/gates.md,CONTRIBUTING.md,docs/PLAN.md"3. Way of working per ticket".
Fixed requirements that apply¶
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
- Test first: every behavior starts with a failing test (red output goes into the report).
- No en dash and no em dash in any text you write.
- White label: only example.com, example.org and the names Alex, Sam, Robin in examples; provider names in tests like "Company SSO".
- Configuration values only through named keys from
docs/operations/configuration.md. - Drivers deliver an
ExternalIdentitywith exactly the fields of 08 "2.1"; everything after it is the same for all drivers (08 "2.1"). - At most one
trusted_headerand onelocaldriver per instance, any number ofoidc(08 "2.1", 03 unique index). - Mapping only through
(provider_id, subject)withunlinked_at IS NULL, for OIDC alsoissuer; never through e-mail or display name (08 "3.1", R-ID-02, ID-T04). - Provider management is owner only with step-up and audit (05 "4.2"); reading is owner only.
- OIDC client secrets or keys only as
secret_ref(03identity_providers, 19 "10"). identity.unlinkfails when no sign-in method remains: no identity, no passkey, no active device for device pairing (08 "3.3"); self or owner, step-up, audit (05 "4.3").identity.readlists identities, passkeys and local credentials without secrets; self or owner (05 "4.3").
Scope¶
- Migration
services/server/migrations/<next free number>_identity_providers.sql: tablesidentity_providersanduser_identitiesexactly as 03 "4.2" (columns, defaults, CHECKs, FKs, uniquelower(name), partial unique(kind) WHERE kind IN ('trusted_header','local'), partial unique(provider_id, subject) WHERE unlinked_at IS NULL, index(user_id)), plusALTER TABLE auth_sessions ADD CONSTRAINT ... FOREIGN KEY (identity_id) REFERENCES user_identities(id) ON DELETE SET NULL(deferred from M1-34 because the table did not exist) andALTER TABLE groups ADD CONSTRAINT ... FOREIGN KEY (provider_id) REFERENCES identity_providers(id) ON DELETE RESTRICT(deferred from M1-27, 03groups). - sqlc queries
internal/identity/adapters/postgres/queries/providers.sqlandidentities.sql, repository. - Port
internal/identity/providers.go(names fixed):ProviderKind(oidc,trusted_header,local).ExternalIdentity{ProviderID ID; Issuer string; Subject string; DisplayName string; EmailHint string; Groups []string; AuthTime time.Time; AMR []string}with the doc comments of 08 "2.1" (Issuer empty fortrusted_headerandlocal; EmailHint and DisplayName only for display).Provider(members of the API schemaIdentityProvider),CreateProvider,UpdateProvider(pointer fields for PATCH semantics),ProviderInfo{ID, Kind, Name}.type Providers interface{ List(ctx, actor.Actor) ([]Provider, error); Create(ctx, actor.Actor, CreateProvider) (Provider, error); Get(ctx, actor.Actor, ID) (Provider, error); Update(ctx, actor.Actor, ID, UpdateProvider) (Provider, error); Delete(ctx, actor.Actor, ID) error; Enabled(ctx, ID) (Provider, error); ListForDiscovery(ctx) ([]ProviderInfo, error) }.Enabledreturns an enabled provider ornot_foundand is what login drivers call;ListForDiscoveryfeedsgetDiscovery(M1-46).Resolution{Outcome ResolutionOutcome; UserID ID; IdentityID ID}with outcomesmatchedandunmatched.type Identities interface{ Resolve(ctx, ExternalIdentity) (Resolution, error); Link(ctx, userID ID, ExternalIdentity) (Identity, error); MarkLogin(ctx, identityID ID, at time.Time) error; Credentials(ctx, actor.Actor, userID ID) (CredentialOverview, error); Unlink(ctx, actor.Actor, userID, identityID ID) error }.Resolve,LinkandMarkLoginjoin the ambient unit of work of the calling login flow.
- App-level extension points in
internal/identity/app(unexported types are fine, exported only inside the module):ProviderKindRulesper kind:Validate(raw json.RawMessage) (json.RawMessage, error)returns the normalized config. M1-43 adds the loopback check of W4 fortrusted_headerto this interface; M1-41 uses the OIDC rules.CredentialSourceper credential kind: contributes toCredentialOverviewand counts remaining sign-in methods forUnlink. This ticket registers the sources "identities" and "active devices"; M1-39 (local password, TOTP) and M1-40 (passkeys) register theirs.
- Config validation per kind in
internal/identity/domain/provider(pure, strict decoding: unknown members, duplicate keys and JSONnullrejected), exactly the schemas of 08 "2.6" (normative; field names and rules there):identity_provider.oidc.v1:issuer,client_id,client_auth,client_secret_ref(exactly withclient_secret_basic),client_key_ref(exactly withprivate_key_jwt),client_key_alg,client_key_id,client_cert_x5t_s256,allowed_tenant_ids(required whenissuercontains{tenantid}),app_redirect_uris,groups_claim. Secret references are validated for syntax only, never resolved here.identity_provider.trusted_header.v1:login_header(required, an HTTP field name token per RFC 9110),name_header(optional, same rule).identity_provider.local.v1: empty object. Secret reference syntax is checked through a small portSecretRefs{ Valid(ref string) error }implemented ininternal/wirewith the parser ofinternal/platform/secrets(the domain and app may not importplatform).
- App use cases
internal/identity/app/providers.go,identities.go; adaptersinternal/identity/adapters/httpapi/providers.go,credentials.gofor the eight operations; enable them inservices/server/api/operations.yamland regenerate (command fromservices/server/README.md). - Docs: 08 "2.6" and "3.3" already state the schemas and the unlink, disable and delete rules
(spec first; change them only where the implementation proves them wrong); rulings in
docs/DECISIONS.md; identity README;services/server/CHANGELOG.md.
Out of scope¶
- The drivers themselves: OIDC flows and discovery fetch (M1-41), group sync (M1-42), header
login and the W4 loopback check at
identity_provider.manage(M1-43), local password and TOTP (M1-39), passkeys (M1-40). - 08 "3.1" step 2 (link through an enrollment code, M1-37) and step 3 (provisioning as
pendingwhenallow_provisioning, M1-41):Resolvereturnsunmatchedand the caller applies them. - The neutral denial message and the
denyaudit of a login (auth.login.complete) belong to the login tickets; this ticket provides theunmatchedoutcome they act on. beginIdentityLink,completeIdentityLink(M1-41);revokePasskey(M1-40).- Discovery output (M1-46) beyond
ListForDiscovery.
Boundaries¶
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Work in a dedicated git worktree on the branch
feat/M1-36-identity-providers. - Conventional Commits with scope
identity(ordocswhere fitting), each signed off (git commit -s). - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope above except
services/server/CHANGELOG.md,docs/DECISIONS.mdand generated references. - Follow the host rules supplied by the launcher.
- Tests bind only
127.0.0.1and port 0; PostgreSQL 18 as a throwaway container via the M1-12 helper; no Docker runtime means failure, never a skip. - Stop only processes you started, by PID; temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets; secret references in tests point to nothing real.
- No network calls except package downloads for pinned tools; no call to any identity provider.
- A gap in the core contract ends the ticket with
Verdict: BLOCKED(P-11).
Design notes¶
Provider management (actions from 05; authorize with resource instance):
| Operation | Action | Rules |
|---|---|---|
listIdentityProviders, getIdentityProvider |
identity_provider.read |
owner only (non-owners 403 forbidden reason owner_only); unknown id 404 |
createIdentityProvider |
identity_provider.manage |
owner, step-up, Idempotency-Key (M1-20), audit; 201 |
updateIdentityProvider |
identity_provider.manage |
kind cannot change; absent members stay; config, if given, is validated against the stored kind; updated_at = now; 200 |
deleteIdentityProvider |
identity_provider.manage |
409 conflict with hint "disable the provider instead" while any user_identities row references it (FK RESTRICT, also unlinked rows); otherwise the row is deleted (auth_login_flows cascade); 204; a repeated delete is 404 because providers have no tombstone |
nameis trimmed, non-empty, passes the display-text check of M1-26 (SEC-IN-4); a second provider with the same name ignoring case gives409 conflict; a secondtrusted_headerorlocalprovider gives409 conflict. Map the unique violations of PostgreSQL to these conflicts; never leak constraint names.- Validation errors are
400 validation_failedwitherrors[]pointers into/config/.... enabled = falsestops new sign-ins through the provider (the drivers callEnabled) and, in the same unit of work, revokes every live session started with an identity of this provider (revoke_reason = 'admin', families revoked, onesession.revokedeach) throughAuth.RevokeSessionof M1-34 (08 "3.3", R-ID-12).- Responses never contain secret values;
configcontainssecret_refnames only, exactly as stored after normalization. Audit details name the provider id, kind and the names of changed members, never config values. - No event type exists for providers (02 "4.7"); do not invent one.
Mapping (Resolve, 08 "3.1" steps 1 and 4):
- The provider must exist and be enabled; otherwise
unmatched. - Look up
user_identitiesbyprovider_idandsubjectwithunlinked_at IS NULL; foroidcadditionallyissuermust equal the storedissuer(a multi-tenant provider has one issuer per tenant). - Found:
matchedwith user and identity. The user's status is not decided here;authorize(principal_inactive) and the login tickets handle it. - Not found:
unmatched.EmailHint,DisplayName,users.emailandemail_at_linkare never read for matching. A test proves that a known e-mail with an unknown subject staysunmatched(ID-T04).
Linking (Link): provider enabled; insert a row with issuer only for oidc, email_at_link =
EmailHint if not empty, linked_at = now; an already linked subject (active row) gives
409 conflict; a subject linked before and unlinked since may be linked again. The caller writes
the identity.link audit entry.
Credential overview (getMyCredentials resource user = caller, getUserCredentials resource
user = path id; action identity.read; self or owner; others and unknown ids 404; a pending
device 403 device_pending): identities are the active rows with provider_kind from the
provider, issuer and email_at_link only when set, last_login_at when set; passkeys is
[] and local_password_set and totp_enrolled are false until M1-40 and M1-39 register their
sources; these values are correct while those tables do not exist.
Unlink (unlinkIdentity, action identity.unlink, resource user = path id; self or owner,
step-up, audit):
- Load the identity by
user_idandidentity_id; missing or not visible404(same body). - Already unlinked:
204without change (DELETE semantics of 06 "7"). - Count the sign-in methods that remain after unlinking, through every registered
CredentialSource: active identities at enabled providers other than this one; active devices of the person (device pairing, 08 "5.3"); later passkeys. Zero gives409 conflictwithreasonno_login_method_leftand ahint; the person keeps the identity. - Otherwise set
unlinked_at = nowand revoke every live session started with this identity (revoke_reasonlogoutwhen the person unlinks their own identity,adminotherwise; families revoked; onesession.revokedeach) throughAuth.RevokeSessionof M1-34 (08 "3.3", R-ID-12); audit allow;204.
Concurrency: two unlinks of different identities of one person in parallel must not both pass the
count. Lock the person's active user_identities rows FOR UPDATE in ascending id (03 "6.3")
before counting.
Tests (write them first)¶
Unit tests:
TestProviderConfig_OIDC: valid config normalized; missingissuer,client_id,client_auth,secret_ref;httpissuer; issuer with query; inlineclient_secretmember; unknown member;nullvalue; duplicate key; invalid secret reference syntax;/organizations/issuer withoutallowed_tenant_ids.TestProviderConfig_TrustedHeader: missinglogin_header; header name with space or colon.TestProviderConfig_Local:{}accepted; any member rejected.TestResolve_IDT04_EmailNeverMatches: person withusers.emailequal toEmailHintand a different subject:unmatched.
Integration tests against PostgreSQL 18 with real Authenticator (M1-34), real step-up (M1-35),
real authz and audit:
TestProvidersMigration_Constraints: unique name ignoring case, onetrusted_header, onelocal, manyoidc; partial unique subject; FKauth_sessions.identity_idpresent withON DELETE SET NULL.TestProviders_OwnerWithStepUp: create, read, update, delete as owner with fresh step-up.TestProviders_WithoutStepUp: manage operations401 step_up_required; read works.TestProviders_NonOwner:403 forbidden.TestProviders_SecondTrustedHeaderOrLocal:409 conflict.TestProviders_KindImmutableandTestProviders_ConfigValidatedOnUpdate.TestProviders_DeleteWithIdentities:409 conflict; without identities204; again404.TestProviders_Idempotency: sameIdempotency-Keyreturns the stored201.TestProviders_AuditNoConfigValues: audit details without config values.TestResolve_MatchedAndIssuer: same subject with a different issuer isunmatched; disabled providerunmatched; unlinked identityunmatched.TestLink_Conflicts: active subject twice409; relink after unlink allowed.TestCredentials_SelfOwnerOther: self and owner200, other person and unknown id404, pending device403 device_pending, nonullmembers,passkeysempty.TestUnlink_LastMethod: only identity and no active device:409 conflictreasonno_login_method_left; with an active device:204; with a second identity at a disabled provider only:409; already unlinked:204; step-up missing:401 step_up_required; owner unlinks for another person:204; non-owner for another person:404.TestUnlink_ConcurrentLastTwo: a person with exactly two identities and no device; two parallel unlinks: exactly one succeeds.TestUnlink_EndsItsSessions: a session started with the identity is revoked (logoutfor self,adminfor an owner) withsession.revoked; a session of another identity stays.TestDisableProvider_EndsSessions: disabling revokes the sessions of all its identities (admin) and new sign-ins fail; re-enabling restores no session.TestListForDiscovery: only enabled providers, only id, kind, name.
Commands that must pass:
cd services/server && go test -race ./internal/identity/...
make arch
make codegen-check
make migrations-lint
make docs
make check
Acceptance criteria¶
- Migration creates
identity_providersanduser_identitiesexactly as 03 and adds the FKs onauth_sessions.identity_idandgroups.provider_id. -
ExternalIdentity,ProvidersandIdentitiesexist with the names of "Scope". - Config schemas of the three kinds are enforced strictly and documented in 08 "2.6"; secrets
only as
secret_ref, never resolved or returned as values. - At most one
trusted_headerand onelocalprovider; names unique ignoring case. - Provider management only for owners with step-up, audited; reading only for owners.
- ID-T04 green: mapping never uses e-mail or display name.
- Unlink refuses to remove the last sign-in method, also under concurrency.
- Credential overview conforms to
CredentialOverviewwithoutnullmembers. - Unlinking and disabling end the sessions they concern (08 "3.3"); rulings in DECISIONS.md link the spec.
-
make checkgreen.
Definition of done¶
Code, tests and docs complete: identity README (status, ports Providers and Identities,
extension points, tables, actions, test ids), a doc comment on every public symbol, comments
explain why, ADR 0019 stays as is unless you take an architecture decision beyond it, the
CHANGELOG entry under Unreleased of services/server/CHANGELOG.md, make docs green with the new
08 subsection. make check green. Independent review (make review plus a code review of the
diff) and red team gate (docs/quality/red-team-gate.md) over the diff with both verdicts in the
report; merge locally into the base branch only if both are clean and make check is green; then
git worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits, the files with line counts, the test results with counts (including the red output of
the first failing tests), what is not proven, and follow-ups that could not be done in scope, each
with its reason. Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.