fix(sso): never relink an already-claimed account (LC-698) #664
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/LC-698-sso-adoption-guard"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
[operator-action] LC-618 removed the LC-588 takeover guard from the verified-email adoption UPDATE, so
adopt_bunyip_subforce-setbunyip_subon whatever row owned the incoming email. Which local account an incoming Bunyip identity binds to was therefore decided by a string any authenticated user can set on their own profile without verification (post_profilevalidates shape only; LC-22 retired verification mail). A user who set their profile email to a colleague's address before that colleague's first SSO login had the colleague's login land inside their account, with their own session cookie still live.Restores the guard as
link_bunyip_sub:AND (bunyip_sub IS NULL OR bunyip_sub = ''), which is LC-588's case and is the only automatic adoption. A row already linked to a different subject is now an explicitResolveError::IdentityConflictrendered as/login?sso_error=identity_conflict, which keeps LC-618's actual complaint fixed (an actionable message instead of an opaque 500 from the colliding INSERT) without the silent relink.The resolver's email lookup moves from
find_user_id_by_emailtofind_email_owner, which reportsbunyip_sub,is_bot,is_bannedand the verified stamp so each shape is classified rather than collapsed: banned or bot owner refuses; an owner holding the address unverified has it released (a self-service address has no claim while the OP vouches for this login, and releasing it also stops that row from receiving the address's mail) and the login provisions a fresh account; a verified owner linked to another subject refuses; a verified unlinked owner is linked.mark_email_verified_if_unsetgains an email guard. Without it the LC-627 stamp made the verified-email gate vacuous: a user could set a stranger's address, log in as themselves, and have their own login stamp that address verified.Every
bunyip_subchange now deletes the row's sessions, so neither a link nor an authorized relink can leave a previous holder signed in.Adds the deliberate relink path LC-618 asked for:
POST /admin/users/{id}/unlink-ssoclears the subject, is audited assso_unlinkthroughlog_mod_action, and leaves the row adoptable by the next login. The button renders only when the row is linked, and a rejected row action toasts rather than swapping nothing.Migration 0043 makes the
users_bunyip_sub_uniqueindex partial (WHERE bunyip_sub <> ''). The unconditional index made the empty-string unlinked marker itself unique, so at most one account could sit unlinked and an admin could not unlink a second user. The subject lookups also exclude the empty string, and the callback rejects an emptysubclaim, so the unlinked marker can never resolve as an identity.Operators: a login whose OP subject has rotated now fails with
sso_error=identity_conflictuntil an admin uses Unlink SSO on that user; the runbook troubleshooting table carries the procedure. Unlinking signs that user out everywhere.#LC-698
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com
[operator-action] LC-618 removed the LC-588 takeover guard from the verified-email adoption UPDATE, so `adopt_bunyip_sub` force-set `bunyip_sub` on whatever row owned the incoming email. Which local account an incoming Bunyip identity binds to was therefore decided by a string any authenticated user can set on their own profile without verification (`post_profile` validates shape only; LC-22 retired verification mail). A user who set their profile email to a colleague's address before that colleague's first SSO login had the colleague's login land inside their account, with their own session cookie still live. Restores the guard as `link_bunyip_sub`: `AND (bunyip_sub IS NULL OR bunyip_sub = '')`, which is LC-588's case and is the only automatic adoption. A row already linked to a different subject is now an explicit `ResolveError::IdentityConflict` rendered as `/login?sso_error=identity_conflict`, which keeps LC-618's actual complaint fixed (an actionable message instead of an opaque 500 from the colliding INSERT) without the silent relink. The resolver's email lookup moves from `find_user_id_by_email` to `find_email_owner`, which reports `bunyip_sub`, `is_bot`, `is_banned` and the verified stamp so each shape is classified rather than collapsed: banned or bot owner refuses; an owner holding the address unverified has it released (a self-service address has no claim while the OP vouches for this login, and releasing it also stops that row from receiving the address's mail) and the login provisions a fresh account; a verified owner linked to another subject refuses; a verified unlinked owner is linked. `mark_email_verified_if_unset` gains an email guard. Without it the LC-627 stamp made the verified-email gate vacuous: a user could set a stranger's address, log in as themselves, and have their own login stamp that address verified. Every `bunyip_sub` change now deletes the row's sessions, so neither a link nor an authorized relink can leave a previous holder signed in. Adds the deliberate relink path LC-618 asked for: `POST /admin/users/{id}/unlink-sso` clears the subject, is audited as `sso_unlink` through `log_mod_action`, and leaves the row adoptable by the next login. The button renders only when the row is linked, and a rejected row action toasts rather than swapping nothing. Migration 0043 makes the `users_bunyip_sub_unique` index partial (`WHERE bunyip_sub <> ''`). The unconditional index made the empty-string unlinked marker itself unique, so at most one account could sit unlinked and an admin could not unlink a second user. The subject lookups also exclude the empty string, and the callback rejects an empty `sub` claim, so the unlinked marker can never resolve as an identity. Operators: a login whose OP subject has rotated now fails with `sso_error=identity_conflict` until an admin uses Unlink SSO on that user; the runbook troubleshooting table carries the procedure. Unlinking signs that user out everywhere. #LC-698 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>