Phase 0's API half: a working FastAPI app with register/login/me/logout, backed by a Postgres schema where row-level security is real and independently proven, not just declared. The core design decision, and the reason this lands as one PR instead of several: request-scoped queries run as `velodrome_app` (NOBYPASSRLS), but looking up identity in the first place — login by email, a session by its token hash — has to happen *before* app.user_id can be set, so those specific lookups run as a second role, `velodrome_auth` (BYPASSRLS), used nowhere else in the codebase. See velodrome/db.py's module docstring and apps/api/README.md for the full rationale. This is genuinely one reviewable unit: the migration, the models, and the auth service only make sense evaluated together, since they're three views of the same invariant. tests/test_auth.py::test_rls_blocks_cross_user_session_reads is the test worth reading first — it doesn't trust the RLS policy SQL because it reads correctly, it proves isolation by registering two users and confirming a scoped read of `sessions` for user A returns exactly one row, never two. Bugs found and fixed while actually running this against real Postgres (everything below was verified against a live postgis/postgis:16-3.4 container and a built Docker image, not just read for correctness): - CREATE ROLE's PASSWORD clause is DDL, not DML — it doesn't accept bind parameters (`PASSWORD $1` is a syntax error). Fixed with dollar-quoting. - Postgres roles are cluster-wide, not per-database — a second database in the same cluster hit "role already exists" on a plain CREATE ROLE. Fixed with a DO block catching duplicate_object. - A bare `Mapped[datetime]` on the ORM models infers a naive timestamp, silently disagreeing with the migration's correct `DateTime(timezone=True)` — asyncpg rejected the mismatch at insert time. Fixed once, at the declarative Base level via type_annotation_map, rather than per-column. - The session cookie's `secure` flag was gated on `!= "development"`, so anything else — including local testing and a real deploy running temporarily without TLS in front — got a Secure cookie no HTTP client will ever send back, breaking every authenticated request after login with no visible error. Gated on `== "production"` instead. - `alembic check` initially flagged every PostGIS/TIGER-installed table (dozens of them) as drift, because they're not in our metadata. A schema-based denylist doesn't work — reflected foreign tables come back with schema=None regardless of their real schema. Fixed with an allowlist keyed on target_metadata.tables instead, which is also more robust against future PostGIS versions adding more tables. - The migration itself was missing `nullable=False` on three timestamp columns that the ORM model assumed were never null — a genuine model/migration drift that alembic check caught once the PostGIS noise above was filtered out. Fixed in 0001 directly, since it's never shipped. - Two indexes the migration creates explicitly weren't declared on the ORM models, causing the same kind of drift. Added index=True to match. Deliberately deferred, not forgotten: per-IP/per-account login rate limiting (docs/PLAN.md mentions it; Phase 0's bar is a working skeleton, and this needs its own design pass) and the procrastinate job runner / worker container (nothing to run yet — arrives with the ingestion pipeline). ci.yml updated to match: the api and migrations jobs now provision the same two runtime roles this code actually needs, replacing the single placeholder DATABASE_URL from before any code existed. Verified: ruff check, ruff format --check, and mypy --strict all clean. 12/12 pytest passing against a real Postgres. Full alembic upgrade -> downgrade -1 -> upgrade cycle run twice (once standalone, once inside a two-database cluster to specifically catch the role-collision bug). alembic check clean. Docker image builds and serves real traffic — register and an authenticated GET /me both exercised against the actual built container, not just the test suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
276 lines
9.3 KiB
Python
276 lines
9.3 KiB
Python
"""Auth endpoint tests, plus — the one that matters most — an empirical proof of RLS isolation.
|
|
|
|
Per CLAUDE.md: 'auth/, RLS policies — security, and a mistake exposes another user's data.' The
|
|
whole point of test_rls_blocks_cross_user_session_reads below is that it doesn't trust the SQL in
|
|
the migration is correct because it reads correctly — it proves it by actually trying to read
|
|
another user's row through the RLS-scoped role and confirming zero rows come back.
|
|
"""
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from uuid import UUID
|
|
|
|
import httpx
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from velodrome.auth.security import hash_invite_code, hash_password
|
|
from velodrome.db import scoped_session, unscoped_session
|
|
|
|
_REGISTER_PASSWORD = "correct horse battery staple"
|
|
|
|
|
|
async def _seed_invite(db_auth: AsyncSession, *, code: str = "TESTCODE123") -> UUID:
|
|
"""Insert a usable invite directly, bypassing the API — this is fixture setup, not the
|
|
thing under test."""
|
|
creator_id = UUID(int=0)
|
|
await db_auth.execute(
|
|
text(
|
|
"INSERT INTO users (id, email, display_name, password_hash) "
|
|
"VALUES (:id, 'seed@example.com', 'Seed', :ph) ON CONFLICT DO NOTHING"
|
|
),
|
|
{"id": str(creator_id), "ph": hash_password("unused")},
|
|
)
|
|
await db_auth.execute(
|
|
text(
|
|
"INSERT INTO invites (id, code_hash, created_by, expires_at, max_uses, used_count) "
|
|
"VALUES (gen_random_uuid(), :hash, :creator, :expires, 1, 0)"
|
|
),
|
|
{
|
|
"hash": hash_invite_code(code),
|
|
"creator": str(creator_id),
|
|
"expires": datetime.now(UTC) + timedelta(days=1),
|
|
},
|
|
)
|
|
await db_auth.commit()
|
|
return creator_id
|
|
|
|
|
|
async def test_register_with_valid_invite_creates_session(
|
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
|
) -> None:
|
|
await _seed_invite(db_auth)
|
|
resp = await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "rider@example.com",
|
|
"password": _REGISTER_PASSWORD,
|
|
"display_name": "Rider",
|
|
"invite_code": "TESTCODE123",
|
|
},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
assert resp.json()["email"] == "rider@example.com"
|
|
assert "vd_session" in resp.cookies
|
|
|
|
|
|
async def test_register_rejects_unknown_invite_code(client: httpx.AsyncClient) -> None:
|
|
resp = await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "rider@example.com",
|
|
"password": _REGISTER_PASSWORD,
|
|
"display_name": "Rider",
|
|
"invite_code": "no-such-code",
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
async def test_register_rejects_reused_invite(
|
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
|
) -> None:
|
|
await _seed_invite(db_auth)
|
|
body = {
|
|
"password": _REGISTER_PASSWORD,
|
|
"display_name": "Rider",
|
|
"invite_code": "TESTCODE123",
|
|
}
|
|
first = await client.post("/api/v1/auth/register", json={**body, "email": "first@example.com"})
|
|
assert first.status_code == 201
|
|
|
|
second = await client.post(
|
|
"/api/v1/auth/register", json={**body, "email": "second@example.com"}
|
|
)
|
|
assert second.status_code == 400
|
|
|
|
|
|
async def test_login_with_correct_password_succeeds(
|
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
|
) -> None:
|
|
await _seed_invite(db_auth)
|
|
await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "rider@example.com",
|
|
"password": _REGISTER_PASSWORD,
|
|
"display_name": "Rider",
|
|
"invite_code": "TESTCODE123",
|
|
},
|
|
)
|
|
resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": "rider@example.com", "password": _REGISTER_PASSWORD},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "vd_session" in resp.cookies
|
|
|
|
|
|
async def test_login_with_wrong_password_fails(
|
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
|
) -> None:
|
|
await _seed_invite(db_auth)
|
|
await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "rider@example.com",
|
|
"password": _REGISTER_PASSWORD,
|
|
"display_name": "Rider",
|
|
"invite_code": "TESTCODE123",
|
|
},
|
|
)
|
|
resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
json={"email": "rider@example.com", "password": "wrong password entirely"},
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_me_requires_authentication(client: httpx.AsyncClient) -> None:
|
|
resp = await client.get("/api/v1/auth/me")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
async def test_me_returns_current_user_with_valid_session(
|
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
|
) -> None:
|
|
await _seed_invite(db_auth)
|
|
await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "rider@example.com",
|
|
"password": _REGISTER_PASSWORD,
|
|
"display_name": "Rider",
|
|
"invite_code": "TESTCODE123",
|
|
},
|
|
)
|
|
resp = await client.get("/api/v1/auth/me")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["email"] == "rider@example.com"
|
|
|
|
|
|
async def test_logout_revokes_the_session(client: httpx.AsyncClient, db_auth: AsyncSession) -> None:
|
|
await _seed_invite(db_auth)
|
|
await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "rider@example.com",
|
|
"password": _REGISTER_PASSWORD,
|
|
"display_name": "Rider",
|
|
"invite_code": "TESTCODE123",
|
|
},
|
|
)
|
|
logout_resp = await client.post(
|
|
"/api/v1/auth/logout", headers={"Origin": "http://localhost:5173"}
|
|
)
|
|
assert logout_resp.status_code == 204
|
|
|
|
me_resp = await client.get("/api/v1/auth/me")
|
|
assert me_resp.status_code == 401
|
|
|
|
|
|
async def test_logout_without_matching_origin_is_rejected(
|
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
|
) -> None:
|
|
await _seed_invite(db_auth)
|
|
await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "rider@example.com",
|
|
"password": _REGISTER_PASSWORD,
|
|
"display_name": "Rider",
|
|
"invite_code": "TESTCODE123",
|
|
},
|
|
)
|
|
resp = await client.post("/api/v1/auth/logout", headers={"Origin": "https://evil.example"})
|
|
assert resp.status_code == 403
|
|
|
|
|
|
async def test_rls_blocks_cross_user_session_reads(
|
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
|
) -> None:
|
|
"""The load-bearing test: prove RLS actually enforces isolation, not just that the migration
|
|
ran without a syntax error.
|
|
|
|
Two users register (creating two session rows). We then open a `scoped_session` as user A —
|
|
the exact code path every real request handler will use once Phase 1 adds user-owned domain
|
|
tables — and confirm the raw SQL result set contains ONLY user A's session, even though it
|
|
runs no WHERE clause on user_id at all. If this test ever passes with more than one row, RLS
|
|
is not doing its job and every other invariant in this codebase is resting on nothing.
|
|
"""
|
|
await _seed_invite(db_auth, code="CODE-FOR-A")
|
|
await _seed_invite(db_auth, code="CODE-FOR-B")
|
|
|
|
resp_a = await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "user-a@example.com",
|
|
"password": _REGISTER_PASSWORD,
|
|
"display_name": "User A",
|
|
"invite_code": "CODE-FOR-A",
|
|
},
|
|
)
|
|
user_a_id = UUID(resp_a.json()["id"])
|
|
|
|
resp_b = await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "user-b@example.com",
|
|
"password": _REGISTER_PASSWORD,
|
|
"display_name": "User B",
|
|
"invite_code": "CODE-FOR-B",
|
|
},
|
|
)
|
|
user_b_id = UUID(resp_b.json()["id"])
|
|
assert user_a_id != user_b_id
|
|
|
|
# Sanity check first: BOTH sessions genuinely exist, via the bypass role.
|
|
total = (await db_auth.execute(text("SELECT count(*) FROM sessions"))).scalar_one()
|
|
assert total == 2
|
|
|
|
# Now the real test: as user A, scoped through the RLS-subject role, with NO WHERE clause.
|
|
async with scoped_session(user_a_id) as scoped_db:
|
|
rows = (await scoped_db.execute(text("SELECT user_id FROM sessions"))).scalars().all()
|
|
|
|
assert len(rows) == 1, (
|
|
f"expected exactly 1 row (user A's own session) via RLS, got {len(rows)} — "
|
|
"RLS is not isolating users"
|
|
)
|
|
assert UUID(str(rows[0])) == user_a_id
|
|
|
|
# And the mirror image, as user B, proving this isn't a coincidence of row ordering.
|
|
async with scoped_session(user_b_id) as scoped_db:
|
|
rows_b = (await scoped_db.execute(text("SELECT user_id FROM sessions"))).scalars().all()
|
|
assert len(rows_b) == 1
|
|
assert UUID(str(rows_b[0])) == user_b_id
|
|
|
|
|
|
async def test_unscoped_session_sees_zero_rows_of_user_owned_tables(
|
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
|
) -> None:
|
|
"""The default-deny half of the same proof: with app.user_id unset entirely (the state the
|
|
health check and any other unauthenticated code path runs in), RLS denies everything."""
|
|
await _seed_invite(db_auth)
|
|
await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "rider@example.com",
|
|
"password": _REGISTER_PASSWORD,
|
|
"display_name": "Rider",
|
|
"invite_code": "TESTCODE123",
|
|
},
|
|
)
|
|
|
|
async with unscoped_session() as db:
|
|
rows = (await db.execute(text("SELECT * FROM sessions"))).all()
|
|
assert rows == []
|