fix(auth): reopen a pre-dated approval instead of auto-granting it

Replaces the activation added in ea931dc, which round 2 of the review showed was
a real weakening: on a no-SMTP instance it made a standing whitelist entry a
bearer grant — the first anonymous party to register an approved-but-unclaimed
address got an immediately usable account under that identity, with no
attestation, no admin action and no signal anywhere. The verify-time activation
had the same shape one step removed, and neither site ran the SEC-10 preset-
password guard or left an audit trail.

Keep a human in the loop instead. An approval granted before any account existed
was a decision about the ADDRESS, so registration reopens the invite
(upsert_pending_invite gains revive_approved, used only by the register worker).
The request lands in the Access-requests queue, approving it runs
_activate_user_by_email — and with it revoke_preset_password_if_unverified — and
the account activates. One mechanism, already audited, no new grant path, and
the "awaiting admin approval" copy is honest again.

The explicit commit from the previous commit still matters: with an invite
already pending, upsert returns without committing and _issue_token is gated on
SMTP, so a no-SMTP registration was still being rolled back.

Also from round 2: gate the admin notice on a fresh decision (matching the two
other upsert callers) so a retry no longer re-pings every admin, and reset the
module-level rate limiters between tests — the suite pooled every login into one
300s window and was two logins short of failing an unrelated test with a 429.
This commit is contained in:
2026-09-01 21:18:14 +02:00
parent ea931dca30
commit b5a1002412
5 changed files with 142 additions and 87 deletions
+26 -27
View File
@@ -147,16 +147,25 @@ def _client_ip(request: Request) -> str:
return peer
def upsert_pending_invite(db: Session, email: str) -> Invite | None:
def upsert_pending_invite(
db: Session, email: str, *, revive_approved: bool = False
) -> 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."""
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."""
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 approved/pending rows.
if inv.status == "denied":
# 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"):
inv.status = "pending"
inv.requested_at = datetime.now(timezone.utc)
inv.decided_at = None
@@ -636,29 +645,25 @@ def _register_account(email: str, password: str) -> None:
# no-op as the pre-check hit — roll back and stop.
db.rollback()
return
upsert_pending_invite(db, email) # admin-approval gate (commits only when it writes a row)
# Honour an approval the admin had ALREADY given for this address. Without this the account
# lands is_active=False against a non-pending invite: it never shows up in the Access-requests
# queue (which lists pending only) and approve_invite 409s on an already-decided invite, so
# the admin has no obvious way to let the person in. Attestation still gates it — with SMTP on
# the address is unverified at this point, so activation waits for the verify link (see
# verify_email_confirm). With no SMTP there is no attestation to wait for and the admin's
# whitelist is by design the only gate, the very one add_invite already activates on.
if not email_ok and is_allowed(db, email):
user.is_active = True
# Persist the account ourselves. upsert_pending_invite commits only when it actually inserts
# (or revives) an invite row, and _issue_token below only runs with SMTP on — so for an email
# the admin had ALREADY approved, 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.
# 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)
# 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.
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)
if admins:
# 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.
if inv is not None and admins:
email_mod.send_admin_new_request(admins, email)
@@ -672,12 +677,6 @@ def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
status_code=400, detail="This verification link is invalid or has expired."
)
user.email_verified = True
# Ownership is attested now (only the mailbox owner can follow this link), so an approval the
# admin granted BEFORE the person signed up can finally be honoured. Otherwise they sit at
# is_active=False behind a request no admin screen shows — the invite is already decided, so it
# is absent from the pending queue and approve_invite would 409 on it.
if not user.is_active and is_allowed(db, user.email):
user.is_active = True
db.commit()
return {"ok": True}