feat: dedicated verify-before-apply email-change flow - #115
Open
felixgateru wants to merge 7 commits into
Open
Conversation
…ability so it matches the migration-seeded database contract Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
…ady hit the undeclared api_endpoint applicability error Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
… verified email-change flow Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
…confirm flow Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
…contracts Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
Signed-off-by: Felix Gateru <felix.gateru@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Workstream A of #110: a dedicated verify-before-apply email-change flow for global human identities, kept deliberately separate from the generic self-profile path (#109), which excludes email precisely because it is a login/recovery/OAuth-linking identifier, not display metadata.
POST /auth/email/change/request— real-session-only (no access token, scoped or unscoped), rejects a session older thanATOM_EMAIL_CHANGE_MAX_SESSION_AGE_SECS(default 900s, the deliberate stand-in for step-up reauthentication since Atom has no dedicated mechanism for it). Mutates nothing; only mints a single-useatomc_-prefixed token bound to the entity, the email captured as current at request time, and the proposed email. Enumeration-resistant: an already-taken proposed email returns the same 202 without minting a token or sending mail.POST /auth/email/change/confirm— unauthenticated (the token, provable only by receipt at the proposed mailbox, is the credential); deliberately does not require the requesting session to still be alive, since every session gets revoked on success anyway and the token may legitimately be opened on a different device. Locks the entity then the canonicalentity_emailsrow (same order assync_entity_email_from_attrs_in_tx), fails safely — without consuming the token — if the live email drifted since the request, explicitly rechecks case-insensitive uniqueness (independent of the case-sensitive DB index, which only achieves case-insensitivity because every writer normalizes first), then atomically updates the canonical email, the active password credential's identifier, and — only if already present — theattributes.emailcompatibility mirror; invalidates stale verification/reset tokens; revokes every session; and notifies the old address after commit with no token/link in the message.entity.updatedomain event rather than inventing a new event name —domain-event-catalog.json's compatibility rule freezes the event-name set for v1, and a new optionalfielddetail key is within that rule.A real concurrency fix along the way
identity::service::upsert_oauth_identity's auto-link-by-email lookup only locked theentitiesrow it read (FOR UPDATE OF e), not the joinedentity_emailsrow. That meant it could commit an OAuth link keyed to an email a concurrent write was moving away — not just against this new flow, but against the existing adminsync_entity_email_from_attrs_in_txpath too. Now locks both (FOR UPDATE OF e, ee); a new test proves the lookup actually blocks on a concurrententity_emailslock holder.Contract/tooling changes
002_email_change_tokens.sql— the first migration ever added since the v1.0.0 launch squash.scripts/check-v1-contracts.sh's migration check previously required themigrations/directory to contain exactly the one frozen baseline file, which would have permanently blocked any future migration. Changed it to verify the pinned baseline is present and unmodified, without constraining what else exists — the baseline itself (migrations/001_initial.sql) stays byte-frozen.apidocs/openapi.yamlgains the two new paths and their request schemas;api/v1/deployment-config.jsongains the 3 new env vars (and its own cross-checked count inmodels/enums.rs);contracts-v1.0.0.sha384hashes recomputed for both.Test plan
cargo fmt --check,cargo clippy -- -D warnings(only pre-existing, unrelated warnings remain — confirmed identical on a clean checkout ofmain)cargo test --lib— 338 passed, including the deployment-surface contract cross-checkcargo test --test api_contract— 13 passedbash scripts/check-v1-contracts.sh— all contracts and the migration baseline validatecargo test --test m52_email_change -- --ignoredagainst local Postgres — 14/14 passed, covering: request leaves current login untouched; access tokens (scoped and unscoped) rejected; stale session rejected; enumeration resistance including a case-variant probe; same-email rejection and request supersession; wrong/expired/replayed/superseded tokens; atomic canonical-email + credential-identifier + attributes-mirror update; a never-present mirror staying untouched; case-variant collision recheck at confirmation; old-login-fails/new-login-succeeds; stale verification/reset token invalidation; safe (non-consuming) handling of a drifted current email; a lock-contention proof for the OAuth fix; single-connection-pool coverageCloses part of #110 (workstream A only; the legacy-identity audit report and remediation tooling remain separate PRs per the issue's delivery order).