[Backend] set_admin --email crashes instead of disambiguating when two identities share an email #436
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
The defect
_set_admin_by_email(webapp/backend/app/cli.py) looks a user up by email alone:usersis unique on(oidc_sub, oidc_issuer), not on email. So one person can legitimately have several rows — and when they do,scalar_one_or_none()raisesMultipleResultsFound. The operator gets a SQLAlchemy traceback rather than "two users share that address, disambiguate with--oidc-sub".make set-admin EMAIL=…wraps this path, so the documented convenience command is the one that breaks.Not hypothetical — it is what a prod→dev restore produces
Hit on the dev host, 2026-08-29:
Same
oidc_sub, different issuer, because dev authenticates against its own OIDC application. Restoring production onto dev brings prod's user rows across, and the operator's dev identity is a separate row that was never granted admin.So the exact circumstance that makes someone reach for
set_admin— "I restored prod onto dev and I am not an admin any more" — is the circumstance in which--emailcannot work. Had to use--oidc-sub+--oidc-issuerinstead.Worth noting the framing trap this creates: it presents as lost admin, when in fact the admin flag is intact on a different row. An operator without database access could reasonably conclude the restore corrupted something.
Proposed fix
Make the ambiguity a first-class outcome rather than an exception:
.scalars().all()and branch on the count.is_admin) and tell the operator to re-run with--oidc-sub/--oidc-issuer. Do not guess, and do not grant to all of them._set_admin_by_oidcis already correct and needs no change.Acceptance criteria
MultipleResultsFoundRelated
docs/OPERATIONS.mdmay be worth a line about multiple identities after a cross-environment restore, since the symptom reads as lost access.Done in PR #480. The issue's evidence held up exactly against the current code —
scalar_one_or_none()on an email-only lookup, with_set_admin_by_oidcalready correct and untouched.Ambiguity is now an outcome rather than an exception. The candidates are listed with
oidc_sub, issuer and current admin status, the message names the flags to disambiguate, it exits non-zero, and nothing is modified. Admin status is in there because it is usually the whole answer: the operator typically already holds admin on one row and needs the other one.Guessing would have been worse than failing. The rows differ precisely in which identity they are, so granting to the wrong one leaves the operator still locked out with no sign of why.
Also took the optional suggestion and added an
OPERATIONS.mdtroubleshooting section, since this presents as lost access when the flag is intact on another row.7 mutations, all caught. The one worth naming is reverting to
scalar_one_or_none()— that raises rather than exiting, sopytest.raises(SystemExit)pins the actual behaviour change and not merely the wording.Two things about the tests worth recording, both cases of a test that would otherwise have passed for the wrong reason:
AsyncSessionLocal(), a different connection from the transactionaldbfixture, so it cannot see uncommitted fixture rows. Written the obvious way, every "no user found" assertion would have passed against an effectively empty table. These use real commits with cleanup instead — the same trap #457 documented for the Celery purge tasks.[False, True]— indistinguishable. A second test seeds two non-admin rows, where any grant at all shows up.Backend 1613 passed; ruff clean. All five acceptance criteria met.