feat(auth)!: remove the public access-request endpoint

POST /auth/request-access had no caller left: the login page's form was
gone, and registration and a refused Google sign-in already land in the
same admin approval. A third public write path with no protection of
its own (owner decision, E34).

Its one guard moves to where every path passes: upsert_pending_invite no
longer opens a pending row for an address the env already lets in. The
registration path lacked it — registering b.ob@gmail.com beside an
env-granted bob@gmail.com opened a pending row, and is_allowed answers
from the invite rows once any exist, so the real owner was locked out.
The tests that drove the endpoint now drive registration or the shared
function.
This commit is contained in:
2026-10-05 00:19:19 +02:00
parent a499f00209
commit 7f08c510bf
5 changed files with 39 additions and 107 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ def lang_for_user(user: User | None) -> str:
def lang_for_email(db: Session, email: str) -> str:
"""The language for an address that may not have an account yet: their own account preference,
else the language they filled our access-request form in, else English.
else the language their access request came in (the locale Google reported), else English.
Matched CANONICALLY like every other identity lookup in this epic, and it walks past rows that
carry no usable code instead of stopping at the first one — prod really does hold two accounts
+11 -42
View File
@@ -29,6 +29,7 @@ from app.auth import (
WRITE_SCOPES,
current_user,
env_admin_canonicals,
env_allowed_canonicals,
find_user_by_email,
get_or_create_demo_user,
google_enabled,
@@ -72,7 +73,6 @@ _reset_limiter = RateLimiter(max_events=5, window_seconds=300)
# Separate from _reset_limiter on purpose: opening a link must not eat the budget for REQUESTING one.
_reset_check_limiter = RateLimiter(max_events=10, window_seconds=300)
_verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300)
_request_access_limiter = RateLimiter(max_events=5, window_seconds=300)
_suspend_email_limiter = RateLimiter(max_events=1, window_seconds=3600)
@@ -196,9 +196,9 @@ def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> I
not reopen a decision that has already been made for that mailbox. The row is still STORED under
the address as typed, because that is where mail has to go.
`lang` is the language of the surface the request came from — the sign-in page, or the locale
Google reported for a refused sign-in. The latest one wins: someone who asks again from a
Hungarian page is telling us something newer than last time. Anything unsupported is ignored
`lang` is the language the request came in — the locale Google reported for a refused sign-in.
The latest one wins: someone who asks again under another locale is telling us something newer
than last time. Anything unsupported is ignored
rather than stored, so a forged body cannot put junk in the column.
The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely
@@ -215,6 +215,12 @@ def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> I
.scalars()
.all()
)
# An address the env already lets in has nothing to ask for, and a pending row for it would be
# worse than nothing: `is_allowed` answers from the invite rows once any exist, so a stranger
# registering a sibling spelling (`b.ob@` beside an env-granted `bob@`) would lock the real
# owner out of a grant no admin could see.
if not rows and canon in env_allowed_canonicals():
return InviteRequest(None, False)
# An approved row wins, so a denied duplicate can't mask an approval. Then a row that is not
# DENIED, preferring the exact spelling — a denied sibling must never be picked while a pending
# row exists, or the reopen branch below would fire and put one person in the admin's queue
@@ -628,43 +634,6 @@ async def logout(request: Request):
return JSONResponse({"ok": True, "switched": False})
class AccessRequestIn(EmailIn):
# The language of the page this request was made from. Same reasoning as registration's `lang`
# (and the same shape): it is the ONLY moment this preference exists anywhere the server can
# see, since the requester has no account to carry it, and it decides what language the approval
# or denial mail is written in.
lang: str | None = None
@router.post("/request-access")
async def request_access(
body: AccessRequestIn,
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
) -> dict:
"""Public: ask for access. Idempotent — repeat requests for the same email don't
duplicate or re-spam admins (upsert answers `is_new=False` for an already-pending row).
Answers a uniform ``"pending"`` for EVERY outcome — already-allowed, newly-pending,
already-pending, and rate-limited alike. The old ``"approved"`` reply for an allow-listed
address made this a public enumeration oracle for the allow-list (which the rest of the module
is careful to avoid, cf. demo_login); an already-allowed user can still just sign in with Google.
Rate-limited per client IP like register/login/reset, and a block returns "pending" too so the
endpoint can't be probed for throttling either."""
email = (body.email or "").strip().lower()
if not valid_email(email):
raise refused(400, "invalid_email")
if not _request_access_limiter.allow(client_key(request)):
return {"ok": True, "status": "pending"}
if is_allowed(db, email):
return {"ok": True, "status": "pending"} # already allowed — don't reveal it
req = upsert_pending_invite(db, email, body.lang)
if req.is_new:
notify_admins_of_request(db, background, email)
return {"ok": True, "status": "pending"}
@router.post("/demo")
def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -> dict:
"""Hidden demo entry: a whitelisted email logs straight into the shared demo account,
@@ -918,7 +887,7 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non
db.commit()
if email_ok:
send_verification_link(db, user)
# Fired unconditionally, unlike the other two callers which gate on a fresh invite row: for
# Fired unconditionally, unlike the Google callback, which gates on a fresh invite row: for
# an address the admin already approved, nothing lands in the Access-requests queue, so this
# is the only place the account's existence is announced at all. Delivery is a separate
# matter — with no working SMTP `_send` drops it, which is why the pre-approved registrant
+17 -42
View File
@@ -131,6 +131,12 @@ def test_the_lookup_is_canonical_like_every_other_identity_match(db, make_user):
# --- capturing the signal at the one moment it exists ---------------------------------------------
def _ask(db, lang: str) -> None:
"""A pending request carrying the language it was made in — what a refused Google sign-in records
(with the locale Google reported), through the one function every entry point shares."""
auth_routes.upsert_pending_invite(db, "asking@example.com", lang)
def test_registration_records_the_language_of_the_sign_up_page(db, client):
r = client.post(
"/auth/register", json={"email": "signup@example.com", "password": _PW, "lang": "hu"}
@@ -141,31 +147,16 @@ def test_registration_records_the_language_of_the_sign_up_page(db, client):
assert (user.preferences or {}).get("language") == "hu"
def test_an_access_request_records_the_language_it_was_made_in(db, client):
assert (
client.post(
"/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
).status_code
== 200
)
def test_an_access_request_records_the_language_it_was_made_in(db):
_ask(db, "hu")
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
assert inv.lang == "hu"
def test_asking_again_from_another_language_updates_the_row(db, client):
assert (
client.post(
"/auth/request-access", json={"email": "asking@example.com", "lang": "en"}
).status_code
== 200
)
assert (
client.post(
"/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
).status_code
== 200
)
def test_asking_again_from_another_language_updates_the_row(db):
_ask(db, "en")
_ask(db, "hu")
db.expire_all()
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
@@ -174,13 +165,8 @@ def test_asking_again_from_another_language_updates_the_row(db, client):
assert db.execute(select(Invite)).scalars().all() == [inv]
def test_an_unsupported_language_is_ignored_rather_than_stored(db, client):
assert (
client.post(
"/auth/request-access", json={"email": "asking@example.com", "lang": "../../etc"}
).status_code
== 200
)
def test_an_unsupported_language_is_ignored_rather_than_stored(db):
_ask(db, "../../etc")
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
assert inv.lang is None
@@ -222,21 +208,15 @@ def test_the_confirmation_mail_is_written_in_the_accounts_language(db, client, m
def test_the_approval_mail_answers_in_the_language_the_request_was_made_in(
db, client, make_user, as_user, monkeypatch
db, make_user, as_user, monkeypatch
):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
assert (
client.post(
"/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
).status_code
== 200
)
_ask(db, "hu")
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
sent.clear() # drop the admin notification; this test is about the reply to the requester
r = as_user(admin).post(f"/api/admin/invites/{inv.id}/approve")
@@ -246,18 +226,13 @@ def test_the_approval_mail_answers_in_the_language_the_request_was_made_in(
assert subject == mailtext.MAIL_TEXT["access_approved.subject"]["hu"]
def test_a_denial_answers_in_that_language_too(db, client, make_user, as_user, monkeypatch):
def test_a_denial_answers_in_that_language_too(db, make_user, as_user, monkeypatch):
from app import email as email_mod
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
sent = _bodies(monkeypatch)
admin = make_user(email="admin@example.com", role="admin")
assert (
client.post(
"/auth/request-access", json={"email": "asking@example.com", "lang": "hu"}
).status_code
== 200
)
_ask(db, "hu")
inv = db.execute(select(Invite).where(Invite.email == "asking@example.com")).scalar_one()
sent.clear()
+9 -14
View File
@@ -175,7 +175,7 @@ def test_a_stripped_account_keeps_its_recovery_stamp(db, client, smtp_on, make_u
assert row.password_hash is None and row.password_revoked_at is not None
def test_a_denied_sibling_is_not_reopened_while_a_pending_row_exists(db, client):
def test_a_denied_sibling_is_not_reopened_while_a_pending_row_exists(db):
# Canonical siblings exist on prod. Picking the denied one reopens it and re-notifies every
# admin, putting one person in the queue twice — and the exact-spelling tie-break made that
# deterministic rather than merely possible.
@@ -187,9 +187,7 @@ def test_a_denied_sibling_is_not_reopened_while_a_pending_row_exists(db, client)
)
db.commit()
assert (
client.post("/auth/request-access", json={"email": "pairone@gmail.com"}).status_code == 200
)
auth_routes.upsert_pending_invite(db, "pairone@gmail.com")
db.expire_all()
rows = {i.email: i.status for i in db.execute(select(Invite)).scalars().all()}
@@ -210,12 +208,13 @@ def test_the_env_allow_list_is_matched_canonically(db, monkeypatch):
def test_an_env_granted_address_cannot_be_shadowed_by_a_stranger(db, client, monkeypatch):
# `is_allowed` short-circuits on ANY invite row for the canonical form, so before this an
# unauthenticated caller could request access under a sibling spelling and permanently shadow an
# env grant. With the env list matched canonically the request never opens a row.
# `is_allowed` short-circuits on ANY invite row for the canonical form, so an unauthenticated
# caller registering under a sibling spelling would open a pending row and permanently shadow an
# env grant. The env list is matched canonically, so the registration never opens a row.
monkeypatch.setattr(settings, "allowed_emails", "bob@gmail.com")
assert client.post("/auth/request-access", json={"email": "b.ob@gmail.com"}).status_code == 200
r = client.post("/auth/register", json={"email": "b.ob@gmail.com", "password": _PW})
assert r.status_code == 200
assert db.execute(select(Invite)).scalars().all() == []
assert auth_core.is_allowed(db, "bob@gmail.com") is True
@@ -351,12 +350,8 @@ def test_two_admins_two_languages_two_mails(db, client, make_user, smtp_on, wire
make_user(email="hu.admin@example.com", role="admin", preferences={"language": "hu"})
make_user(email="en.admin@example.com", role="admin", preferences={"language": "en"})
assert (
client.post(
"/auth/request-access", json={"email": "asking@example.com", "lang": "en"}
).status_code
== 200
)
r = client.post("/auth/register", json={"email": "asking@example.com", "password": _PW})
assert r.status_code == 200
notices = [w for w in wire if "asking@example.com" in w[1]]
assert len(notices) == 2