M1.5 backend: admin role + DB-backed settings, DB-URL-only install
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 9s
CI & Build / Build & push image (push) Successful in 31s

- users.is_admin; first registered user becomes admin; registration gated by the
  allow_registration setting (first account always allowed). is_admin in
  /api/auth/* responses; require_admin guard (live DB check).
- settings table + code registry (site_name, allow_registration, session_ttl_days)
  with typed defaults — empty table = all defaults (rule 26). get/set/validate
  service; GET /api/config (public) + GET/PATCH /api/settings (admin), live
  session-TTL apply with no restart (rule 25).
- Cookie-signing secret now persisted in the DB (before_serving load-or-create),
  so sessions survive restarts with no volume. Config: DATABASE_URL is the only
  required env; SECRET_KEY + DATA_DIR are optional break-glass items.
- Migration 0003; DB-free tests for settings validation + admin guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-19 18:25:04 -04:00
co-authored by Claude Opus 4.8
parent 0f604f9a26
commit b46bda38ee
11 changed files with 413 additions and 44 deletions
+52 -8
View File
@@ -4,11 +4,12 @@ import functools
import uuid
from quart import Blueprint, g, jsonify, request, session
from sqlalchemy import select
from sqlalchemy import func, select
from .db import session_scope
from .models.user import User
from .security import hash_password, verify_password
from .settings import get_setting
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@@ -22,22 +23,52 @@ def _serialize_user(user: User) -> dict:
"email": user.email,
"display_name": user.display_name,
"email_verified": user.email_verified,
"is_admin": user.is_admin,
}
def _session_user_id() -> uuid.UUID | None:
raw = session.get(SESSION_KEY)
if not raw:
return None
try:
return uuid.UUID(raw)
except (ValueError, TypeError):
session.pop(SESSION_KEY, None)
return None
def login_required(fn):
"""Guard: 401 unless a valid session is present. Sets g.user_id for the view."""
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
raw = session.get(SESSION_KEY)
if not raw:
uid = _session_user_id()
if uid is None:
return jsonify({"error": "authentication required"}), 401
try:
g.user_id = uuid.UUID(raw)
except (ValueError, TypeError):
session.pop(SESSION_KEY, None)
g.user_id = uid
return await fn(*args, **kwargs)
return wrapper
def require_admin(fn):
"""Guard: 401 unauthenticated, 403 non-admin. Checks is_admin live from the DB
so a demoted admin loses access immediately."""
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
uid = _session_user_id()
if uid is None:
return jsonify({"error": "authentication required"}), 401
async with session_scope() as db:
user = await db.get(User, uid)
if user is None:
session.pop(SESSION_KEY, None)
return jsonify({"error": "authentication required"}), 401
if not user.is_admin:
return jsonify({"error": "admin access required"}), 403
g.user_id = uid
return await fn(*args, **kwargs)
return wrapper
@@ -58,14 +89,26 @@ async def register():
display_name = email.split("@", 1)[0]
async with session_scope() as db:
user_count = await db.scalar(select(func.count()).select_from(User)) or 0
is_first = user_count == 0
# The first account bootstraps the admin and is always allowed, even when
# registration is otherwise closed.
if not is_first and not await get_setting(db, "allow_registration"):
return jsonify({"error": "registration is closed"}), 403
existing = await db.scalar(select(User).where(User.email == email))
if existing is not None:
return jsonify({"error": "an account with that email already exists"}), 409
user = User(email=email, password_hash=hash_password(password), display_name=display_name)
user = User(
email=email,
password_hash=hash_password(password),
display_name=display_name,
is_admin=is_first,
)
db.add(user)
await db.commit()
await db.refresh(user)
session[SESSION_KEY] = str(user.id)
session.permanent = True
return jsonify(_serialize_user(user)), 201
@@ -80,6 +123,7 @@ async def login():
if user is None or not user.password_hash or not verify_password(password, user.password_hash):
return jsonify({"error": "invalid email or password"}), 401
session[SESSION_KEY] = str(user.id)
session.permanent = True
return jsonify(_serialize_user(user))