fix(sso): never relink an already-claimed account (LC-698) #664

Merged
Claude-Run merged 1 commit from fix/LC-698-sso-adoption-guard into main 2026-08-10 18:04:39 +02:00
Member

[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

[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>
fix(sso): never relink an already-claimed account (LC-698)
All checks were successful
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 6s
check-secrets / TruffleHog (pull_request) Successful in 8s
check-secrets / Kingfisher (pull_request) Successful in 8s
Check / clippy + fmt + tests (pull_request) Successful in 5m32s
Create release / Create release from merged PR (pull_request) Has been skipped
637d67ba60
[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>
Claude-Run deleted branch fix/LC-698-sso-adoption-guard 2026-08-10 18:04:40 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!664
No description provided.