fix(auth): match identity on a canonical email, so one Gmail mailbox is one account (E9.5 S3)

Gmail delivers x.y@, xy@ and xy+tag@ to one inbox; the invite gate only lowercased.
On 2026-09-03 that turned an approved user's Google sign-in into a silent SECOND
access request -- OAuth returns the account's stored primary, which need not be the
spelling they typed -- and left prod with two user rows for one mailbox (ids 13, 14).
The +tag case fails every time: a Google account cannot contain +, so OAuth can only
ever answer with the base address.

Mail still goes to the address AS TYPED; only identity matching uses the canonical
form. utils.canonical_email is the single definition, and a model validator derives
users.email_canonical / invites.email_canonical on every write, so no call site can
forget it. Scope is narrow on purpose: dots are significant on most providers and the
Gmail rule does not hold for Workspace custom domains.

Swept every sibling of the same principle, not just the reported one: is_allowed,
upsert_pending_invite, the Google-callback adoption, _register_account (so no new
duplicate pair can be created), _activate_user_by_email, admin _accounts_for (an
invite backing a live user under another spelling used to look deletable) and
add_invite, plus purge_user, which now erases every canonically-equal invite instead
of leaving one behind that still grants access.

Migration 0079 backfills both columns and indexes them NON-uniquely: prod already
carries canonical duplicates (approved invite pairs since 2026-06-16), and merging
accounts is a judgement call about people's data, not something a migration does on
its own. A test pins the backfill SQL against the Python helper.
This commit is contained in:
2026-09-04 00:14:12 +02:00
parent e5997ca567
commit 876cece881
7 changed files with 375 additions and 26 deletions
+33 -10
View File
@@ -27,20 +27,27 @@ from app.models import (
Invite,
User,
)
from app.utils import iso, valid_email
from app.utils import canonical_email, iso, valid_email
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _accounts_for(db: Session, emails: Iterable[str]) -> set[str]:
"""Which of these addresses already have an account. Batched, so listing the invites costs one
extra query. An invite whose address has an account must not be deletable: `is_allowed` reads
this row, so removing it would silently revoke a live user's access."""
wanted = {e.lower() for e in emails if e}
"""Which of these addresses already have an account, as a set of CANONICAL forms. Batched, so
listing the invites costs one extra query. An invite whose address has an account must not be
deletable: `is_allowed` reads this row, so removing it would silently revoke a live user's access.
Canonical on both sides, because `is_allowed` matches that way: an invite for `x.y@gmail.com`
whose account registered as `xy@gmail.com` would otherwise look account-less, offer the admin a
delete button, and take away a live user's access — precisely the accident this guard exists to
prevent."""
wanted = {canonical_email(e) for e in emails if e}
if not wanted:
return set()
rows = db.execute(select(User.email).where(User.email.in_(wanted))).scalars()
return {e.lower() for e in rows if e}
rows = db.execute(
select(User.email_canonical).where(User.email_canonical.in_(wanted))
).scalars()
return {e for e in rows if e}
def _env_allowed(email: str) -> bool:
@@ -82,7 +89,7 @@ def list_invites(
order = {"pending": 0, "approved": 1, "denied": 2}
rows.sort(key=lambda i: order.get(i.status, 9))
accounts = _accounts_for(db, (i.email for i in rows))
return [_serialize(i, has_account=(i.email or "").lower() in accounts) for i in rows]
return [_serialize(i, has_account=canonical_email(i.email) in accounts) for i in rows]
def _activate_user_by_email(db: Session, email: str) -> None:
@@ -97,7 +104,18 @@ def _activate_user_by_email(db: Session, email: str) -> None:
DEFAULT experience, because approve-before-verify is the normal ordering. The revoke stays on
the Google-adoption path (auth.revoke_preset_password_if_unverified), where the real owner does
attest ownership and the preset password must not survive."""
user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none()
# Canonical, with the literal spelling preferred when both exist: approving
# `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`,
# since Gmail delivers both to one mailbox and the admin decided about the mailbox.
email = email.lower()
candidates = (
db.execute(
select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id)
)
.scalars()
.all()
)
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
if user is not None and not user.is_active:
user.is_active = True
db.commit()
@@ -169,7 +187,12 @@ def add_invite(
email = body.email.strip().lower()
if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.")
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
# Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries
# exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@),
# which is how an admin ends up approving the same human twice and still seeing them bounce.
canon = canonical_email(email)
rows = db.execute(select(Invite).where(Invite.email_canonical == canon)).scalars().all()
inv = next((r for r in rows if r.email == email), None) or next(iter(rows), None)
if inv is None:
inv = Invite(email=email)
db.add(inv)