feat(api): FastAPI skeleton with two-role RLS auth foundation #2

Merged
BBergle merged 1 commits from feat/api-skeleton into main 2026-09-21 08:26:12 -04:00
32 changed files with 3085 additions and 7 deletions
+15 -6
View File
@@ -58,8 +58,10 @@ jobs:
postgres: postgres:
image: postgis/postgis:16-3.4 image: postgis/postgis:16-3.4
env: env:
POSTGRES_USER: velodrome # Superuser — this is the "owner" role migrations run as (see alembic/env.py); it's
POSTGRES_PASSWORD: velodrome # what CREATEs the two runtime roles below, which is why it isn't one of them.
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: velodrome_test POSTGRES_DB: velodrome_test
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready
@@ -67,7 +69,12 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
env: env:
DATABASE_URL: postgresql+asyncpg://velodrome:velodrome@postgres:5432/velodrome_test VELODROME_ENVIRONMENT: test
VELODROME_DATABASE_URL_MIGRATE: postgresql+asyncpg://postgres:postgres@postgres:5432/velodrome_test
VELODROME_DB_APP_PASSWORD: ci-only-app-password
VELODROME_DB_AUTH_PASSWORD: ci-only-auth-password
VELODROME_DATABASE_URL_APP: postgresql+asyncpg://velodrome_app:ci-only-app-password@postgres:5432/velodrome_test
VELODROME_DATABASE_URL_AUTH: postgresql+asyncpg://velodrome_auth:ci-only-auth-password@postgres:5432/velodrome_test
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -162,8 +169,8 @@ jobs:
postgres: postgres:
image: postgis/postgis:16-3.4 image: postgis/postgis:16-3.4
env: env:
POSTGRES_USER: velodrome POSTGRES_USER: postgres
POSTGRES_PASSWORD: velodrome POSTGRES_PASSWORD: postgres
POSTGRES_DB: velodrome_mig POSTGRES_DB: velodrome_mig
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready
@@ -171,7 +178,9 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
env: env:
DATABASE_URL: postgresql+asyncpg://velodrome:velodrome@postgres:5432/velodrome_mig VELODROME_DATABASE_URL_MIGRATE: postgresql+asyncpg://postgres:postgres@postgres:5432/velodrome_mig
VELODROME_DB_APP_PASSWORD: ci-only-app-password
VELODROME_DB_AUTH_PASSWORD: ci-only-auth-password
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+31
View File
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS builder
RUN pip install --no-cache-dir uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
# Split into two syncs so dependency installation caches independently of source changes: this
# first one installs only dependencies (--no-install-project), so touching velodrome/ doesn't
# invalidate this layer.
RUN uv sync --frozen --no-install-project --no-dev
COPY velodrome ./velodrome
COPY alembic ./alembic
COPY alembic.ini ./
# Now install the project itself into the same venv.
RUN uv sync --frozen --no-dev
FROM python:3.12-slim AS runtime
RUN groupadd --system velodrome && useradd --system --gid velodrome --create-home velodrome
WORKDIR /app
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
USER velodrome
EXPOSE 8000
# Migrations run as an explicit step before this in deploy.yml (see docs/PLAN.md) — never from
# the entrypoint, so a failed migration fails the deploy visibly instead of crash-looping here.
CMD ["uvicorn", "velodrome.app:app", "--host", "0.0.0.0", "--port", "8000"]
+104 -1
View File
@@ -1 +1,104 @@
Python 3.12 / FastAPI / SQLAlchemy async / Alembic. Not yet scaffolded — Phase 0. # apps/api
Python 3.12 / FastAPI / SQLAlchemy 2.0 async / Alembic. See `docs/PLAN.md` for the overall
design and `docs/DECISIONS.md` for why things are built this way.
## Layout
```
velodrome/
app.py FastAPI app factory
config.py Settings (env-driven, see below)
db.py Two database engines — read this first, it's the load-bearing module
ids.py UUIDv7 generation
models/ SQLAlchemy models
auth/ Password hashing, session service, FastAPI auth dependencies
api/v1/ Route handlers
schemas/ Pydantic request/response models
alembic/ Migrations. 0001_baseline.py creates the identity tables, the two
runtime DB roles, and their RLS policies — read its module docstring.
tests/ pytest, against a real Postgres, never mocked
```
## Why two database connections
The app connects to Postgres as **two different roles**, not one — this is the single most
important thing to understand before touching `auth/` or `db.py`:
- **`velodrome_app`** — `NOBYPASSRLS`. Every request that already knows who's calling uses this,
via `db.scoped_session(user_id)`, which sets `app.user_id` for the transaction so Postgres RLS
policies can key off it.
- **`velodrome_auth`** — `BYPASSRLS`. Used *only* by `auth/service.py`, and only for the narrow
set of lookups that must happen *before* identity is known: login by email, a session by its
token hash, an invite by its code hash — plus the inserts that create those rows. Every one of
those queries is an exact match on a unique key, never an unfiltered scan, which is what makes
bypassing RLS safe there.
A third connection — `VELODROME_DATABASE_URL_MIGRATE` — is used only by Alembic. It needs enough
privilege to `CREATE ROLE` and `GRANT`, so in practice it's the Postgres superuser (or a
schema-owning role); the app itself never connects with it.
If you're adding a new table with per-user data: give it a `user_id` column, enable RLS on it in
a migration, and query it only through `scoped_session`. See CLAUDE.md's invariants.
## Environment variables
| Variable | Used by | Notes |
|---|---|---|
| `VELODROME_DATABASE_URL_MIGRATE` | Alembic only | Superuser/owner DSN. Never used by the app itself. |
| `VELODROME_DB_APP_PASSWORD` | Alembic (creates the role) | No default — the migration fails loudly if unset. |
| `VELODROME_DB_AUTH_PASSWORD` | Alembic (creates the role) | Same. |
| `VELODROME_DATABASE_URL_APP` | The app | Connects as `velodrome_app`. |
| `VELODROME_DATABASE_URL_AUTH` | The app | Connects as `velodrome_auth`. |
| `VELODROME_SECRET_KEY` | The app | Not yet used (arrives with the Bryton credential encryption in a later phase); declared now so the settings shape is stable. |
| `VELODROME_ENVIRONMENT` | The app | `development` / `test` / `production`. Gates the session cookie's `Secure` flag — see the comment in `api/v1/auth.py` before changing this condition. |
| `VELODROME_PUBLIC_URL` | The app | Used for the CSRF `Origin` check on cookie-authenticated mutations. |
## Running locally
```bash
uv sync --all-extras
# a throwaway Postgres
docker run -d --name velodrome-dev-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=velodrome \
-p 5432:5432 postgis/postgis:16-3.4
export VELODROME_DATABASE_URL_MIGRATE=postgresql+asyncpg://postgres:postgres@localhost:5432/velodrome
export VELODROME_DB_APP_PASSWORD=devpassword1
export VELODROME_DB_AUTH_PASSWORD=devpassword2
export VELODROME_DATABASE_URL_APP=postgresql+asyncpg://velodrome_app:devpassword1@localhost:5432/velodrome
export VELODROME_DATABASE_URL_AUTH=postgresql+asyncpg://velodrome_auth:devpassword2@localhost:5432/velodrome
uv run alembic upgrade head
uv run uvicorn velodrome.app:app --reload
```
`GET /api/v1/healthz` should return `{"status": "ok"}`; `GET /api/v1/docs` has interactive
Swagger UI outside production.
## Testing
```bash
uv run ruff check . && uv run ruff format --check .
uv run mypy --strict velodrome
uv run pytest
```
Tests run against a real Postgres (a service container in CI, or point `tests/conftest.py`'s
defaults at your own) — never mocks for DB behaviour, per CLAUDE.md. The test suite runs the real
Alembic migration at session start, not a parallel schema-creation shortcut, so it's exercising
the exact same path a real deploy uses.
The test worth reading if you're new to this codebase is
`tests/test_auth.py::test_rls_blocks_cross_user_session_reads` — it doesn't trust that the RLS
policy SQL is correct because it reads correctly; it proves it by actually trying to read another
user's row through the scoped role and asserting zero come back.
## A note on `alembic check`
CI's `migrations` job runs `alembic check` to catch drift between the ORM models and the actual
migrations. `alembic/env.py`'s `include_object` filter restricts that comparison to tables our own
metadata declares — **deliberately an allowlist, not a denylist of PostGIS/TIGER tables**, because
reflected foreign tables can come back with `schema=None` regardless of which schema they actually
live in (confirmed against a real `postgis/postgis:16-3.4` container), which makes a schema-based
denylist unreliable. If you add a new model, it's automatically covered — no filter to update.
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+89
View File
@@ -0,0 +1,89 @@
"""Alembic environment.
Deliberately independent of velodrome.config.Settings: migrations run as a privileged/owner
connection (VELODROME_DATABASE_URL_MIGRATE — a superuser or schema-owning role, which trivially
satisfies "BYPASSRLS" since superusers always bypass RLS), never as either of the two runtime
roles the app itself uses. Reading env vars directly here, rather than importing the app's
settings, keeps a migration-only CI job from needing every runtime env var the app requires.
"""
import asyncio
import os
import sqlalchemy as sa
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from velodrome.models import Base
config = context.config
target_metadata = Base.metadata
# postgis/postgis ships an entire pre-installed schema of its own (PostGIS core tables plus the
# TIGER geocoder's tiger/topology schemas — dozens of tables) that Alembic never created and
# doesn't manage. Without this filter, `alembic check`/autogenerate sees every single one as
# "should be dropped" simply because it's not in our SQLAlchemy metadata — which would make CI's
# `alembic check` step permanently useless (always red, for reasons that have nothing to do with
# an actual drift).
#
# A denylist keyed on schema name is NOT reliable here: reflected foreign tables can come back
# with `schema=None` on their Table object regardless of which schema they actually live in on
# the server (confirmed against a real postgis/postgis:16-3.4 container — tables that `\dt`
# clearly shows under the `tiger` schema still reflect with schema=None). An allowlist is the
# robust version of the same idea: only ever compare tables OUR metadata declares, so a future
# PostGIS/TIGER version adding more foreign tables can never cause a false positive here.
def include_object(
object: sa.schema.SchemaItem, name: str | None, type_: str, reflected: bool, compare_to: object
) -> bool:
if type_ == "table":
return name in target_metadata.tables
return True
def _migrate_url() -> str:
url = os.environ.get("VELODROME_DATABASE_URL_MIGRATE")
if not url:
# Local-dev convenience only — every real environment (CI, deploy/) sets this explicitly.
url = "postgresql+asyncpg://postgres:postgres@localhost:5432/velodrome"
return url
def run_migrations_offline() -> None:
context.configure(
url=_migrate_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
include_object=include_object,
)
with context.begin_transaction():
context.run_migrations()
def _do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
include_object=include_object,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section) or {}
configuration["sqlalchemy.url"] = _migrate_url()
connectable = async_engine_from_config(configuration, prefix="sqlalchemy.")
async with connectable.connect() as connection:
await connection.run_sync(_do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: str | None = ${repr(down_revision)}
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+238
View File
@@ -0,0 +1,238 @@
"""Baseline: identity tables, two runtime DB roles, RLS policies.
This migration creates the schema AND the two application-facing Postgres roles it depends on
(velodrome_app, velodrome_auth) — see db.py and auth/service.py module docstrings for the full
rationale. It runs as a privileged/owner connection (VELODROME_DATABASE_URL_MIGRATE), which is
why it's able to CREATE ROLE and GRANT at all; neither of the two roles it creates could do this
to itself.
Revision ID: 0001
Revises:
Create Date: 2026-09-21
"""
import os
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql as pg
from alembic import op
revision: str = "0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _require_password(env_var: str) -> str:
value = os.environ.get(env_var)
if not value:
raise RuntimeError(
f"{env_var} must be set before running this migration — see deploy/.env.example. "
"There is no default: these are the passwords for real runtime database roles."
)
return value
def _dollar_quoted(password: str) -> str:
"""Safely embed a password literal in DDL.
CREATE ROLE's PASSWORD clause is DDL, not DML — it does NOT accept bind parameters over the
wire (Postgres rejects `PASSWORD $1` with a syntax error; ask how we know). Dollar-quoting
sidesteps manual escaping entirely rather than hand-rolling quote-doubling, which is easy to
get subtly wrong for a password an operator chose.
"""
tag = "$velodrome_pw$"
if tag in password:
raise RuntimeError(
f"password must not contain the literal sequence {tag!r} — pick a different one"
)
return f"{tag}{password}{tag}"
def upgrade() -> None:
app_password = _require_password("VELODROME_DB_APP_PASSWORD")
auth_password = _require_password("VELODROME_DB_AUTH_PASSWORD")
# --- Runtime roles -------------------------------------------------------------------
# velodrome_app: NOBYPASSRLS — every request-scoped query after identity is established.
# velodrome_auth: BYPASSRLS — ONLY auth/service.py's pre-identity lookups. See db.py.
# Roles are cluster-wide in Postgres, not per-database — if this migration ever runs
# against a second database sharing the same cluster (exactly what a local dev + test setup
# commonly looks like), a plain CREATE ROLE fails with "role already exists" even though
# THIS database has never seen the migration before. Postgres has no CREATE ROLE IF NOT
# EXISTS, so the standard idiom is catching duplicate_object in a DO block. ALTER ROLE
# afterwards keeps the password in sync with the current env var either way, rather than
# silently keeping whatever password the role happened to be created with previously.
op.execute(
f"""
DO $$
BEGIN
CREATE ROLE velodrome_app LOGIN PASSWORD {_dollar_quoted(app_password)}
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
EXCEPTION WHEN duplicate_object THEN
ALTER ROLE velodrome_app WITH LOGIN PASSWORD {_dollar_quoted(app_password)}
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
END
$$;
"""
)
op.execute(
f"""
DO $$
BEGIN
CREATE ROLE velodrome_auth LOGIN PASSWORD {_dollar_quoted(auth_password)}
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS;
EXCEPTION WHEN duplicate_object THEN
ALTER ROLE velodrome_auth WITH LOGIN PASSWORD {_dollar_quoted(auth_password)}
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS;
END
$$;
"""
)
# --- Tables ----------------------------------------------------------------------------
op.create_table(
"users",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
sa.Column("email", sa.String(320), nullable=False, unique=True),
sa.Column("display_name", sa.String(200), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False),
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
sa.Column("timezone", sa.String(64), nullable=False, server_default="UTC"),
sa.Column("unit_system", sa.String(10), nullable=False, server_default="imperial"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
)
op.create_table(
"invites",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
sa.Column("code_hash", sa.LargeBinary(32), nullable=False, unique=True),
sa.Column(
"created_by",
pg.UUID(as_uuid=True),
sa.ForeignKey("users.id"),
nullable=False,
),
sa.Column("email", sa.String(320), nullable=True),
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("max_uses", sa.Integer(), nullable=False, server_default="1"),
sa.Column("used_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_table(
"sessions",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
sa.Column(
"user_id",
pg.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("token_hash", sa.LargeBinary(32), nullable=False, unique=True),
sa.Column("client", sa.String(20), nullable=False, server_default="web"),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("ip", pg.INET(), nullable=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column(
"last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
op.create_table(
"api_tokens",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
sa.Column(
"user_id",
pg.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("token_hash", sa.LargeBinary(32), nullable=False, unique=True),
sa.Column(
"scopes",
pg.ARRAY(sa.String()),
nullable=False,
server_default="{}",
),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"])
# --- Grants ------------------------------------------------------------------------------
# velodrome_auth needs full read/write on these four tables — it's the only thing that ever
# creates a user, a session, or redeems an invite. velodrome_app needs the same grants
# because RLS policies (below) restrict WHICH ROWS it sees, not whether the underlying
# privilege exists — GRANT and POLICY are two independent layers, both required.
# Deliberately NOT granting TRUNCATE: it's a whole-table operation that RLS cannot filter
# (Postgres RLS policies do not apply to TRUNCATE at all), so granting it to velodrome_app
# would let any bug in ordinary request-handling code wipe an entire table in one statement,
# defeating the isolation these policies exist to provide. Neither runtime role needs it —
# tests use DELETE for fixture cleanup instead (see tests/conftest.py).
for table in ("users", "invites", "sessions", "api_tokens"):
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO velodrome_app")
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO velodrome_auth")
# --- Row-level security ------------------------------------------------------------------
# Policies below apply to velodrome_app only — velodrome_auth has BYPASSRLS and ignores them
# entirely, by design (see module docstring). current_setting('app.user_id', true) returns
# NULL when unset, which makes every policy below deny-by-default for an unscoped connection.
op.execute("ALTER TABLE users ENABLE ROW LEVEL SECURITY")
op.execute(
"CREATE POLICY own_row ON users FOR ALL "
"USING (id = current_setting('app.user_id', true)::uuid) "
"WITH CHECK (id = current_setting('app.user_id', true)::uuid)"
)
op.execute("ALTER TABLE sessions ENABLE ROW LEVEL SECURITY")
op.execute(
"CREATE POLICY own_rows ON sessions FOR ALL "
"USING (user_id = current_setting('app.user_id', true)::uuid) "
"WITH CHECK (user_id = current_setting('app.user_id', true)::uuid)"
)
op.execute("ALTER TABLE api_tokens ENABLE ROW LEVEL SECURITY")
op.execute(
"CREATE POLICY own_rows ON api_tokens FOR ALL "
"USING (user_id = current_setting('app.user_id', true)::uuid) "
"WITH CHECK (user_id = current_setting('app.user_id', true)::uuid)"
)
op.execute("ALTER TABLE invites ENABLE ROW LEVEL SECURITY")
op.execute(
"CREATE POLICY own_rows ON invites FOR ALL "
"USING (created_by = current_setting('app.user_id', true)::uuid) "
"WITH CHECK (created_by = current_setting('app.user_id', true)::uuid)"
)
def downgrade() -> None:
for table in ("invites", "api_tokens", "sessions", "users"):
op.execute(f"DROP POLICY IF EXISTS own_rows ON {table}")
op.execute(f"DROP POLICY IF EXISTS own_row ON {table}")
# Children before parents (FK order).
op.drop_table("api_tokens")
op.drop_table("sessions")
op.drop_table("invites")
op.drop_table("users")
# Dropping the tables drops the GRANTs that referenced them; the roles themselves remain
# until explicitly dropped here.
op.execute("DROP ROLE IF EXISTS velodrome_app")
op.execute("DROP ROLE IF EXISTS velodrome_auth")
+56
View File
@@ -0,0 +1,56 @@
[project]
name = "velodrome"
version = "0.1.0"
description = "Self-hosted cycling app API"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"sqlalchemy[asyncio]>=2.0.35",
"asyncpg>=0.30",
"alembic>=1.13",
"pydantic[email]>=2.9",
"pydantic-settings>=2.6",
"argon2-cffi>=23.1",
"uuid6>=2024.7.10",
]
[project.optional-dependencies]
dev = [
"ruff>=0.7",
"mypy>=1.13",
"pytest>=8.3",
"pytest-asyncio>=0.24",
"httpx>=0.27",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["velodrome"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC"]
[tool.ruff.lint.flake8-bugbear]
# FastAPI's dependency-injection idiom is Depends(...) as a default argument value — that IS
# the framework's intended pattern (see https://fastapi.tiangolo.com/tutorial/dependencies/),
# not the mutable-default-argument bug B008 exists to catch. Same reasoning for Query/Body/
# Cookie/Header — declared now even though only Depends is used yet, so nobody hits this same
# false positive when the others show up in a later phase.
extend-immutable-calls = ["fastapi.Depends", "fastapi.Query", "fastapi.Body", "fastapi.Cookie", "fastapi.Header"]
[tool.mypy]
python_version = "3.12"
strict = true
plugins = ["pydantic.mypy"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
View File
+80
View File
@@ -0,0 +1,80 @@
"""Test fixtures.
Runs the real Alembic migrations once per session against a real Postgres (never mocked — see
CLAUDE.md's test policy), then truncates the identity tables between tests for isolation. This
deliberately exercises the exact same migration path CI's `migrations` job and a real deploy use,
not a parallel test-only schema-creation shortcut.
"""
import os
from collections.abc import AsyncIterator
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from alembic import command
os.environ.setdefault("VELODROME_ENVIRONMENT", "test")
# Test-only role passwords. Never used outside this process; the migration requires them to be
# set explicitly (see alembic/versions/0001_baseline.py::_require_password) rather than default
# to anything, on purpose — that's the same rule for a real deploy, just satisfied differently.
_APP_PW = "test-only-app-password"
_AUTH_PW = "test-only-auth-password"
os.environ.setdefault(
"VELODROME_DATABASE_URL_MIGRATE",
"postgresql+asyncpg://postgres:postgres@localhost:5432/velodrome_test",
)
os.environ.setdefault("VELODROME_DB_APP_PASSWORD", _APP_PW)
os.environ.setdefault("VELODROME_DB_AUTH_PASSWORD", _AUTH_PW)
os.environ.setdefault(
"VELODROME_DATABASE_URL_APP",
f"postgresql+asyncpg://velodrome_app:{_APP_PW}@localhost:5432/velodrome_test",
)
os.environ.setdefault(
"VELODROME_DATABASE_URL_AUTH",
f"postgresql+asyncpg://velodrome_auth:{_AUTH_PW}@localhost:5432/velodrome_test",
)
# Settings/engines must not be constructed before the env vars above are set, so these imports
# are deliberately below the os.environ.setdefault block, not at module top.
import httpx # noqa: E402
from velodrome.app import app # noqa: E402
from velodrome.db import auth_session # noqa: E402
@pytest.fixture(scope="session", autouse=True)
def _run_migrations() -> None:
cfg = Config(os.path.join(os.path.dirname(__file__), "..", "alembic.ini"))
cfg.set_main_option("script_location", os.path.join(os.path.dirname(__file__), "..", "alembic"))
command.downgrade(cfg, "base") # clean slate even on a reused test database
command.upgrade(cfg, "head")
@pytest.fixture(autouse=True)
async def _clean_tables() -> AsyncIterator[None]:
yield
async with auth_session() as db:
async with db.begin():
# DELETE, not TRUNCATE — velodrome_auth deliberately isn't granted TRUNCATE in
# production (see the migration's comment), and using the same privilege level in
# tests as in prod is the point. Children before parents for the FK constraints.
for table in ("sessions", "api_tokens", "invites", "users"):
await db.execute(text(f"DELETE FROM {table}"))
@pytest.fixture
async def db_auth() -> AsyncIterator[AsyncSession]:
async with auth_session() as db:
yield db
@pytest.fixture
async def client() -> AsyncIterator[httpx.AsyncClient]:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
yield c
+275
View File
@@ -0,0 +1,275 @@
"""Auth endpoint tests, plus — the one that matters most — an empirical proof of RLS isolation.
Per CLAUDE.md: 'auth/, RLS policies — security, and a mistake exposes another user's data.' The
whole point of test_rls_blocks_cross_user_session_reads below is that it doesn't trust the SQL in
the migration is correct because it reads correctly — it proves it by actually trying to read
another user's row through the RLS-scoped role and confirming zero rows come back.
"""
from datetime import UTC, datetime, timedelta
from uuid import UUID
import httpx
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from velodrome.auth.security import hash_invite_code, hash_password
from velodrome.db import scoped_session, unscoped_session
_REGISTER_PASSWORD = "correct horse battery staple"
async def _seed_invite(db_auth: AsyncSession, *, code: str = "TESTCODE123") -> UUID:
"""Insert a usable invite directly, bypassing the API — this is fixture setup, not the
thing under test."""
creator_id = UUID(int=0)
await db_auth.execute(
text(
"INSERT INTO users (id, email, display_name, password_hash) "
"VALUES (:id, 'seed@example.com', 'Seed', :ph) ON CONFLICT DO NOTHING"
),
{"id": str(creator_id), "ph": hash_password("unused")},
)
await db_auth.execute(
text(
"INSERT INTO invites (id, code_hash, created_by, expires_at, max_uses, used_count) "
"VALUES (gen_random_uuid(), :hash, :creator, :expires, 1, 0)"
),
{
"hash": hash_invite_code(code),
"creator": str(creator_id),
"expires": datetime.now(UTC) + timedelta(days=1),
},
)
await db_auth.commit()
return creator_id
async def test_register_with_valid_invite_creates_session(
client: httpx.AsyncClient, db_auth: AsyncSession
) -> None:
await _seed_invite(db_auth)
resp = await client.post(
"/api/v1/auth/register",
json={
"email": "rider@example.com",
"password": _REGISTER_PASSWORD,
"display_name": "Rider",
"invite_code": "TESTCODE123",
},
)
assert resp.status_code == 201, resp.text
assert resp.json()["email"] == "rider@example.com"
assert "vd_session" in resp.cookies
async def test_register_rejects_unknown_invite_code(client: httpx.AsyncClient) -> None:
resp = await client.post(
"/api/v1/auth/register",
json={
"email": "rider@example.com",
"password": _REGISTER_PASSWORD,
"display_name": "Rider",
"invite_code": "no-such-code",
},
)
assert resp.status_code == 400
async def test_register_rejects_reused_invite(
client: httpx.AsyncClient, db_auth: AsyncSession
) -> None:
await _seed_invite(db_auth)
body = {
"password": _REGISTER_PASSWORD,
"display_name": "Rider",
"invite_code": "TESTCODE123",
}
first = await client.post("/api/v1/auth/register", json={**body, "email": "first@example.com"})
assert first.status_code == 201
second = await client.post(
"/api/v1/auth/register", json={**body, "email": "second@example.com"}
)
assert second.status_code == 400
async def test_login_with_correct_password_succeeds(
client: httpx.AsyncClient, db_auth: AsyncSession
) -> None:
await _seed_invite(db_auth)
await client.post(
"/api/v1/auth/register",
json={
"email": "rider@example.com",
"password": _REGISTER_PASSWORD,
"display_name": "Rider",
"invite_code": "TESTCODE123",
},
)
resp = await client.post(
"/api/v1/auth/login",
json={"email": "rider@example.com", "password": _REGISTER_PASSWORD},
)
assert resp.status_code == 200
assert "vd_session" in resp.cookies
async def test_login_with_wrong_password_fails(
client: httpx.AsyncClient, db_auth: AsyncSession
) -> None:
await _seed_invite(db_auth)
await client.post(
"/api/v1/auth/register",
json={
"email": "rider@example.com",
"password": _REGISTER_PASSWORD,
"display_name": "Rider",
"invite_code": "TESTCODE123",
},
)
resp = await client.post(
"/api/v1/auth/login",
json={"email": "rider@example.com", "password": "wrong password entirely"},
)
assert resp.status_code == 401
async def test_me_requires_authentication(client: httpx.AsyncClient) -> None:
resp = await client.get("/api/v1/auth/me")
assert resp.status_code == 401
async def test_me_returns_current_user_with_valid_session(
client: httpx.AsyncClient, db_auth: AsyncSession
) -> None:
await _seed_invite(db_auth)
await client.post(
"/api/v1/auth/register",
json={
"email": "rider@example.com",
"password": _REGISTER_PASSWORD,
"display_name": "Rider",
"invite_code": "TESTCODE123",
},
)
resp = await client.get("/api/v1/auth/me")
assert resp.status_code == 200
assert resp.json()["email"] == "rider@example.com"
async def test_logout_revokes_the_session(client: httpx.AsyncClient, db_auth: AsyncSession) -> None:
await _seed_invite(db_auth)
await client.post(
"/api/v1/auth/register",
json={
"email": "rider@example.com",
"password": _REGISTER_PASSWORD,
"display_name": "Rider",
"invite_code": "TESTCODE123",
},
)
logout_resp = await client.post(
"/api/v1/auth/logout", headers={"Origin": "http://localhost:5173"}
)
assert logout_resp.status_code == 204
me_resp = await client.get("/api/v1/auth/me")
assert me_resp.status_code == 401
async def test_logout_without_matching_origin_is_rejected(
client: httpx.AsyncClient, db_auth: AsyncSession
) -> None:
await _seed_invite(db_auth)
await client.post(
"/api/v1/auth/register",
json={
"email": "rider@example.com",
"password": _REGISTER_PASSWORD,
"display_name": "Rider",
"invite_code": "TESTCODE123",
},
)
resp = await client.post("/api/v1/auth/logout", headers={"Origin": "https://evil.example"})
assert resp.status_code == 403
async def test_rls_blocks_cross_user_session_reads(
client: httpx.AsyncClient, db_auth: AsyncSession
) -> None:
"""The load-bearing test: prove RLS actually enforces isolation, not just that the migration
ran without a syntax error.
Two users register (creating two session rows). We then open a `scoped_session` as user A —
the exact code path every real request handler will use once Phase 1 adds user-owned domain
tables — and confirm the raw SQL result set contains ONLY user A's session, even though it
runs no WHERE clause on user_id at all. If this test ever passes with more than one row, RLS
is not doing its job and every other invariant in this codebase is resting on nothing.
"""
await _seed_invite(db_auth, code="CODE-FOR-A")
await _seed_invite(db_auth, code="CODE-FOR-B")
resp_a = await client.post(
"/api/v1/auth/register",
json={
"email": "user-a@example.com",
"password": _REGISTER_PASSWORD,
"display_name": "User A",
"invite_code": "CODE-FOR-A",
},
)
user_a_id = UUID(resp_a.json()["id"])
resp_b = await client.post(
"/api/v1/auth/register",
json={
"email": "user-b@example.com",
"password": _REGISTER_PASSWORD,
"display_name": "User B",
"invite_code": "CODE-FOR-B",
},
)
user_b_id = UUID(resp_b.json()["id"])
assert user_a_id != user_b_id
# Sanity check first: BOTH sessions genuinely exist, via the bypass role.
total = (await db_auth.execute(text("SELECT count(*) FROM sessions"))).scalar_one()
assert total == 2
# Now the real test: as user A, scoped through the RLS-subject role, with NO WHERE clause.
async with scoped_session(user_a_id) as scoped_db:
rows = (await scoped_db.execute(text("SELECT user_id FROM sessions"))).scalars().all()
assert len(rows) == 1, (
f"expected exactly 1 row (user A's own session) via RLS, got {len(rows)}"
"RLS is not isolating users"
)
assert UUID(str(rows[0])) == user_a_id
# And the mirror image, as user B, proving this isn't a coincidence of row ordering.
async with scoped_session(user_b_id) as scoped_db:
rows_b = (await scoped_db.execute(text("SELECT user_id FROM sessions"))).scalars().all()
assert len(rows_b) == 1
assert UUID(str(rows_b[0])) == user_b_id
async def test_unscoped_session_sees_zero_rows_of_user_owned_tables(
client: httpx.AsyncClient, db_auth: AsyncSession
) -> None:
"""The default-deny half of the same proof: with app.user_id unset entirely (the state the
health check and any other unauthenticated code path runs in), RLS denies everything."""
await _seed_invite(db_auth)
await client.post(
"/api/v1/auth/register",
json={
"email": "rider@example.com",
"password": _REGISTER_PASSWORD,
"display_name": "Rider",
"invite_code": "TESTCODE123",
},
)
async with unscoped_session() as db:
rows = (await db.execute(text("SELECT * FROM sessions"))).all()
assert rows == []
+7
View File
@@ -0,0 +1,7 @@
import httpx
async def test_healthz_returns_ok(client: httpx.AsyncClient) -> None:
resp = await client.get("/api/v1/healthz")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
+1378
View File
File diff suppressed because it is too large Load Diff
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