feat(api): FastAPI skeleton with two-role RLS auth foundation
CI / Repo hygiene (pull_request) Successful in 3s
CI / Web (lint, typecheck, build) (pull_request) Successful in 2s
CI / Migrations reversible (pull_request) Successful in 12s
CI / API (lint, types, tests) (pull_request) Successful in 1m46s

Phase 0's API half: a working FastAPI app with register/login/me/logout,
backed by a Postgres schema where row-level security is real and
independently proven, not just declared.

The core design decision, and the reason this lands as one PR instead of
several: request-scoped queries run as `velodrome_app` (NOBYPASSRLS), but
looking up identity in the first place — login by email, a session by its
token hash — has to happen *before* app.user_id can be set, so those specific
lookups run as a second role, `velodrome_auth` (BYPASSRLS), used nowhere else
in the codebase. See velodrome/db.py's module docstring and apps/api/README.md
for the full rationale. This is genuinely one reviewable unit: the migration,
the models, and the auth service only make sense evaluated together, since
they're three views of the same invariant.

tests/test_auth.py::test_rls_blocks_cross_user_session_reads is the test
worth reading first — it doesn't trust the RLS policy SQL because it reads
correctly, it proves isolation by registering two users and confirming a
scoped read of `sessions` for user A returns exactly one row, never two.

Bugs found and fixed while actually running this against real Postgres
(everything below was verified against a live postgis/postgis:16-3.4
container and a built Docker image, not just read for correctness):

- CREATE ROLE's PASSWORD clause is DDL, not DML — it doesn't accept bind
  parameters (`PASSWORD $1` is a syntax error). Fixed with dollar-quoting.
- Postgres roles are cluster-wide, not per-database — a second database in
  the same cluster hit "role already exists" on a plain CREATE ROLE. Fixed
  with a DO block catching duplicate_object.
- A bare `Mapped[datetime]` on the ORM models infers a naive timestamp,
  silently disagreeing with the migration's correct `DateTime(timezone=True)`
  — asyncpg rejected the mismatch at insert time. Fixed once, at the
  declarative Base level via type_annotation_map, rather than per-column.
- The session cookie's `secure` flag was gated on `!= "development"`, so
  anything else — including local testing and a real deploy running
  temporarily without TLS in front — got a Secure cookie no HTTP client
  will ever send back, breaking every authenticated request after login
  with no visible error. Gated on `== "production"` instead.
- `alembic check` initially flagged every PostGIS/TIGER-installed table
  (dozens of them) as drift, because they're not in our metadata. A
  schema-based denylist doesn't work — reflected foreign tables come back
  with schema=None regardless of their real schema. Fixed with an
  allowlist keyed on target_metadata.tables instead, which is also more
  robust against future PostGIS versions adding more tables.
- The migration itself was missing `nullable=False` on three timestamp
  columns that the ORM model assumed were never null — a genuine
  model/migration drift that alembic check caught once the PostGIS noise
  above was filtered out. Fixed in 0001 directly, since it's never shipped.
- Two indexes the migration creates explicitly weren't declared on the
  ORM models, causing the same kind of drift. Added index=True to match.

Deliberately deferred, not forgotten: per-IP/per-account login rate
limiting (docs/PLAN.md mentions it; Phase 0's bar is a working skeleton,
and this needs its own design pass) and the procrastinate job runner /
worker container (nothing to run yet — arrives with the ingestion
pipeline).

ci.yml updated to match: the api and migrations jobs now provision the
same two runtime roles this code actually needs, replacing the single
placeholder DATABASE_URL from before any code existed.

Verified: ruff check, ruff format --check, and mypy --strict all clean.
12/12 pytest passing against a real Postgres. Full alembic upgrade ->
downgrade -1 -> upgrade cycle run twice (once standalone, once inside a
two-database cluster to specifically catch the role-collision bug).
alembic check clean. Docker image builds and serves real traffic —
register and an authenticated GET /me both exercised against the actual
built container, not just the test suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 08:14:06 -04:00
co-authored by Claude Opus 5
parent e7cb06a6bc
commit ddea750792
32 changed files with 3085 additions and 7 deletions
View File
+56
View File
@@ -0,0 +1,56 @@
"""FastAPI dependencies for authenticating a request.
One verification path for two transports, per docs/PLAN.md "Auth": a bearer token in the
Authorization header takes priority (that's how a non-browser client, or a future native app,
would authenticate), falling back to the session cookie the PWA uses. The cookie is HttpOnly —
JavaScript never touches it — so it's read here purely server-side; the web client gets no
capability a bearer-authenticated client wouldn't also have.
CSRF: a cookie-authenticated request that MUTATES state must have an Origin header matching the
configured public URL. A bearer-authenticated request skips this check, because a cross-origin
attacker's page cannot set an Authorization header on a request it tricks the browser into
sending — that's the whole CSRF attack surface, and it doesn't exist for bearer auth.
"""
from fastapi import Cookie, Header, HTTPException, Request, status
from velodrome.auth.service import AuthenticatedSession, SessionInvalid, validate_session
from velodrome.config import get_settings
_UNAUTHORIZED = HTTPException(status.HTTP_401_UNAUTHORIZED, detail="not authenticated")
def _extract_bearer(authorization: str | None) -> str | None:
if authorization is None:
return None
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
return None
return token
async def get_current_user(
authorization: str | None = Header(default=None),
session_cookie: str | None = Cookie(default=None, alias="vd_session"),
) -> AuthenticatedSession:
raw_token = _extract_bearer(authorization) or session_cookie
if raw_token is None:
raise _UNAUTHORIZED
try:
return await validate_session(raw_token)
except SessionInvalid as exc:
raise _UNAUTHORIZED from exc
async def require_same_origin_for_cookie_auth(
request: Request,
authorization: str | None = Header(default=None),
) -> None:
"""Apply to every mutating route. No-ops for bearer auth; enforces Origin for cookie auth."""
if _extract_bearer(authorization) is not None:
return # bearer-authenticated; CSRF doesn't apply, see module docstring.
origin = request.headers.get("origin")
expected = get_settings().public_url.rstrip("/")
if origin is None or origin.rstrip("/") != expected:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="cross-origin request rejected")
+46
View File
@@ -0,0 +1,46 @@
"""Password hashing and opaque token generation.
CLAUDE.md invariant #5: secrets never leave the server. Nothing in this file is ever included in
a Pydantic response model — that's enforced by schemas/auth.py simply not declaring these fields,
not by anything here, so double-check any new response schema doesn't accidentally add one back.
"""
import hashlib
import secrets
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# t=3, m=64MiB, p=4 — matches docs/PLAN.md's stated parameters exactly.
_hasher = PasswordHasher(time_cost=3, memory_cost=64 * 1024, parallelism=4)
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
try:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
return False
def generate_token() -> tuple[str, bytes]:
"""Return (opaque_token_to_hand_to_the_client, sha256_digest_to_store).
The raw token is returned to the caller exactly once and is never persisted anywhere —
only its digest is stored, so a database leak doesn't hand out working session tokens.
"""
raw = secrets.token_urlsafe(32)
digest = hashlib.sha256(raw.encode("ascii")).digest()
return raw, digest
def hash_token(raw_token: str) -> bytes:
"""Recompute the digest of a client-presented token, for lookup by token_hash."""
return hashlib.sha256(raw_token.encode("ascii")).digest()
def hash_invite_code(code: str) -> bytes:
return hashlib.sha256(code.encode("ascii")).digest()
+197
View File
@@ -0,0 +1,197 @@
"""Auth bootstrap logic: register, login, session validation, logout.
Every function here runs against the BYPASSRLS `auth` database role (see db.py's module
docstring for why) and every query is an exact match on a unique key — email, token_hash, or
code_hash — never an unfiltered scan. That's what makes bypassing RLS safe here: there's no
"list everything" code path for these functions to accidentally expose.
Nothing outside this module should import `db.auth_session` — if a new feature needs it, that's
a sign the feature belongs here, not that the import should spread.
"""
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from uuid import UUID
from sqlalchemy import select, update
from velodrome.auth.security import (
generate_token,
hash_invite_code,
hash_password,
hash_token,
verify_password,
)
from velodrome.config import get_settings
from velodrome.db import auth_session
from velodrome.models import Invite, Session, User
class AuthError(Exception):
"""Base class for auth failures the API layer turns into 4xx responses."""
class InvalidCredentials(AuthError):
pass
class InvalidInvite(AuthError):
pass
class EmailAlreadyRegistered(AuthError):
pass
class SessionInvalid(AuthError):
pass
@dataclass(frozen=True)
class AuthenticatedSession:
user_id: UUID
email: str
display_name: str
async def register(
*, email: str, password: str, display_name: str, invite_code: str
) -> AuthenticatedSession:
"""Validate an invite and create a user, atomically.
Open signup does not exist — see docs/PLAN.md "Auth". The `SELECT ... FOR UPDATE` on the
invite row is what stops a shared invite link being redeemed twice concurrently; without it,
two requests could both read `used_count < max_uses` as true before either commits.
"""
code_hash = hash_invite_code(invite_code)
async with auth_session() as db:
async with db.begin():
invite = (
await db.execute(
select(Invite).where(Invite.code_hash == code_hash).with_for_update()
)
).scalar_one_or_none()
if invite is None:
raise InvalidInvite("invite code not found")
if invite.revoked_at is not None:
raise InvalidInvite("invite has been revoked")
if invite.expires_at < datetime.now(UTC):
raise InvalidInvite("invite has expired")
if invite.used_count >= invite.max_uses:
raise InvalidInvite("invite has already been used")
if invite.email is not None and invite.email.lower() != email.lower():
raise InvalidInvite("invite is pinned to a different email address")
existing = (
await db.execute(select(User).where(User.email == email))
).scalar_one_or_none()
if existing is not None:
raise EmailAlreadyRegistered("an account with this email already exists")
user = User(
email=email,
display_name=display_name,
password_hash=hash_password(password),
role=invite.role,
)
db.add(user)
await db.flush() # populate user.id before we reference it below
invite.used_count += 1
return AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name
)
async def login(
*, email: str, password: str, client: str, user_agent: str | None, ip: str | None
) -> tuple[str, AuthenticatedSession]:
"""Verify credentials and create a session. Returns (raw_token, session) — the raw token is
handed to the caller exactly once; only its hash is ever stored.
"""
async with auth_session() as db:
async with db.begin():
user = (await db.execute(select(User).where(User.email == email))).scalar_one_or_none()
# Deliberately identical error for "no such user" and "wrong password" — this is the
# one place a timing/response difference would leak which emails are registered.
if user is None or not user.is_active:
# Still run the hasher so this branch isn't measurably faster than a real
# mismatch (argon2's own duration masks a database-lookup-only shortcut).
verify_password(password, hash_password("decoy-password-never-matches"))
raise InvalidCredentials("invalid email or password")
if not verify_password(password, user.password_hash):
raise InvalidCredentials("invalid email or password")
settings = get_settings()
raw_token, token_hash = generate_token()
session_row = Session(
user_id=user.id,
token_hash=token_hash,
client=client,
user_agent=user_agent,
ip=ip,
expires_at=datetime.now(UTC) + timedelta(days=settings.session_ttl_days),
)
db.add(session_row)
return raw_token, AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name
)
# Sessions are only touched this often to avoid a write on every single request; see
# docs/PLAN.md's auth section for the same throttling rule applied to last_seen_at.
_LAST_SEEN_THROTTLE = timedelta(minutes=5)
async def validate_session(raw_token: str) -> AuthenticatedSession:
"""Look a bearer token up and return the identity it belongs to, or raise SessionInvalid.
This is the auth entrypoint for every authenticated request — it's what runs *before*
db.scoped_session() can be used, because app.user_id isn't known until this returns.
"""
token_hash = hash_token(raw_token)
async with auth_session() as db:
async with db.begin():
row = (
await db.execute(
select(Session, User)
.join(User, User.id == Session.user_id)
.where(Session.token_hash == token_hash)
)
).one_or_none()
if row is None:
raise SessionInvalid("session not found")
session_row, user = row
if session_row.revoked_at is not None:
raise SessionInvalid("session has been revoked")
if session_row.expires_at < datetime.now(UTC):
raise SessionInvalid("session has expired")
if not user.is_active:
raise SessionInvalid("account is disabled")
now = datetime.now(UTC)
if now - session_row.last_seen_at > _LAST_SEEN_THROTTLE:
await db.execute(
update(Session).where(Session.id == session_row.id).values(last_seen_at=now)
)
return AuthenticatedSession(
user_id=user.id, email=user.email, display_name=user.display_name
)
async def logout(raw_token: str) -> None:
token_hash = hash_token(raw_token)
async with auth_session() as db:
async with db.begin():
await db.execute(
update(Session)
.where(Session.token_hash == token_hash, Session.revoked_at.is_(None))
.values(revoked_at=datetime.now(UTC))
)