Files
bike-app/apps/api/velodrome/auth/service.py
T
BBergleandClaude Opus 5 ddea750792
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
feat(api): FastAPI skeleton with two-role RLS auth foundation
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>
2026-09-21 08:14:06 -04:00

198 lines
7.3 KiB
Python

"""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))
)