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
View File
+91
View File
@@ -0,0 +1,91 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from velodrome.auth import service
from velodrome.auth.dependencies import get_current_user, require_same_origin_for_cookie_auth
from velodrome.auth.service import AuthenticatedSession
from velodrome.config import get_settings
from velodrome.schemas.auth import LoginRequest, RegisterRequest, UserOut
router = APIRouter(prefix="/auth", tags=["auth"])
def _set_session_cookie(response: Response, raw_token: str) -> None:
settings = get_settings()
# secure=True only in production, not merely "not development": a Secure cookie is never
# sent back by any client over plain HTTP, by spec. Gating on anything other than an
# explicit "production" (e.g. the previous `!= "development"`) breaks every non-dev
# environment served without TLS in front — including local testing and a real self-hosted
# deploy the operator is running temporarily without a reverse proxy — which fails silently
# as "login succeeds but every subsequent request looks unauthenticated."
response.set_cookie(
key=settings.session_cookie_name,
value=raw_token,
httponly=True,
secure=settings.environment == "production",
samesite="lax",
max_age=settings.session_ttl_days * 24 * 60 * 60,
path="/",
)
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def register(body: RegisterRequest, response: Response) -> UserOut:
try:
await service.register(
email=body.email,
password=body.password,
display_name=body.display_name,
invite_code=body.invite_code,
)
except service.InvalidInvite as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except service.EmailAlreadyRegistered as exc:
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from exc
# A second call, not a shortcut through register()'s own result — this keeps session
# creation in exactly one place (service.login) rather than duplicating it, at the cost of
# one redundant password verification. See auth/service.py if that cost ever matters.
raw_token, session_info = await service.login(
email=body.email, password=body.password, client="web", user_agent=None, ip=None
)
_set_session_cookie(response, raw_token)
return UserOut(
id=session_info.user_id, email=session_info.email, display_name=session_info.display_name
)
@router.post("/login", response_model=UserOut)
async def login(body: LoginRequest, request: Request, response: Response) -> UserOut:
try:
raw_token, session_info = await service.login(
email=body.email,
password=body.password,
client="web",
user_agent=request.headers.get("user-agent"),
ip=request.client.host if request.client else None,
)
except service.InvalidCredentials as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
_set_session_cookie(response, raw_token)
return UserOut(
id=session_info.user_id, email=session_info.email, display_name=session_info.display_name
)
@router.get("/me", response_model=UserOut)
async def me(current: AuthenticatedSession = Depends(get_current_user)) -> UserOut:
return UserOut(id=current.user_id, email=current.email, display_name=current.display_name)
@router.post(
"/logout",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_same_origin_for_cookie_auth)],
)
async def logout(request: Request, response: Response) -> None:
settings = get_settings()
raw_token = request.cookies.get(settings.session_cookie_name)
if raw_token:
await service.logout(raw_token)
response.delete_cookie(settings.session_cookie_name, path="/")
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter, status
from sqlalchemy import text
from velodrome.db import unscoped_session
router = APIRouter(tags=["health"])
@router.get("/healthz", status_code=status.HTTP_200_OK)
async def healthz() -> dict[str, str]:
"""Liveness + a real database round trip.
Not RLS-scoped (there's no user yet at this point) — see db.unscoped_session's docstring for
why that's safe: it can see zero rows of any user-owned table regardless.
"""
async with unscoped_session() as db:
await db.execute(text("SELECT 1"))
return {"status": "ok"}
+7
View File
@@ -0,0 +1,7 @@
from fastapi import APIRouter
from velodrome.api.v1 import auth, health
router = APIRouter(prefix="/api/v1")
router.include_router(health.router)
router.include_router(auth.router)
+22
View File
@@ -0,0 +1,22 @@
from fastapi import FastAPI
from velodrome.api.v1.router import router as v1_router
from velodrome.config import get_settings
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(
title="Velodrome",
version="0.1.0",
# Committed to packages/openapi/openapi.json — CI's openapi-drift job (added once that
# job has real code to check) regenerates this and diffs it, so the contract can never
# silently drift from what's actually deployed.
openapi_url="/api/v1/openapi.json",
docs_url="/api/v1/docs" if settings.environment != "production" else None,
)
app.include_router(v1_router)
return app
app = create_app()
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))
)
+36
View File
@@ -0,0 +1,36 @@
"""Application settings, read from environment variables.
Two separate database DSNs are deliberate, not an oversight — see db.py for why: one connects
as a role with BYPASSRLS (used only by the auth bootstrap path, which must look identity up
*before* it can be scoped), the other as a normal RLS-subject role (used for every other query).
"""
from functools import lru_cache
from pydantic import PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="VELODROME_", extra="ignore")
# Scoped role: NOBYPASSRLS. Every request-handling query outside the auth bootstrap uses this.
database_url_app: PostgresDsn
# Bootstrap role: BYPASSRLS. Used ONLY by velodrome.auth.service for the pre-identity lookups
# (login by email, session-by-token, invite-by-code) and for creating new users/sessions.
database_url_auth: PostgresDsn
# AES-GCM key for encrypting third-party credentials (e.g. the Bryton digest, added in a later
# phase). Not used yet in Phase 0, but declared now so the settings shape is stable.
secret_key: str = "dev-only-insecure-placeholder-change-me"
session_cookie_name: str = "vd_session"
session_ttl_days: int = 90
public_url: str = "http://localhost:5173"
environment: str = "development"
@lru_cache
def get_settings() -> Settings:
return Settings()
+113
View File
@@ -0,0 +1,113 @@
"""Two database engines, on purpose.
`app` engine: connects as a role with RLS enforced (NOBYPASSRLS). Every request handler that has
already established who the caller is uses this, wrapped in `scoped_session()` below, which sets
`app.user_id` for the transaction so RLS policies can key off it.
`auth` engine: connects as a role with BYPASSRLS. Used ONLY by velodrome.auth.service, and only
for the narrow set of queries that must run *before* identity is known — looking a user up by
email at login, a session up by its hashed token, an invite up by its hashed code — plus the
inserts that create those rows in the first place. Nothing outside auth/service.py should import
this engine; if you find yourself reaching for it elsewhere, the query almost certainly belongs
in a repository method on the scoped session instead (see CLAUDE.md invariant #4).
"""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from uuid import UUID
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.pool import NullPool
from velodrome.config import get_settings
def _make_engine(url: str) -> AsyncEngine:
settings = get_settings()
# NullPool in dev/test keeps behaviour predictable across the pytest-asyncio event loop;
# production tuning (pool_size etc.) is a deploy-time concern, not a Phase-0 one.
return create_async_engine(
url,
poolclass=NullPool if settings.environment == "test" else None,
echo=False,
)
_app_engine: AsyncEngine | None = None
_auth_engine: AsyncEngine | None = None
def app_engine() -> AsyncEngine:
global _app_engine
if _app_engine is None:
_app_engine = _make_engine(str(get_settings().database_url_app))
return _app_engine
def auth_engine() -> AsyncEngine:
global _auth_engine
if _auth_engine is None:
_auth_engine = _make_engine(str(get_settings().database_url_auth))
return _auth_engine
_app_sessionmaker: async_sessionmaker[AsyncSession] | None = None
_auth_sessionmaker: async_sessionmaker[AsyncSession] | None = None
def _app_sessions() -> async_sessionmaker[AsyncSession]:
global _app_sessionmaker
if _app_sessionmaker is None:
_app_sessionmaker = async_sessionmaker(app_engine(), expire_on_commit=False)
return _app_sessionmaker
def _auth_sessions() -> async_sessionmaker[AsyncSession]:
global _auth_sessionmaker
if _auth_sessionmaker is None:
_auth_sessionmaker = async_sessionmaker(auth_engine(), expire_on_commit=False)
return _auth_sessionmaker
@asynccontextmanager
async def auth_session() -> AsyncIterator[AsyncSession]:
"""A BYPASSRLS session. See module docstring — auth/service.py only."""
async with _auth_sessions()() as session:
yield session
@asynccontextmanager
async def scoped_session(user_id: UUID) -> AsyncIterator[AsyncSession]:
"""An RLS-scoped session for a known, authenticated user.
`SET LOCAL` binds to the current transaction, not the connection, so this is safe under
connection pooling — it can never leak `app.user_id` from one request into a pooled
connection reused by a different request.
"""
async with _app_sessions()() as session:
async with session.begin():
await session.execute(
# bound parameter, not string interpolation — user_id is a UUID we generated
# or validated ourselves, but there is no reason to ever risk it.
text("SELECT set_config('app.user_id', :uid, true)"),
{"uid": str(user_id)},
)
yield session
@asynccontextmanager
async def unscoped_session() -> AsyncIterator[AsyncSession]:
"""An `app`-role session with no `app.user_id` set.
RLS policies default-deny when `current_setting('app.user_id', true)` is NULL, so this sees
zero rows of any user-owned table — useful for health checks and anything that only touches
non-RLS tables. Prefer `scoped_session` whenever a user is known.
"""
async with _app_sessions()() as session:
yield session
+15
View File
@@ -0,0 +1,15 @@
"""UUIDv7 generation.
Postgres 16 has no built-in uuidv7() function (that lands in PG 18) and we deliberately don't
want random UUIDv4 for primary keys — v7 is time-ordered, which keeps btree index locality sane
and lets a future client generate its own row IDs offline (see docs/PLAN.md, streams/offline
outbox design). So IDs are generated in application code, not by the database.
"""
from uuid import UUID
from uuid6 import uuid7
def new_id() -> UUID:
return uuid7()
+4
View File
@@ -0,0 +1,4 @@
from velodrome.models.base import Base
from velodrome.models.identity import ApiToken, Invite, Session, User
__all__ = ["ApiToken", "Base", "Invite", "Session", "User"]
+15
View File
@@ -0,0 +1,15 @@
from datetime import datetime
from sqlalchemy import DateTime
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
# Every timestamp in this schema is timestamptz UTC (docs/PLAN.md's convention) — without
# this, a bare `Mapped[datetime]` infers a naive TIMESTAMP column, which then silently
# disagrees with a migration that (correctly) declares DateTime(timezone=True), and asyncpg
# rejects the mismatch at insert time. Setting it once here means every current and future
# model gets it right by default instead of each column needing to repeat it.
type_annotation_map = {
datetime: DateTime(timezone=True),
}
+100
View File
@@ -0,0 +1,100 @@
"""Identity tables: users, invites, sessions, api_tokens.
See docs/PLAN.md "Auth" and "Schema > Tables > Identity" for the design rationale, and
db.py's module docstring for why two DB roles exist. RLS policies for these tables are created
in the baseline Alembic migration (alembic/versions/0001_baseline.py), not here — SQLAlchemy
models describe columns, not database-level security policy, and keeping the policy SQL visible
and reviewable in the migration is deliberate.
"""
from datetime import datetime
from uuid import UUID
from sqlalchemy import ARRAY, ForeignKey, LargeBinary, String, Text
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from velodrome.ids import new_id
from velodrome.models.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id)
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC")
# Display-only, per CLAUDE.md invariant #3 — storage is always SI, this never touches a query.
unit_system: Mapped[str] = mapped_column(String(10), nullable=False, default="imperial")
is_active: Mapped[bool] = mapped_column(nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
sessions: Mapped[list["Session"]] = relationship(back_populates="user")
api_tokens: Mapped[list["ApiToken"]] = relationship(back_populates="user")
class Invite(Base):
__tablename__ = "invites"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id)
# sha256 digest of the invite code. The code itself is never stored anywhere — see
# auth/service.py. 32 bytes for sha256.
code_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False)
created_by: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id"), nullable=False
)
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
expires_at: Mapped[datetime] = mapped_column(nullable=False)
max_uses: Mapped[int] = mapped_column(nullable=False, default=1)
used_count: Mapped[int] = mapped_column(nullable=False, default=0)
revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
class Session(Base):
__tablename__ = "sessions"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# sha256 of the opaque bearer token. The token itself is returned to the client exactly once,
# at login, and never stored — see auth/security.py.
token_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False)
client: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
ip: Mapped[str | None] = mapped_column(INET, nullable=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
last_seen_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
expires_at: Mapped[datetime] = mapped_column(nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
user: Mapped["User"] = relationship(back_populates="sessions")
class ApiToken(Base):
__tablename__ = "api_tokens"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
token_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False)
scopes: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=list)
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(nullable=True)
revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
user: Mapped["User"] = relationship(back_populates="api_tokens")
+29
View File
@@ -0,0 +1,29 @@
"""Request/response models for the auth endpoints.
These are the ONLY thing standing between the database and the HTTP response — FastAPI serialises
a SQLAlchemy/dataclass object through whichever `response_model` a route declares, so a field
simply not being listed here is what keeps password_hash/token_hash out of every response. When
adding a new field, ask whether it belongs in a response before adding it, not after.
"""
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=8, max_length=200)
display_name: str = Field(min_length=1, max_length=200)
invite_code: str = Field(min_length=1, max_length=200)
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=200)
class UserOut(BaseModel):
id: UUID
email: str
display_name: str