Identity data lifecycle — audit of sign-ins and launches, retention, erasure
What the platform records about a person's account beyond the account row itself, how long it keeps it, and how it goes away. The package invariants of identity, auth and audit stay in tenancy-credentials-spend.md; this page is the doctrine across them. Decision records: ADR-157.
Sign-ins are audited at the HTTP site, one row per attempt
- Every handler of
pkg/serverthat mints or refuses a session writesauth.login.succeededorauth.login.failedthroughauditLoginSucceeded/auditLoginFailed(audit_login.go): password login, the forced rotation of a pending account, registration, both branches of the OIDC callback, the desktop ticket's redemption, the reset redemption and the self-service password change.TestEverySessionMintingHandlerIsAuditedreads the session-minting methods ofauth.Servicefrom source and refuses a function that calls one without both events — a ninth mint path is red until it is audited or exempted by name with its reason. - An OIDC callback refused before an identity resolves (expired state, failed exchange, IdP
error=) writes no row — no subject; theWarnlog is its trace. The login-CSRF guard (agent_binding) is recorded. Refreshis the exemption: ~100 rows per active user per day for a fact the session lineage already records./cli-authmints a personal access token, not a session: its row ispat.created.
What a row carries, and what it never does
- A success is a tenant row keyed on the org the session is active in (never a team: the org purge sweeps
audit_eventsby org id), actor = the account,meta.method(password|github|google|oidc|org_sso|desktop;oidcincludes the deployment connector namedsso,org_ssois an org's own IdP —ssoLoginMethod),meta.flowwhen the session is a side effect (register,password_change,password_reset),meta.provider(connector slug; a desktop ticket carries it to the redemption),meta.team_id. No active org → a platform row. Read atGET /api/orgs/{id}/audit, not the team route. - A refusal is a platform row with no tenant, actor
anonymousunless the route is authenticated,meta.reasontyped (unknown_account,bad_password,locked,disabled,password_change_required,link_required,sso_restricted,ticket_invalid,reset_token_invalid,agent_binding,password_changed_sign_in,error…), never the message — the wire collapses several of these into one answer on purpose (auth.ReasonOfis the typed side).target_idis set only for an account the server resolved (loginAttempt.account), never from what the client typed. - The identifier attempted is never stored in clear:
meta.subject_hash= HMAC-SHA256 underderivedKey(activeKID, "iterion/audit/subject/v1"), 16 hex (auth.Service.SubjectDigest). A bare hash of an e-mail is a dictionary lookup; the pepper is the signing key. A key rotation starts a new series. No address → no digest. - IP is
s.clientIP(r): a forwarded header only from a peer inTrustedProxyCIDRs, which no setting fills yet (#2673) — so today the peer'sip:port, the ingress pod's behind an ingress. User-agent is the request's. - A 429 writes no row; the log adds no bound. Today's bounds: login and forced rotation per attempted address (burst 5); the per-IP tier is per connection until #2673; register burst 3; the OIDC callback none (an IdP round trip per row). ADR-157 lists them.
Retention
- One TTL index on
audit_events.created_at, 400 days (audit.RetentionDays), sign-in rows included; submitted to the DPO as one question with the backup retention. If the DPO asks for the CNIL's 6–12 months on connection logs, the prepared change is a per-actionexpires_at— never a second collection. sessionskeep their own IP/user-agent/lineage and their own purge (DeleteExpired); the audit row does not replace them.
Launches — the second PR of ADR-157
run.launched, one row per run, through one wrapper arounds.runs.Launchon every launcher (API, studio, webhook, schedule, board dispatcher, pipeline admission), actor user | webhook | system; a test refuses aLaunchoutside the wrapper. To be written with that change.
Erasure — to be completed by gap 3
- The account-erasure change scrubs
{meta.subject_hash, ip, user_agent}of the rows whose actor or target is the erased account AND of the refusals by address, which carry no account id: matched onSubjectDigest(email)recomputed under every key still in the signing key set (a retired key's digest is unlinkable). One method ofaudit.Store(Memory and Mongo in lock-step); it also deletes the account'ssessionsrows (revocation alone keeps them). Section owned by that change.
