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
+60 -4
View File
@@ -30,6 +30,12 @@ class SessionUser:
email: str
avatar_url: str
role: str
# v0.8.0 / §6.1 — admission gate. Three states: 'pending' (waiting
# for an admin grant), 'granted' (active contributor), 'revoked'
# (was granted, later removed). Existing rows at migration time
# default to 'granted' so grandfathered users are unaffected; OTC
# provisions fresh users with 'pending' (see `app/otc.py`).
permission_state: str = "granted"
def as_actor(self) -> Actor:
return Actor(
@@ -90,6 +96,13 @@ def allowlist_is_active() -> bool:
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
"""Decide whether a freshly-completed OAuth profile may sign in.
v0.8.0 (item #6) replaces the allowlist gate with an admin-grant
flow at the OTC `/request` surface, but the Gitea OAuth callback
in `main.py` still consults this helper so the fallback path
keeps the v0.3.0 admission shape during the OAuth migration
window. The eventual removal of the OAuth callback (§19.2)
retires this function alongside it.
Three accept paths:
1. The allowlist is empty (gate off).
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
@@ -132,17 +145,27 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
if existing is None:
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
# v0.8.0: a fresh OAuth-provisioned user is also subject to
# the admin-grant flow. The OAuth fallback only fires for
# users who pass `is_allowed_sign_in` (so they're already on
# the legacy allowlist or are grandfathered by gitea_id);
# 'granted' is the right default here since the allowlist
# check is itself the admin gesture. A future release that
# retires the OAuth callback (§19.2) collapses both paths
# under the same gate.
cur = c.execute(
"""
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
VALUES (?, ?, ?, ?, ?, ?, 'granted')
""",
(gitea_id, login, email, display, avatar, role),
)
user_id = cur.lastrowid
permission_state = "granted"
else:
user_id = existing["id"]
role = existing["role"]
permission_state = existing["permission_state"] or "granted"
c.execute(
"""
UPDATE users
@@ -160,6 +183,7 @@ def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
email=email,
avatar_url=avatar,
role=role,
permission_state=permission_state,
)
@@ -178,6 +202,12 @@ def store_session(request: Request, user: SessionUser) -> None:
"email": user.email,
"avatar_url": user.avatar_url,
"role": user.role,
# v0.8.0: persist the admission state on the cookie payload so
# the post-cookie audit doesn't second-guess the row. The DB
# is re-read on every `current_user` call regardless (so an
# admin grant takes effect on the next request); this field
# is purely structural redundancy for the cookie shape.
"permission_state": user.permission_state,
}
@@ -188,7 +218,7 @@ def current_user(request: Request) -> SessionUser | None:
# Re-read the role from the database every request so role changes
# take effect on the next API call without forcing a logout.
row = db.conn().execute(
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role FROM users WHERE id = ?",
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state FROM users WHERE id = ?",
(raw["user_id"],),
).fetchone()
if row is None:
@@ -199,6 +229,11 @@ def current_user(request: Request) -> SessionUser | None:
# of which sign-in path the row came from. The DB remains the
# source of truth for "is this an OAuth-linked user" (gitea_id IS
# NOT NULL); the in-memory SessionUser is the per-request handle.
# v0.8.0: permission_state comes off the row directly. A NULL
# column value (shouldn't happen under the migration's
# NOT NULL DEFAULT, but be defensive) reads as 'granted' so the
# gate fails open for grandfathered surfaces rather than locking
# everyone out on a malformed row.
return SessionUser(
user_id=row["id"],
gitea_id=row["gitea_id"] or 0,
@@ -207,6 +242,7 @@ def current_user(request: Request) -> SessionUser | None:
email=row["email"] or "",
avatar_url=row["avatar_url"] or "",
role=row["role"],
permission_state=row["permission_state"] or "granted",
)
@@ -218,11 +254,31 @@ def require_user(request: Request) -> SessionUser:
def require_contributor(request: Request) -> SessionUser:
"""§6.1: authenticated, not write-muted."""
"""§6.1: authenticated, not write-muted, and granted by an admin.
v0.8.0 (item #6) widens this gate. A fresh OTC sign-in lands in
`permission_state='pending'`; the user can read everything an
anonymous viewer can read, but every write-shaped endpoint that
funnels through this dependency now refuses with 403 until an
admin grants them. The `pending` blast radius is the same as
anonymous (item #4 / v0.6.0 already audited the anon-write
refusal at every write site), so this widening is structurally
a relabel — the same surfaces that already refused 401 to
anonymous now also refuse 403 to pending.
"""
user = require_user(request)
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
if row and row["muted"]:
raise HTTPException(status_code=403, detail="Your account is muted")
if user.permission_state != "granted":
# 'pending' is the post-OTC waiting state; 'revoked' is the
# admin-undid-the-grant state. Both refuse with the same 403
# shape; the client distinguishes via `/api/auth/me` which
# carries `permission_state` in the response.
raise HTTPException(
status_code=403,
detail="Your beta access request is in review",
)
return user