Summary
The pre-change existing-user path refused a same-subject email change when another local row owned the address (if email_owner is not None and email_owner.id != user.id: raise OIDCIdentityCollisionError). The multi-provider rewrite kept that guard on the new-triple creation path (services/users.py:166-167) but dropped it from the update path — _refresh_profile is now called with any email unconditionally (src/iceberg/services/users.py:180-190).
Failure scenario
The cross-provider relaxation only needed to tolerate bound co-owners (the designed "same person under two IdPs" case). Instead it tolerates unbound owners too, enabling a two-step bypass of the admin-must-link policy:
- Attacker with self-service email change at the IdP (normal in Auth0/Authentik) logs in once with a fresh email → new user row created.
- Attacker changes their IdP email to a legacy/dev account's address, logs in again → the update path calls
_refresh_profile and silently shadows the account that the creation path insists an administrator link explicitly.
Side effect: dev_login for that legacy account then 409s forever (users.py:194-195 blocks when any owner of the email is bound).
Suggested fix
Restore the unbound-owner collision check on the update path: reject an email owned by a different unbound row (bound co-owners remain allowed for the legitimate cross-provider case). Add a test for the email-change-to-claimed-address path.
Found in Fable review of PR #232 → HEAD.
Summary
The pre-change existing-user path refused a same-subject email change when another local row owned the address (
if email_owner is not None and email_owner.id != user.id: raise OIDCIdentityCollisionError). The multi-provider rewrite kept that guard on the new-triple creation path (services/users.py:166-167) but dropped it from the update path —_refresh_profileis now called with any email unconditionally (src/iceberg/services/users.py:180-190).Failure scenario
The cross-provider relaxation only needed to tolerate bound co-owners (the designed "same person under two IdPs" case). Instead it tolerates unbound owners too, enabling a two-step bypass of the admin-must-link policy:
_refresh_profileand silently shadows the account that the creation path insists an administrator link explicitly.Side effect:
dev_loginfor that legacy account then 409s forever (users.py:194-195blocks when any owner of the email is bound).Suggested fix
Restore the unbound-owner collision check on the update path: reject an email owned by a different unbound row (bound co-owners remain allowed for the legitimate cross-provider case). Add a test for the email-change-to-claimed-address path.
Found in Fable review of PR #232 → HEAD.