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
+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
already established who the caller is uses this, wrapped in `scoped_session()` below, which sets
`app.user_id` for the transaction so RLS policies can key off it.
Phase 0 originally ran two Postgres roles with row-level security as a database-enforced
isolation layer (`velodrome_app`/`velodrome_auth` — see docs/DECISIONS.md D4). D15 moved the
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
for the narrow set of queries that must run *before* identity is known — looking a user up by
email at login, a session up by its hashed token, an invite up by its hashed code — plus the
inserts that create those rows in the first place. Nothing outside auth/service.py should import
this engine; if you find yourself reaching for it elsewhere, the query almost certainly belongs
in a repository method on the scoped session instead (see CLAUDE.md invariant #4).
Two access patterns:
- `scoped_session(user_id)` — for every query against a user-owned table once identity is known.
Yields a `Scope`, whose `select()` is the ONLY way to build a query through it — every query it
builds is pre-filtered to that user_id, on any model that declares a `user_id` column. There is
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 contextlib import asynccontextmanager
from typing import Any
from uuid import UUID
from sqlalchemy import text
from sqlalchemy import Select, event, select
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import NullPool
from velodrome.config import get_settings
def _make_engine(url: str) -> AsyncEngine:
settings = get_settings()
# NullPool in dev/test keeps behaviour predictable across the pytest-asyncio event loop;
# production tuning (pool_size etc.) is a deploy-time concern, not a Phase-0 one.
return create_async_engine(
url,
poolclass=NullPool if settings.environment == "test" else None,
echo=False,
)
def _configure_sqlite_for_concurrent_writers(eng: AsyncEngine) -> None:
"""Two SQLite defaults that silently do the wrong thing if left alone — confirmed empirically
against a real aiosqlite connection, not assumed from docs:
1. Foreign key enforcement, `ON DELETE CASCADE` included, is OFF by default per connection.
Without `PRAGMA foreign_keys=ON`, deleting a user leaves its sessions/api_tokens rows
behind instead of cascading — verified this happens silently, no error either way.
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
_auth_engine: AsyncEngine | None = None
_engine: AsyncEngine | None = None
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
def app_engine() -> AsyncEngine:
global _app_engine
if _app_engine is None:
_app_engine = _make_engine(str(get_settings().database_url_app))
return _app_engine
def _engine_instance() -> AsyncEngine:
global _engine
if _engine is None:
settings = get_settings()
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:
global _auth_engine
if _auth_engine is None:
_auth_engine = _make_engine(str(get_settings().database_url_auth))
return _auth_engine
_app_sessionmaker: async_sessionmaker[AsyncSession] | None = None
_auth_sessionmaker: async_sessionmaker[AsyncSession] | None = None
def _app_sessions() -> async_sessionmaker[AsyncSession]:
global _app_sessionmaker
if _app_sessionmaker is None:
_app_sessionmaker = async_sessionmaker(app_engine(), expire_on_commit=False)
return _app_sessionmaker
def _auth_sessions() -> async_sessionmaker[AsyncSession]:
global _auth_sessionmaker
if _auth_sessionmaker is None:
_auth_sessionmaker = async_sessionmaker(auth_engine(), expire_on_commit=False)
return _auth_sessionmaker
def _sessions() -> async_sessionmaker[AsyncSession]:
global _sessionmaker
if _sessionmaker is None:
_sessionmaker = async_sessionmaker(_engine_instance(), expire_on_commit=False)
return _sessionmaker
@asynccontextmanager
async def auth_session() -> AsyncIterator[AsyncSession]:
"""A BYPASSRLS session. See module docstring — auth/service.py only."""
async with _auth_sessions()() as session:
"""See module docstring — auth/service.py only."""
async with _sessions()() as session:
yield session
@asynccontextmanager
async def scoped_session(user_id: UUID) -> AsyncIterator[AsyncSession]:
"""An RLS-scoped session for a known, authenticated user.
`SET LOCAL` binds to the current transaction, not the connection, so this is safe under
connection pooling — it can never leak `app.user_id` from one request into a pooled
connection reused by a different request.
"""
async with _app_sessions()() as session:
async with session.begin():
await session.execute(
# bound parameter, not string interpolation — user_id is a UUID we generated
# or validated ourselves, but there is no reason to ever risk it.
text("SELECT set_config('app.user_id', :uid, true)"),
{"uid": str(user_id)},
)
yield session
@asynccontextmanager
async def unscoped_session() -> AsyncIterator[AsyncSession]:
"""An `app`-role session with no `app.user_id` set.
RLS policies default-deny when `current_setting('app.user_id', true)` is NULL, so this sees
zero rows of any user-owned table — useful for health checks and anything that only touches
non-RLS tables. Prefer `scoped_session` whenever a user is known.
"""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
for this instead of that for a query against a user-owned table is exactly the mistake
invariant #4 exists to catch in review.
"""
async with _app_sessions()() as session:
async with _sessions()() as 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)