Merge pull request 'feat(api): FastAPI skeleton with two-role RLS auth foundation' (#2) from feat/api-skeleton into main
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
+15
-6
@@ -58,8 +58,10 @@ jobs:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_USER: velodrome
|
||||
POSTGRES_PASSWORD: velodrome
|
||||
# Superuser — this is the "owner" role migrations run as (see alembic/env.py); it's
|
||||
# what CREATEs the two runtime roles below, which is why it isn't one of them.
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: velodrome_test
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
@@ -67,7 +69,12 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -162,8 +169,8 @@ jobs:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_USER: velodrome
|
||||
POSTGRES_PASSWORD: velodrome
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: velodrome_mig
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
@@ -171,7 +178,9 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
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:
|
||||
- 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