fix(auth): drop the invite reopen — keep the fix to the lost account alone

Round 3 of the review killed the reopen from b5a1002: flipping an approved
invite back to pending let an unauthenticated POST /auth/register REVOKE a
standing approval for any whitelisted-but-unclaimed address, which then fails
is_allowed() and locks the real owner out of Google sign-in — the primary way
people get in here. It also re-armed the pre-emption window it was meant to
close, because with no SMTP email_verified is set True and the SEC-10 guard the
design leaned on never fires.

Two attempts at fixing the stranded-account UX in the same change both came back
worse than the wart. What remains is the narrow, real bug: the account was
silently rolled back and is now committed. An admin still binds the approval to
the account, exactly as before this branch. UX-25 in the roadmap records the
follow-up and both dead ends so they are not retried.

Also from round 3: the upsert docstring no longer claims the admin UI cannot
reach these accounts (re-entering the address in the same panel's whitelist box
does, and a test now pins it), the release notes are scoped to what the diff
actually changes with the operator note moved where the dialog renders it, and
RateLimiter has unit tests so reset() is not production code only a fixture
calls.
This commit is contained in:
2026-09-01 21:34:38 +02:00
parent b5a1002412
commit b64c3f514d
4 changed files with 116 additions and 78 deletions
+14 -24
View File
@@ -147,25 +147,16 @@ def _client_ip(request: Request) -> str:
return peer
def upsert_pending_invite(
db: Session, email: str, *, revive_approved: bool = False
) -> Invite | None:
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
"""Idempotently record an access request. Returns the Invite if a *new* pending row
was created (so the caller can notify admins), else None for already-known emails.
`revive_approved` is for the registration path only. An approval granted BEFORE any account
existed was a decision about the ADDRESS, not about whoever has now set a password on it, so the
request goes back in front of a human rather than becoming an automatic grant. It also un-strands
the registrant: an approved invite never appears in the Access-requests queue and `_decide` 409s
on an already-decided one, so nothing in the admin UI could otherwise activate the account."""
was created (so the caller can notify admins), else None for already-known emails."""
email = (email or "").lower()
if not valid_email(email):
return None
inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none()
if inv is not None:
# Let a previously-denied person ask again; don't disturb a pending row (the admin already
# has that request in front of them).
if inv.status == "denied" or (revive_approved and inv.status == "approved"):
# Let a previously-denied person ask again; don't disturb approved/pending rows.
if inv.status == "denied":
inv.status = "pending"
inv.requested_at = datetime.now(timezone.utc)
inv.decided_at = None
@@ -645,24 +636,23 @@ def _register_account(email: str, password: str) -> None:
# no-op as the pre-check hit — roll back and stop.
db.rollback()
return
# admin-approval gate. revive_approved: an approval that predates the account is re-opened so
# a human binds it to THIS registration — that keeps the SEC-10 preset-password guard in the
# loop (approve_invite runs it) and puts the request somewhere the admin can actually see.
inv = upsert_pending_invite(db, email, revive_approved=True)
inv = upsert_pending_invite(db, email) # admin-approval gate
# Persist the account ourselves. upsert_pending_invite commits only when it actually writes an
# invite row, and _issue_token below only runs with SMTP on — so when a request for this
# address was already pending, on an instance with no SMTP, nothing along this path committed
# and the flushed user was silently rolled back when the session closed. The would-be user got
# a uniform "ok" and no account. Commit unconditionally; it is a no-op when upsert already
# committed the same transaction.
# invite row — not when this address already has a pending or approved one — and _issue_token
# below only runs with SMTP on. So for an address the admin had already approved (or one whose
# request was still pending), on an instance with no working SMTP, NOTHING along this path
# committed and the flushed user was silently rolled back when the session closed: the
# would-be user got the uniform "ok" and no account, and re-registering did the same again.
# Commit unconditionally; it is a no-op when upsert already committed the same transaction.
db.commit()
if email_ok:
raw = _issue_token(db, user, "verify", VERIFY_TTL)
# Fragment (#), not a query token: keeps the token out of proxy/access logs + Referer (SB3).
email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}")
admins = admin_notify_emails(db)
# Only when there is a fresh decision to make, matching the other two call sites. Without the
# guard an address whose request was already pending re-pinged every admin on each retry.
# Only when there is a fresh decision to make, matching the other two call sites. Otherwise
# someone who used Request access and then registered sent admins a second copy of the same
# request, and an already-approved address produced a notice pointing at an empty queue.
if inv is not None and admins:
email_mod.send_admin_new_request(admins, email)