Release 0.8.0: open beta-access request flow (first/last/why)

Replaces the v0.3.0 / v0.7.0 allowed_emails admission gate with an
admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 / §14.1 / §17).
Any valid email can sign in via OTC; a fresh user lands in
permission_state='pending' with a captured first/last/why profile,
and an admin grant flips them to 'granted' before write endpoints
accept them. Grandfathered users pass through the migration with
the column default 'granted' so existing contributors are unaffected.
The allowed_emails table stays in the schema as a fast-path bypass
pending v0.9.0's admin user-management page (item #7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 02:25:59 -07:00
parent 8aa65014b4
commit ca8ba69acb
17 changed files with 1327 additions and 145 deletions
+52 -45
View File
@@ -1,4 +1,4 @@
"""§6.2 / v0.7.0: email + one-time-code sign-in.
"""§6.2 / v0.7.0 / v0.8.0: email + one-time-code sign-in.
Replaces the Gitea OAuth gesture as the primary human-auth path. The
Gitea bot user + token are still needed for server-side git
@@ -25,14 +25,25 @@ The shape:
`users` row already carries `email` (case-insensitive), it is
reused — `gitea_id` is left alone so a grandfathered OAuth-era
user keeps the linker intact. Otherwise a fresh contributor
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`.
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`,
and `permission_state = 'pending'` (v0.8.0 — see below).
The endpoints in `main.py` thin-wrap this module. The allowlist gate
from v0.3.0 is consulted at request time — if `allowed_emails` is
populated and the requested address isn't on it, the request returns
202 as usual but no email is sent. This intentionally does not leak
allowlist state to the caller; the §19.2 candidate for v0.8.0
replaces this gate with an admin-grant flow.
The endpoints in `main.py` thin-wrap this module.
v0.8.0 (roadmap item #6) replaces the v0.3.0 `allowed_emails` gate at
the request surface. The request handler used to silently drop OTC
requests for emails not on the allowlist; now any valid email
receives a code. The admission gate moves to `permission_state` on
the freshly-provisioned `users` row: a fresh user lands in 'pending'
and waits for an admin grant before write endpoints accept them.
Read surfaces stay open (the same blast radius v0.6.0 / item #4
already audited for anonymous viewers).
The `allowed_emails` table itself stays in the schema as a
fast-path bypass — the admin UI from v0.3.0 continues to manage it,
and a future release (v0.9.0's admin user-management page) collapses
the two admission surfaces into one. The OTC request path no
longer consults the table.
"""
from __future__ import annotations
@@ -44,7 +55,7 @@ from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser, allowlist_is_active
from .auth import SessionUser
log = logging.getLogger(__name__)
@@ -101,25 +112,15 @@ def _check_code(code: str, code_hash: str) -> bool:
return False
# ---------------------------------------------------------------------------
# Allowlist gate — shared with the OAuth flow.
# ---------------------------------------------------------------------------
def _allowlist_admits(email: str) -> bool:
"""The same allowlist v0.3.0 introduced for OAuth, applied to OTC
requests. If the allowlist is populated and the email is not on it,
we still respond 202 to the caller, but no code is sent."""
if not allowlist_is_active():
return True
row = db.conn().execute(
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
).fetchone()
return row is not None
# ---------------------------------------------------------------------------
# Request path
#
# v0.8.0: the allowlist gate from v0.7.0 / v0.3.0 is removed here. Any
# valid email receives a code; the admission gate moved to
# `permission_state` on the freshly-provisioned `users` row (see
# `provision_or_link_user`). The `allowed_emails` table stays in the
# schema (admin UI from v0.3.0 still manages it); v0.9.0's admin
# user-management page will collapse the two surfaces.
# ---------------------------------------------------------------------------
@@ -127,14 +128,16 @@ def _allowlist_admits(email: str) -> bool:
class RequestOutcome:
"""The outcome of a `request_code` call.
`code` is None whenever no code was generated — either because the
allowlist denied the email or because the cooldown window blocked
the request. The caller (the API endpoint) does not surface this
distinction to the user; it returns 202 either way.
`code` is None whenever no code was generated — the cooldown
window blocked the request or the email was syntactically
invalid. The caller (the API endpoint) does not surface the
invalid-email shape to the user; it returns 202 either way.
The cooldown shape surfaces as a loud 429 per the v0.7.0
contract.
"""
sent: bool
code: str | None
reason: str # 'sent' | 'allowlist' | 'cooldown' | 'invalid'
reason: str # 'sent' | 'cooldown' | 'invalid'
def request_code(email: str) -> RequestOutcome:
@@ -160,13 +163,6 @@ def request_code(email: str) -> RequestOutcome:
if row is not None:
return RequestOutcome(sent=False, code=None, reason="cooldown")
# Allowlist: silently drop the send if the email isn't on the list.
# The row is not written either — there's nothing for verify to
# match against, so the user-facing experience is "I never got an
# email", which is the intended shape for the private-beta gate.
if not _allowlist_admits(email):
return RequestOutcome(sent=False, code=None, reason="allowlist")
# Invalidate prior unused codes for this email. A re-request is
# always for the most recent code; older codes are dead.
db.conn().execute(
@@ -275,12 +271,16 @@ def provision_or_link_user(email: str) -> SessionUser:
1. An existing row whose email equals (case-insensitive) the
requested email — the OAuth-era user is grandfathered in via
this path. `gitea_id` is preserved so a future OAuth round
trip still resolves the same row.
trip still resolves the same row. `permission_state` is
read off the row as-is — grandfathered users come through
migration with 'granted' (the column default), so their
contributor capabilities are unaffected.
2. Otherwise: a fresh contributor row with `gitea_id = NULL`,
`gitea_login = NULL`. The display name defaults to the local
part of the email (everything before the `@`) — users can
rename later via the §19.2 first-OTC profile-capture flow
that v0.8.0 introduces.
`gitea_login = NULL`, and `permission_state = 'pending'`
(v0.8.0). The display name defaults to the local part of
the email (everything before the `@`); a separate
`POST /auth/me/beta-request` call lands first name / last
name / "why I want access" on the same row.
The §6.1 owner-zero bootstrap still applies: if the email matches
the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row
@@ -307,13 +307,19 @@ def provision_or_link_user(email: str) -> SessionUser:
email=existing["email"] or email,
avatar_url=existing["avatar_url"] or "",
role=existing["role"],
permission_state=existing["permission_state"] or "granted",
)
display = email.split("@", 1)[0] or email
# v0.8.0: 'pending' is the explicit insert value; the migration
# default of 'granted' is what passes grandfathered users
# through. A fresh OTC user lands in 'pending' regardless of
# what the migration default says, so the gate engages reliably
# even if a future migration changes the default.
cur = db.conn().execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
VALUES (NULL, NULL, ?, ?, '', 'contributor')
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
""",
(email, display),
)
@@ -326,4 +332,5 @@ def provision_or_link_user(email: str) -> SessionUser:
email=email,
avatar_url="",
role="contributor",
permission_state="pending",
)