feat(api): FastAPI skeleton with two-role RLS auth foundation
Phase 0's API half: a working FastAPI app with register/login/me/logout, backed by a Postgres schema where row-level security is real and independently proven, not just declared. The core design decision, and the reason this lands as one PR instead of several: request-scoped queries run as `velodrome_app` (NOBYPASSRLS), but looking up identity in the first place — login by email, a session by its token hash — has to happen *before* app.user_id can be set, so those specific lookups run as a second role, `velodrome_auth` (BYPASSRLS), used nowhere else in the codebase. See velodrome/db.py's module docstring and apps/api/README.md for the full rationale. This is genuinely one reviewable unit: the migration, the models, and the auth service only make sense evaluated together, since they're three views of the same invariant. tests/test_auth.py::test_rls_blocks_cross_user_session_reads is the test worth reading first — it doesn't trust the RLS policy SQL because it reads correctly, it proves isolation by registering two users and confirming a scoped read of `sessions` for user A returns exactly one row, never two. Bugs found and fixed while actually running this against real Postgres (everything below was verified against a live postgis/postgis:16-3.4 container and a built Docker image, not just read for correctness): - CREATE ROLE's PASSWORD clause is DDL, not DML — it doesn't accept bind parameters (`PASSWORD $1` is a syntax error). Fixed with dollar-quoting. - Postgres roles are cluster-wide, not per-database — a second database in the same cluster hit "role already exists" on a plain CREATE ROLE. Fixed with a DO block catching duplicate_object. - A bare `Mapped[datetime]` on the ORM models infers a naive timestamp, silently disagreeing with the migration's correct `DateTime(timezone=True)` — asyncpg rejected the mismatch at insert time. Fixed once, at the declarative Base level via type_annotation_map, rather than per-column. - The session cookie's `secure` flag was gated on `!= "development"`, so anything else — including local testing and a real deploy running temporarily without TLS in front — got a Secure cookie no HTTP client will ever send back, breaking every authenticated request after login with no visible error. Gated on `== "production"` instead. - `alembic check` initially flagged every PostGIS/TIGER-installed table (dozens of them) as drift, because they're not in our metadata. A schema-based denylist doesn't work — reflected foreign tables come back with schema=None regardless of their real schema. Fixed with an allowlist keyed on target_metadata.tables instead, which is also more robust against future PostGIS versions adding more tables. - The migration itself was missing `nullable=False` on three timestamp columns that the ORM model assumed were never null — a genuine model/migration drift that alembic check caught once the PostGIS noise above was filtered out. Fixed in 0001 directly, since it's never shipped. - Two indexes the migration creates explicitly weren't declared on the ORM models, causing the same kind of drift. Added index=True to match. Deliberately deferred, not forgotten: per-IP/per-account login rate limiting (docs/PLAN.md mentions it; Phase 0's bar is a working skeleton, and this needs its own design pass) and the procrastinate job runner / worker container (nothing to run yet — arrives with the ingestion pipeline). ci.yml updated to match: the api and migrations jobs now provision the same two runtime roles this code actually needs, replacing the single placeholder DATABASE_URL from before any code existed. Verified: ruff check, ruff format --check, and mypy --strict all clean. 12/12 pytest passing against a real Postgres. Full alembic upgrade -> downgrade -1 -> upgrade cycle run twice (once standalone, once inside a two-database cluster to specifically catch the role-collision bug). alembic check clean. Docker image builds and serves real traffic — register and an authenticated GET /me both exercised against the actual built container, not just the test suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+15
-6
@@ -58,8 +58,10 @@ jobs:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgis/postgis:16-3.4
|
image: postgis/postgis:16-3.4
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: velodrome
|
# Superuser — this is the "owner" role migrations run as (see alembic/env.py); it's
|
||||||
POSTGRES_PASSWORD: velodrome
|
# what CREATEs the two runtime roles below, which is why it isn't one of them.
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: velodrome_test
|
POSTGRES_DB: velodrome_test
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
@@ -67,7 +69,12 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql+asyncpg://velodrome:velodrome@postgres:5432/velodrome_test
|
VELODROME_ENVIRONMENT: test
|
||||||
|
VELODROME_DATABASE_URL_MIGRATE: postgresql+asyncpg://postgres:postgres@postgres:5432/velodrome_test
|
||||||
|
VELODROME_DB_APP_PASSWORD: ci-only-app-password
|
||||||
|
VELODROME_DB_AUTH_PASSWORD: ci-only-auth-password
|
||||||
|
VELODROME_DATABASE_URL_APP: postgresql+asyncpg://velodrome_app:ci-only-app-password@postgres:5432/velodrome_test
|
||||||
|
VELODROME_DATABASE_URL_AUTH: postgresql+asyncpg://velodrome_auth:ci-only-auth-password@postgres:5432/velodrome_test
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -162,8 +169,8 @@ jobs:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgis/postgis:16-3.4
|
image: postgis/postgis:16-3.4
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: velodrome
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: velodrome
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: velodrome_mig
|
POSTGRES_DB: velodrome_mig
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
@@ -171,7 +178,9 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 10
|
--health-retries 10
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql+asyncpg://velodrome:velodrome@postgres:5432/velodrome_mig
|
VELODROME_DATABASE_URL_MIGRATE: postgresql+asyncpg://postgres:postgres@postgres:5432/velodrome_mig
|
||||||
|
VELODROME_DB_APP_PASSWORD: ci-only-app-password
|
||||||
|
VELODROME_DB_AUTH_PASSWORD: ci-only-auth-password
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir uv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
# Split into two syncs so dependency installation caches independently of source changes: this
|
||||||
|
# first one installs only dependencies (--no-install-project), so touching velodrome/ doesn't
|
||||||
|
# invalidate this layer.
|
||||||
|
RUN uv sync --frozen --no-install-project --no-dev
|
||||||
|
|
||||||
|
COPY velodrome ./velodrome
|
||||||
|
COPY alembic ./alembic
|
||||||
|
COPY alembic.ini ./
|
||||||
|
# Now install the project itself into the same venv.
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS runtime
|
||||||
|
|
||||||
|
RUN groupadd --system velodrome && useradd --system --gid velodrome --create-home velodrome
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
USER velodrome
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Migrations run as an explicit step before this in deploy.yml (see docs/PLAN.md) — never from
|
||||||
|
# the entrypoint, so a failed migration fails the deploy visibly instead of crash-looping here.
|
||||||
|
CMD ["uvicorn", "velodrome.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
+104
-1
@@ -1 +1,104 @@
|
|||||||
Python 3.12 / FastAPI / SQLAlchemy async / Alembic. Not yet scaffolded — Phase 0.
|
# apps/api
|
||||||
|
|
||||||
|
Python 3.12 / FastAPI / SQLAlchemy 2.0 async / Alembic. See `docs/PLAN.md` for the overall
|
||||||
|
design and `docs/DECISIONS.md` for why things are built this way.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
velodrome/
|
||||||
|
app.py FastAPI app factory
|
||||||
|
config.py Settings (env-driven, see below)
|
||||||
|
db.py Two database engines — read this first, it's the load-bearing module
|
||||||
|
ids.py UUIDv7 generation
|
||||||
|
models/ SQLAlchemy models
|
||||||
|
auth/ Password hashing, session service, FastAPI auth dependencies
|
||||||
|
api/v1/ Route handlers
|
||||||
|
schemas/ Pydantic request/response models
|
||||||
|
alembic/ Migrations. 0001_baseline.py creates the identity tables, the two
|
||||||
|
runtime DB roles, and their RLS policies — read its module docstring.
|
||||||
|
tests/ pytest, against a real Postgres, never mocked
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why two database connections
|
||||||
|
|
||||||
|
The app connects to Postgres as **two different roles**, not one — this is the single most
|
||||||
|
important thing to understand before touching `auth/` or `db.py`:
|
||||||
|
|
||||||
|
- **`velodrome_app`** — `NOBYPASSRLS`. Every request that already knows who's calling uses this,
|
||||||
|
via `db.scoped_session(user_id)`, which sets `app.user_id` for the transaction so Postgres RLS
|
||||||
|
policies can key off it.
|
||||||
|
- **`velodrome_auth`** — `BYPASSRLS`. Used *only* by `auth/service.py`, and only for the narrow
|
||||||
|
set of lookups that must happen *before* identity is known: login by email, a session by its
|
||||||
|
token hash, an invite by its code hash — plus the inserts that create those rows. Every one of
|
||||||
|
those queries is an exact match on a unique key, never an unfiltered scan, which is what makes
|
||||||
|
bypassing RLS safe there.
|
||||||
|
|
||||||
|
A third connection — `VELODROME_DATABASE_URL_MIGRATE` — is used only by Alembic. It needs enough
|
||||||
|
privilege to `CREATE ROLE` and `GRANT`, so in practice it's the Postgres superuser (or a
|
||||||
|
schema-owning role); the app itself never connects with it.
|
||||||
|
|
||||||
|
If you're adding a new table with per-user data: give it a `user_id` column, enable RLS on it in
|
||||||
|
a migration, and query it only through `scoped_session`. See CLAUDE.md's invariants.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Used by | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `VELODROME_DATABASE_URL_MIGRATE` | Alembic only | Superuser/owner DSN. Never used by the app itself. |
|
||||||
|
| `VELODROME_DB_APP_PASSWORD` | Alembic (creates the role) | No default — the migration fails loudly if unset. |
|
||||||
|
| `VELODROME_DB_AUTH_PASSWORD` | Alembic (creates the role) | Same. |
|
||||||
|
| `VELODROME_DATABASE_URL_APP` | The app | Connects as `velodrome_app`. |
|
||||||
|
| `VELODROME_DATABASE_URL_AUTH` | The app | Connects as `velodrome_auth`. |
|
||||||
|
| `VELODROME_SECRET_KEY` | The app | Not yet used (arrives with the Bryton credential encryption in a later phase); declared now so the settings shape is stable. |
|
||||||
|
| `VELODROME_ENVIRONMENT` | The app | `development` / `test` / `production`. Gates the session cookie's `Secure` flag — see the comment in `api/v1/auth.py` before changing this condition. |
|
||||||
|
| `VELODROME_PUBLIC_URL` | The app | Used for the CSRF `Origin` check on cookie-authenticated mutations. |
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --all-extras
|
||||||
|
|
||||||
|
# a throwaway Postgres
|
||||||
|
docker run -d --name velodrome-dev-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=velodrome \
|
||||||
|
-p 5432:5432 postgis/postgis:16-3.4
|
||||||
|
|
||||||
|
export VELODROME_DATABASE_URL_MIGRATE=postgresql+asyncpg://postgres:postgres@localhost:5432/velodrome
|
||||||
|
export VELODROME_DB_APP_PASSWORD=devpassword1
|
||||||
|
export VELODROME_DB_AUTH_PASSWORD=devpassword2
|
||||||
|
export VELODROME_DATABASE_URL_APP=postgresql+asyncpg://velodrome_app:devpassword1@localhost:5432/velodrome
|
||||||
|
export VELODROME_DATABASE_URL_AUTH=postgresql+asyncpg://velodrome_auth:devpassword2@localhost:5432/velodrome
|
||||||
|
|
||||||
|
uv run alembic upgrade head
|
||||||
|
uv run uvicorn velodrome.app:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /api/v1/healthz` should return `{"status": "ok"}`; `GET /api/v1/docs` has interactive
|
||||||
|
Swagger UI outside production.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ruff check . && uv run ruff format --check .
|
||||||
|
uv run mypy --strict velodrome
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests run against a real Postgres (a service container in CI, or point `tests/conftest.py`'s
|
||||||
|
defaults at your own) — never mocks for DB behaviour, per CLAUDE.md. The test suite runs the real
|
||||||
|
Alembic migration at session start, not a parallel schema-creation shortcut, so it's exercising
|
||||||
|
the exact same path a real deploy uses.
|
||||||
|
|
||||||
|
The test worth reading if you're new to this codebase is
|
||||||
|
`tests/test_auth.py::test_rls_blocks_cross_user_session_reads` — it doesn't trust that the RLS
|
||||||
|
policy SQL is correct because it reads correctly; it proves it by actually trying to read another
|
||||||
|
user's row through the scoped role and asserting zero come back.
|
||||||
|
|
||||||
|
## A note on `alembic check`
|
||||||
|
|
||||||
|
CI's `migrations` job runs `alembic check` to catch drift between the ORM models and the actual
|
||||||
|
migrations. `alembic/env.py`'s `include_object` filter restricts that comparison to tables our own
|
||||||
|
metadata declares — **deliberately an allowlist, not a denylist of PostGIS/TIGER tables**, because
|
||||||
|
reflected foreign tables can come back with `schema=None` regardless of which schema they actually
|
||||||
|
live in (confirmed against a real `postgis/postgis:16-3.4` container), which makes a schema-based
|
||||||
|
denylist unreliable. If you add a new model, it's automatically covered — no filter to update.
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Alembic environment.
|
||||||
|
|
||||||
|
Deliberately independent of velodrome.config.Settings: migrations run as a privileged/owner
|
||||||
|
connection (VELODROME_DATABASE_URL_MIGRATE — a superuser or schema-owning role, which trivially
|
||||||
|
satisfies "BYPASSRLS" since superusers always bypass RLS), never as either of the two runtime
|
||||||
|
roles the app itself uses. Reading env vars directly here, rather than importing the app's
|
||||||
|
settings, keeps a migration-only CI job from needing every runtime env var the app requires.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from velodrome.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
# postgis/postgis ships an entire pre-installed schema of its own (PostGIS core tables plus the
|
||||||
|
# TIGER geocoder's tiger/topology schemas — dozens of tables) that Alembic never created and
|
||||||
|
# doesn't manage. Without this filter, `alembic check`/autogenerate sees every single one as
|
||||||
|
# "should be dropped" simply because it's not in our SQLAlchemy metadata — which would make CI's
|
||||||
|
# `alembic check` step permanently useless (always red, for reasons that have nothing to do with
|
||||||
|
# an actual drift).
|
||||||
|
#
|
||||||
|
# A denylist keyed on schema name is NOT reliable here: reflected foreign tables can come back
|
||||||
|
# with `schema=None` on their Table object regardless of which schema they actually live in on
|
||||||
|
# the server (confirmed against a real postgis/postgis:16-3.4 container — tables that `\dt`
|
||||||
|
# clearly shows under the `tiger` schema still reflect with schema=None). An allowlist is the
|
||||||
|
# robust version of the same idea: only ever compare tables OUR metadata declares, so a future
|
||||||
|
# PostGIS/TIGER version adding more foreign tables can never cause a false positive here.
|
||||||
|
def include_object(
|
||||||
|
object: sa.schema.SchemaItem, name: str | None, type_: str, reflected: bool, compare_to: object
|
||||||
|
) -> bool:
|
||||||
|
if type_ == "table":
|
||||||
|
return name in target_metadata.tables
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_url() -> str:
|
||||||
|
url = os.environ.get("VELODROME_DATABASE_URL_MIGRATE")
|
||||||
|
if not url:
|
||||||
|
# Local-dev convenience only — every real environment (CI, deploy/) sets this explicitly.
|
||||||
|
url = "postgresql+asyncpg://postgres:postgres@localhost:5432/velodrome"
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=_migrate_url(),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
include_object=include_object,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
include_object=include_object,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online() -> None:
|
||||||
|
configuration = config.get_section(config.config_ini_section) or {}
|
||||||
|
configuration["sqlalchemy.url"] = _migrate_url()
|
||||||
|
connectable = async_engine_from_config(configuration, prefix="sqlalchemy.")
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(_do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_migrations_online())
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""Baseline: identity tables, two runtime DB roles, RLS policies.
|
||||||
|
|
||||||
|
This migration creates the schema AND the two application-facing Postgres roles it depends on
|
||||||
|
(velodrome_app, velodrome_auth) — see db.py and auth/service.py module docstrings for the full
|
||||||
|
rationale. It runs as a privileged/owner connection (VELODROME_DATABASE_URL_MIGRATE), which is
|
||||||
|
why it's able to CREATE ROLE and GRANT at all; neither of the two roles it creates could do this
|
||||||
|
to itself.
|
||||||
|
|
||||||
|
Revision ID: 0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-09-21
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql as pg
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0001"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_password(env_var: str) -> str:
|
||||||
|
value = os.environ.get(env_var)
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{env_var} must be set before running this migration — see deploy/.env.example. "
|
||||||
|
"There is no default: these are the passwords for real runtime database roles."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _dollar_quoted(password: str) -> str:
|
||||||
|
"""Safely embed a password literal in DDL.
|
||||||
|
|
||||||
|
CREATE ROLE's PASSWORD clause is DDL, not DML — it does NOT accept bind parameters over the
|
||||||
|
wire (Postgres rejects `PASSWORD $1` with a syntax error; ask how we know). Dollar-quoting
|
||||||
|
sidesteps manual escaping entirely rather than hand-rolling quote-doubling, which is easy to
|
||||||
|
get subtly wrong for a password an operator chose.
|
||||||
|
"""
|
||||||
|
tag = "$velodrome_pw$"
|
||||||
|
if tag in password:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"password must not contain the literal sequence {tag!r} — pick a different one"
|
||||||
|
)
|
||||||
|
return f"{tag}{password}{tag}"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
app_password = _require_password("VELODROME_DB_APP_PASSWORD")
|
||||||
|
auth_password = _require_password("VELODROME_DB_AUTH_PASSWORD")
|
||||||
|
|
||||||
|
# --- Runtime roles -------------------------------------------------------------------
|
||||||
|
# velodrome_app: NOBYPASSRLS — every request-scoped query after identity is established.
|
||||||
|
# velodrome_auth: BYPASSRLS — ONLY auth/service.py's pre-identity lookups. See db.py.
|
||||||
|
# Roles are cluster-wide in Postgres, not per-database — if this migration ever runs
|
||||||
|
# against a second database sharing the same cluster (exactly what a local dev + test setup
|
||||||
|
# commonly looks like), a plain CREATE ROLE fails with "role already exists" even though
|
||||||
|
# THIS database has never seen the migration before. Postgres has no CREATE ROLE IF NOT
|
||||||
|
# EXISTS, so the standard idiom is catching duplicate_object in a DO block. ALTER ROLE
|
||||||
|
# afterwards keeps the password in sync with the current env var either way, rather than
|
||||||
|
# silently keeping whatever password the role happened to be created with previously.
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE ROLE velodrome_app LOGIN PASSWORD {_dollar_quoted(app_password)}
|
||||||
|
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN
|
||||||
|
ALTER ROLE velodrome_app WITH LOGIN PASSWORD {_dollar_quoted(app_password)}
|
||||||
|
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE ROLE velodrome_auth LOGIN PASSWORD {_dollar_quoted(auth_password)}
|
||||||
|
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS;
|
||||||
|
EXCEPTION WHEN duplicate_object THEN
|
||||||
|
ALTER ROLE velodrome_auth WITH LOGIN PASSWORD {_dollar_quoted(auth_password)}
|
||||||
|
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Tables ----------------------------------------------------------------------------
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("email", sa.String(320), nullable=False, unique=True),
|
||||||
|
sa.Column("display_name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.Text(), nullable=False),
|
||||||
|
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
|
||||||
|
sa.Column("timezone", sa.String(64), nullable=False, server_default="UTC"),
|
||||||
|
sa.Column("unit_system", sa.String(10), nullable=False, server_default="imperial"),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"invites",
|
||||||
|
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("code_hash", sa.LargeBinary(32), nullable=False, unique=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_by",
|
||||||
|
pg.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("email", sa.String(320), nullable=True),
|
||||||
|
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("max_uses", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("used_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"sessions",
|
||||||
|
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
pg.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("token_hash", sa.LargeBinary(32), nullable=False, unique=True),
|
||||||
|
sa.Column("client", sa.String(20), nullable=False, server_default="web"),
|
||||||
|
sa.Column("user_agent", sa.Text(), nullable=True),
|
||||||
|
sa.Column("ip", pg.INET(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"api_tokens",
|
||||||
|
sa.Column("id", pg.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
pg.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.LargeBinary(32), nullable=False, unique=True),
|
||||||
|
sa.Column(
|
||||||
|
"scopes",
|
||||||
|
pg.ARRAY(sa.String()),
|
||||||
|
nullable=False,
|
||||||
|
server_default="{}",
|
||||||
|
),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_api_tokens_user_id", "api_tokens", ["user_id"])
|
||||||
|
|
||||||
|
# --- Grants ------------------------------------------------------------------------------
|
||||||
|
# velodrome_auth needs full read/write on these four tables — it's the only thing that ever
|
||||||
|
# creates a user, a session, or redeems an invite. velodrome_app needs the same grants
|
||||||
|
# because RLS policies (below) restrict WHICH ROWS it sees, not whether the underlying
|
||||||
|
# privilege exists — GRANT and POLICY are two independent layers, both required.
|
||||||
|
# Deliberately NOT granting TRUNCATE: it's a whole-table operation that RLS cannot filter
|
||||||
|
# (Postgres RLS policies do not apply to TRUNCATE at all), so granting it to velodrome_app
|
||||||
|
# would let any bug in ordinary request-handling code wipe an entire table in one statement,
|
||||||
|
# defeating the isolation these policies exist to provide. Neither runtime role needs it —
|
||||||
|
# tests use DELETE for fixture cleanup instead (see tests/conftest.py).
|
||||||
|
for table in ("users", "invites", "sessions", "api_tokens"):
|
||||||
|
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO velodrome_app")
|
||||||
|
op.execute(f"GRANT SELECT, INSERT, UPDATE, DELETE ON {table} TO velodrome_auth")
|
||||||
|
|
||||||
|
# --- Row-level security ------------------------------------------------------------------
|
||||||
|
# Policies below apply to velodrome_app only — velodrome_auth has BYPASSRLS and ignores them
|
||||||
|
# entirely, by design (see module docstring). current_setting('app.user_id', true) returns
|
||||||
|
# NULL when unset, which makes every policy below deny-by-default for an unscoped connection.
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE users ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(
|
||||||
|
"CREATE POLICY own_row ON users FOR ALL "
|
||||||
|
"USING (id = current_setting('app.user_id', true)::uuid) "
|
||||||
|
"WITH CHECK (id = current_setting('app.user_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE sessions ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(
|
||||||
|
"CREATE POLICY own_rows ON sessions FOR ALL "
|
||||||
|
"USING (user_id = current_setting('app.user_id', true)::uuid) "
|
||||||
|
"WITH CHECK (user_id = current_setting('app.user_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE api_tokens ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(
|
||||||
|
"CREATE POLICY own_rows ON api_tokens FOR ALL "
|
||||||
|
"USING (user_id = current_setting('app.user_id', true)::uuid) "
|
||||||
|
"WITH CHECK (user_id = current_setting('app.user_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE invites ENABLE ROW LEVEL SECURITY")
|
||||||
|
op.execute(
|
||||||
|
"CREATE POLICY own_rows ON invites FOR ALL "
|
||||||
|
"USING (created_by = current_setting('app.user_id', true)::uuid) "
|
||||||
|
"WITH CHECK (created_by = current_setting('app.user_id', true)::uuid)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for table in ("invites", "api_tokens", "sessions", "users"):
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS own_rows ON {table}")
|
||||||
|
op.execute(f"DROP POLICY IF EXISTS own_row ON {table}")
|
||||||
|
|
||||||
|
# Children before parents (FK order).
|
||||||
|
op.drop_table("api_tokens")
|
||||||
|
op.drop_table("sessions")
|
||||||
|
op.drop_table("invites")
|
||||||
|
op.drop_table("users")
|
||||||
|
|
||||||
|
# Dropping the tables drops the GRANTs that referenced them; the roles themselves remain
|
||||||
|
# until explicitly dropped here.
|
||||||
|
op.execute("DROP ROLE IF EXISTS velodrome_app")
|
||||||
|
op.execute("DROP ROLE IF EXISTS velodrome_auth")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
[project]
|
||||||
|
name = "velodrome"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Self-hosted cycling app API"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.32",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.35",
|
||||||
|
"asyncpg>=0.30",
|
||||||
|
"alembic>=1.13",
|
||||||
|
"pydantic[email]>=2.9",
|
||||||
|
"pydantic-settings>=2.6",
|
||||||
|
"argon2-cffi>=23.1",
|
||||||
|
"uuid6>=2024.7.10",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.7",
|
||||||
|
"mypy>=1.13",
|
||||||
|
"pytest>=8.3",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["velodrome"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B", "ASYNC"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-bugbear]
|
||||||
|
# FastAPI's dependency-injection idiom is Depends(...) as a default argument value — that IS
|
||||||
|
# the framework's intended pattern (see https://fastapi.tiangolo.com/tutorial/dependencies/),
|
||||||
|
# not the mutable-default-argument bug B008 exists to catch. Same reasoning for Query/Body/
|
||||||
|
# Cookie/Header — declared now even though only Depends is used yet, so nobody hits this same
|
||||||
|
# false positive when the others show up in a later phase.
|
||||||
|
extend-immutable-calls = ["fastapi.Depends", "fastapi.Query", "fastapi.Body", "fastapi.Cookie", "fastapi.Header"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
strict = true
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Test fixtures.
|
||||||
|
|
||||||
|
Runs the real Alembic migrations once per session against a real Postgres (never mocked — see
|
||||||
|
CLAUDE.md's test policy), then truncates the identity tables between tests for isolation. This
|
||||||
|
deliberately exercises the exact same migration path CI's `migrations` job and a real deploy use,
|
||||||
|
not a parallel test-only schema-creation shortcut.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
|
||||||
|
os.environ.setdefault("VELODROME_ENVIRONMENT", "test")
|
||||||
|
|
||||||
|
# Test-only role passwords. Never used outside this process; the migration requires them to be
|
||||||
|
# set explicitly (see alembic/versions/0001_baseline.py::_require_password) rather than default
|
||||||
|
# to anything, on purpose — that's the same rule for a real deploy, just satisfied differently.
|
||||||
|
_APP_PW = "test-only-app-password"
|
||||||
|
_AUTH_PW = "test-only-auth-password"
|
||||||
|
|
||||||
|
os.environ.setdefault(
|
||||||
|
"VELODROME_DATABASE_URL_MIGRATE",
|
||||||
|
"postgresql+asyncpg://postgres:postgres@localhost:5432/velodrome_test",
|
||||||
|
)
|
||||||
|
os.environ.setdefault("VELODROME_DB_APP_PASSWORD", _APP_PW)
|
||||||
|
os.environ.setdefault("VELODROME_DB_AUTH_PASSWORD", _AUTH_PW)
|
||||||
|
os.environ.setdefault(
|
||||||
|
"VELODROME_DATABASE_URL_APP",
|
||||||
|
f"postgresql+asyncpg://velodrome_app:{_APP_PW}@localhost:5432/velodrome_test",
|
||||||
|
)
|
||||||
|
os.environ.setdefault(
|
||||||
|
"VELODROME_DATABASE_URL_AUTH",
|
||||||
|
f"postgresql+asyncpg://velodrome_auth:{_AUTH_PW}@localhost:5432/velodrome_test",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Settings/engines must not be constructed before the env vars above are set, so these imports
|
||||||
|
# are deliberately below the os.environ.setdefault block, not at module top.
|
||||||
|
import httpx # noqa: E402
|
||||||
|
|
||||||
|
from velodrome.app import app # noqa: E402
|
||||||
|
from velodrome.db import auth_session # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def _run_migrations() -> None:
|
||||||
|
cfg = Config(os.path.join(os.path.dirname(__file__), "..", "alembic.ini"))
|
||||||
|
cfg.set_main_option("script_location", os.path.join(os.path.dirname(__file__), "..", "alembic"))
|
||||||
|
command.downgrade(cfg, "base") # clean slate even on a reused test database
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def _clean_tables() -> AsyncIterator[None]:
|
||||||
|
yield
|
||||||
|
async with auth_session() as db:
|
||||||
|
async with db.begin():
|
||||||
|
# DELETE, not TRUNCATE — velodrome_auth deliberately isn't granted TRUNCATE in
|
||||||
|
# production (see the migration's comment), and using the same privilege level in
|
||||||
|
# tests as in prod is the point. Children before parents for the FK constraints.
|
||||||
|
for table in ("sessions", "api_tokens", "invites", "users"):
|
||||||
|
await db.execute(text(f"DELETE FROM {table}"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_auth() -> AsyncIterator[AsyncSession]:
|
||||||
|
async with auth_session() as db:
|
||||||
|
yield db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client() -> AsyncIterator[httpx.AsyncClient]:
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""Auth endpoint tests, plus — the one that matters most — an empirical proof of RLS isolation.
|
||||||
|
|
||||||
|
Per CLAUDE.md: 'auth/, RLS policies — security, and a mistake exposes another user's data.' The
|
||||||
|
whole point of test_rls_blocks_cross_user_session_reads below is that it doesn't trust the SQL in
|
||||||
|
the migration is correct because it reads correctly — it proves it by actually trying to read
|
||||||
|
another user's row through the RLS-scoped role and confirming zero rows come back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from velodrome.auth.security import hash_invite_code, hash_password
|
||||||
|
from velodrome.db import scoped_session, unscoped_session
|
||||||
|
|
||||||
|
_REGISTER_PASSWORD = "correct horse battery staple"
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_invite(db_auth: AsyncSession, *, code: str = "TESTCODE123") -> UUID:
|
||||||
|
"""Insert a usable invite directly, bypassing the API — this is fixture setup, not the
|
||||||
|
thing under test."""
|
||||||
|
creator_id = UUID(int=0)
|
||||||
|
await db_auth.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO users (id, email, display_name, password_hash) "
|
||||||
|
"VALUES (:id, 'seed@example.com', 'Seed', :ph) ON CONFLICT DO NOTHING"
|
||||||
|
),
|
||||||
|
{"id": str(creator_id), "ph": hash_password("unused")},
|
||||||
|
)
|
||||||
|
await db_auth.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO invites (id, code_hash, created_by, expires_at, max_uses, used_count) "
|
||||||
|
"VALUES (gen_random_uuid(), :hash, :creator, :expires, 1, 0)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"hash": hash_invite_code(code),
|
||||||
|
"creator": str(creator_id),
|
||||||
|
"expires": datetime.now(UTC) + timedelta(days=1),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await db_auth.commit()
|
||||||
|
return creator_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_with_valid_invite_creates_session(
|
||||||
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
await _seed_invite(db_auth)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "rider@example.com",
|
||||||
|
"password": _REGISTER_PASSWORD,
|
||||||
|
"display_name": "Rider",
|
||||||
|
"invite_code": "TESTCODE123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
assert resp.json()["email"] == "rider@example.com"
|
||||||
|
assert "vd_session" in resp.cookies
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_rejects_unknown_invite_code(client: httpx.AsyncClient) -> None:
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "rider@example.com",
|
||||||
|
"password": _REGISTER_PASSWORD,
|
||||||
|
"display_name": "Rider",
|
||||||
|
"invite_code": "no-such-code",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_rejects_reused_invite(
|
||||||
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
await _seed_invite(db_auth)
|
||||||
|
body = {
|
||||||
|
"password": _REGISTER_PASSWORD,
|
||||||
|
"display_name": "Rider",
|
||||||
|
"invite_code": "TESTCODE123",
|
||||||
|
}
|
||||||
|
first = await client.post("/api/v1/auth/register", json={**body, "email": "first@example.com"})
|
||||||
|
assert first.status_code == 201
|
||||||
|
|
||||||
|
second = await client.post(
|
||||||
|
"/api/v1/auth/register", json={**body, "email": "second@example.com"}
|
||||||
|
)
|
||||||
|
assert second.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_with_correct_password_succeeds(
|
||||||
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
await _seed_invite(db_auth)
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "rider@example.com",
|
||||||
|
"password": _REGISTER_PASSWORD,
|
||||||
|
"display_name": "Rider",
|
||||||
|
"invite_code": "TESTCODE123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "rider@example.com", "password": _REGISTER_PASSWORD},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "vd_session" in resp.cookies
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_with_wrong_password_fails(
|
||||||
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
await _seed_invite(db_auth)
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "rider@example.com",
|
||||||
|
"password": _REGISTER_PASSWORD,
|
||||||
|
"display_name": "Rider",
|
||||||
|
"invite_code": "TESTCODE123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "rider@example.com", "password": "wrong password entirely"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_me_requires_authentication(client: httpx.AsyncClient) -> None:
|
||||||
|
resp = await client.get("/api/v1/auth/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_me_returns_current_user_with_valid_session(
|
||||||
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
await _seed_invite(db_auth)
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "rider@example.com",
|
||||||
|
"password": _REGISTER_PASSWORD,
|
||||||
|
"display_name": "Rider",
|
||||||
|
"invite_code": "TESTCODE123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = await client.get("/api/v1/auth/me")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["email"] == "rider@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_logout_revokes_the_session(client: httpx.AsyncClient, db_auth: AsyncSession) -> None:
|
||||||
|
await _seed_invite(db_auth)
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "rider@example.com",
|
||||||
|
"password": _REGISTER_PASSWORD,
|
||||||
|
"display_name": "Rider",
|
||||||
|
"invite_code": "TESTCODE123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
logout_resp = await client.post(
|
||||||
|
"/api/v1/auth/logout", headers={"Origin": "http://localhost:5173"}
|
||||||
|
)
|
||||||
|
assert logout_resp.status_code == 204
|
||||||
|
|
||||||
|
me_resp = await client.get("/api/v1/auth/me")
|
||||||
|
assert me_resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_logout_without_matching_origin_is_rejected(
|
||||||
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
await _seed_invite(db_auth)
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "rider@example.com",
|
||||||
|
"password": _REGISTER_PASSWORD,
|
||||||
|
"display_name": "Rider",
|
||||||
|
"invite_code": "TESTCODE123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp = await client.post("/api/v1/auth/logout", headers={"Origin": "https://evil.example"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rls_blocks_cross_user_session_reads(
|
||||||
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""The load-bearing test: prove RLS actually enforces isolation, not just that the migration
|
||||||
|
ran without a syntax error.
|
||||||
|
|
||||||
|
Two users register (creating two session rows). We then open a `scoped_session` as user A —
|
||||||
|
the exact code path every real request handler will use once Phase 1 adds user-owned domain
|
||||||
|
tables — and confirm the raw SQL result set contains ONLY user A's session, even though it
|
||||||
|
runs no WHERE clause on user_id at all. If this test ever passes with more than one row, RLS
|
||||||
|
is not doing its job and every other invariant in this codebase is resting on nothing.
|
||||||
|
"""
|
||||||
|
await _seed_invite(db_auth, code="CODE-FOR-A")
|
||||||
|
await _seed_invite(db_auth, code="CODE-FOR-B")
|
||||||
|
|
||||||
|
resp_a = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "user-a@example.com",
|
||||||
|
"password": _REGISTER_PASSWORD,
|
||||||
|
"display_name": "User A",
|
||||||
|
"invite_code": "CODE-FOR-A",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
user_a_id = UUID(resp_a.json()["id"])
|
||||||
|
|
||||||
|
resp_b = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "user-b@example.com",
|
||||||
|
"password": _REGISTER_PASSWORD,
|
||||||
|
"display_name": "User B",
|
||||||
|
"invite_code": "CODE-FOR-B",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
user_b_id = UUID(resp_b.json()["id"])
|
||||||
|
assert user_a_id != user_b_id
|
||||||
|
|
||||||
|
# Sanity check first: BOTH sessions genuinely exist, via the bypass role.
|
||||||
|
total = (await db_auth.execute(text("SELECT count(*) FROM sessions"))).scalar_one()
|
||||||
|
assert total == 2
|
||||||
|
|
||||||
|
# Now the real test: as user A, scoped through the RLS-subject role, with NO WHERE clause.
|
||||||
|
async with scoped_session(user_a_id) as scoped_db:
|
||||||
|
rows = (await scoped_db.execute(text("SELECT user_id FROM sessions"))).scalars().all()
|
||||||
|
|
||||||
|
assert len(rows) == 1, (
|
||||||
|
f"expected exactly 1 row (user A's own session) via RLS, got {len(rows)} — "
|
||||||
|
"RLS is not isolating users"
|
||||||
|
)
|
||||||
|
assert UUID(str(rows[0])) == user_a_id
|
||||||
|
|
||||||
|
# And the mirror image, as user B, proving this isn't a coincidence of row ordering.
|
||||||
|
async with scoped_session(user_b_id) as scoped_db:
|
||||||
|
rows_b = (await scoped_db.execute(text("SELECT user_id FROM sessions"))).scalars().all()
|
||||||
|
assert len(rows_b) == 1
|
||||||
|
assert UUID(str(rows_b[0])) == user_b_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unscoped_session_sees_zero_rows_of_user_owned_tables(
|
||||||
|
client: httpx.AsyncClient, db_auth: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""The default-deny half of the same proof: with app.user_id unset entirely (the state the
|
||||||
|
health check and any other unauthenticated code path runs in), RLS denies everything."""
|
||||||
|
await _seed_invite(db_auth)
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={
|
||||||
|
"email": "rider@example.com",
|
||||||
|
"password": _REGISTER_PASSWORD,
|
||||||
|
"display_name": "Rider",
|
||||||
|
"invite_code": "TESTCODE123",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async with unscoped_session() as db:
|
||||||
|
rows = (await db.execute(text("SELECT * FROM sessions"))).all()
|
||||||
|
assert rows == []
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
async def test_healthz_returns_ok(client: httpx.AsyncClient) -> None:
|
||||||
|
resp = await client.get("/api/v1/healthz")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == {"status": "ok"}
|
||||||
Generated
+1378
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
|
||||||
|
from velodrome.auth import service
|
||||||
|
from velodrome.auth.dependencies import get_current_user, require_same_origin_for_cookie_auth
|
||||||
|
from velodrome.auth.service import AuthenticatedSession
|
||||||
|
from velodrome.config import get_settings
|
||||||
|
from velodrome.schemas.auth import LoginRequest, RegisterRequest, UserOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session_cookie(response: Response, raw_token: str) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
# secure=True only in production, not merely "not development": a Secure cookie is never
|
||||||
|
# sent back by any client over plain HTTP, by spec. Gating on anything other than an
|
||||||
|
# explicit "production" (e.g. the previous `!= "development"`) breaks every non-dev
|
||||||
|
# environment served without TLS in front — including local testing and a real self-hosted
|
||||||
|
# deploy the operator is running temporarily without a reverse proxy — which fails silently
|
||||||
|
# as "login succeeds but every subsequent request looks unauthenticated."
|
||||||
|
response.set_cookie(
|
||||||
|
key=settings.session_cookie_name,
|
||||||
|
value=raw_token,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.environment == "production",
|
||||||
|
samesite="lax",
|
||||||
|
max_age=settings.session_ttl_days * 24 * 60 * 60,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register(body: RegisterRequest, response: Response) -> UserOut:
|
||||||
|
try:
|
||||||
|
await service.register(
|
||||||
|
email=body.email,
|
||||||
|
password=body.password,
|
||||||
|
display_name=body.display_name,
|
||||||
|
invite_code=body.invite_code,
|
||||||
|
)
|
||||||
|
except service.InvalidInvite as exc:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
except service.EmailAlreadyRegistered as exc:
|
||||||
|
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
# A second call, not a shortcut through register()'s own result — this keeps session
|
||||||
|
# creation in exactly one place (service.login) rather than duplicating it, at the cost of
|
||||||
|
# one redundant password verification. See auth/service.py if that cost ever matters.
|
||||||
|
raw_token, session_info = await service.login(
|
||||||
|
email=body.email, password=body.password, client="web", user_agent=None, ip=None
|
||||||
|
)
|
||||||
|
_set_session_cookie(response, raw_token)
|
||||||
|
return UserOut(
|
||||||
|
id=session_info.user_id, email=session_info.email, display_name=session_info.display_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=UserOut)
|
||||||
|
async def login(body: LoginRequest, request: Request, response: Response) -> UserOut:
|
||||||
|
try:
|
||||||
|
raw_token, session_info = await service.login(
|
||||||
|
email=body.email,
|
||||||
|
password=body.password,
|
||||||
|
client="web",
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
)
|
||||||
|
except service.InvalidCredentials as exc:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
_set_session_cookie(response, raw_token)
|
||||||
|
return UserOut(
|
||||||
|
id=session_info.user_id, email=session_info.email, display_name=session_info.display_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
async def me(current: AuthenticatedSession = Depends(get_current_user)) -> UserOut:
|
||||||
|
return UserOut(id=current.user_id, email=current.email, display_name=current.display_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/logout",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_same_origin_for_cookie_auth)],
|
||||||
|
)
|
||||||
|
async def logout(request: Request, response: Response) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
raw_token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
if raw_token:
|
||||||
|
await service.logout(raw_token)
|
||||||
|
response.delete_cookie(settings.session_cookie_name, path="/")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from fastapi import APIRouter, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from velodrome.db import unscoped_session
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/healthz", status_code=status.HTTP_200_OK)
|
||||||
|
async def healthz() -> dict[str, str]:
|
||||||
|
"""Liveness + a real database round trip.
|
||||||
|
|
||||||
|
Not RLS-scoped (there's no user yet at this point) — see db.unscoped_session's docstring for
|
||||||
|
why that's safe: it can see zero rows of any user-owned table regardless.
|
||||||
|
"""
|
||||||
|
async with unscoped_session() as db:
|
||||||
|
await db.execute(text("SELECT 1"))
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from velodrome.api.v1 import auth, health
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
router.include_router(health.router)
|
||||||
|
router.include_router(auth.router)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from velodrome.api.v1.router import router as v1_router
|
||||||
|
from velodrome.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
settings = get_settings()
|
||||||
|
app = FastAPI(
|
||||||
|
title="Velodrome",
|
||||||
|
version="0.1.0",
|
||||||
|
# Committed to packages/openapi/openapi.json — CI's openapi-drift job (added once that
|
||||||
|
# job has real code to check) regenerates this and diffs it, so the contract can never
|
||||||
|
# silently drift from what's actually deployed.
|
||||||
|
openapi_url="/api/v1/openapi.json",
|
||||||
|
docs_url="/api/v1/docs" if settings.environment != "production" else None,
|
||||||
|
)
|
||||||
|
app.include_router(v1_router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""FastAPI dependencies for authenticating a request.
|
||||||
|
|
||||||
|
One verification path for two transports, per docs/PLAN.md "Auth": a bearer token in the
|
||||||
|
Authorization header takes priority (that's how a non-browser client, or a future native app,
|
||||||
|
would authenticate), falling back to the session cookie the PWA uses. The cookie is HttpOnly —
|
||||||
|
JavaScript never touches it — so it's read here purely server-side; the web client gets no
|
||||||
|
capability a bearer-authenticated client wouldn't also have.
|
||||||
|
|
||||||
|
CSRF: a cookie-authenticated request that MUTATES state must have an Origin header matching the
|
||||||
|
configured public URL. A bearer-authenticated request skips this check, because a cross-origin
|
||||||
|
attacker's page cannot set an Authorization header on a request it tricks the browser into
|
||||||
|
sending — that's the whole CSRF attack surface, and it doesn't exist for bearer auth.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import Cookie, Header, HTTPException, Request, status
|
||||||
|
|
||||||
|
from velodrome.auth.service import AuthenticatedSession, SessionInvalid, validate_session
|
||||||
|
from velodrome.config import get_settings
|
||||||
|
|
||||||
|
_UNAUTHORIZED = HTTPException(status.HTTP_401_UNAUTHORIZED, detail="not authenticated")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bearer(authorization: str | None) -> str | None:
|
||||||
|
if authorization is None:
|
||||||
|
return None
|
||||||
|
scheme, _, token = authorization.partition(" ")
|
||||||
|
if scheme.lower() != "bearer" or not token:
|
||||||
|
return None
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
session_cookie: str | None = Cookie(default=None, alias="vd_session"),
|
||||||
|
) -> AuthenticatedSession:
|
||||||
|
raw_token = _extract_bearer(authorization) or session_cookie
|
||||||
|
if raw_token is None:
|
||||||
|
raise _UNAUTHORIZED
|
||||||
|
try:
|
||||||
|
return await validate_session(raw_token)
|
||||||
|
except SessionInvalid as exc:
|
||||||
|
raise _UNAUTHORIZED from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def require_same_origin_for_cookie_auth(
|
||||||
|
request: Request,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> None:
|
||||||
|
"""Apply to every mutating route. No-ops for bearer auth; enforces Origin for cookie auth."""
|
||||||
|
if _extract_bearer(authorization) is not None:
|
||||||
|
return # bearer-authenticated; CSRF doesn't apply, see module docstring.
|
||||||
|
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
expected = get_settings().public_url.rstrip("/")
|
||||||
|
if origin is None or origin.rstrip("/") != expected:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="cross-origin request rejected")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Password hashing and opaque token generation.
|
||||||
|
|
||||||
|
CLAUDE.md invariant #5: secrets never leave the server. Nothing in this file is ever included in
|
||||||
|
a Pydantic response model — that's enforced by schemas/auth.py simply not declaring these fields,
|
||||||
|
not by anything here, so double-check any new response schema doesn't accidentally add one back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
|
||||||
|
# t=3, m=64MiB, p=4 — matches docs/PLAN.md's stated parameters exactly.
|
||||||
|
_hasher = PasswordHasher(time_cost=3, memory_cost=64 * 1024, parallelism=4)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _hasher.verify(password_hash, password)
|
||||||
|
except VerifyMismatchError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token() -> tuple[str, bytes]:
|
||||||
|
"""Return (opaque_token_to_hand_to_the_client, sha256_digest_to_store).
|
||||||
|
|
||||||
|
The raw token is returned to the caller exactly once and is never persisted anywhere —
|
||||||
|
only its digest is stored, so a database leak doesn't hand out working session tokens.
|
||||||
|
"""
|
||||||
|
raw = secrets.token_urlsafe(32)
|
||||||
|
digest = hashlib.sha256(raw.encode("ascii")).digest()
|
||||||
|
return raw, digest
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(raw_token: str) -> bytes:
|
||||||
|
"""Recompute the digest of a client-presented token, for lookup by token_hash."""
|
||||||
|
return hashlib.sha256(raw_token.encode("ascii")).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_invite_code(code: str) -> bytes:
|
||||||
|
return hashlib.sha256(code.encode("ascii")).digest()
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Auth bootstrap logic: register, login, session validation, logout.
|
||||||
|
|
||||||
|
Every function here runs against the BYPASSRLS `auth` database role (see db.py's module
|
||||||
|
docstring for why) and every query is an exact match on a unique key — email, token_hash, or
|
||||||
|
code_hash — never an unfiltered scan. That's what makes bypassing RLS safe here: there's no
|
||||||
|
"list everything" code path for these functions to accidentally expose.
|
||||||
|
|
||||||
|
Nothing outside this module should import `db.auth_session` — if a new feature needs it, that's
|
||||||
|
a sign the feature belongs here, not that the import should spread.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
|
from velodrome.auth.security import (
|
||||||
|
generate_token,
|
||||||
|
hash_invite_code,
|
||||||
|
hash_password,
|
||||||
|
hash_token,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from velodrome.config import get_settings
|
||||||
|
from velodrome.db import auth_session
|
||||||
|
from velodrome.models import Invite, Session, User
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(Exception):
|
||||||
|
"""Base class for auth failures the API layer turns into 4xx responses."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidCredentials(AuthError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidInvite(AuthError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class EmailAlreadyRegistered(AuthError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SessionInvalid(AuthError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthenticatedSession:
|
||||||
|
user_id: UUID
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
async def register(
|
||||||
|
*, email: str, password: str, display_name: str, invite_code: str
|
||||||
|
) -> AuthenticatedSession:
|
||||||
|
"""Validate an invite and create a user, atomically.
|
||||||
|
|
||||||
|
Open signup does not exist — see docs/PLAN.md "Auth". The `SELECT ... FOR UPDATE` on the
|
||||||
|
invite row is what stops a shared invite link being redeemed twice concurrently; without it,
|
||||||
|
two requests could both read `used_count < max_uses` as true before either commits.
|
||||||
|
"""
|
||||||
|
code_hash = hash_invite_code(invite_code)
|
||||||
|
async with auth_session() as db:
|
||||||
|
async with db.begin():
|
||||||
|
invite = (
|
||||||
|
await db.execute(
|
||||||
|
select(Invite).where(Invite.code_hash == code_hash).with_for_update()
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if invite is None:
|
||||||
|
raise InvalidInvite("invite code not found")
|
||||||
|
if invite.revoked_at is not None:
|
||||||
|
raise InvalidInvite("invite has been revoked")
|
||||||
|
if invite.expires_at < datetime.now(UTC):
|
||||||
|
raise InvalidInvite("invite has expired")
|
||||||
|
if invite.used_count >= invite.max_uses:
|
||||||
|
raise InvalidInvite("invite has already been used")
|
||||||
|
if invite.email is not None and invite.email.lower() != email.lower():
|
||||||
|
raise InvalidInvite("invite is pinned to a different email address")
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
await db.execute(select(User).where(User.email == email))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise EmailAlreadyRegistered("an account with this email already exists")
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
display_name=display_name,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=invite.role,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush() # populate user.id before we reference it below
|
||||||
|
|
||||||
|
invite.used_count += 1
|
||||||
|
|
||||||
|
return AuthenticatedSession(
|
||||||
|
user_id=user.id, email=user.email, display_name=user.display_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def login(
|
||||||
|
*, email: str, password: str, client: str, user_agent: str | None, ip: str | None
|
||||||
|
) -> tuple[str, AuthenticatedSession]:
|
||||||
|
"""Verify credentials and create a session. Returns (raw_token, session) — the raw token is
|
||||||
|
handed to the caller exactly once; only its hash is ever stored.
|
||||||
|
"""
|
||||||
|
async with auth_session() as db:
|
||||||
|
async with db.begin():
|
||||||
|
user = (await db.execute(select(User).where(User.email == email))).scalar_one_or_none()
|
||||||
|
|
||||||
|
# Deliberately identical error for "no such user" and "wrong password" — this is the
|
||||||
|
# one place a timing/response difference would leak which emails are registered.
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
# Still run the hasher so this branch isn't measurably faster than a real
|
||||||
|
# mismatch (argon2's own duration masks a database-lookup-only shortcut).
|
||||||
|
verify_password(password, hash_password("decoy-password-never-matches"))
|
||||||
|
raise InvalidCredentials("invalid email or password")
|
||||||
|
if not verify_password(password, user.password_hash):
|
||||||
|
raise InvalidCredentials("invalid email or password")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
raw_token, token_hash = generate_token()
|
||||||
|
session_row = Session(
|
||||||
|
user_id=user.id,
|
||||||
|
token_hash=token_hash,
|
||||||
|
client=client,
|
||||||
|
user_agent=user_agent,
|
||||||
|
ip=ip,
|
||||||
|
expires_at=datetime.now(UTC) + timedelta(days=settings.session_ttl_days),
|
||||||
|
)
|
||||||
|
db.add(session_row)
|
||||||
|
|
||||||
|
return raw_token, AuthenticatedSession(
|
||||||
|
user_id=user.id, email=user.email, display_name=user.display_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Sessions are only touched this often to avoid a write on every single request; see
|
||||||
|
# docs/PLAN.md's auth section for the same throttling rule applied to last_seen_at.
|
||||||
|
_LAST_SEEN_THROTTLE = timedelta(minutes=5)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_session(raw_token: str) -> AuthenticatedSession:
|
||||||
|
"""Look a bearer token up and return the identity it belongs to, or raise SessionInvalid.
|
||||||
|
|
||||||
|
This is the auth entrypoint for every authenticated request — it's what runs *before*
|
||||||
|
db.scoped_session() can be used, because app.user_id isn't known until this returns.
|
||||||
|
"""
|
||||||
|
token_hash = hash_token(raw_token)
|
||||||
|
async with auth_session() as db:
|
||||||
|
async with db.begin():
|
||||||
|
row = (
|
||||||
|
await db.execute(
|
||||||
|
select(Session, User)
|
||||||
|
.join(User, User.id == Session.user_id)
|
||||||
|
.where(Session.token_hash == token_hash)
|
||||||
|
)
|
||||||
|
).one_or_none()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise SessionInvalid("session not found")
|
||||||
|
session_row, user = row
|
||||||
|
|
||||||
|
if session_row.revoked_at is not None:
|
||||||
|
raise SessionInvalid("session has been revoked")
|
||||||
|
if session_row.expires_at < datetime.now(UTC):
|
||||||
|
raise SessionInvalid("session has expired")
|
||||||
|
if not user.is_active:
|
||||||
|
raise SessionInvalid("account is disabled")
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
if now - session_row.last_seen_at > _LAST_SEEN_THROTTLE:
|
||||||
|
await db.execute(
|
||||||
|
update(Session).where(Session.id == session_row.id).values(last_seen_at=now)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AuthenticatedSession(
|
||||||
|
user_id=user.id, email=user.email, display_name=user.display_name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def logout(raw_token: str) -> None:
|
||||||
|
token_hash = hash_token(raw_token)
|
||||||
|
async with auth_session() as db:
|
||||||
|
async with db.begin():
|
||||||
|
await db.execute(
|
||||||
|
update(Session)
|
||||||
|
.where(Session.token_hash == token_hash, Session.revoked_at.is_(None))
|
||||||
|
.values(revoked_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Application settings, read from environment variables.
|
||||||
|
|
||||||
|
Two separate database DSNs are deliberate, not an oversight — see db.py for why: one connects
|
||||||
|
as a role with BYPASSRLS (used only by the auth bootstrap path, which must look identity up
|
||||||
|
*before* it can be scoped), the other as a normal RLS-subject role (used for every other query).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic import PostgresDsn
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_prefix="VELODROME_", extra="ignore")
|
||||||
|
|
||||||
|
# Scoped role: NOBYPASSRLS. Every request-handling query outside the auth bootstrap uses this.
|
||||||
|
database_url_app: PostgresDsn
|
||||||
|
# Bootstrap role: BYPASSRLS. Used ONLY by velodrome.auth.service for the pre-identity lookups
|
||||||
|
# (login by email, session-by-token, invite-by-code) and for creating new users/sessions.
|
||||||
|
database_url_auth: PostgresDsn
|
||||||
|
|
||||||
|
# AES-GCM key for encrypting third-party credentials (e.g. the Bryton digest, added in a later
|
||||||
|
# phase). Not used yet in Phase 0, but declared now so the settings shape is stable.
|
||||||
|
secret_key: str = "dev-only-insecure-placeholder-change-me"
|
||||||
|
|
||||||
|
session_cookie_name: str = "vd_session"
|
||||||
|
session_ttl_days: int = 90
|
||||||
|
public_url: str = "http://localhost:5173"
|
||||||
|
|
||||||
|
environment: str = "development"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Two database engines, on purpose.
|
||||||
|
|
||||||
|
`app` engine: connects as a role with RLS enforced (NOBYPASSRLS). Every request handler that has
|
||||||
|
already established who the caller is uses this, wrapped in `scoped_session()` below, which sets
|
||||||
|
`app.user_id` for the transaction so RLS policies can key off it.
|
||||||
|
|
||||||
|
`auth` engine: connects as a role with BYPASSRLS. Used ONLY by velodrome.auth.service, and only
|
||||||
|
for the narrow set of queries that must run *before* identity is known — looking a user up by
|
||||||
|
email at login, a session up by its hashed token, an invite up by its hashed code — plus the
|
||||||
|
inserts that create those rows in the first place. Nothing outside auth/service.py should import
|
||||||
|
this engine; if you find yourself reaching for it elsewhere, the query almost certainly belongs
|
||||||
|
in a repository method on the scoped session instead (see CLAUDE.md invariant #4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
from velodrome.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _make_engine(url: str) -> AsyncEngine:
|
||||||
|
settings = get_settings()
|
||||||
|
# NullPool in dev/test keeps behaviour predictable across the pytest-asyncio event loop;
|
||||||
|
# production tuning (pool_size etc.) is a deploy-time concern, not a Phase-0 one.
|
||||||
|
return create_async_engine(
|
||||||
|
url,
|
||||||
|
poolclass=NullPool if settings.environment == "test" else None,
|
||||||
|
echo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_app_engine: AsyncEngine | None = None
|
||||||
|
_auth_engine: AsyncEngine | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def app_engine() -> AsyncEngine:
|
||||||
|
global _app_engine
|
||||||
|
if _app_engine is None:
|
||||||
|
_app_engine = _make_engine(str(get_settings().database_url_app))
|
||||||
|
return _app_engine
|
||||||
|
|
||||||
|
|
||||||
|
def auth_engine() -> AsyncEngine:
|
||||||
|
global _auth_engine
|
||||||
|
if _auth_engine is None:
|
||||||
|
_auth_engine = _make_engine(str(get_settings().database_url_auth))
|
||||||
|
return _auth_engine
|
||||||
|
|
||||||
|
|
||||||
|
_app_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
_auth_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _app_sessions() -> async_sessionmaker[AsyncSession]:
|
||||||
|
global _app_sessionmaker
|
||||||
|
if _app_sessionmaker is None:
|
||||||
|
_app_sessionmaker = async_sessionmaker(app_engine(), expire_on_commit=False)
|
||||||
|
return _app_sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_sessions() -> async_sessionmaker[AsyncSession]:
|
||||||
|
global _auth_sessionmaker
|
||||||
|
if _auth_sessionmaker is None:
|
||||||
|
_auth_sessionmaker = async_sessionmaker(auth_engine(), expire_on_commit=False)
|
||||||
|
return _auth_sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def auth_session() -> AsyncIterator[AsyncSession]:
|
||||||
|
"""A BYPASSRLS session. See module docstring — auth/service.py only."""
|
||||||
|
async with _auth_sessions()() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def scoped_session(user_id: UUID) -> AsyncIterator[AsyncSession]:
|
||||||
|
"""An RLS-scoped session for a known, authenticated user.
|
||||||
|
|
||||||
|
`SET LOCAL` binds to the current transaction, not the connection, so this is safe under
|
||||||
|
connection pooling — it can never leak `app.user_id` from one request into a pooled
|
||||||
|
connection reused by a different request.
|
||||||
|
"""
|
||||||
|
async with _app_sessions()() as session:
|
||||||
|
async with session.begin():
|
||||||
|
await session.execute(
|
||||||
|
# bound parameter, not string interpolation — user_id is a UUID we generated
|
||||||
|
# or validated ourselves, but there is no reason to ever risk it.
|
||||||
|
text("SELECT set_config('app.user_id', :uid, true)"),
|
||||||
|
{"uid": str(user_id)},
|
||||||
|
)
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def unscoped_session() -> AsyncIterator[AsyncSession]:
|
||||||
|
"""An `app`-role session with no `app.user_id` set.
|
||||||
|
|
||||||
|
RLS policies default-deny when `current_setting('app.user_id', true)` is NULL, so this sees
|
||||||
|
zero rows of any user-owned table — useful for health checks and anything that only touches
|
||||||
|
non-RLS tables. Prefer `scoped_session` whenever a user is known.
|
||||||
|
"""
|
||||||
|
async with _app_sessions()() as session:
|
||||||
|
yield session
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""UUIDv7 generation.
|
||||||
|
|
||||||
|
Postgres 16 has no built-in uuidv7() function (that lands in PG 18) and we deliberately don't
|
||||||
|
want random UUIDv4 for primary keys — v7 is time-ordered, which keeps btree index locality sane
|
||||||
|
and lets a future client generate its own row IDs offline (see docs/PLAN.md, streams/offline
|
||||||
|
outbox design). So IDs are generated in application code, not by the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from uuid6 import uuid7
|
||||||
|
|
||||||
|
|
||||||
|
def new_id() -> UUID:
|
||||||
|
return uuid7()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from velodrome.models.base import Base
|
||||||
|
from velodrome.models.identity import ApiToken, Invite, Session, User
|
||||||
|
|
||||||
|
__all__ = ["ApiToken", "Base", "Invite", "Session", "User"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
# Every timestamp in this schema is timestamptz UTC (docs/PLAN.md's convention) — without
|
||||||
|
# this, a bare `Mapped[datetime]` infers a naive TIMESTAMP column, which then silently
|
||||||
|
# disagrees with a migration that (correctly) declares DateTime(timezone=True), and asyncpg
|
||||||
|
# rejects the mismatch at insert time. Setting it once here means every current and future
|
||||||
|
# model gets it right by default instead of each column needing to repeat it.
|
||||||
|
type_annotation_map = {
|
||||||
|
datetime: DateTime(timezone=True),
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Identity tables: users, invites, sessions, api_tokens.
|
||||||
|
|
||||||
|
See docs/PLAN.md "Auth" and "Schema > Tables > Identity" for the design rationale, and
|
||||||
|
db.py's module docstring for why two DB roles exist. RLS policies for these tables are created
|
||||||
|
in the baseline Alembic migration (alembic/versions/0001_baseline.py), not here — SQLAlchemy
|
||||||
|
models describe columns, not database-level security policy, and keeping the policy SQL visible
|
||||||
|
and reviewable in the migration is deliberate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import ARRAY, ForeignKey, LargeBinary, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import INET
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
from velodrome.ids import new_id
|
||||||
|
from velodrome.models.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id)
|
||||||
|
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC")
|
||||||
|
# Display-only, per CLAUDE.md invariant #3 — storage is always SI, this never touches a query.
|
||||||
|
unit_system: Mapped[str] = mapped_column(String(10), nullable=False, default="imperial")
|
||||||
|
is_active: Mapped[bool] = mapped_column(nullable=False, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
|
||||||
|
|
||||||
|
sessions: Mapped[list["Session"]] = relationship(back_populates="user")
|
||||||
|
api_tokens: Mapped[list["ApiToken"]] = relationship(back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
|
class Invite(Base):
|
||||||
|
__tablename__ = "invites"
|
||||||
|
|
||||||
|
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id)
|
||||||
|
# sha256 digest of the invite code. The code itself is never stored anywhere — see
|
||||||
|
# auth/service.py. 32 bytes for sha256.
|
||||||
|
code_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False)
|
||||||
|
created_by: Mapped[UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True), ForeignKey("users.id"), nullable=False
|
||||||
|
)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||||
|
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(nullable=False)
|
||||||
|
max_uses: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||||
|
used_count: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
# sha256 of the opaque bearer token. The token itself is returned to the client exactly once,
|
||||||
|
# at login, and never stored — see auth/security.py.
|
||||||
|
token_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False)
|
||||||
|
client: Mapped[str] = mapped_column(String(20), nullable=False, default="web")
|
||||||
|
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
ip: Mapped[str | None] = mapped_column(INET, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(server_default=func.now(), nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(nullable=False)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="sessions")
|
||||||
|
|
||||||
|
|
||||||
|
class ApiToken(Base):
|
||||||
|
__tablename__ = "api_tokens"
|
||||||
|
|
||||||
|
id: Mapped[UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=new_id)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(
|
||||||
|
PGUUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||||
|
token_hash: Mapped[bytes] = mapped_column(LargeBinary(32), unique=True, nullable=False)
|
||||||
|
scopes: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=list)
|
||||||
|
last_used_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(back_populates="api_tokens")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Request/response models for the auth endpoints.
|
||||||
|
|
||||||
|
These are the ONLY thing standing between the database and the HTTP response — FastAPI serialises
|
||||||
|
a SQLAlchemy/dataclass object through whichever `response_model` a route declares, so a field
|
||||||
|
simply not being listed here is what keeps password_hash/token_hash out of every response. When
|
||||||
|
adding a new field, ask whether it belongs in a response before adding it, not after.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8, max_length=200)
|
||||||
|
display_name: str = Field(min_length=1, max_length=200)
|
||||||
|
invite_code: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
Reference in New Issue
Block a user