fix(§6/auth): reconcile by email in provision_user so OTC→OAuth doesn't 500 (v0.54.1)
The Gitea-OAuth callback's `auth.provision_user` matched an existing `users` row only by `gitea_id`. A human who signed in first via OTC owns an email-only row (`gitea_id` NULL); a later Gitea-OAuth sign-in with the same email missed that row and INSERTed a new one, colliding on the `idx_users_email` case-insensitive unique index → IntegrityError → 500 callback. Fix (mirror of `otc.provision_or_link_user`): when no `gitea_id` row exists, reconcile by email and link the OAuth identity (gitea_id/gitea_login/profile) onto the existing email row. Owner-zero (§6.1) bootstrap applied on link (matching the fresh-insert path); `permission_state` preserved so linking never changes admission status as a side effect. One §9-surfaced framework fragility of two. backend 680 green (+3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+27
-3
@@ -145,6 +145,19 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
||||
|
||||
c = db.conn()
|
||||
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
|
||||
# No row for this gitea_id yet. A prior OTC sign-in (`provision_or_link_user`)
|
||||
# may have created an email-only row with this same email and `gitea_id`
|
||||
# NULL; `idx_users_email` is a unique index, so a blind INSERT below would
|
||||
# raise IntegrityError and 500 the OAuth callback. Reconcile by email: link
|
||||
# the OAuth identity onto that existing row instead. Mirror of the OTC
|
||||
# linker, which links an OAuth-era email row on first OTC sign-in.
|
||||
linked = False
|
||||
if existing is None and email:
|
||||
existing = c.execute(
|
||||
"SELECT * FROM users WHERE email = ? COLLATE NOCASE AND gitea_id IS NULL LIMIT 1",
|
||||
(email,),
|
||||
).fetchone()
|
||||
linked = existing is not None
|
||||
if existing is None:
|
||||
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
|
||||
# v0.8.0: a fresh OAuth-provisioned user is also subject to
|
||||
@@ -166,15 +179,26 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
||||
permission_state = "granted"
|
||||
else:
|
||||
user_id = existing["id"]
|
||||
role = existing["role"]
|
||||
# On a fresh link this OAuth sign-in is the row's first, so apply the
|
||||
# §6.1 owner-zero bootstrap (matching the INSERT path). For a row already
|
||||
# matched by gitea_id the role is settled — preserve it. `permission_state`
|
||||
# is preserved either way: linking an OAuth identity onto an existing
|
||||
# email row must not silently change the human's admission status.
|
||||
if linked and config.owner_gitea_login and login == config.owner_gitea_login:
|
||||
role = "owner"
|
||||
else:
|
||||
role = existing["role"]
|
||||
permission_state = existing["permission_state"] or "granted"
|
||||
# Setting `gitea_id` matters only on the link path (it was NULL); on the
|
||||
# gitea_id-matched path it re-writes the same value. `role` is likewise a
|
||||
# no-op there. Always refresh the mutable profile fields + last_seen.
|
||||
c.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET gitea_login = ?, email = ?, display_name = ?, avatar_url = ?, last_seen_at = datetime('now')
|
||||
SET gitea_id = ?, gitea_login = ?, email = ?, display_name = ?, avatar_url = ?, role = ?, last_seen_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(login, email, display, avatar, user_id),
|
||||
(gitea_id, login, email, display, avatar, role, user_id),
|
||||
)
|
||||
|
||||
return SessionUser(
|
||||
|
||||
Reference in New Issue
Block a user