refactor(api): move from Postgres+RLS to single-engine SQLite
CI / Repo hygiene (pull_request) Successful in 2s
CI / Web (lint, typecheck, build) (pull_request) Successful in 13s
CI / Migrations reversible (pull_request) Successful in 6s
CI / API (lint, types, tests) (pull_request) Successful in 52s

Reverses a shipped, tested, merged decision (D4/PR #2) rather than building
on it — see docs/DECISIONS.md D15 for the full record: what was rejected
(Postgres as a second container; Postgres+PostGIS bundled inside the single
container via a supervisor), what this costs (no database-level RLS, no
PostGIS, procrastinate needs replacing — all stated as a concern before this
was decided, and reaffirmed anyway, which is the user's call to make about
their own instance).

The one invariant-critical consequence: isolation between users now rests
entirely on the repository-layer scope (db.py's `Scope.select()`), not two
layers. CLAUDE.md's invariant #4 is revised accordingly. This is not a
downgrade-and-hope — `Scope` is built so an unfiltered query against a
user-owned table is structurally harder to write than a scoped one (there is
no method on `Scope` that returns one), and
tests/test_auth.py::test_scoped_session_blocks_cross_user_reads replaces the
old RLS proof with the same empirical standard: it doesn't trust the query
builder filters correctly because the code reads correctly, it registers two
real users and checks.
test_unscoped_session_can_see_every_user_when_misused is the deliberately
alarming companion — it demonstrates exactly what a reviewer must now catch,
since nothing else will.

Six real, non-obvious SQLite behaviours found and fixed by actually running
this against a real file, not assumed from docs:

- Foreign keys, ON DELETE CASCADE included, are OFF by default per
  connection — deleting a user silently left orphaned sessions/api_tokens,
  no error either way. Fixed with PRAGMA foreign_keys=ON on every connect.
- Transactions default to DEFERRED, which only takes a write lock on the
  first actual write — a real check-then-act race for invite redemption
  (two concurrent redemptions could both read used_count < max_uses as true
  before either commits). Fixed by disabling the driver's implicit BEGIN and
  issuing BEGIN IMMEDIATE ourselves — SQLAlchemy's own documented recipe for
  this, not improvised.
- DateTime(timezone=True) does NOT round-trip tzinfo on SQLite — a tz-aware
  datetime goes in, a naive one comes back out, and every
  `expires_at < datetime.now(UTC)` comparison in auth/service.py then raises
  TypeError. Fixed once at the Base level with a UTCDateTime TypeDecorator
  rather than per-column.
- Uuid(as_uuid=True) stores as 32-char hex with NO hyphens on SQLite, not
  str(uuid)'s hyphenated form. A test fixture that raw-inserted the
  hyphenated form left rows the ORM's own later UPDATE (via
  invite.used_count += 1's autoflush) could never match by primary key,
  updating zero rows and raising StaleDataError. Fixed by using .hex to
  match exactly what the ORM itself writes.
- BEGIN IMMEDIATE applies to every transaction, reads included — a
  long-lived test fixture that autobegins a transaction via a bare read and
  never explicitly closes it holds SQLite's exclusive write lock for the
  rest of the test, and a later scoped_session() call fails with "database
  is locked". Not an app-code bug (every real session block closes cleanly
  on exit), but real enough to document since the next person writing a
  test against the db_auth fixture will hit it too.
- Python's sqlite3 module deprecates its own implicit datetime adapter as of
  3.12 — silent today, warns on every raw-SQL datetime bind. Only ever hit
  test fixture code (the ORM path never uses it, confirmed by running the
  ORM-only health test with warnings promoted to errors and it stayed
  clean); fixed there with an explicit .isoformat() rather than left for a
  future Python version to turn into a real failure.

Also, since with_for_update() silently no-ops on SQLite (confirmed —
SQLAlchemy emits no SQL for it, no error either) rather than actually
locking anything: removed it from register()'s invite-redemption query and
corrected the comment to attribute the concurrency guarantee to BEGIN
IMMEDIATE, where it now actually lives.

One PR, not several, for the same reason PR #2 was: the migration, the
models, db.py, and the docs recording why are five views of one decision —
splitting them wouldn't make review easier, just disconnected. 552
insertions / 548 deletions across 17 files, most of it necessarily touching
what PR #2 shipped rather than net-new code.

Deliberately deferred, not solved here: PostGIS's replacement for spatial
storage, procrastinate's replacement for background jobs, and the
EXCLUDE USING gist constraint's replacement for component_installs — none
of those tables exist yet (Phase 1-2), so none of it is broken, and
docs/DECISIONS.md D15 records exactly what each future phase needs to
decide before it can be built. .gitea/workflows/deploy pipeline (PR #4,
built for the old 3-container Postgres compose stack) was closed as
superseded rather than merged; the single-container image build is
follow-up work, not part of this change.

Verified: ruff check, ruff format --check, and mypy --strict all clean.
13/13 pytest passing against a real SQLite file, including with
DeprecationWarning promoted to an error (confirms the sqlite3 adapter
deprecation fix actually holds, not just that it's quiet by default). Full
alembic upgrade -> downgrade -1 -> upgrade cycle run clean. alembic check
clean with no include_object filter needed at all now (SQLite starts with
nothing but what our own migrations create — no PostGIS/TIGER noise to
filter out in the first place). CI's exact migration command sequence
reproduced locally end to end before touching the workflow file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 15:42:44 -04:00
co-authored by Claude Opus 5
parent 1832059e03
commit e7392a5723
17 changed files with 548 additions and 544 deletions
+5 -34
View File
@@ -54,27 +54,11 @@ jobs:
api: api:
name: API (lint, types, tests) name: API (lint, types, tests)
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: # No postgres service container — SQLite (docs/DECISIONS.md D15) needs no server to talk to;
postgres: # the test suite creates its own temp file (see tests/conftest.py). This also makes the job
image: postgis/postgis:16-3.4 # meaningfully faster: no service container to start and health-check before tests can run.
env:
# Superuser — this is the "owner" role migrations run as (see alembic/env.py); it's
# 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
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 10
env: env:
VELODROME_ENVIRONMENT: 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
@@ -165,22 +149,9 @@ jobs:
migrations: migrations:
name: Migrations reversible name: Migrations reversible
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: # No postgres service container — see the api job's comment; same reasoning.
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: velodrome_mig
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 10
env: env:
VELODROME_DATABASE_URL_MIGRATE: postgresql+asyncpg://postgres:postgres@postgres:5432/velodrome_mig VELODROME_DATABASE_URL: sqlite+aiosqlite:///./velodrome-ci-migrations.db
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
+15 -7
View File
@@ -17,7 +17,8 @@ say so and argue it — but don't silently contradict it.
apps/api/ Python 3.12 / FastAPI / SQLAlchemy async / Alembic apps/api/ Python 3.12 / FastAPI / SQLAlchemy async / Alembic
apps/web/ SvelteKit static SPA (installable PWA) apps/web/ SvelteKit static SPA (installable PWA)
packages/openapi/ openapi.json — COMMITTED contract artefact, CI enforces it matches the code packages/openapi/ openapi.json — COMMITTED contract artefact, CI enforces it matches the code
deploy/ docker-compose, Caddyfile, systemd units, backup scripts deploy/ single-container Dockerfile, Caddyfile, systemd units, backup scripts
see docs/DECISIONS.md D15 for why this isn't docker-compose
docs/ plan, decisions, research docs/ plan, decisions, research
scripts/ repo tooling (PR helpers, etc.) scripts/ repo tooling (PR helpers, etc.)
.gitea/workflows/ CI .gitea/workflows/ CI
@@ -34,8 +35,12 @@ These are load-bearing. Breaking one is a correctness bug, not a style choice.
time-ranged `component_installs`. Never add a stored running total to a component. time-ranged `component_installs`. Never add a stored running total to a component.
3. **All physical quantities are SI integers** in storage — metres, seconds, mm/s, centimetres, 3. **All physical quantities are SI integers** in storage — metres, seconds, mm/s, centimetres,
grams, minor currency units. Imperial is display-only. Never store a float mile. grams, minor currency units. Imperial is display-only. Never store a float mile.
4. **Every user-owned table has `user_id`, an RLS policy, and a repository-layer scope.** Both 4. **Every user-owned table has `user_id`, and every query against it goes through the
layers, always. Never rely on the query alone. repository-layer scope helper — never a raw query filtered by hand.** This used to be backed
by Postgres RLS as a second, database-enforced layer (see `docs/DECISIONS.md` D4/D15); SQLite
has no equivalent, so the repository-layer scope is now the *only* enforcement, which makes it
non-negotiable rather than defense-in-depth. A new domain table without a passing isolation
test (see `tests/test_auth.py`'s pattern) is not done.
5. **Secrets never leave the server.** The Bryton credential is password-equivalent. It must not 5. **Secrets never leave the server.** The Bryton credential is password-equivalent. It must not
appear in any API response model, any log line, or any error message. appear in any API response model, any log line, or any error message.
6. **One ingestion path.** All sources funnel through `ingest_bytes()`. Never add a second parse 6. **One ingestion path.** All sources funnel through `ingest_bytes()`. Never add a second parse
@@ -47,8 +52,8 @@ These are load-bearing. Breaking one is a correctness bug, not a style choice.
calls in request handlers. calls in request handlers.
- **SQL:** migrations via Alembic only, never manual DDL. Every migration must survive - **SQL:** migrations via Alembic only, never manual DDL. Every migration must survive
`upgrade -> downgrade -1 -> upgrade`. `upgrade -> downgrade -1 -> upgrade`.
- **Tests:** pytest against a real Postgres service container, never mocks for DB behaviour. - **Tests:** pytest against a real SQLite file, never mocks for DB behaviour. Parser changes need
Parser changes need a golden fixture in `apps/api/tests/fixtures/fit/`. a golden fixture in `apps/api/tests/fixtures/fit/`.
- **Commits:** imperative mood, explain *why* in the body. Conventional-commit prefixes - **Commits:** imperative mood, explain *why* in the body. Conventional-commit prefixes
(`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, `ci:`). (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, `ci:`).
- Match surrounding code. Don't introduce a new pattern when one exists. - Match surrounding code. Don't introduce a new pattern when one exists.
@@ -100,7 +105,10 @@ silently corrupts data for six months is expensive work.
are silent and corrupt the archive. are silent and corrupt the archive.
- `wear/` — the wear SQL. Wrong numbers that still look plausible are the worst failure mode in the - `wear/` — the wear SQL. Wrong numbers that still look plausible are the worst failure mode in the
product, because nobody notices. product, because nobody notices.
- `auth/`, RLS policies — security, and a mistake exposes another user's data. - `auth/`, any repository-layer user-scoping code — security, and a mistake exposes another user's
data. This carries more weight than it used to: there is no database-enforced RLS backstop
anymore (see invariant #4 and `docs/DECISIONS.md` D15), so this code *is* the isolation
boundary, not one layer of it.
- `sources/bryton/` — a reverse-engineered protocol with no spec to check against. - `sources/bryton/` — a reverse-engineered protocol with no spec to check against.
- Schema migrations that alter or drop existing columns. - Schema migrations that alter or drop existing columns.
- Debugging anything that two Sonnet attempts have already failed to fix. - Debugging anything that two Sonnet attempts have already failed to fix.
@@ -113,7 +121,7 @@ careful transcription plus ordinary judgement, and CI catches the rest:
- CRUD endpoints, Pydantic models, repository methods - CRUD endpoints, Pydantic models, repository methods
- SvelteKit components, routes, styling, the service worker - SvelteKit components, routes, styling, the service worker
- Tests against an already-decided behaviour - Tests against an already-decided behaviour
- Additive migrations, docker-compose and Caddy config, CI workflows - Additive migrations, the deploy Dockerfile/Caddy config, CI workflows
- Documentation - Documentation
### Haiku — mechanical work ### Haiku — mechanical work
+73 -49
View File
@@ -1,7 +1,8 @@
# apps/api # apps/api
Python 3.12 / FastAPI / SQLAlchemy 2.0 async / Alembic. See `docs/PLAN.md` for the overall Python 3.12 / FastAPI / SQLAlchemy 2.0 async / Alembic / SQLite. See `docs/PLAN.md` for the
design and `docs/DECISIONS.md` for why things are built this way. overall design and `docs/DECISIONS.md` (D15 especially) for why the database is SQLite and not
the Postgres+PostGIS setup Phase 0 originally shipped with.
## Layout ## Layout
@@ -9,47 +10,78 @@ design and `docs/DECISIONS.md` for why things are built this way.
velodrome/ velodrome/
app.py FastAPI app factory app.py FastAPI app factory
config.py Settings (env-driven, see below) config.py Settings (env-driven, see below)
db.py Two database engines — read this first, it's the load-bearing module db.py Single engine + the repository-layer scope — read this first, it's the
load-bearing module for user isolation now that there's no RLS
ids.py UUIDv7 generation ids.py UUIDv7 generation
models/ SQLAlchemy models models/ SQLAlchemy models
auth/ Password hashing, session service, FastAPI auth dependencies auth/ Password hashing, session service, FastAPI auth dependencies
api/v1/ Route handlers api/v1/ Route handlers
schemas/ Pydantic request/response models schemas/ Pydantic request/response models
alembic/ Migrations. 0001_baseline.py creates the identity tables, the two alembic/ Migrations. 0001_baseline.py creates the identity tables — no roles, no
runtime DB roles, and their RLS policies — read its module docstring. RLS, no GRANTs, none of those concepts exist in SQLite.
tests/ pytest, against a real Postgres, never mocked tests/ pytest, against a real SQLite file, never mocked
``` ```
## Why two database connections ## Why isolation is enforced in Python now, not the database
The app connects to Postgres as **two different roles**, not one — this is the single most Phase 0 originally ran two Postgres roles (`velodrome_app`/`velodrome_auth`) with row-level
important thing to understand before touching `auth/` or `db.py`: security as a database-enforced isolation layer. SQLite has no roles, no session variables, and no
policy engine — there is no database-level backstop anymore. 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, - **`scoped_session(user_id)`** — for every query against a user-owned table once identity is
via `db.scoped_session(user_id)`, which sets `app.user_id` for the transaction so Postgres RLS known. Yields a `Scope`, whose `select()` is the *only* way to build a query through it, and
policies can key off it. every query it builds is pre-filtered to that `user_id` on any model that declares one. There is
- **`velodrome_auth`** — `BYPASSRLS`. Used *only* by `auth/service.py`, and only for the narrow no method on `Scope` that returns an unfiltered query — see `tests/test_auth.py`'s
set of lookups that must happen *before* identity is known: login by email, a session by its `test_scope_select_rejects_models_without_user_id` for what happens if you try it on a model
token hash, an invite by its code hash — plus the inserts that create those rows. Every one of that isn't user-owned (`Invite`, scoped by `created_by` rather than `user_id`, is the real
those queries is an exact match on a unique key, never an unfiltered scan, which is what makes example used there).
bypassing RLS safe there. - **`auth_session()`** — used *only* by `auth/service.py`, 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 — that discipline is what makes it safe to use a plain
session here instead of `Scope`.
- **`unscoped_session()`** — a plain session with no scoping applied at all, for health checks and
anything that never touches a user-owned table. `tests/test_auth.py`'s
`test_unscoped_session_can_see_every_user_when_misused` demonstrates, deliberately, what happens
if this gets used on a user-owned table instead of `scoped_session` — it sees *everyone's* rows.
That test exists to make the point vivid: reaching for `unscoped_session()` (or a raw
`auth_session()` query) against a user-owned table is a review-blocking mistake now, not a style
preference, because there's nothing else standing behind it.
A third connection — `VELODROME_DATABASE_URL_MIGRATE` — is used only by Alembic. It needs enough If you're adding a new table with per-user data: give it a `user_id` column and query it *only*
privilege to `CREATE ROLE` and `GRANT`, so in practice it's the Postgres superuser (or a through `scoped_session`. See CLAUDE.md's invariant #4.
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 ## Two SQLite behaviours that don't match the defaults you'd expect
a migration, and query it only through `scoped_session`. See CLAUDE.md's invariants.
Both confirmed empirically against real `aiosqlite`, not assumed from docs — see `db.py`'s
`_configure_sqlite_for_concurrent_writers` for the fixes:
1. **Foreign keys, `ON DELETE CASCADE` included, are OFF by default per connection.** Without
`PRAGMA foreign_keys=ON`, deleting a user silently leaves its sessions/api_tokens behind
instead of cascading — no error either way.
2. **Transactions default to DEFERRED**, which only takes a write lock on the first actual write —
leaving a real check-then-act race window (e.g. two concurrent redemptions of the same invite
code both reading `used_count < max_uses` as true before either commits). Every transaction on
this engine issues `BEGIN IMMEDIATE` instead, which takes the write lock up front. One
consequence worth knowing if you're writing tests: a session that autobegins a transaction via
a bare read and never explicitly commits/rolls back holds that write lock until the session
closes — see the comment above `await db_auth.commit()` in
`test_scoped_session_blocks_cross_user_reads` for a real example of this biting a long-lived
test fixture.
Also worth knowing: `Uuid(as_uuid=True)` stores as 32-char hex **with no hyphens** on SQLite, not
`str(uuid)`'s hyphenated form. This only matters if you ever write a UUID into this schema via raw
SQL instead of the ORM (as the test fixtures do, to set up state without going through the API) —
use `.hex`, not `str()`, or the ORM's own later queries against that row won't match it. See the
docstring on `_seed_invite` in `tests/test_auth.py` for the failure this caused when it was
gotten wrong.
## Environment variables ## Environment variables
| Variable | Used by | Notes | | Variable | Used by | Notes |
|---|---|---| |---|---|---|
| `VELODROME_DATABASE_URL_MIGRATE` | Alembic only | Superuser/owner DSN. Never used by the app itself. | | `VELODROME_DATABASE_URL` | The app and Alembic, both | e.g. `sqlite+aiosqlite:////data/velodrome.db`. One DSN — there's no separate migration role anymore since SQLite has no roles to separate. |
| `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_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_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. | | `VELODROME_PUBLIC_URL` | The app | Used for the CSRF `Origin` check on cookie-authenticated mutations. |
@@ -59,15 +91,7 @@ a migration, and query it only through `scoped_session`. See CLAUDE.md's invaria
```bash ```bash
uv sync --all-extras uv sync --all-extras
# a throwaway Postgres export VELODROME_DATABASE_URL=sqlite+aiosqlite:///./velodrome-dev.db
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 alembic upgrade head
uv run uvicorn velodrome.app:app --reload uv run uvicorn velodrome.app:app --reload
@@ -84,21 +108,21 @@ uv run mypy --strict velodrome
uv run pytest uv run pytest
``` ```
Tests run against a real Postgres (a service container in CI, or point `tests/conftest.py`'s Tests run against a real SQLite file in a temp directory (never `:memory:`, which gives each
defaults at your own) — never mocks for DB behaviour, per CLAUDE.md. The test suite runs the real separate connection its own isolated database rather than one shared one, and never mocks for DB
Alembic migration at session start, not a parallel schema-creation shortcut, so it's exercising behaviour, per CLAUDE.md). The suite runs the real Alembic migration at session start, not a
the exact same path a real deploy uses. 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 The test worth reading first 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 `tests/test_auth.py::test_scoped_session_blocks_cross_user_reads` — it doesn't trust that
policy SQL is correct because it reads correctly; it proves it by actually trying to read another `Scope.select()` filters correctly because the code reads correctly; it proves it by registering
user's row through the scoped role and asserting zero come back. two real users and confirming a scoped read for one never returns the other's row, even though
both exist in the same table.
## A note on `alembic check` ## A note on `alembic check`
CI's `migrations` job runs `alembic check` to catch drift between the ORM models and the actual 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 migrations. There's no `include_object` filter in `alembic/env.py` anymore — Phase 0's version
metadata declares — **deliberately an allowlist, not a denylist of PostGIS/TIGER tables**, because needed one to exclude PostGIS/TIGER's own pre-installed tables from the comparison, but SQLite
reflected foreign tables can come back with `schema=None` regardless of which schema they actually starts with nothing but what this app's own migrations create, so there's no foreign-table noise
live in (confirmed against a real `postgis/postgis:16-3.4` container), which makes a schema-based to filter out in the first place.
denylist unreliable. If you add a new model, it's automatically covered — no filter to update.
+12 -39
View File
@@ -1,16 +1,15 @@
"""Alembic environment. """Alembic environment.
Deliberately independent of velodrome.config.Settings: migrations run as a privileged/owner Reads VELODROME_DATABASE_URL directly from the environment rather than importing
connection (VELODROME_DATABASE_URL_MIGRATE — a superuser or schema-owning role, which trivially velodrome.config.Settings — keeps a migration-only invocation from needing every other runtime
satisfies "BYPASSRLS" since superusers always bypass RLS), never as either of the two runtime env var the app requires, even though today they'd resolve to the same value. There's no more
roles the app itself uses. Reading env vars directly here, rather than importing the app's separate migration/owner role to reason about here (docs/DECISIONS.md D15): SQLite has no roles,
settings, keeps a migration-only CI job from needing every runtime env var the app requires. so migrations run against the exact same file and connection the app itself uses.
""" """
import asyncio import asyncio
import os import os
import sqlalchemy as sa
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.ext.asyncio import async_engine_from_config
@@ -21,60 +20,34 @@ config = context.config
target_metadata = Base.metadata target_metadata = Base.metadata
# postgis/postgis ships an entire pre-installed schema of its own (PostGIS core tables plus the def _database_url() -> str:
# TIGER geocoder's tiger/topology schemas — dozens of tables) that Alembic never created and url = os.environ.get("VELODROME_DATABASE_URL")
# 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: if not url:
# Local-dev convenience only — every real environment (CI, deploy/) sets this explicitly. # Local-dev convenience only — CI and deploy/ both set this explicitly.
url = "postgresql+asyncpg://postgres:postgres@localhost:5432/velodrome" url = "sqlite+aiosqlite:///./velodrome.db"
return url return url
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
context.configure( context.configure(
url=_migrate_url(), url=_database_url(),
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
include_object=include_object,
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
def _do_run_migrations(connection: Connection) -> None: def _do_run_migrations(connection: Connection) -> None:
context.configure( context.configure(connection=connection, target_metadata=target_metadata)
connection=connection,
target_metadata=target_metadata,
include_object=include_object,
)
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
async def run_migrations_online() -> None: async def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section) or {} configuration = config.get_section(config.config_ini_section) or {}
configuration["sqlalchemy.url"] = _migrate_url() configuration["sqlalchemy.url"] = _database_url()
connectable = async_engine_from_config(configuration, prefix="sqlalchemy.") connectable = async_engine_from_config(configuration, prefix="sqlalchemy.")
async with connectable.connect() as connection: async with connectable.connect() as connection:
+22 -155
View File
@@ -1,21 +1,18 @@
"""Baseline: identity tables, two runtime DB roles, RLS policies. """Baseline: identity tables (users, invites, sessions, api_tokens).
This migration creates the schema AND the two application-facing Postgres roles it depends on No roles, no RLS, no GRANTs — SQLite has none of those concepts. Isolation between users is
(velodrome_app, velodrome_auth) — see db.py and auth/service.py module docstrings for the full enforced entirely at the application layer now; see db.py and CLAUDE.md's invariant #4, and
rationale. It runs as a privileged/owner connection (VELODROME_DATABASE_URL_MIGRATE), which is docs/DECISIONS.md D15 for the full story of why this migration looks nothing like the Postgres
why it's able to CREATE ROLE and GRANT at all; neither of the two roles it creates could do this version it replaced.
to itself.
Revision ID: 0001 Revision ID: 0001
Revises: Revises:
Create Date: 2026-09-21 Create Date: 2026-09-21
""" """
import os
from collections.abc import Sequence from collections.abc import Sequence
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects import postgresql as pg
from alembic import op from alembic import op
@@ -25,77 +22,10 @@ branch_labels: str | Sequence[str] | None = None
depends_on: 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: 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( op.create_table(
"users", "users",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True), sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
sa.Column("email", sa.String(320), nullable=False, unique=True), sa.Column("email", sa.String(320), nullable=False, unique=True),
sa.Column("display_name", sa.String(200), nullable=False), sa.Column("display_name", sa.String(200), nullable=False),
sa.Column("password_hash", sa.Text(), nullable=False), sa.Column("password_hash", sa.Text(), nullable=False),
@@ -103,18 +33,16 @@ def upgrade() -> None:
sa.Column("timezone", sa.String(64), nullable=False, server_default="UTC"), 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("unit_system", sa.String(10), nullable=False, server_default="imperial"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column( sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
),
) )
op.create_table( op.create_table(
"invites", "invites",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True), sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
sa.Column("code_hash", sa.LargeBinary(32), nullable=False, unique=True), sa.Column("code_hash", sa.LargeBinary(32), nullable=False, unique=True),
sa.Column( sa.Column(
"created_by", "created_by",
pg.UUID(as_uuid=True), sa.Uuid(as_uuid=True),
sa.ForeignKey("users.id"), sa.ForeignKey("users.id"),
nullable=False, nullable=False,
), ),
@@ -128,23 +56,20 @@ def upgrade() -> None:
op.create_table( op.create_table(
"sessions", "sessions",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True), sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
sa.Column( sa.Column(
"user_id", "user_id",
pg.UUID(as_uuid=True), sa.Uuid(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, nullable=False,
), ),
sa.Column("token_hash", sa.LargeBinary(32), nullable=False, unique=True), sa.Column("token_hash", sa.LargeBinary(32), nullable=False, unique=True),
sa.Column("client", sa.String(20), nullable=False, server_default="web"), sa.Column("client", sa.String(20), nullable=False, server_default="web"),
sa.Column("user_agent", sa.Text(), nullable=True), sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("ip", pg.INET(), nullable=True), # Plain string — SQLite has no INET type, and nothing queries the address's structure.
sa.Column( sa.Column("ip", sa.String(45), nullable=True),
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
), sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False),
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("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
) )
@@ -152,87 +77,29 @@ def upgrade() -> None:
op.create_table( op.create_table(
"api_tokens", "api_tokens",
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True), sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True),
sa.Column( sa.Column(
"user_id", "user_id",
pg.UUID(as_uuid=True), sa.Uuid(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"), sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, nullable=False,
), ),
sa.Column("name", sa.String(200), nullable=False), sa.Column("name", sa.String(200), nullable=False),
sa.Column("token_hash", sa.LargeBinary(32), nullable=False, unique=True), sa.Column("token_hash", sa.LargeBinary(32), nullable=False, unique=True),
sa.Column( # JSON-encoded TEXT — SQLite has no array type; see models/identity.py.
"scopes", sa.Column("scopes", sa.JSON(), nullable=False, server_default="[]"),
pg.ARRAY(sa.String()),
nullable=False,
server_default="{}",
),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("expires_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), sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
) )
op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"]) 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: def downgrade() -> None:
for table in ("invites", "api_tokens", "sessions", "users"): # Children before parents (FK order) — matters even with ON DELETE CASCADE enforced (see
op.execute(f"DROP POLICY IF EXISTS own_rows ON {table}") # db.py's PRAGMA foreign_keys=ON), since dropping a table Postgres-style doesn't rely on that
op.execute(f"DROP POLICY IF EXISTS own_row ON {table}") # at all; SQLite's DROP TABLE has no dependency ordering of its own to lean on.
# Children before parents (FK order).
op.drop_table("api_tokens") op.drop_table("api_tokens")
op.drop_table("sessions") op.drop_table("sessions")
op.drop_table("invites") op.drop_table("invites")
op.drop_table("users") 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")
+1 -1
View File
@@ -7,7 +7,7 @@ dependencies = [
"fastapi>=0.115", "fastapi>=0.115",
"uvicorn[standard]>=0.32", "uvicorn[standard]>=0.32",
"sqlalchemy[asyncio]>=2.0.35", "sqlalchemy[asyncio]>=2.0.35",
"asyncpg>=0.30", "aiosqlite>=0.20",
"alembic>=1.13", "alembic>=1.13",
"pydantic[email]>=2.9", "pydantic[email]>=2.9",
"pydantic-settings>=2.6", "pydantic-settings>=2.6",
+13 -30
View File
@@ -1,13 +1,16 @@
"""Test fixtures. """Test fixtures.
Runs the real Alembic migrations once per session against a real Postgres (never mocked — see Runs the real Alembic migrations once per session against a real SQLite file (never mocked — see
CLAUDE.md's test policy), then truncates the identity tables between tests for isolation. This CLAUDE.md's test policy — and never `:memory:`, which gives each separate connection its own
deliberately exercises the exact same migration path CI's `migrations` job and a real deploy use, isolated database rather than one shared one), then deletes all rows between tests for isolation.
not a parallel test-only schema-creation shortcut. 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 import os
import tempfile
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from pathlib import Path
import pytest import pytest
from alembic.config import Config from alembic.config import Config
@@ -18,29 +21,12 @@ from alembic import command
os.environ.setdefault("VELODROME_ENVIRONMENT", "test") os.environ.setdefault("VELODROME_ENVIRONMENT", "test")
# Test-only role passwords. Never used outside this process; the migration requires them to be _tmp_dir = tempfile.mkdtemp(prefix="velodrome-test-")
# set explicitly (see alembic/versions/0001_baseline.py::_require_password) rather than default _db_path = Path(_tmp_dir) / "test.db"
# to anything, on purpose — that's the same rule for a real deploy, just satisfied differently. os.environ.setdefault("VELODROME_DATABASE_URL", f"sqlite+aiosqlite:///{_db_path}")
_APP_PW = "test-only-app-password"
_AUTH_PW = "test-only-auth-password"
os.environ.setdefault( # Settings/engines must not be constructed before the env var above is set, so these imports are
"VELODROME_DATABASE_URL_MIGRATE", # deliberately below it, not at module top.
"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 import httpx # noqa: E402
from velodrome.app import app # noqa: E402 from velodrome.app import app # noqa: E402
@@ -51,7 +37,6 @@ from velodrome.db import auth_session # noqa: E402
def _run_migrations() -> None: def _run_migrations() -> None:
cfg = Config(os.path.join(os.path.dirname(__file__), "..", "alembic.ini")) 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")) 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") command.upgrade(cfg, "head")
@@ -60,9 +45,7 @@ async def _clean_tables() -> AsyncIterator[None]:
yield yield
async with auth_session() as db: async with auth_session() as db:
async with db.begin(): async with db.begin():
# DELETE, not TRUNCATE — velodrome_auth deliberately isn't granted TRUNCATE in # Children before parents for the FK constraints.
# 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"): for table in ("sessions", "api_tokens", "invites", "users"):
await db.execute(text(f"DELETE FROM {table}")) await db.execute(text(f"DELETE FROM {table}"))
+107 -41
View File
@@ -1,44 +1,73 @@
"""Auth endpoint tests, plus — the one that matters most — an empirical proof of RLS isolation. """Auth endpoint tests, plus — the one that matters most — an empirical proof that the
repository-layer scope actually enforces isolation between users.
Per CLAUDE.md: 'auth/, RLS policies — security, and a mistake exposes another user's data.' The Per CLAUDE.md invariant #4: since D15 removed Postgres RLS, `Scope` in db.py is not one layer of
whole point of test_rls_blocks_cross_user_session_reads below is that it doesn't trust the SQL in isolation among two — it's the only one. test_scoped_session_blocks_cross_user_reads below doesn't
the migration is correct because it reads correctly it proves it by actually trying to read trust that `Scope.select()` filters correctly because the code reads correctly; it proves it by
another user's row through the RLS-scoped role and confirming zero rows come back. registering two real users and confirming a scoped read for user A never returns user B's row,
even when both exist in the same table.
""" """
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID, uuid4
import httpx import httpx
from sqlalchemy import text import pytest
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from velodrome.auth.security import hash_invite_code, hash_password from velodrome.auth.security import hash_invite_code, hash_password
from velodrome.db import scoped_session, unscoped_session from velodrome.db import scoped_session, unscoped_session
from velodrome.models import Invite, Session
_REGISTER_PASSWORD = "correct horse battery staple" _REGISTER_PASSWORD = "correct horse battery staple"
async def _seed_invite(db_auth: AsyncSession, *, code: str = "TESTCODE123") -> UUID: 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 """Insert a usable invite directly, bypassing the API — this is fixture setup, not the
thing under test.""" thing under test.
UUID values are passed as `.hex` (32 hex chars, no hyphens), not `str()` (36 chars, hyphenated)
or a raw UUID object — a `text()` query has no ORM-level column-type awareness, so this is
fixture setup working around two separate things confirmed empirically, not assumed:
(1) aiosqlite's driver has no built-in adapter for a Python `UUID` object at all (raises
"type 'UUID' is not supported"); (2) SQLAlchemy's `Uuid(as_uuid=True)` column type stores as
the 32-char hex form on SQLite, NOT the hyphenated `str()` form — inserting the hyphenated
form via raw SQL left rows the ORM's own later `UPDATE ... WHERE id = ?` (implicitly issued
by `invite.used_count += 1` in auth/service.py) could never match, silently updating zero
rows. `.hex` is what the ORM itself writes, so raw-SQL-inserted rows are indistinguishable
from ORM-inserted ones.
`creator_id` is a fixed sentinel, not a fresh `uuid4()` per call — confirmed the hard way: a
test calling this twice (two invite codes) with a fresh id each time but the same hardcoded
seed email hit the `ON CONFLICT DO NOTHING` on email on the second call, which silently
no-ops, leaving that call's fresh id never actually inserted — so its invite's `created_by`
pointed at a user row that was never created, and the FK constraint on the invites insert
failed. A stable id makes repeat calls genuinely idempotent instead of just quiet about it.
"""
# datetime binds use .isoformat() explicitly, not a raw datetime object — Python's sqlite3
# module has its own implicit datetime adapter, which is deprecated as of 3.12 and warns on
# every use (confirmed against this exact fixture); the ORM's own UTCDateTime type never hits
# this because SQLAlchemy's dialect handles the conversion itself rather than delegating to
# sqlite3's adapter, but a bare text() bind param has no such handling.
creator_id = UUID(int=0) creator_id = UUID(int=0)
await db_auth.execute( await db_auth.execute(
text( text(
"INSERT INTO users (id, email, display_name, password_hash) " "INSERT INTO users (id, email, display_name, password_hash, created_at) "
"VALUES (:id, 'seed@example.com', 'Seed', :ph) ON CONFLICT DO NOTHING" "VALUES (:id, 'seed@example.com', 'Seed', :ph, :now) ON CONFLICT DO NOTHING"
), ),
{"id": str(creator_id), "ph": hash_password("unused")}, {"id": creator_id.hex, "ph": hash_password("unused"), "now": datetime.now(UTC).isoformat()},
) )
await db_auth.execute( await db_auth.execute(
text( text(
"INSERT INTO invites (id, code_hash, created_by, expires_at, max_uses, used_count) " "INSERT INTO invites (id, code_hash, created_by, expires_at, max_uses, used_count) "
"VALUES (gen_random_uuid(), :hash, :creator, :expires, 1, 0)" "VALUES (:id, :hash, :creator, :expires, 1, 0)"
), ),
{ {
"id": uuid4().hex,
"hash": hash_invite_code(code), "hash": hash_invite_code(code),
"creator": str(creator_id), "creator": creator_id.hex,
"expires": datetime.now(UTC) + timedelta(days=1), "expires": (datetime.now(UTC) + timedelta(days=1)).isoformat(),
}, },
) )
await db_auth.commit() await db_auth.commit()
@@ -195,17 +224,18 @@ async def test_logout_without_matching_origin_is_rejected(
assert resp.status_code == 403 assert resp.status_code == 403
async def test_rls_blocks_cross_user_session_reads( async def test_scoped_session_blocks_cross_user_reads(
client: httpx.AsyncClient, db_auth: AsyncSession client: httpx.AsyncClient, db_auth: AsyncSession
) -> None: ) -> None:
"""The load-bearing test: prove RLS actually enforces isolation, not just that the migration """The load-bearing test: prove the repository-layer scope actually enforces isolation, not
ran without a syntax error. just that `Scope.select()` reads like it should.
Two users register (creating two session rows). We then open a `scoped_session` as user A — Two users register (creating two session rows). We then open `scoped_session` as user A — the
the exact code path every real request handler will use once Phase 1 adds user-owned domain 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 tables — and confirm `Scope.select(Session)` returns ONLY user A's row, even though both exist
runs no WHERE clause on user_id at all. If this test ever passes with more than one row, RLS in the same table. If this test ever passes with more than one row, the scope is not isolating
is not doing its job and every other invariant in this codebase is resting on nothing. users and every other invariant in this codebase is resting on nothing (see CLAUDE.md
invariant #4 — there is no database-level backstop anymore; this IS the isolation boundary).
""" """
await _seed_invite(db_auth, code="CODE-FOR-A") await _seed_invite(db_auth, code="CODE-FOR-A")
await _seed_invite(db_auth, code="CODE-FOR-B") await _seed_invite(db_auth, code="CODE-FOR-B")
@@ -233,43 +263,79 @@ async def test_rls_blocks_cross_user_session_reads(
user_b_id = UUID(resp_b.json()["id"]) user_b_id = UUID(resp_b.json()["id"])
assert user_a_id != user_b_id assert user_a_id != user_b_id
# Sanity check first: BOTH sessions genuinely exist, via the bypass role. # Sanity check first: BOTH sessions genuinely exist, via the pre-identity bootstrap path.
total = (await db_auth.execute(text("SELECT count(*) FROM sessions"))).scalar_one() total = (await db_auth.execute(text("SELECT count(*) FROM sessions"))).scalar_one()
assert total == 2 assert total == 2
# Close out the transaction this read just autobegan. db.py's BEGIN IMMEDIATE (see its
# docstring) takes SQLite's exclusive write lock the instant ANY transaction starts, read
# included — confirmed the hard way: without this commit, the db_auth fixture's session
# (which the pytest fixture keeps open for the whole test, not just this block) holds that
# lock indefinitely, and the scoped_session() calls below then fail with "database is
# locked" trying to acquire their own. Every write in this file already commits promptly;
# a read needs the same discipline once BEGIN IMMEDIATE is in play.
await db_auth.commit()
# Now the real test: as user A, scoped through the RLS-subject role, with NO WHERE clause. # Now the real test: Scope.select(), which is the only way application code is meant to
async with scoped_session(user_a_id) as scoped_db: # query a user-owned table once identity is known.
rows = (await scoped_db.execute(text("SELECT user_id FROM sessions"))).scalars().all() async with scoped_session(user_a_id) as scope:
rows = (await scope.session.execute(scope.select(Session))).scalars().all()
assert len(rows) == 1, ( assert len(rows) == 1, (
f"expected exactly 1 row (user A's own session) via RLS, got {len(rows)}" f"expected exactly 1 row (user A's own session), got {len(rows)}"
"RLS is not isolating users" "the repository-layer scope is not isolating users"
) )
assert UUID(str(rows[0])) == user_a_id assert rows[0].user_id == user_a_id
# And the mirror image, as user B, proving this isn't a coincidence of row ordering. # 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: async with scoped_session(user_b_id) as scope:
rows_b = (await scoped_db.execute(text("SELECT user_id FROM sessions"))).scalars().all() rows_b = (await scope.session.execute(scope.select(Session))).scalars().all()
assert len(rows_b) == 1 assert len(rows_b) == 1
assert UUID(str(rows_b[0])) == user_b_id assert rows_b[0].user_id == user_b_id
async def test_unscoped_session_sees_zero_rows_of_user_owned_tables( async def test_scope_select_rejects_models_without_user_id() -> None:
"""`Scope.select()` refuses to build a query for a model that has no `user_id` column,
instead of silently returning an unfiltered (and therefore unsafe) query. `Invite` is a real
example, not a contrived one: it's scoped by `created_by`, not `user_id`."""
async with scoped_session(uuid4()) as scope:
with pytest.raises(TypeError, match="has no user_id column"):
scope.select(Invite)
async def test_unscoped_session_can_see_every_user_when_misused(
client: httpx.AsyncClient, db_auth: AsyncSession client: httpx.AsyncClient, db_auth: AsyncSession
) -> None: ) -> None:
"""The default-deny half of the same proof: with app.user_id unset entirely (the state the """The deliberately alarming counterpart to the isolation test above: `unscoped_session()`
health check and any other unauthenticated code path runs in), RLS denies everything.""" plus a raw `select(Session)` returns EVERY user's rows, with no filtering at all — there is no
await _seed_invite(db_auth) database-level backstop to catch this mistake anymore (docs/DECISIONS.md D15). This is exactly
why CLAUDE.md invariant #4 treats reaching for `unscoped_session()` on a user-owned table as a
review-blocking mistake, not a style preference: the code review IS the isolation boundary
for this specific failure mode, `Scope` is the boundary for the query-construction one.
"""
await _seed_invite(db_auth, code="CODE-FOR-A")
await _seed_invite(db_auth, code="CODE-FOR-B")
await client.post( await client.post(
"/api/v1/auth/register", "/api/v1/auth/register",
json={ json={
"email": "rider@example.com", "email": "user-a@example.com",
"password": _REGISTER_PASSWORD, "password": _REGISTER_PASSWORD,
"display_name": "Rider", "display_name": "User A",
"invite_code": "TESTCODE123", "invite_code": "CODE-FOR-A",
},
)
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",
}, },
) )
async with unscoped_session() as db: async with unscoped_session() as db:
rows = (await db.execute(text("SELECT * FROM sessions"))).all() rows = (await db.execute(select(Session))).scalars().all()
assert rows == [] assert len(rows) == 2, (
"unscoped_session with a raw query sees every user's rows — by design, this is the "
"unsafe path Scope exists to replace"
)
+11 -42
View File
@@ -7,6 +7,15 @@ resolution-markers = [
"python_full_version < '3.14'", "python_full_version < '3.14'",
] ]
[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.20.0" version = "1.20.0"
@@ -171,46 +180,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" },
] ]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.7.22" version = "2026.7.22"
@@ -1170,9 +1139,9 @@ name = "velodrome"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" },
{ name = "alembic" }, { name = "alembic" },
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "pydantic", extra = ["email"] }, { name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
@@ -1192,9 +1161,9 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20" },
{ name = "alembic", specifier = ">=1.13" }, { name = "alembic", specifier = ">=1.13" },
{ name = "argon2-cffi", specifier = ">=23.1" }, { name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.30" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13" },
+2 -2
View File
@@ -10,8 +10,8 @@ router = APIRouter(tags=["health"])
async def healthz() -> dict[str, str]: async def healthz() -> dict[str, str]:
"""Liveness + a real database round trip. """Liveness + a real database round trip.
Not RLS-scoped (there's no user yet at this point) — see db.unscoped_session's docstring for Uses `unscoped_session()`, not `scoped_session()` — there's no user yet at this point, and
why that's safe: it can see zero rows of any user-owned table regardless. `SELECT 1` never touches a user-owned table anyway. See db.py's module docstring.
""" """
async with unscoped_session() as db: async with unscoped_session() as db:
await db.execute(text("SELECT 1")) await db.execute(text("SELECT 1"))
+12 -10
View File
@@ -1,9 +1,8 @@
"""Auth bootstrap logic: register, login, session validation, logout. """Auth bootstrap logic: register, login, session validation, logout.
Every function here runs against the BYPASSRLS `auth` database role (see db.py's module Every function here runs against `db.auth_session()` (see its docstring for why these specific
docstring for why) and every query is an exact match on a unique key — email, token_hash, or pre-identity lookups need it) and every query is an exact match on a unique key — email,
code_hash — never an unfiltered scan. That's what makes bypassing RLS safe here: there's no token_hash, or code_hash — never an unfiltered scan.
"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 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. a sign the feature belongs here, not that the import should spread.
@@ -59,17 +58,20 @@ async def register(
) -> AuthenticatedSession: ) -> AuthenticatedSession:
"""Validate an invite and create a user, atomically. """Validate an invite and create a user, atomically.
Open signup does not exist — see docs/PLAN.md "Auth". The `SELECT ... FOR UPDATE` on the Open signup does not exist — see docs/PLAN.md "Auth". What stops a shared invite link being
invite row is what stops a shared invite link being redeemed twice concurrently; without it, redeemed twice concurrently is db.py's `BEGIN IMMEDIATE` setup, not a row lock on this
two requests could both read `used_count < max_uses` as true before either commits. SELECT — SQLite has no `SELECT ... FOR UPDATE` (SQLAlchemy's SQLite dialect silently no-ops
`.with_for_update()`, confirmed empirically; it used to appear here when this ran against
Postgres — see git history). `BEGIN IMMEDIATE` takes SQLite's write lock for the whole
transaction up front, so two concurrent redemptions can't both read `used_count < max_uses`
as true before either commits — the second one simply waits for the first transaction to
finish, then sees the incremented count.
""" """
code_hash = hash_invite_code(invite_code) code_hash = hash_invite_code(invite_code)
async with auth_session() as db: async with auth_session() as db:
async with db.begin(): async with db.begin():
invite = ( invite = (
await db.execute( await db.execute(select(Invite).where(Invite.code_hash == code_hash))
select(Invite).where(Invite.code_hash == code_hash).with_for_update()
)
).scalar_one_or_none() ).scalar_one_or_none()
if invite is None: if invite is None:
+8 -9
View File
@@ -1,24 +1,23 @@
"""Application settings, read from environment variables. """Application settings, read from environment variables.
Two separate database DSNs are deliberate, not an oversight — see db.py for why: one connects Single SQLite database (see docs/DECISIONS.md D15 for why this isn't the two-role Postgres+RLS
as a role with BYPASSRLS (used only by the auth bootstrap path, which must look identity up setup Phase 0 originally shipped with) — one DSN, one engine, no BYPASSRLS/NOBYPASSRLS split.
*before* it can be scoped), the other as a normal RLS-subject role (used for every other query). Isolation between users is enforced entirely by the repository-layer scope now; see db.py and
CLAUDE.md's invariant #4.
""" """
from functools import lru_cache from functools import lru_cache
from pydantic import PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="VELODROME_", extra="ignore") model_config = SettingsConfigDict(env_prefix="VELODROME_", extra="ignore")
# Scoped role: NOBYPASSRLS. Every request-handling query outside the auth bootstrap uses this. # A SQLAlchemy URL, e.g. sqlite+aiosqlite:////data/velodrome.db. Not typed as a stricter DSN
database_url_app: PostgresDsn # (pydantic has no built-in sqlite+aiosqlite validator) — Alembic and the app both read this
# Bootstrap role: BYPASSRLS. Used ONLY by velodrome.auth.service for the pre-identity lookups # same setting, so keep it a plain string rather than inventing a validator neither needs.
# (login by email, session-by-token, invite-by-code) and for creating new users/sessions. database_url: str = "sqlite+aiosqlite:///./velodrome.db"
database_url_auth: PostgresDsn
# AES-GCM key for encrypting third-party credentials (e.g. the Bryton digest, added in a later # 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. # phase). Not used yet in Phase 0, but declared now so the settings shape is stable.
+107 -77
View File
@@ -1,113 +1,143 @@
"""Two database engines, on purpose. """Single-engine SQLite access.
`app` engine: connects as a role with RLS enforced (NOBYPASSRLS). Every request handler that has Phase 0 originally ran two Postgres roles with row-level security as a database-enforced
already established who the caller is uses this, wrapped in `scoped_session()` below, which sets isolation layer (`velodrome_app`/`velodrome_auth` — see docs/DECISIONS.md D4). D15 moved the
`app.user_id` for the transaction so RLS policies can key off it. database to SQLite, which has no roles, no session variables, and no policy engine — there is no
database-enforced layer left. Isolation between users now rests entirely on the query builder
below. CLAUDE.md's invariant #4 treats this file as load-bearing, not a convenience wrapper: a
new user-owned table without a passing isolation test (see tests/test_auth.py's pattern) is not
done.
`auth` engine: connects as a role with BYPASSRLS. Used ONLY by velodrome.auth.service, and only Two access patterns:
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 - `scoped_session(user_id)` — for every query against a user-owned table once identity is known.
inserts that create those rows in the first place. Nothing outside auth/service.py should import Yields a `Scope`, whose `select()` is the ONLY way to build a query through it — every query it
this engine; if you find yourself reaching for it elsewhere, the query almost certainly belongs builds is pre-filtered to that user_id, on any model that declares a `user_id` column. There is
in a repository method on the scoped session instead (see CLAUDE.md invariant #4). no method on `Scope` that returns an unfiltered query. This is what makes "forgot the WHERE
clause" structurally harder than remembering to write one by hand.
- `auth_session()` — for velodrome.auth.service ONLY: the handful of pre-identity lookups (login
by email, a session by its token hash, an invite by its code hash) that by definition can't be
scoped to a user_id nobody has established yet. Every query on this session must be an exact
match on a unique key, never an unfiltered scan — that discipline is what made bypassing RLS
safe before, and it's what keeps this safe now that RLS is gone. Nothing outside auth/service.py
should import this.
""" """
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any
from uuid import UUID from uuid import UUID
from sqlalchemy import text from sqlalchemy import Select, event, select
from sqlalchemy.ext.asyncio import ( from sqlalchemy.ext.asyncio import (
AsyncEngine, AsyncEngine,
AsyncSession, AsyncSession,
async_sessionmaker, async_sessionmaker,
create_async_engine, create_async_engine,
) )
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from velodrome.config import get_settings from velodrome.config import get_settings
def _make_engine(url: str) -> AsyncEngine: def _configure_sqlite_for_concurrent_writers(eng: AsyncEngine) -> None:
settings = get_settings() """Two SQLite defaults that silently do the wrong thing if left alone — confirmed empirically
# NullPool in dev/test keeps behaviour predictable across the pytest-asyncio event loop; against a real aiosqlite connection, not assumed from docs:
# production tuning (pool_size etc.) is a deploy-time concern, not a Phase-0 one.
return create_async_engine( 1. Foreign key enforcement, `ON DELETE CASCADE` included, is OFF by default per connection.
url, Without `PRAGMA foreign_keys=ON`, deleting a user leaves its sessions/api_tokens rows
poolclass=NullPool if settings.environment == "test" else None, behind instead of cascading — verified this happens silently, no error either way.
echo=False, 2. pysqlite/aiosqlite default to a DEFERRED transaction, which only takes a write lock on the
) first actual write statement — leaving a real check-then-act race window. Concretely: two
concurrent redemptions of the same invite code could both read `used_count < max_uses` as
true before either commits, double-spending a single-use invite. Disable the driver's own
implicit BEGIN handling and issue `BEGIN IMMEDIATE` ourselves instead, which takes the
write lock at transaction start and correctly serializes writers — SQLAlchemy's own
documented recipe for this, not a workaround improvised here.
"""
@event.listens_for(eng.sync_engine, "connect")
def _on_connect(dbapi_connection: Any, connection_record: Any) -> None:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
dbapi_connection.isolation_level = None
@event.listens_for(eng.sync_engine, "begin")
def _begin_immediate(conn: Any) -> None:
conn.exec_driver_sql("BEGIN IMMEDIATE")
_app_engine: AsyncEngine | None = None _engine: AsyncEngine | None = None
_auth_engine: AsyncEngine | None = None _sessionmaker: async_sessionmaker[AsyncSession] | None = None
def app_engine() -> AsyncEngine: def _engine_instance() -> AsyncEngine:
global _app_engine global _engine
if _app_engine is None: if _engine is None:
_app_engine = _make_engine(str(get_settings().database_url_app)) settings = get_settings()
return _app_engine url = settings.database_url
# NullPool in dev/test keeps behaviour predictable across the pytest-asyncio event loop;
# production pool tuning is a deploy-time concern, not a Phase-0 one.
_engine = create_async_engine(
url,
poolclass=NullPool if settings.environment == "test" else None,
echo=False,
)
if url.startswith("sqlite"):
_configure_sqlite_for_concurrent_writers(_engine)
return _engine
def auth_engine() -> AsyncEngine: def _sessions() -> async_sessionmaker[AsyncSession]:
global _auth_engine global _sessionmaker
if _auth_engine is None: if _sessionmaker is None:
_auth_engine = _make_engine(str(get_settings().database_url_auth)) _sessionmaker = async_sessionmaker(_engine_instance(), expire_on_commit=False)
return _auth_engine return _sessionmaker
_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 @asynccontextmanager
async def auth_session() -> AsyncIterator[AsyncSession]: async def auth_session() -> AsyncIterator[AsyncSession]:
"""A BYPASSRLS session. See module docstring — auth/service.py only.""" """See module docstring — auth/service.py only."""
async with _auth_sessions()() as session: async with _sessions()() as session:
yield 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 @asynccontextmanager
async def unscoped_session() -> AsyncIterator[AsyncSession]: async def unscoped_session() -> AsyncIterator[AsyncSession]:
"""An `app`-role session with no `app.user_id` set. """A plain session with no user scoping applied at all — for health checks and anything that
never touches a user-owned table. Prefer `scoped_session` whenever a user is known; reaching
RLS policies default-deny when `current_setting('app.user_id', true)` is NULL, so this sees for this instead of that for a query against a user-owned table is exactly the mistake
zero rows of any user-owned table — useful for health checks and anything that only touches invariant #4 exists to catch in review.
non-RLS tables. Prefer `scoped_session` whenever a user is known.
""" """
async with _app_sessions()() as session: async with _sessions()() as session:
yield session yield session
class Scope:
"""A user-scoped query builder. `select()` is the only way to build a query through this
object, and it is always pre-filtered to `user_id` — there is no method here that returns an
unfiltered query against a user-owned table.
"""
def __init__(self, session: AsyncSession, user_id: UUID) -> None:
self.session = session
self.user_id = user_id
def select(self, model: type[DeclarativeBase]) -> "Select[Any]":
if not hasattr(model, "user_id"):
raise TypeError(
f"{model.__name__} has no user_id column — it isn't a user-owned table, so "
"scoped_session() is the wrong tool here. Use auth_session() (pre-identity "
"lookups only) or unscoped_session() (health checks etc.) instead."
)
return select(model).where(model.user_id == self.user_id)
@asynccontextmanager
async def scoped_session(user_id: UUID) -> AsyncIterator[Scope]:
"""A `Scope` for a known, authenticated user. See module docstring."""
async with _sessions()() as session:
async with session.begin():
yield Scope(session, user_id)
+34 -7
View File
@@ -1,15 +1,42 @@
from datetime import datetime from datetime import UTC, datetime
from typing import Any
from sqlalchemy import DateTime from sqlalchemy import DateTime
from sqlalchemy.engine import Dialect
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.types import TypeDecorator
class UTCDateTime(TypeDecorator[datetime]):
"""`DateTime(timezone=True)` does NOT round-trip tzinfo on SQLite — confirmed empirically:
a tz-aware datetime goes in, a naive one comes back out, and every
`expires_at < datetime.now(UTC)` style comparison in auth/service.py then raises
`TypeError: can't compare offset-naive and offset-aware datetimes`. Not a quirk worth a
per-column workaround — every timestamp in this app is UTC by convention (docs/PLAN.md), so
re-attach UTC on load here, once, rather than trust the driver to preserve it.
"""
impl = DateTime(timezone=True)
cache_ok = True
def process_bind_param(self, value: datetime | None, dialect: Dialect) -> datetime | None:
if value is not None and value.tzinfo is None:
raise ValueError(
"naive datetime passed to a UTCDateTime column — always construct with "
"datetime.now(UTC), never datetime.now() or datetime.utcnow()"
)
return value
def process_result_value(self, value: Any, dialect: Dialect) -> datetime | None:
if value is not None and value.tzinfo is None:
value = value.replace(tzinfo=UTC)
return value # type: ignore[no-any-return]
class Base(DeclarativeBase): class Base(DeclarativeBase):
# Every timestamp in this schema is timestamptz UTC (docs/PLAN.md's convention) — without # Every timestamp in this schema is UTC (docs/PLAN.md's convention) — without this, a bare
# this, a bare `Mapped[datetime]` infers a naive TIMESTAMP column, which then silently # `Mapped[datetime]` infers a plain DateTime, which hits the SQLite round-trip bug above on
# disagrees with a migration that (correctly) declares DateTime(timezone=True), and asyncpg # every single column instead of being fixed once, here, for every current and future model.
# 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 = { type_annotation_map = {
datetime: DateTime(timezone=True), datetime: UTCDateTime(),
} }
+30 -22
View File
@@ -1,29 +1,33 @@
"""Identity tables: users, invites, sessions, api_tokens. """Identity tables: users, invites, sessions, api_tokens.
See docs/PLAN.md "Auth" and "Schema > Tables > Identity" for the design rationale, and See docs/PLAN.md "Auth" and "Schema > Tables > Identity" for the design rationale, and db.py's
db.py's module docstring for why two DB roles exist. RLS policies for these tables are created module docstring for how isolation between users is enforced now that there's no RLS to do it at
in the baseline Alembic migration (alembic/versions/0001_baseline.py), not here — SQLAlchemy the database level (docs/DECISIONS.md D15).
models describe columns, not database-level security policy, and keeping the policy SQL visible
and reviewable in the migration is deliberate. Types are deliberately dialect-generic (`sqlalchemy.Uuid`, `JSON`, plain `String` for the IP
column) rather than the `postgresql.*` variants Phase 0 originally used — this schema now targets
SQLite only, but there's no reason to hand-tie it to a Postgres-only type where a portable one
works identically. See models/base.py for why timestamps need a custom type at all.
""" """
from datetime import datetime from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
from sqlalchemy import ARRAY, ForeignKey, LargeBinary, String, Text from sqlalchemy import JSON, ForeignKey, LargeBinary, String, Text, Uuid
from sqlalchemy.dialects.postgresql import INET
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from velodrome.ids import new_id from velodrome.ids import new_id
from velodrome.models.base import Base from velodrome.models.base import Base
def _now_utc() -> datetime:
return datetime.now(UTC)
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id) id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id)
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False) email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(200), nullable=False) display_name: Mapped[str] = mapped_column(String(200), nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False) password_hash: Mapped[str] = mapped_column(Text, nullable=False)
@@ -32,7 +36,7 @@ class User(Base):
# Display-only, per CLAUDE.md invariant #3 — storage is always SI, this never touches a query. # 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") unit_system: Mapped[str] = mapped_column(String(10), nullable=False, default="imperial")
is_active: Mapped[bool] = mapped_column(nullable=False, default=True) is_active: Mapped[bool] = mapped_column(nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False) created_at: Mapped[datetime] = mapped_column(nullable=False, default=_now_utc)
sessions: Mapped[list["Session"]] = relationship(back_populates="user") sessions: Mapped[list["Session"]] = relationship(back_populates="user")
api_tokens: Mapped[list["ApiToken"]] = relationship(back_populates="user") api_tokens: Mapped[list["ApiToken"]] = relationship(back_populates="user")
@@ -41,12 +45,12 @@ class User(Base):
class Invite(Base): class Invite(Base):
__tablename__ = "invites" __tablename__ = "invites"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id) id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id)
# sha256 digest of the invite code. The code itself is never stored anywhere — see # sha256 digest of the invite code. The code itself is never stored anywhere — see
# auth/service.py. 32 bytes for sha256. # auth/service.py. 32 bytes for sha256.
code_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False) code_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False)
created_by: Mapped[UUID] = mapped_column( created_by: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id"), nullable=False Uuid(as_uuid=True), ForeignKey("users.id"), nullable=False
) )
email: Mapped[str | None] = mapped_column(String(320), nullable=True) email: Mapped[str | None] = mapped_column(String(320), nullable=True)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member") role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
@@ -59,9 +63,9 @@ class Invite(Base):
class Session(Base): class Session(Base):
__tablename__ = "sessions" __tablename__ = "sessions"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id) id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column( user_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), Uuid(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True, index=True,
@@ -71,9 +75,11 @@ class Session(Base):
token_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False) token_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False)
client: Mapped[str] = mapped_column(String(20), nullable=False, default="web") client: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True) user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
ip: Mapped[str | None] = mapped_column(INET, nullable=True) # Plain string, not a native INET type — SQLite has no such type, and app code never queries
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False) # or indexes on structure within the address, only stores/displays it.
last_seen_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False) ip: Mapped[str | None] = mapped_column(String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(nullable=False, default=_now_utc)
last_seen_at: Mapped[datetime] = mapped_column(nullable=False, default=_now_utc)
expires_at: Mapped[datetime] = mapped_column(nullable=False) expires_at: Mapped[datetime] = mapped_column(nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(nullable=True) revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
@@ -83,16 +89,18 @@ class Session(Base):
class ApiToken(Base): class ApiToken(Base):
__tablename__ = "api_tokens" __tablename__ = "api_tokens"
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id) id: Mapped[UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=new_id)
user_id: Mapped[UUID] = mapped_column( user_id: Mapped[UUID] = mapped_column(
PGUUID(as_uuid=True), Uuid(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"), ForeignKey("users.id", ondelete="CASCADE"),
nullable=False, nullable=False,
index=True, index=True,
) )
name: Mapped[str] = mapped_column(String(200), nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False)
token_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, 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) # JSON, not ARRAY(String) — SQLite has no array type. Stored as a JSON-encoded TEXT column;
# SQLAlchemy handles the (de)serialization transparently.
scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True) last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
expires_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) revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
+63 -4
View File
@@ -38,7 +38,7 @@ zero-touch *and* higher fidelity than the current Strava route.
**Fallbacks, both built:** USB watch folder (also the historical-backfill mechanism, so it stays **Fallbacks, both built:** USB watch folder (also the historical-backfill mechanism, so it stays
exercised rather than bit-rotting) and manual upload. exercised rather than bit-rotting) and manual upload.
### D4 — Python / FastAPI / Postgres+PostGIS ### D4 — Python / FastAPI / Postgres+PostGIS — **database choice superseded by D15**
**Chosen:** Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0 async, Alembic, PostgreSQL 16 + PostGIS 3.4. **Chosen:** Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0 async, Alembic, PostgreSQL 16 + PostGIS 3.4.
**Rejected:** TypeScript full-stack, Go. **Rejected:** TypeScript full-stack, Go.
**Why:** `fitdecode` is Python-only, and the Bryton poller reference implementation is Python **Why:** `fitdecode` is Python-only, and the Bryton poller reference implementation is Python
@@ -47,7 +47,13 @@ looks in JS). FastAPI emits OpenAPI 3.1 for free. PostGIS is needed for heatmaps
self-segment matching. Go has the best raw performance and a fine FIT library but the weakest self-segment matching. Go has the best raw performance and a fine FIT library but the weakest
data-analysis ecosystem for the later analytics work. data-analysis ecosystem for the later analytics work.
### D5 — procrastinate for background jobs The Python/FastAPI half of this still stands. **The database half — Postgres+PostGIS — was
replaced with SQLite in D15**, after Phase 0 was already built and merged against Postgres. Kept
here, marked superseded rather than deleted, so the PostGIS-specific reasoning (heatmaps, bbox
queries, segment matching) is still visible as context for whatever Phase 3 ends up doing about
spatial storage without it.
### D5 — procrastinate for background jobs — **needs a replacement, see D15**
**Chosen:** `procrastinate` (Postgres-backed queue). **Chosen:** `procrastinate` (Postgres-backed queue).
**Rejected:** Celery (needs Redis/RabbitMQ, heavyweight, weak async, second-class Postgres broker), **Rejected:** Celery (needs Redis/RabbitMQ, heavyweight, weak async, second-class Postgres broker),
arq (Redis-only — a whole container for ~50 tasks/day), APScheduler (a scheduler, not a durable arq (Redis-only — a whole container for ~50 tasks/day), APScheduler (a scheduler, not a durable
@@ -57,6 +63,13 @@ enqueue commit atomically on one connection, so there are no orphaned blobs and
rolled-back rows. That's impossible with a Redis broker without inventing an outbox. It also has rolled-back rows. That's impossible with a Redis broker without inventing an outbox. It also has
built-in cron, which removes the scheduler container, and keeps job state inside the same `pg_dump`. built-in cron, which removes the scheduler container, and keeps job state inside the same `pg_dump`.
`procrastinate` is Postgres-only — no SQLite backend exists, so **D15's move to SQLite invalidates
this choice**. Nothing consumes a job queue yet (no ingestion pipeline exists), so this is a
deferred decision, not an urgent one: whatever replaces it (APScheduler for a *scheduler*, or a
hand-rolled `SELECT ... WHERE claimed_at IS NULL LIMIT 1` polling table for real job durability —
SQLite's single-writer model makes even a crude polling table viable at this app's scale) needs
picking before Phase 1's ingestion pipeline, not before.
### D6 — Opaque bearer tokens, no JWT ### D6 — Opaque bearer tokens, no JWT
**Chosen:** Argon2id passwords + opaque tokens in a `sessions` table, HttpOnly cookie for the PWA. **Chosen:** Argon2id passwords + opaque tokens in a `sessions` table, HttpOnly cookie for the PWA.
**Rejected:** JWT. **Rejected:** JWT.
@@ -82,6 +95,13 @@ downstream number self-corrects. Parts moving between bikes is two rows. A store
neither without a reconciliation nightmare — which is exactly where FitTrackee's flat equipment tag neither without a reconciliation nightmare — which is exactly where FitTrackee's flat equipment tag
falls over in year two. falls over in year two.
The time-ranged-association *model* doesn't depend on Postgres. The `EXCLUDE USING gist` constraint
enforcing "a component is in exactly one place at a time" does — SQLite has no range types and no
exclusion constraints. `component_installs` doesn't exist yet (Phase 2), so this is another
deferred casualty of D15, not an active one: the same invariant will need enforcing at the
application layer (check-then-insert inside a transaction) instead of the database refusing an
overlapping row outright.
### D9 — Streams as columnar int32 arrays ### D9 — Streams as columnar int32 arrays
**Chosen:** one row per channel per activity, `values_i32[]` with a scale factor. **Chosen:** one row per channel per activity, `values_i32[]` with a scale factor.
**Rejected:** a normalized per-sample table (~5x larger with index, and every real query wants the **Rejected:** a normalized per-sample table (~5x larger with index, and every real query wants the
@@ -123,6 +143,44 @@ provide) — now every artefact builds on the same runner.
reverse-engineering, synthesis-heavy); Sonnet for the two breadth surveys (existing self-hosted reverse-engineering, synthesis-heavy); Sonnet for the two breadth surveys (existing self-hosted
apps, Gitea CI patterns) where material is well-documented and the work is volume. apps, Gitea CI patterns) where material is well-documented and the work is volume.
### D15 — SQLite, single container, no database-level RLS
**Chosen:** SQLite as the database, and the whole app (Caddy + API, static web build baked in) as
a single container. Made explicitly *after* Phase 0 was already built, tested, and merged against
Postgres+PostGIS with a two-role RLS architecture (D4, and the `velodrome_app`/`velodrome_auth`
split in `apps/api/velodrome/db.py`) — this is a deliberate reversal of a shipped decision, not a
greenfield choice, and it was made with the costs stated plainly first.
**Rejected, with reasons on the record:** keeping Postgres as a second container (rejected —
explicitly wanted exactly one container total); bundling Postgres+PostGIS *inside* the single
container via a process supervisor (offered as the way to get "one container" without losing RLS
or PostGIS — rejected in favour of SQLite specifically).
**What this costs, stated once here rather than re-litigated every time it's felt:**
- **Row-level security is gone.** SQLite has no roles, no session variables, no policy engine —
there is no database-enforced layer left, only the repository-layer scope. CLAUDE.md's
invariant #4 is revised accordingly (see the file) to describe app-layer scoping as the sole
mechanism rather than one of two layers. The user isolation test in `tests/test_auth.py` that
used to prove RLS itself now proves the repository-layer scope does the same job in its
absence — read it before touching any query that filters by `user_id`.
- **PostGIS is gone.** No native geometry columns, no GIST spatial indexes, no `ST_Envelope`.
Nothing in the schema uses it yet (Phase 0 has no `activities` table), so this is a live
decision for Phase 1/3 to make, not a retrofit — options include SpatiaLite, or storing tracks
as GeoJSON/WKB in a `TEXT`/`BLOB` column with spatial math done in application code.
- **`procrastinate` is gone** (D5) — Postgres-only, no SQLite backend. Also nothing consumes it
yet; a replacement gets picked before Phase 1's ingestion pipeline needs one, not now.
- **The `EXCLUDE USING gist` constraint design for `component_installs`** (D8) — doesn't exist
yet either (Phase 2); the "one place at a time" invariant will need application-layer
enforcement instead of the database refusing an overlapping row outright.
**Why proceed anyway:** raised as a concern in-session, with each cost above stated before this
decision was made; the user heard the full list and confirmed SQLite regardless. That's their call
to make about their own single-user/family-scale instance, not an oversight to correct for them.
**What's unchanged:** invariants #1 (raw bytes immutable), #3 (SI integers in storage), #5 (secret
containment), #6 (single ingestion path) — none of those were ever Postgres-specific. Auth design
(D6, opaque bearer tokens) is unaffected. FastAPI/SQLAlchemy/Alembic stay exactly as chosen in D4;
only the database engine underneath them changed.
--- ---
## Deliberately deferred ## Deliberately deferred
@@ -130,7 +188,8 @@ apps, Gitea CI patterns) where material is well-documented and the work is volum
- **Routing** (Valhalla/Photon/Overpass) — Phase 5, optional. Several GB of RAM for something - **Routing** (Valhalla/Photon/Overpass) — Phase 5, optional. Several GB of RAM for something
Komoot already does well. Komoot already does well.
- **Local LLM ride summaries** (Ollama) — Phase 4, behind a compose profile. - **Local LLM ride summaries** (Ollama) — Phase 4, behind a compose profile.
- **Friends/family cross-visibility** — Phase 4, as an *additive* widened RLS policy, never as - **Friends/family cross-visibility** — Phase 4, as an *additive* widening of the repository-layer
removal of the default scope. scope (an RLS policy pre-D15; see D15 for why that's no longer the mechanism), never as removal
of the default per-user scope.
- **Legacy Bryton format support** — out of scope entirely. The Rider 650 writes `.fit`. - **Legacy Bryton format support** — out of scope entirely. The Rider 650 writes `.fit`.
- **Reverse-engineering Bryton's BLE** — explicitly rejected. See RESEARCH.md §1. - **Reverse-engineering Bryton's BLE** — explicitly rejected. See RESEARCH.md §1.
+33 -15
View File
@@ -61,8 +61,8 @@ can pull rides off the head unit. Explicitly out of scope.
| Layer | Choice | Why | | Layer | Choice | Why |
|---|---|---| |---|---|---|
| API | Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0 async, Alembic | `fitdecode` is Python-only, so Python is forced; FastAPI emits OpenAPI 3.1 free | | API | Python 3.12, FastAPI, Pydantic v2, SQLAlchemy 2.0 async, Alembic | `fitdecode` is Python-only, so Python is forced; FastAPI emits OpenAPI 3.1 free |
| DB | PostgreSQL 16 + PostGIS 3.4 (`postgis/postgis:16-3.4`) | Needed for heatmaps, bbox queries, self-segment matching | | DB | **SQLite** (single file, inside the app container) | Chosen over Postgres+PostGIS specifically to keep the whole deploy to one container — see `docs/DECISIONS.md` D15 for the full reasoning and what it costs (no database-level RLS, no PostGIS, `procrastinate` needs replacing) |
| Jobs | **procrastinate** (Postgres-backed queue) | Transactional enqueue; no Redis; built-in cron and retries | | Jobs | **TBD before Phase 1**`procrastinate` no longer fits (Postgres-only) | Nothing enqueues a job yet; pick this when the ingestion pipeline actually needs it, not before |
| Frontend | SvelteKit `adapter-static` **SPA + PWA** — no Node process in prod | Static files served by Caddy; enforces API-first by construction | | Frontend | SvelteKit `adapter-static` **SPA + PWA** — no Node process in prod | Static files served by Caddy; enforces API-first by construction |
| Maps | MapLibre GL JS from day one | Renders raster tiles now, self-hosted PMTiles vector later — a config change, not a rewrite | | Maps | MapLibre GL JS from day one | Renders raster tiles now, self-hosted PMTiles vector later — a config change, not a rewrite |
| FIT parsing | `fitdecode` | Thread-safe, preserves header+CRC, correct developer-field handling; `python-fitparse`'s own maintainers point here | | FIT parsing | `fitdecode` | Thread-safe, preserves header+CRC, correct developer-field handling; `python-fitparse`'s own maintainers point here |
@@ -106,27 +106,45 @@ code-generation exercise against a stable contract rather than a rewrite.
## Service topology ## Service topology
**v1 — 4 containers, under 2GB RAM total:** **Revised by D15 — single container**, not the multi-container compose topology this section
- `caddy` — serves the static SPA, proxies `/api/*`. Same-origin, so no CORS. Your existing reverse proxy terminates TLS in front. originally described. Caddy and the FastAPI app run together in one image via a lightweight
- `api` — uvicorn/FastAPI. Runs Alembic on entrypoint. process supervisor; SQLite is a file inside the same container's persistent volume, not a
- `worker` — same image, `procrastinate worker`. Owns periodic tasks too, so no separate scheduler. separate service. See `docs/DECISIONS.md` D15 for why, and `deploy/`'s own README for the actual
- `db` — postgis/postgis:16-3.4. supervisor config once it exists.
Volumes: `pgdata`, `blobstore` (content-addressed raw FIT + attachments), `import_inbox` (USB watch folder bind-mount). Volumes: one persistent volume holding the SQLite file, `blobstore` (content-addressed raw FIT +
attachments), and `import_inbox` (USB watch folder bind-mount).
**Phase 2 adds zero containers** (poller is a periodic task; Open-Meteo is a public API). **Later phases that would have been "add a container" under the old topology**`tileserver`,
**Phase 3 adds two:** `tileserver` (tileserver-gl-light + regional PMTiles, ~200MB1GB) and `topodata` (Open Topo Data + region-clipped SRTM). `topodata`, `ollama`, `grafana`, `valhalla`/`photon`/`overpass` — still make sense as genuinely
**Phase 4+ behind opt-in compose profiles:** `ollama`, `grafana`, and optionally `valhalla`/`photon`/`overpass`. separate containers even under a single-container-for-the-app model (they're independent services
with their own resource profiles, not part of "the app"). Whether the app container talks to them
over a shared Docker network or they stay fully optional add-ons is a decision for whichever phase
actually needs the first one — not resolved here speculatively.
**Explicitly NOT on day one:** Valhalla, Photon, Overpass, Nominatim (>1TB/128GB RAM — never), Ollama, Redis, MinIO, Grafana, a separate scheduler. **Explicitly NOT on day one:** Valhalla, Photon, Overpass, Nominatim (>1TB/128GB RAM — never), Ollama, Redis, MinIO, Grafana.
--- ---
## Schema ## Schema
Conventions: UUIDv7 PKs (time-ordered, URL-safe, client-generatable). All `timestamptz` UTC. **All **Note on what's below vs. what's actually built:** the Identity tables (§ Tables > Identity) are
physical quantities as SI integers** — distance in metres, time in seconds, speed in mm/s, altitude real, built, and running on SQLite — `apps/api/velodrome/models/identity.py` and
in cm, money in minor units. Units are a presentation concern. `apps/api/alembic/versions/0001_baseline.py` are the source of truth for those, not this
prose. Everything past Identity (activities, streams, bikes/components, service rules,
notifications, weather) was designed against Postgres/PostGIS conventions — `geometry(...)`
columns, `ARRAY`, `INET`, GIST indexes, RLS policies — before D15 moved the database to SQLite.
None of it is built yet, so none of it is broken; it just needs a real pass for SQLite
compatibility (TEXT/BLOB for geometry, JSON-encoded TEXT for arrays, plain TEXT for IP addresses,
application-layer exclusion checks instead of `EXCLUDE USING gist`) when each phase actually
builds it, informed by whatever's learned finishing the SQLite migration on Identity first —
not a mechanical find-and-replace on speculative schema now.
Conventions: UUIDv7 PKs (time-ordered, URL-safe, client-generatable). All timestamps UTC (SQLite
has no native timezone-aware timestamp type — see `apps/api/velodrome/models/base.py` for how
Identity stores them; the same convention applies going forward). **All physical quantities as SI
integers** — distance in metres, time in seconds, speed in mm/s, altitude in cm, money in minor
units. Units are a presentation concern.
### Two architectural rules that everything else depends on ### Two architectural rules that everything else depends on