Scaffold FastAPI + MySQL + Keycloak service with devcontainer
Sets up the project skeleton: - FastAPI app factory with lifespan, request-id middleware, and RFC 9457 problem+json error handlers - Async SQLAlchemy 2.0 over MySQL (asyncmy), with a constraint naming convention in place before the first migration and async Alembic - Keycloak as a pure resource server: OIDC discovery, cached JWKS with rotation-aware refresh, and require_roles dependencies - Devcontainer running MySQL 8.4 and Keycloak 26.7 as compose siblings, with the realm (clients, roles, test users) imported on first boot - Test suite covering the endpoints plus the token validator itself, exercised against a locally generated RSA keypair - uv packaging, ruff, mypy --strict, pre-commit, Gitea CI, prod Dockerfile Two Keycloak-in-containers traps are handled explicitly and documented in the README: the issuer/internal-URL split (the browser sees localhost:8080, the API sees keycloak:8080) and the audience mapper that stops Keycloak issuing tokens with aud=account. The devices resource is a placeholder proving the routing -> auth -> ORM -> migration path end to end; replace it with the real domain. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
commit
0526d34e42
52 files changed
+4433
No files matched your search
@@ -0,0 +1,16 @@
|
|||||||
|
FROM mcr.microsoft.com/devcontainers/python:1-3.13-bookworm
|
||||||
|
|
||||||
|
# `mysql` CLI for poking at the database during development.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends default-mysql-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Pinned rather than :latest so a rebuild months from now is reproducible.
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.9.7 /uv /uvx /usr/local/bin/
|
||||||
|
|
||||||
|
ENV UV_PROJECT_ENVIRONMENT=/workspaces/v2x-server/.venv \
|
||||||
|
# Bind mounts can't be hardlinked into, which is uv's default install mode.
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PATH=/workspaces/v2x-server/.venv/bin:$PATH
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Adds the development container to the stack defined in ../compose.yaml.
|
||||||
|
# Merged by devcontainer.json; not useful on its own.
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: .devcontainer/Dockerfile
|
||||||
|
command: sleep infinity
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ..:/workspaces/v2x-server:cached
|
||||||
|
# Keep the Linux venv out of the macOS/Windows bind mount. Without this,
|
||||||
|
# host-side tooling and platform-specific wheels collide.
|
||||||
|
- venv:/workspaces/v2x-server/.venv
|
||||||
|
environment:
|
||||||
|
APP_ENV: local
|
||||||
|
DEBUG: "true"
|
||||||
|
DATABASE_URL: mysql+asyncmy://v2x:v2xpassword@mysql:3306/v2x?charset=utf8mb4
|
||||||
|
TEST_DATABASE_URL: mysql+asyncmy://v2x:v2xpassword@mysql:3306/v2x_test?charset=utf8mb4
|
||||||
|
# Validated against the `iss` claim -- must match the URL the browser used
|
||||||
|
# to obtain the token, not the one this container dials.
|
||||||
|
KEYCLOAK_ISSUER: http://localhost:8080/realms/v2x
|
||||||
|
# Where this container actually reaches Keycloak for discovery + JWKS.
|
||||||
|
# KC_HOSTNAME_BACKCHANNEL_DYNAMIC makes discovery hand back a jwks_uri on
|
||||||
|
# this same host, so the fetch stays on the compose network.
|
||||||
|
KEYCLOAK_INTERNAL_URL: http://keycloak:8080
|
||||||
|
KEYCLOAK_REALM: v2x
|
||||||
|
KEYCLOAK_AUDIENCE: v2x-api
|
||||||
|
KEYCLOAK_SWAGGER_CLIENT_ID: v2x-swagger
|
||||||
|
CORS_ORIGINS: '["http://localhost:3000","http://localhost:8000"]'
|
||||||
|
depends_on:
|
||||||
|
# Migrations need a live database, so wait for MySQL properly.
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
# Keycloak is only contacted lazily, on the first token validation. Not
|
||||||
|
# blocking on its health keeps a slow or failed realm import from
|
||||||
|
# preventing the devcontainer from opening at all.
|
||||||
|
keycloak:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
venv:
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "v2x-server",
|
||||||
|
"dockerComposeFile": ["../compose.yaml", "compose.override.yaml"],
|
||||||
|
"service": "app",
|
||||||
|
"workspaceFolder": "/workspaces/v2x-server",
|
||||||
|
"shutdownAction": "stopCompose",
|
||||||
|
|
||||||
|
"forwardPorts": [8000, 8080, 3306],
|
||||||
|
"portsAttributes": {
|
||||||
|
"8000": { "label": "API", "onAutoForward": "notify" },
|
||||||
|
"8080": { "label": "Keycloak", "onAutoForward": "silent" },
|
||||||
|
"3306": { "label": "MySQL", "onAutoForward": "silent" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// The venv lives on a named volume owned by root until first use.
|
||||||
|
"postCreateCommand": "sudo chown vscode:vscode .venv && uv sync --frozen && uv run pre-commit install",
|
||||||
|
|
||||||
|
"remoteUser": "vscode",
|
||||||
|
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.vscode-pylance",
|
||||||
|
"charliermarsh.ruff",
|
||||||
|
"ms-python.mypy-type-checker",
|
||||||
|
"tamasfe.even-better-toml"
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"python.defaultInterpreterPath": "/workspaces/v2x-server/.venv/bin/python",
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
|
"python.testing.pytestArgs": ["tests"],
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"[python]": {
|
||||||
|
"editor.defaultFormatter": "charliermarsh.ruff",
|
||||||
|
"editor.codeActionsOnSave": { "source.organizeImports": "explicit" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.devcontainer
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
.coverage
|
||||||
|
htmlcov
|
||||||
|
tests
|
||||||
|
docker
|
||||||
|
compose.yaml
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Copy to .env only if you need to override the devcontainer defaults.
|
||||||
|
# The devcontainer supplies all of these through compose, so a fresh clone runs
|
||||||
|
# with no .env file at all.
|
||||||
|
|
||||||
|
APP_ENV=local
|
||||||
|
DEBUG=true
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# --- database ---------------------------------------------------------------
|
||||||
|
DATABASE_URL=mysql+asyncmy://v2x:v2xpassword@mysql:3306/v2x?charset=utf8mb4
|
||||||
|
TEST_DATABASE_URL=mysql+asyncmy://v2x:v2xpassword@mysql:3306/v2x_test?charset=utf8mb4
|
||||||
|
|
||||||
|
# --- keycloak ---------------------------------------------------------------
|
||||||
|
# These two URLs differ on purpose in local development; see README, "The two
|
||||||
|
# URL traps". KEYCLOAK_ISSUER is compared against the token's `iss` claim and
|
||||||
|
# must match the URL the *client* used. KEYCLOAK_INTERNAL_URL is how this
|
||||||
|
# process reaches Keycloak on the container network.
|
||||||
|
KEYCLOAK_ISSUER=http://localhost:8080/realms/v2x
|
||||||
|
KEYCLOAK_INTERNAL_URL=http://keycloak:8080
|
||||||
|
KEYCLOAK_REALM=v2x
|
||||||
|
KEYCLOAK_AUDIENCE=v2x-api
|
||||||
|
KEYCLOAK_SWAGGER_CLIENT_ID=v2x-swagger
|
||||||
|
|
||||||
|
# --- http -------------------------------------------------------------------
|
||||||
|
CORS_ORIGINS=["http://localhost:3000","http://localhost:8000"]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
env:
|
||||||
|
MYSQL_ROOT_PASSWORD: rootpassword
|
||||||
|
MYSQL_DATABASE: v2x_test
|
||||||
|
MYSQL_USER: v2x
|
||||||
|
MYSQL_PASSWORD: v2xpassword
|
||||||
|
ports:
|
||||||
|
- 3306:3306
|
||||||
|
options: >-
|
||||||
|
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -prootpassword"
|
||||||
|
--health-interval=5s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=20
|
||||||
|
|
||||||
|
env:
|
||||||
|
# CI reaches MySQL over the mapped port; Keycloak is never contacted
|
||||||
|
# because the auth tests mint their own tokens against a stub JWKS.
|
||||||
|
DATABASE_URL: mysql+asyncmy://v2x:[email protected]:3306/v2x_test?charset=utf8mb4
|
||||||
|
TEST_DATABASE_URL: mysql+asyncmy://v2x:[email protected]:3306/v2x_test?charset=utf8mb4
|
||||||
|
APP_ENV: test
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.9.7"
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: |
|
||||||
|
uv run ruff check .
|
||||||
|
uv run ruff format --check .
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: uv run mypy
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: uv run pytest --cov --cov-report=term-missing
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Tooling caches
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Editors / OS
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-json
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: check-added-large-files
|
||||||
|
- id: detect-private-key
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.9.6
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
name: mypy
|
||||||
|
entry: uv run mypy
|
||||||
|
language: system
|
||||||
|
types: [python]
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
- id: uv-lock-check
|
||||||
|
name: uv.lock matches pyproject.toml
|
||||||
|
entry: uv lock --check
|
||||||
|
language: system
|
||||||
|
files: ^(pyproject\.toml|uv\.lock)$
|
||||||
|
pass_filenames: false
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
# Production image. Development uses .devcontainer/Dockerfile instead.
|
||||||
|
|
||||||
|
FROM python:3.13-slim-bookworm AS builder
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.9.7 /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_PROJECT_ENVIRONMENT=/opt/venv
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Dependencies resolve from the lockfile alone, so this layer stays cached
|
||||||
|
# across source-only changes.
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY README.md ./
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.13-slim-bookworm AS runtime
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 1001 app \
|
||||||
|
&& useradd --system --uid 1001 --gid app --create-home app
|
||||||
|
|
||||||
|
ENV PATH=/opt/venv/bin:$PATH \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
COPY --from=builder --chown=app:app /opt/venv /opt/venv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --chown=app:app alembic.ini ./
|
||||||
|
COPY --chown=app:app migrations/ ./migrations/
|
||||||
|
|
||||||
|
USER app
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Migrations are deliberately NOT run here: with more than one replica they
|
||||||
|
# would race. Run `alembic upgrade head` as a separate deploy step.
|
||||||
|
CMD ["gunicorn", "v2x_server.main:app", \
|
||||||
|
"--worker-class", "uvicorn.workers.UvicornWorker", \
|
||||||
|
"--workers", "4", \
|
||||||
|
"--bind", "0.0.0.0:8000", \
|
||||||
|
"--access-logfile", "-", \
|
||||||
|
"--timeout", "60"]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
.DEFAULT_GOAL := help
|
||||||
|
.PHONY: help install dev migrate revision downgrade test lint fmt typecheck check shell db up down reset kc-export
|
||||||
|
|
||||||
|
help: ## Show this help
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||||
|
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
install: ## Sync the virtualenv from uv.lock
|
||||||
|
uv sync --frozen
|
||||||
|
|
||||||
|
dev: ## Run the API with autoreload on :8000
|
||||||
|
uv run uvicorn v2x_server.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
migrate: ## Apply migrations up to head
|
||||||
|
uv run alembic upgrade head
|
||||||
|
|
||||||
|
revision: ## Autogenerate a migration: make revision m="add widgets"
|
||||||
|
@test -n "$(m)" || (echo 'Usage: make revision m="description"' && exit 1)
|
||||||
|
uv run alembic revision --autogenerate -m "$(m)"
|
||||||
|
|
||||||
|
downgrade: ## Roll back one migration
|
||||||
|
uv run alembic downgrade -1
|
||||||
|
|
||||||
|
test: ## Run the test suite
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
lint: ## Lint (no changes written)
|
||||||
|
uv run ruff check .
|
||||||
|
uv run ruff format --check .
|
||||||
|
|
||||||
|
fmt: ## Format and autofix
|
||||||
|
uv run ruff check --fix .
|
||||||
|
uv run ruff format .
|
||||||
|
|
||||||
|
typecheck: ## Static type check
|
||||||
|
uv run mypy
|
||||||
|
|
||||||
|
check: lint typecheck test ## Everything CI runs
|
||||||
|
|
||||||
|
shell: ## Python REPL with the app importable
|
||||||
|
uv run python
|
||||||
|
|
||||||
|
db: ## Open a MySQL shell on the dev database
|
||||||
|
mysql -h mysql -u v2x -pv2xpassword v2x
|
||||||
|
|
||||||
|
up: ## Start the backing services (outside the devcontainer)
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
down: ## Stop the backing services
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
reset: ## Destroy all data and start clean
|
||||||
|
docker compose down -v
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
kc-export: ## Re-export the realm after editing it in the Keycloak console
|
||||||
|
docker compose exec keycloak /opt/keycloak/bin/kc.sh export \
|
||||||
|
--realm v2x --file /tmp/realm-v2x.json --users realm_file
|
||||||
|
docker compose cp keycloak:/tmp/realm-v2x.json ./docker/keycloak/realm-v2x.json
|
||||||
|
@echo "Exported. Review the diff before committing -- exports include volatile fields."
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# v2x-server
|
||||||
|
|
||||||
|
FastAPI service backed by MySQL, with authentication delegated to Keycloak.
|
||||||
|
|
||||||
|
The API is a pure **OAuth 2.0 resource server**: it validates Bearer access tokens against the realm's
|
||||||
|
published signing keys and never runs a login flow itself. Clients obtain tokens from Keycloak directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quickstart (devcontainer)
|
||||||
|
|
||||||
|
Requires Docker and VS Code with the Dev Containers extension. Nothing else — no local Python.
|
||||||
|
|
||||||
|
1. Clone the repo and open it in VS Code.
|
||||||
|
2. **Reopen in Container** when prompted (or `Cmd-Shift-P` → *Dev Containers: Reopen in Container*).
|
||||||
|
The first build brings up MySQL and Keycloak, waits for both to report healthy, and installs
|
||||||
|
dependencies from `uv.lock`.
|
||||||
|
3. Inside the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make migrate # create the schema
|
||||||
|
make dev # http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Open <http://localhost:8000/docs>, click **Authorize**, and log in as `admin@example.com` /
|
||||||
|
`password`. Every endpoint is now callable from the browser.
|
||||||
|
|
||||||
|
`make help` lists everything else.
|
||||||
|
|
||||||
|
| Service | URL | Credentials |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| API | <http://localhost:8000> | Bearer token from Keycloak |
|
||||||
|
| Swagger UI | <http://localhost:8000/docs> | Authorize button (PKCE) |
|
||||||
|
| Keycloak admin | <http://localhost:8080> | `admin` / `admin` |
|
||||||
|
| MySQL | `localhost:3306` | `v2x` / `v2xpassword`, database `v2x` |
|
||||||
|
|
||||||
|
### Development users
|
||||||
|
|
||||||
|
Realm `v2x`, all with password `password`. Dev-only; they exist because
|
||||||
|
`docker/keycloak/realm-v2x.json` is imported on first boot.
|
||||||
|
|
||||||
|
| User | Realm roles | Can |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `admin@example.com` | `admin`, `operator`, `viewer` | everything, including DELETE |
|
||||||
|
| `operator@example.com` | `operator`, `viewer` | read + create/update |
|
||||||
|
| `viewer@example.com` | `viewer` | read only |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The two URL traps
|
||||||
|
|
||||||
|
Almost every "Keycloak + containers" problem is one of these two. Both are already handled here;
|
||||||
|
this section is so you recognise them if you change the configuration.
|
||||||
|
|
||||||
|
### 1. Issuer mismatch
|
||||||
|
|
||||||
|
Your browser reaches Keycloak at `http://localhost:8080`. The API container reaches it at
|
||||||
|
`http://keycloak:8080`. A token minted through the browser therefore carries
|
||||||
|
`iss: http://localhost:8080/realms/v2x` — and an API that expects the internal hostname rejects
|
||||||
|
every single token with a misleading "invalid issuer".
|
||||||
|
|
||||||
|
The fix is **two separate settings** rather than one:
|
||||||
|
|
||||||
|
| Setting | Value locally | Used for |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `KEYCLOAK_ISSUER` | `http://localhost:8080/realms/v2x` | compared against the token's `iss` claim |
|
||||||
|
| `KEYCLOAK_INTERNAL_URL` | `http://keycloak:8080` | fetching discovery + JWKS |
|
||||||
|
|
||||||
|
Keycloak is pinned with `KC_HOSTNAME=http://localhost:8080` so *all* tokens use the browser-facing
|
||||||
|
issuer, and `KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true` so the discovery document still hands back a
|
||||||
|
`jwks_uri` the container can actually reach. In deployed environments both settings point at the same
|
||||||
|
public URL and the distinction disappears.
|
||||||
|
|
||||||
|
`OIDCProvider` logs a loud warning at startup if these two disagree.
|
||||||
|
|
||||||
|
### 2. The `aud` claim is `account`
|
||||||
|
|
||||||
|
By default Keycloak issues access tokens whose audience is `account`, not your API. Strict audience
|
||||||
|
validation then rejects everything. The fix is an **audience mapper** on the client, which
|
||||||
|
`realm-v2x.json` already includes for both `v2x-api` and `v2x-swagger`.
|
||||||
|
|
||||||
|
If you create a new client in the admin console, add the mapper by hand:
|
||||||
|
*Clients → your client → Client scopes → dedicated → Add mapper → By configuration → Audience →
|
||||||
|
Included Client Audience: `v2x-api`*.
|
||||||
|
|
||||||
|
Check what a token actually contains with `GET /api/v1/me`, or paste it into <https://jwt.io>.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting a token outside the browser
|
||||||
|
|
||||||
|
The dev realm enables the direct password grant for exactly this:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=$(curl -s \
|
||||||
|
-d 'client_id=v2x-swagger' \
|
||||||
|
-d '[email protected]' \
|
||||||
|
-d 'password=password' \
|
||||||
|
-d 'grant_type=password' \
|
||||||
|
http://localhost:8080/realms/v2x/protocol/openid-connect/token | jq -r .access_token)
|
||||||
|
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/me | jq
|
||||||
|
curl -s -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/devices | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not enable the password grant in production — it exists here to keep scripted checks simple.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/v2x_server/
|
||||||
|
├── main.py app factory, lifespan, middleware
|
||||||
|
├── core/ config, logging, error handlers
|
||||||
|
├── auth/ OIDC discovery, JWKS cache, principal, dependencies
|
||||||
|
├── db/ declarative base, async engine + session dependency
|
||||||
|
├── models/ SQLAlchemy models
|
||||||
|
├── schemas/ pydantic request/response models
|
||||||
|
├── repositories/ queries, kept out of route handlers
|
||||||
|
└── api/v1/ routers
|
||||||
|
```
|
||||||
|
|
||||||
|
`devices` is a **placeholder resource**. It exists to prove the whole path — routing, authorization,
|
||||||
|
validation, ORM, migration, tests — actually works end to end. Replace it with the real domain;
|
||||||
|
nothing else depends on it.
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
Roles come from the token; `require_roles` turns them into a dependency:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@router.post("", dependencies=[Depends(require_roles("operator", "admin"))])
|
||||||
|
async def create_device(...): ...
|
||||||
|
```
|
||||||
|
|
||||||
|
An unauthenticated request is **401** with a `WWW-Authenticate` header. An authenticated caller who
|
||||||
|
lacks the role is **403** — retrying with the same token will never help.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common tasks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make dev # run with autoreload
|
||||||
|
make test # pytest
|
||||||
|
make check # lint + typecheck + test (what CI runs)
|
||||||
|
make revision m="add sensors" # autogenerate a migration
|
||||||
|
make migrate # apply migrations
|
||||||
|
make db # MySQL shell
|
||||||
|
make reset # destroy all data and containers, start clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations are never applied automatically at startup: with more than one replica, they would race.
|
||||||
|
Run `alembic upgrade head` as an explicit deploy step.
|
||||||
|
|
||||||
|
### Changing the Keycloak realm
|
||||||
|
|
||||||
|
Edit it in the admin console, then persist the change so it survives `make reset`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make kc-export # rewrites docker/keycloak/realm-v2x.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Review the diff before committing — exports contain volatile ids and timestamps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests run against a real MySQL database (`v2x_test`, created on first boot), not SQLite: this project
|
||||||
|
depends on MySQL's types, collation and constraint behaviour, and a SQLite-backed suite would pass
|
||||||
|
while production broke. Each test runs inside a transaction that is rolled back afterwards.
|
||||||
|
|
||||||
|
Endpoint tests override authentication via the `as_user` fixture. The token validator itself is tested
|
||||||
|
separately in `tests/auth/` against a locally generated RSA keypair, covering expiry, wrong issuer,
|
||||||
|
wrong audience, unknown key id, `alg: none`, tampered payloads and missing claims — because a suite
|
||||||
|
that only ever overrides auth would never notice the validator accepting anything.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production notes
|
||||||
|
|
||||||
|
- Build the runtime image from the root `Dockerfile` (multi-stage, non-root, gunicorn + uvicorn workers).
|
||||||
|
- Set `KEYCLOAK_ISSUER` and `KEYCLOAK_INTERNAL_URL` to the same public HTTPS URL.
|
||||||
|
- Run Keycloak in `start` mode with a real database, not `start-dev`.
|
||||||
|
- `/healthz` is the liveness probe; `/readyz` (checks MySQL and JWKS) is the readiness probe. Do not
|
||||||
|
point liveness at `/readyz` — a brief Keycloak outage would otherwise crash-loop the API.
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = src
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# The URL is intentionally absent: migrations/env.py reads it from Settings so
|
||||||
|
# there is exactly one source of truth for the database location.
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
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,75 @@
|
|||||||
|
# Backing services for v2x-server.
|
||||||
|
#
|
||||||
|
# Used two ways:
|
||||||
|
# * on its own -> `docker compose up -d` for a local stack
|
||||||
|
# * with .devcontainer/compose.override.yaml -> adds the "app" dev container
|
||||||
|
#
|
||||||
|
# Credentials here are development-only and intentionally committed.
|
||||||
|
|
||||||
|
name: v2x-server
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_0900_ai_ci
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: rootpassword
|
||||||
|
MYSQL_DATABASE: v2x
|
||||||
|
MYSQL_USER: v2x
|
||||||
|
MYSQL_PASSWORD: v2xpassword
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql-data:/var/lib/mysql
|
||||||
|
# Init scripts run only on the first boot of an empty data volume.
|
||||||
|
- ./docker/mysql/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-prootpassword"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
keycloak:
|
||||||
|
image: quay.io/keycloak/keycloak:26.7.0
|
||||||
|
command: ["start-dev", "--import-realm"]
|
||||||
|
environment:
|
||||||
|
# Keycloak 26 renamed these from KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD.
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||||
|
# Pin the public identity so every issued token carries
|
||||||
|
# iss=http://localhost:8080/realms/v2x, regardless of which network path
|
||||||
|
# produced it. See README "The two URL traps".
|
||||||
|
KC_HOSTNAME: http://localhost:8080
|
||||||
|
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
|
||||||
|
KC_HTTP_ENABLED: "true"
|
||||||
|
KC_HEALTH_ENABLED: "true"
|
||||||
|
# Dev mode keeps the realm in an embedded H2 file inside this volume --
|
||||||
|
# deliberately not MySQL, so wiping app data never destroys identity data.
|
||||||
|
KC_DB: dev-file
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "9000:9000"
|
||||||
|
volumes:
|
||||||
|
- keycloak-data:/opt/keycloak/data
|
||||||
|
- ./docker/keycloak:/opt/keycloak/data/import:ro
|
||||||
|
healthcheck:
|
||||||
|
# The image ships no curl or wget, so probe the management port through
|
||||||
|
# bash's /dev/tcp. Informational only -- nothing blocks on it, because a
|
||||||
|
# failed probe here should not stop you from opening the devcontainer.
|
||||||
|
test:
|
||||||
|
- "CMD-SHELL"
|
||||||
|
- >-
|
||||||
|
exec 3<>/dev/tcp/127.0.0.1/9000 &&
|
||||||
|
printf 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 &&
|
||||||
|
cat <&3 | grep -q 'UP'
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql-data:
|
||||||
|
keycloak-data:
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
{
|
||||||
|
"realm": "v2x",
|
||||||
|
"displayName": "V2X (development)",
|
||||||
|
"enabled": true,
|
||||||
|
"sslRequired": "none",
|
||||||
|
"registrationAllowed": false,
|
||||||
|
"loginWithEmailAllowed": true,
|
||||||
|
"duplicateEmailsAllowed": false,
|
||||||
|
"accessTokenLifespan": 1800,
|
||||||
|
"ssoSessionIdleTimeout": 3600,
|
||||||
|
"ssoSessionMaxLifespan": 36000,
|
||||||
|
|
||||||
|
"roles": {
|
||||||
|
"realm": [
|
||||||
|
{ "name": "admin", "description": "Full administrative access, including deletes." },
|
||||||
|
{ "name": "operator", "description": "May create and modify resources." },
|
||||||
|
{ "name": "viewer", "description": "Read-only access." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "v2x-api",
|
||||||
|
"name": "V2X API (resource server)",
|
||||||
|
"description": "The FastAPI service. Validates tokens; also holds a service account for machine-to-machine callers.",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": false,
|
||||||
|
"secret": "dev-only-api-secret",
|
||||||
|
"bearerOnly": false,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": true,
|
||||||
|
"fullScopeAllowed": true,
|
||||||
|
"attributes": {
|
||||||
|
"access.token.lifespan": "1800"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "v2x-api-audience",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"included.client.audience": "v2x-api",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"introspection.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "v2x-swagger",
|
||||||
|
"name": "V2X Swagger UI",
|
||||||
|
"description": "Public client used by /docs to run the authorization-code + PKCE flow.",
|
||||||
|
"enabled": true,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"publicClient": true,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": true,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"fullScopeAllowed": true,
|
||||||
|
"redirectUris": [
|
||||||
|
"http://localhost:8000/docs/oauth2-redirect",
|
||||||
|
"http://localhost:8000/*"
|
||||||
|
],
|
||||||
|
"webOrigins": [
|
||||||
|
"http://localhost:8000"
|
||||||
|
],
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256",
|
||||||
|
"post.logout.redirect.uris": "http://localhost:8000/*"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "v2x-api-audience",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-audience-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"included.client.audience": "v2x-api",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"introspection.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "[email protected]",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"firstName": "Ada",
|
||||||
|
"lastName": "Admin",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"requiredActions": [],
|
||||||
|
"credentials": [
|
||||||
|
{ "type": "password", "value": "password", "temporary": false }
|
||||||
|
],
|
||||||
|
"realmRoles": ["admin", "operator", "viewer"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "[email protected]",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"firstName": "Otto",
|
||||||
|
"lastName": "Operator",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"requiredActions": [],
|
||||||
|
"credentials": [
|
||||||
|
{ "type": "password", "value": "password", "temporary": false }
|
||||||
|
],
|
||||||
|
"realmRoles": ["operator", "viewer"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "[email protected]",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"firstName": "Vera",
|
||||||
|
"lastName": "Viewer",
|
||||||
|
"enabled": true,
|
||||||
|
"emailVerified": true,
|
||||||
|
"requiredActions": [],
|
||||||
|
"credentials": [
|
||||||
|
{ "type": "password", "value": "password", "temporary": false }
|
||||||
|
],
|
||||||
|
"realmRoles": ["viewer"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Runs once, on the first boot of an empty mysql-data volume.
|
||||||
|
--
|
||||||
|
-- MYSQL_DATABASE/MYSQL_USER in compose.yaml already create `v2x` and grant the
|
||||||
|
-- app user on it. This adds the separate database the test suite runs against,
|
||||||
|
-- so tests never touch development data.
|
||||||
|
|
||||||
|
CREATE DATABASE IF NOT EXISTS v2x_test
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
GRANT ALL PRIVILEGES ON v2x_test.* TO 'v2x'@'%';
|
||||||
|
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Alembic environment, async flavour."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
# Importing the model package registers every table on Base.metadata.
|
||||||
|
# Without this import, autogenerate would produce an empty migration.
|
||||||
|
from v2x_server.core.config import get_settings
|
||||||
|
from v2x_server.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _database_url() -> str:
|
||||||
|
"""Prefer an explicit override, else the configured application database.
|
||||||
|
|
||||||
|
ALEMBIC_DATABASE_URL is what the test suite sets to migrate `v2x_test`.
|
||||||
|
"""
|
||||||
|
return os.getenv("ALEMBIC_DATABASE_URL") or get_settings().database_url
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=_database_url(),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
configuration = config.get_section(config.config_ini_section, {})
|
||||||
|
configuration["sqlalchemy.url"] = _database_url()
|
||||||
|
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
configuration,
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
${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"}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,65 @@
|
|||||||
|
"""create devices table
|
||||||
|
|
||||||
|
Revision ID: 367021e73a20
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-09-10
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "367021e73a20"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"devices",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("serial", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
sa.Enum(
|
||||||
|
"active",
|
||||||
|
"inactive",
|
||||||
|
"maintenance",
|
||||||
|
name="devicestatus",
|
||||||
|
native_enum=False,
|
||||||
|
length=32,
|
||||||
|
),
|
||||||
|
server_default="active",
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("description", sa.String(length=512), nullable=True),
|
||||||
|
# Plain CURRENT_TIMESTAMP rather than the parenthesised form autogenerate
|
||||||
|
# emits under SQLite: this spelling is valid on both backends.
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("pk_devices")),
|
||||||
|
sa.UniqueConstraint("serial", name=op.f("uq_devices_serial")),
|
||||||
|
mysql_engine="InnoDB",
|
||||||
|
mysql_charset="utf8mb4",
|
||||||
|
mysql_collate="utf8mb4_0900_ai_ci",
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_devices_name"), "devices", ["name"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_devices_name"), table_name="devices")
|
||||||
|
op.drop_table("devices")
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
[project]
|
||||||
|
name = "v2x-server"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "FastAPI service backed by MySQL with Keycloak-issued OIDC authentication."
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.32",
|
||||||
|
"gunicorn>=23.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.36",
|
||||||
|
"asyncmy>=0.2.10",
|
||||||
|
"alembic>=1.14",
|
||||||
|
"pydantic>=2.10",
|
||||||
|
"pydantic-settings>=2.7",
|
||||||
|
"pyjwt[crypto]>=2.10",
|
||||||
|
"httpx>=0.28",
|
||||||
|
"python-json-logger>=3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3",
|
||||||
|
"pytest-asyncio>=0.25",
|
||||||
|
"pytest-cov>=6.0",
|
||||||
|
"mypy>=1.14",
|
||||||
|
"ruff>=0.9",
|
||||||
|
"pre-commit>=4.0",
|
||||||
|
"types-pyyaml>=6.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/v2x_server"]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
default-groups = ["dev"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py313"
|
||||||
|
src = ["src", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"W", # pycodestyle warnings
|
||||||
|
"F", # pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"C4", # flake8-comprehensions
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"ASYNC",# flake8-async
|
||||||
|
"S", # flake8-bandit
|
||||||
|
"T20", # flake8-print
|
||||||
|
"SIM", # flake8-simplify
|
||||||
|
"RUF",
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
"S101", # assert is fine (pytest)
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**" = ["S105", "S106"] # hardcoded test credentials are expected
|
||||||
|
"migrations/**" = ["I001"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.13"
|
||||||
|
strict = true
|
||||||
|
warn_unreachable = true
|
||||||
|
plugins = ["pydantic.mypy"]
|
||||||
|
mypy_path = "src"
|
||||||
|
packages = ["v2x_server"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["asyncmy.*", "alembic.*"]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "session"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-ra --strict-markers"
|
||||||
|
filterwarnings = ["error"]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src/v2x_server"]
|
||||||
|
branch = true
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
Whitespace-only changes.
@@ -0,0 +1,9 @@
|
|||||||
|
"""Aggregate router for API v1."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from v2x_server.api.v1 import devices, identity
|
||||||
|
|
||||||
|
api_router = APIRouter()
|
||||||
|
api_router.include_router(identity.router)
|
||||||
|
api_router.include_router(devices.router)
|
||||||
Whitespace-only changes.
@@ -0,0 +1,97 @@
|
|||||||
|
"""Devices resource: the worked example of an authenticated, role-gated CRUD API.
|
||||||
|
|
||||||
|
Read requires `viewer`; writes require `operator`; delete requires `admin`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from v2x_server.auth.deps import require_roles
|
||||||
|
from v2x_server.core.errors import ConflictError, NotFoundError
|
||||||
|
from v2x_server.db.session import get_session
|
||||||
|
from v2x_server.models.device import Device, DeviceStatus
|
||||||
|
from v2x_server.repositories.device import DeviceRepository
|
||||||
|
from v2x_server.schemas.device import DeviceCreate, DevicePage, DeviceRead, DeviceUpdate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/devices", tags=["devices"])
|
||||||
|
|
||||||
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_repository(session: SessionDep) -> DeviceRepository:
|
||||||
|
return DeviceRepository(session)
|
||||||
|
|
||||||
|
|
||||||
|
RepoDep = Annotated[DeviceRepository, Depends(get_repository)]
|
||||||
|
|
||||||
|
# `admin` is included everywhere so an administrator is never locked out of a
|
||||||
|
# resource they are meant to administer.
|
||||||
|
ReadAccess = Depends(require_roles("viewer", "operator", "admin"))
|
||||||
|
WriteAccess = Depends(require_roles("operator", "admin"))
|
||||||
|
DeleteAccess = Depends(require_roles("admin"))
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_or_404(repo: DeviceRepository, device_id: int) -> Device:
|
||||||
|
device = await repo.get(device_id)
|
||||||
|
if device is None:
|
||||||
|
raise NotFoundError(f"Device {device_id} does not exist")
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DevicePage, dependencies=[ReadAccess])
|
||||||
|
async def list_devices(
|
||||||
|
repo: RepoDep,
|
||||||
|
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||||
|
offset: Annotated[int, Query(ge=0)] = 0,
|
||||||
|
status_filter: Annotated[DeviceStatus | None, Query(alias="status")] = None,
|
||||||
|
) -> DevicePage:
|
||||||
|
items, total = await repo.list(limit=limit, offset=offset, status=status_filter)
|
||||||
|
return DevicePage(
|
||||||
|
items=[DeviceRead.model_validate(item) for item in items],
|
||||||
|
total=total,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{device_id}", response_model=DeviceRead, dependencies=[ReadAccess])
|
||||||
|
async def get_device(device_id: int, repo: RepoDep) -> Device:
|
||||||
|
return await _get_or_404(repo, device_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=DeviceRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[WriteAccess],
|
||||||
|
)
|
||||||
|
async def create_device(payload: DeviceCreate, repo: RepoDep) -> Device:
|
||||||
|
if await repo.get_by_serial(payload.serial):
|
||||||
|
raise ConflictError(f"A device with serial {payload.serial!r} already exists")
|
||||||
|
return await repo.create(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{device_id}", response_model=DeviceRead, dependencies=[WriteAccess])
|
||||||
|
async def update_device(device_id: int, payload: DeviceUpdate, repo: RepoDep) -> Device:
|
||||||
|
device = await _get_or_404(repo, device_id)
|
||||||
|
|
||||||
|
if payload.serial is not None and payload.serial != device.serial:
|
||||||
|
existing = await repo.get_by_serial(payload.serial)
|
||||||
|
if existing is not None and existing.id != device.id:
|
||||||
|
raise ConflictError(f"A device with serial {payload.serial!r} already exists")
|
||||||
|
|
||||||
|
return await repo.update(device, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{device_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[DeleteAccess],
|
||||||
|
)
|
||||||
|
async def delete_device(device_id: int, repo: RepoDep) -> None:
|
||||||
|
device = await _get_or_404(repo, device_id)
|
||||||
|
await repo.delete(device)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Liveness and readiness probes.
|
||||||
|
|
||||||
|
Split deliberately: `/healthz` must not depend on MySQL or Keycloak, or a
|
||||||
|
transient outage in either would make an orchestrator kill healthy pods.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Response, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from v2x_server.auth.deps import get_oidc_provider
|
||||||
|
from v2x_server.auth.keycloak import OIDCProvider
|
||||||
|
from v2x_server.db.session import get_session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/healthz", summary="Liveness probe")
|
||||||
|
async def healthz() -> dict[str, str]:
|
||||||
|
"""Is the process up? No dependencies are consulted."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/readyz", summary="Readiness probe")
|
||||||
|
async def readyz(
|
||||||
|
response: Response,
|
||||||
|
session: Annotated[AsyncSession, Depends(get_session)],
|
||||||
|
provider: Annotated[OIDCProvider, Depends(get_oidc_provider)],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Can we actually serve traffic -- database reachable, signing keys loadable?"""
|
||||||
|
checks: dict[str, str] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
checks["database"] = "ok"
|
||||||
|
except SQLAlchemyError:
|
||||||
|
logger.warning("Readiness: database check failed", exc_info=True)
|
||||||
|
checks["database"] = "error"
|
||||||
|
|
||||||
|
checks["keycloak"] = "ok" if await provider.healthy() else "error"
|
||||||
|
|
||||||
|
ready = all(value == "ok" for value in checks.values())
|
||||||
|
if not ready:
|
||||||
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
|
||||||
|
return {"status": "ready" if ready else "not ready", "checks": checks}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Endpoints that describe the caller to themselves."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from v2x_server.auth.deps import CurrentPrincipal
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/me", tags=["identity"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", summary="Echo the caller's identity and roles")
|
||||||
|
async def whoami(principal: CurrentPrincipal) -> dict[str, object]:
|
||||||
|
"""The quickest way to confirm which roles a token actually carries."""
|
||||||
|
return {
|
||||||
|
"subject": principal.subject,
|
||||||
|
"username": principal.username,
|
||||||
|
"email": principal.email,
|
||||||
|
"realm_roles": sorted(principal.realm_roles),
|
||||||
|
"client_roles": sorted(principal.client_roles),
|
||||||
|
"scopes": sorted(principal.scopes),
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,102 @@
|
|||||||
|
"""FastAPI dependencies for authentication and role-based authorization."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, Security, status
|
||||||
|
from fastapi.security import OAuth2AuthorizationCodeBearer
|
||||||
|
|
||||||
|
from v2x_server.auth.keycloak import OIDCProvider, TokenError
|
||||||
|
from v2x_server.auth.principal import Principal
|
||||||
|
from v2x_server.core.config import Settings, get_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_settings = get_settings()
|
||||||
|
|
||||||
|
# Drives the Authorize button in /docs. These URLs are the ones the *browser*
|
||||||
|
# uses, hence the public realm URL rather than the internal one.
|
||||||
|
oauth2_scheme = OAuth2AuthorizationCodeBearer(
|
||||||
|
authorizationUrl=f"{_settings.public_realm_url}/protocol/openid-connect/auth",
|
||||||
|
tokenUrl=f"{_settings.public_realm_url}/protocol/openid-connect/token",
|
||||||
|
refreshUrl=f"{_settings.public_realm_url}/protocol/openid-connect/token",
|
||||||
|
scopes={"openid": "OpenID Connect", "profile": "Profile", "email": "Email"},
|
||||||
|
auto_error=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unauthorized(detail: str, *, error: str = "invalid_token") -> HTTPException:
|
||||||
|
return HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=detail,
|
||||||
|
headers={"WWW-Authenticate": f'Bearer error="{error}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_oidc_provider(request: Request) -> OIDCProvider:
|
||||||
|
provider = getattr(request.app.state, "oidc_provider", None)
|
||||||
|
if provider is None:
|
||||||
|
raise RuntimeError("OIDC provider not initialised; is the app lifespan running?")
|
||||||
|
return provider # type: ignore[no-any-return]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_principal(
|
||||||
|
token: Annotated[str | None, Security(oauth2_scheme)],
|
||||||
|
provider: Annotated[OIDCProvider, Depends(get_oidc_provider)],
|
||||||
|
settings: Annotated[Settings, Depends(get_settings)],
|
||||||
|
) -> Principal:
|
||||||
|
"""Validate the Bearer token and build the caller's `Principal`."""
|
||||||
|
if not token:
|
||||||
|
raise _unauthorized("Missing bearer token", error="invalid_request")
|
||||||
|
|
||||||
|
try:
|
||||||
|
claims = await provider.decode(token)
|
||||||
|
except TokenError as exc:
|
||||||
|
# Log the specific reason, tell the client only that it failed.
|
||||||
|
logger.info("Rejected token: %s", exc)
|
||||||
|
raise _unauthorized("Invalid or expired token") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Principal.from_claims(claims, client_id=settings.keycloak_audience)
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
logger.warning("Token validated but claims were unusable: %s", exc)
|
||||||
|
raise _unauthorized("Token is missing required claims") from exc
|
||||||
|
|
||||||
|
|
||||||
|
CurrentPrincipal = Annotated[Principal, Depends(get_current_principal)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(
|
||||||
|
*roles: str,
|
||||||
|
mode: Literal["any", "all"] = "any",
|
||||||
|
) -> Callable[[Principal], Coroutine[Any, Any, Principal]]:
|
||||||
|
"""Dependency factory enforcing role membership.
|
||||||
|
|
||||||
|
Authentication failures are 401 (handled upstream); an authenticated caller
|
||||||
|
who simply lacks the role is 403 -- retrying with the same token won't help.
|
||||||
|
|
||||||
|
@router.post("/", dependencies=[Depends(require_roles("operator"))])
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _dependency(principal: CurrentPrincipal) -> Principal:
|
||||||
|
granted = (
|
||||||
|
principal.has_all_roles(*roles) if mode == "all" else principal.has_any_role(*roles)
|
||||||
|
)
|
||||||
|
if not granted:
|
||||||
|
logger.info(
|
||||||
|
"Denied %s (roles=%s); requires %s of %s",
|
||||||
|
principal.username or principal.subject,
|
||||||
|
sorted(principal.roles),
|
||||||
|
mode,
|
||||||
|
sorted(roles),
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=f"Requires {mode} of the following roles: {', '.join(sorted(roles))}",
|
||||||
|
)
|
||||||
|
return principal
|
||||||
|
|
||||||
|
return _dependency
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""OIDC discovery and JWKS handling for a Keycloak-issued access token.
|
||||||
|
|
||||||
|
Deliberately does not use PyJWT's `PyJWKClient`: it fetches over blocking
|
||||||
|
`urllib`, which stalls the event loop on every cache miss. `httpx` plus
|
||||||
|
`PyJWKSet` is barely more code and stays async throughout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
from jwt import PyJWK, PyJWKSet
|
||||||
|
|
||||||
|
from v2x_server.core.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Explicit allowlist. Never derive the algorithm from the token's own header --
|
||||||
|
# that is what enables `alg: none` and RS256->HS256 confusion attacks.
|
||||||
|
ALLOWED_ALGORITHMS = ["RS256"]
|
||||||
|
|
||||||
|
REQUIRED_CLAIMS = ["exp", "iat", "iss", "sub", "aud"]
|
||||||
|
|
||||||
|
|
||||||
|
class TokenError(Exception):
|
||||||
|
"""Token could not be validated. The message is for logs, not for clients."""
|
||||||
|
|
||||||
|
|
||||||
|
class OIDCProvider:
|
||||||
|
"""Caches the realm's discovery document and signing keys."""
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings, client: httpx.AsyncClient) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._client = client
|
||||||
|
# Two locks, not one: fetching the JWKS needs the discovery document, so
|
||||||
|
# a single lock would deadlock (asyncio.Lock is not reentrant).
|
||||||
|
self._metadata_lock = asyncio.Lock()
|
||||||
|
self._jwks_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
self._metadata: dict[str, Any] | None = None
|
||||||
|
self._jwks: PyJWKSet | None = None
|
||||||
|
self._jwks_fetched_at: float = 0.0
|
||||||
|
|
||||||
|
# -- discovery --------------------------------------------------------
|
||||||
|
|
||||||
|
async def metadata(self) -> dict[str, Any]:
|
||||||
|
if self._metadata is None:
|
||||||
|
async with self._metadata_lock:
|
||||||
|
if self._metadata is None:
|
||||||
|
self._metadata = await self._fetch_metadata()
|
||||||
|
return self._metadata
|
||||||
|
|
||||||
|
async def _fetch_metadata(self) -> dict[str, Any]:
|
||||||
|
url = self._settings.discovery_url
|
||||||
|
logger.info("Fetching OIDC discovery document from %s", url)
|
||||||
|
response = await self._client.get(url)
|
||||||
|
response.raise_for_status()
|
||||||
|
metadata: dict[str, Any] = response.json()
|
||||||
|
|
||||||
|
# The discovery document's `issuer` is the *frontend* URL (what tokens
|
||||||
|
# will carry), while we fetched over the backchannel. A mismatch against
|
||||||
|
# the configured issuer means KC_HOSTNAME and KEYCLOAK_ISSUER disagree,
|
||||||
|
# and every token would later fail validation -- so say so loudly now.
|
||||||
|
discovered = str(metadata.get("issuer", "")).rstrip("/")
|
||||||
|
if discovered != self._settings.keycloak_issuer:
|
||||||
|
logger.warning(
|
||||||
|
"Configured issuer %r does not match the issuer advertised by Keycloak %r; "
|
||||||
|
"tokens will be rejected. Check KEYCLOAK_ISSUER against KC_HOSTNAME.",
|
||||||
|
self._settings.keycloak_issuer,
|
||||||
|
discovered,
|
||||||
|
)
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
async def jwks_uri(self) -> str:
|
||||||
|
metadata = await self.metadata()
|
||||||
|
uri = metadata.get("jwks_uri")
|
||||||
|
if not isinstance(uri, str):
|
||||||
|
raise TokenError("Discovery document contains no jwks_uri")
|
||||||
|
return uri
|
||||||
|
|
||||||
|
# -- signing keys -----------------------------------------------------
|
||||||
|
|
||||||
|
async def _fetch_jwks(self) -> PyJWKSet:
|
||||||
|
uri = await self.jwks_uri()
|
||||||
|
response = await self._client.get(uri)
|
||||||
|
response.raise_for_status()
|
||||||
|
jwks = PyJWKSet.from_dict(response.json())
|
||||||
|
self._jwks = jwks
|
||||||
|
self._jwks_fetched_at = time.monotonic()
|
||||||
|
logger.info("Loaded %d signing key(s) from %s", len(jwks.keys), uri)
|
||||||
|
return jwks
|
||||||
|
|
||||||
|
async def get_signing_key(self, kid: str) -> PyJWK:
|
||||||
|
"""Resolve a key id, refreshing once if it is unknown.
|
||||||
|
|
||||||
|
Keycloak rotates realm keys without notice; a cached JWKS that predates
|
||||||
|
a rotation would otherwise reject every new token until restart.
|
||||||
|
"""
|
||||||
|
now = time.monotonic()
|
||||||
|
expired = now - self._jwks_fetched_at > self._settings.keycloak_jwks_ttl_seconds
|
||||||
|
|
||||||
|
if self._jwks is None or expired:
|
||||||
|
async with self._jwks_lock:
|
||||||
|
if self._jwks is None or (
|
||||||
|
time.monotonic() - self._jwks_fetched_at
|
||||||
|
> self._settings.keycloak_jwks_ttl_seconds
|
||||||
|
):
|
||||||
|
await self._fetch_jwks()
|
||||||
|
|
||||||
|
key = self._find_key(kid)
|
||||||
|
if key is not None:
|
||||||
|
return key
|
||||||
|
|
||||||
|
# Unknown kid: refresh, but not more often than the floor allows, so a
|
||||||
|
# flood of junk tokens cannot be amplified into load on Keycloak.
|
||||||
|
async with self._jwks_lock:
|
||||||
|
key = self._find_key(kid)
|
||||||
|
if key is not None:
|
||||||
|
return key
|
||||||
|
since_refresh = time.monotonic() - self._jwks_fetched_at
|
||||||
|
if since_refresh < self._settings.keycloak_jwks_min_refresh_seconds:
|
||||||
|
raise TokenError(f"Unknown signing key {kid!r} (refresh rate-limited)")
|
||||||
|
await self._fetch_jwks()
|
||||||
|
|
||||||
|
key = self._find_key(kid)
|
||||||
|
if key is None:
|
||||||
|
raise TokenError(f"Unknown signing key {kid!r}")
|
||||||
|
return key
|
||||||
|
|
||||||
|
def _find_key(self, kid: str) -> PyJWK | None:
|
||||||
|
if self._jwks is None:
|
||||||
|
return None
|
||||||
|
for key in self._jwks.keys:
|
||||||
|
if key.key_id == kid:
|
||||||
|
return key
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- validation -------------------------------------------------------
|
||||||
|
|
||||||
|
async def decode(self, token: str) -> dict[str, Any]:
|
||||||
|
"""Verify signature and claims, returning the payload.
|
||||||
|
|
||||||
|
Raises `TokenError` for every failure mode; the caller turns that into
|
||||||
|
a generic 401 so we never leak *why* a token was rejected.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
header = jwt.get_unverified_header(token)
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
raise TokenError(f"Malformed token header: {exc}") from exc
|
||||||
|
|
||||||
|
kid = header.get("kid")
|
||||||
|
if not kid:
|
||||||
|
raise TokenError("Token header has no kid")
|
||||||
|
|
||||||
|
signing_key = await self.get_signing_key(kid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload: dict[str, Any] = jwt.decode(
|
||||||
|
token,
|
||||||
|
key=signing_key.key,
|
||||||
|
algorithms=ALLOWED_ALGORITHMS,
|
||||||
|
issuer=self._settings.keycloak_issuer,
|
||||||
|
audience=self._settings.keycloak_audience,
|
||||||
|
leeway=self._settings.keycloak_leeway_seconds,
|
||||||
|
options={"require": REQUIRED_CLAIMS, "verify_aud": True},
|
||||||
|
)
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
raise TokenError(f"Token rejected: {exc}") from exc
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def healthy(self) -> bool:
|
||||||
|
"""Readiness probe: can we reach Keycloak and load its keys?"""
|
||||||
|
try:
|
||||||
|
await self._fetch_jwks()
|
||||||
|
except (httpx.HTTPError, TokenError, ValueError):
|
||||||
|
logger.warning("Keycloak readiness check failed", exc_info=True)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""The authenticated caller, decoupled from Keycloak's claim layout.
|
||||||
|
|
||||||
|
This module is the only place that knows how Keycloak shapes a token. Routes
|
||||||
|
and services depend on `Principal` alone, so swapping the IdP -- or absorbing a
|
||||||
|
claim-format change -- touches one file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Principal(BaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
subject: str
|
||||||
|
username: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
full_name: str | None = None
|
||||||
|
realm_roles: frozenset[str] = Field(default_factory=frozenset)
|
||||||
|
client_roles: frozenset[str] = Field(default_factory=frozenset)
|
||||||
|
scopes: frozenset[str] = Field(default_factory=frozenset)
|
||||||
|
raw_claims: dict[str, Any] = Field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def roles(self) -> frozenset[str]:
|
||||||
|
"""Realm and client roles together; what authorization checks use."""
|
||||||
|
return self.realm_roles | self.client_roles
|
||||||
|
|
||||||
|
def has_any_role(self, *roles: str) -> bool:
|
||||||
|
return bool(self.roles.intersection(roles))
|
||||||
|
|
||||||
|
def has_all_roles(self, *roles: str) -> bool:
|
||||||
|
return set(roles).issubset(self.roles)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_claims(cls, claims: dict[str, Any], *, client_id: str) -> Principal:
|
||||||
|
realm_access = claims.get("realm_access") or {}
|
||||||
|
resource_access = claims.get("resource_access") or {}
|
||||||
|
client_access = resource_access.get(client_id) or {}
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
subject=str(claims["sub"]),
|
||||||
|
username=claims.get("preferred_username"),
|
||||||
|
email=claims.get("email"),
|
||||||
|
full_name=claims.get("name"),
|
||||||
|
realm_roles=frozenset(realm_access.get("roles") or ()),
|
||||||
|
client_roles=frozenset(client_access.get("roles") or ()),
|
||||||
|
scopes=frozenset(str(claims.get("scope") or "").split()),
|
||||||
|
raw_claims=claims,
|
||||||
|
)
|
||||||
Whitespace-only changes.
@@ -0,0 +1,82 @@
|
|||||||
|
"""Application settings, loaded from the environment (and `.env` locally)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
Environment = Literal["local", "test", "staging", "production"]
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- application -----------------------------------------------------
|
||||||
|
app_name: str = "v2x-server"
|
||||||
|
app_env: Environment = "local"
|
||||||
|
debug: bool = False
|
||||||
|
log_level: str = "INFO"
|
||||||
|
api_v1_prefix: str = "/api/v1"
|
||||||
|
cors_origins: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# --- database --------------------------------------------------------
|
||||||
|
database_url: str = "mysql+asyncmy://v2x:v2xpassword@mysql:3306/v2x?charset=utf8mb4"
|
||||||
|
test_database_url: str = "mysql+asyncmy://v2x:v2xpassword@mysql:3306/v2x_test?charset=utf8mb4"
|
||||||
|
db_pool_size: int = 5
|
||||||
|
db_max_overflow: int = 10
|
||||||
|
db_echo: bool = False
|
||||||
|
|
||||||
|
# --- keycloak --------------------------------------------------------
|
||||||
|
#
|
||||||
|
# These are two different URLs on purpose. `keycloak_issuer` is the exact
|
||||||
|
# string compared against a token's `iss` claim, which reflects however the
|
||||||
|
# *client* reached Keycloak (http://localhost:8080 in local development).
|
||||||
|
# `keycloak_internal_url` is how *this process* reaches Keycloak to fetch
|
||||||
|
# discovery and JWKS (http://keycloak:8080 on the compose network).
|
||||||
|
# In deployed environments both point at the same public URL.
|
||||||
|
keycloak_issuer: str = "http://localhost:8080/realms/v2x"
|
||||||
|
keycloak_internal_url: str = "http://keycloak:8080"
|
||||||
|
keycloak_realm: str = "v2x"
|
||||||
|
keycloak_audience: str = "v2x-api"
|
||||||
|
keycloak_swagger_client_id: str = "v2x-swagger"
|
||||||
|
keycloak_jwks_ttl_seconds: int = 3600
|
||||||
|
# Floor between forced JWKS refreshes, so a flood of tokens bearing unknown
|
||||||
|
# key ids cannot be turned into a request amplifier against Keycloak.
|
||||||
|
keycloak_jwks_min_refresh_seconds: int = 30
|
||||||
|
keycloak_leeway_seconds: int = 30
|
||||||
|
keycloak_timeout_seconds: float = 5.0
|
||||||
|
|
||||||
|
@field_validator("keycloak_issuer", "keycloak_internal_url")
|
||||||
|
@classmethod
|
||||||
|
def _strip_trailing_slash(cls, value: str) -> str:
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_local(self) -> bool:
|
||||||
|
return self.app_env in ("local", "test")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def discovery_url(self) -> str:
|
||||||
|
"""OIDC discovery document, fetched over the *internal* route."""
|
||||||
|
return (
|
||||||
|
f"{self.keycloak_internal_url}"
|
||||||
|
f"/realms/{self.keycloak_realm}/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_realm_url(self) -> str:
|
||||||
|
"""Realm base URL as a browser sees it; used for Swagger's OAuth flow."""
|
||||||
|
return self.keycloak_issuer
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Uniform error responses in RFC 9457 `application/problem+json` form."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, status
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from v2x_server.core.logging import request_id_ctx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROBLEM_JSON = "application/problem+json"
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
"""Base for errors the application raises deliberately."""
|
||||||
|
|
||||||
|
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
title: str = "Internal Server Error"
|
||||||
|
|
||||||
|
def __init__(self, detail: str | None = None) -> None:
|
||||||
|
self.detail = detail or self.title
|
||||||
|
super().__init__(self.detail)
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(AppError):
|
||||||
|
status_code = status.HTTP_404_NOT_FOUND
|
||||||
|
title = "Not Found"
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(AppError):
|
||||||
|
status_code = status.HTTP_409_CONFLICT
|
||||||
|
title = "Conflict"
|
||||||
|
|
||||||
|
|
||||||
|
def _problem(
|
||||||
|
status_code: int,
|
||||||
|
title: str,
|
||||||
|
detail: str,
|
||||||
|
*,
|
||||||
|
headers: Mapping[str, str] | None = None,
|
||||||
|
**extra: Any,
|
||||||
|
) -> JSONResponse:
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"type": "about:blank",
|
||||||
|
"title": title,
|
||||||
|
"status": status_code,
|
||||||
|
"detail": detail,
|
||||||
|
"request_id": request_id_ctx.get(),
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status_code,
|
||||||
|
content=body,
|
||||||
|
media_type=PROBLEM_JSON,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def _app_error(_: Request, exc: AppError) -> JSONResponse:
|
||||||
|
return _problem(exc.status_code, exc.title, exc.detail)
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
async def _http_error(_: Request, exc: HTTPException) -> JSONResponse:
|
||||||
|
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
||||||
|
return _problem(
|
||||||
|
exc.status_code,
|
||||||
|
_title_for(exc.status_code),
|
||||||
|
detail,
|
||||||
|
headers=exc.headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def _validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
|
return _problem(
|
||||||
|
# Literal 422 rather than the constant: Starlette renamed it from
|
||||||
|
# HTTP_422_UNPROCESSABLE_ENTITY to ..._CONTENT, so either spelling
|
||||||
|
# ties us to a version range.
|
||||||
|
422,
|
||||||
|
"Validation Error",
|
||||||
|
"The request body or parameters failed validation.",
|
||||||
|
errors=_serializable_errors(exc),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def _unhandled(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
# Log the traceback, return an opaque body. The request id is the thread
|
||||||
|
# connecting what the caller saw to what the logs recorded.
|
||||||
|
logger.exception(
|
||||||
|
"Unhandled exception on %s %s", request.method, request.url.path, exc_info=exc
|
||||||
|
)
|
||||||
|
return _problem(
|
||||||
|
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal Server Error",
|
||||||
|
"An unexpected error occurred. Quote the request id when reporting this.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serializable_errors(exc: RequestValidationError) -> list[dict[str, Any]]:
|
||||||
|
"""Strip the non-JSON-serializable `ctx` values pydantic can attach."""
|
||||||
|
cleaned: list[dict[str, Any]] = []
|
||||||
|
for error in exc.errors():
|
||||||
|
item = {k: v for k, v in error.items() if k != "ctx"}
|
||||||
|
item["loc"] = [str(part) for part in error.get("loc", ())]
|
||||||
|
cleaned.append(item)
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _title_for(status_code: int) -> str:
|
||||||
|
titles = {
|
||||||
|
400: "Bad Request",
|
||||||
|
401: "Unauthorized",
|
||||||
|
403: "Forbidden",
|
||||||
|
404: "Not Found",
|
||||||
|
405: "Method Not Allowed",
|
||||||
|
409: "Conflict",
|
||||||
|
422: "Validation Error",
|
||||||
|
429: "Too Many Requests",
|
||||||
|
503: "Service Unavailable",
|
||||||
|
}
|
||||||
|
return titles.get(status_code, "Error")
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Structured logging plus a request-scoped correlation id.
|
||||||
|
|
||||||
|
The request id is held in a ContextVar so any log record emitted while handling
|
||||||
|
a request carries it, without threading the value through call signatures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pythonjsonlogger import json as jsonlogger
|
||||||
|
|
||||||
|
request_id_ctx: ContextVar[str | None] = ContextVar("request_id", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
class RequestIdFilter(logging.Filter):
|
||||||
|
"""Stamps the current request id onto every record."""
|
||||||
|
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
record.request_id = request_id_ctx.get() or "-"
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _JsonFormatter(jsonlogger.JsonFormatter):
|
||||||
|
def add_fields(
|
||||||
|
self,
|
||||||
|
log_record: dict[str, Any],
|
||||||
|
record: logging.LogRecord,
|
||||||
|
message_dict: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
super().add_fields(log_record, record, message_dict)
|
||||||
|
log_record["level"] = record.levelname
|
||||||
|
log_record["logger"] = record.name
|
||||||
|
log_record.pop("levelname", None)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str = "INFO", *, json_output: bool = True) -> None:
|
||||||
|
"""Install a single stdout handler on the root logger.
|
||||||
|
|
||||||
|
Called once at startup. Uvicorn's own loggers are left to propagate so
|
||||||
|
everything lands in the same format.
|
||||||
|
"""
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.addFilter(RequestIdFilter())
|
||||||
|
|
||||||
|
if json_output:
|
||||||
|
handler.setFormatter(
|
||||||
|
_JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s %(request_id)s")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(levelname)-8s [%(request_id)s] %(name)s: %(message)s")
|
||||||
|
)
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.handlers = [handler]
|
||||||
|
root.setLevel(level.upper())
|
||||||
|
|
||||||
|
# Uvicorn installs its own handlers; drop them so records propagate to root
|
||||||
|
# instead of being emitted twice in two different formats.
|
||||||
|
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||||
|
uvicorn_logger = logging.getLogger(name)
|
||||||
|
uvicorn_logger.handlers = []
|
||||||
|
uvicorn_logger.propagate = True
|
||||||
Whitespace-only changes.
@@ -0,0 +1,43 @@
|
|||||||
|
"""Declarative base and shared column mixins."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, MetaData, func
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
# Deterministic constraint names. This has to be in place *before* the first
|
||||||
|
# migration: MySQL will otherwise invent its own names, and Alembic autogenerate
|
||||||
|
# then produces diffs referencing constraints it cannot reliably drop.
|
||||||
|
NAMING_CONVENTION = {
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
"""Server-side created/updated timestamps.
|
||||||
|
|
||||||
|
Defaults are rendered by MySQL rather than Python so rows written outside
|
||||||
|
the application (migrations, manual SQL) are stamped consistently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
created_at: Mapped[dt.datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at: Mapped[dt.datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Async engine, session factory, and the FastAPI session dependency."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from v2x_server.core.config import Settings
|
||||||
|
|
||||||
|
_engine: AsyncEngine | None = None
|
||||||
|
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def create_engine(settings: Settings) -> AsyncEngine:
|
||||||
|
kwargs: dict[str, object] = {
|
||||||
|
"echo": settings.db_echo,
|
||||||
|
# Verify a pooled connection is alive before handing it out.
|
||||||
|
"pool_pre_ping": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# SQLite (used only for quick local smoke tests) has no such pool knobs.
|
||||||
|
if not settings.database_url.startswith("sqlite"):
|
||||||
|
kwargs.update(
|
||||||
|
pool_size=settings.db_pool_size,
|
||||||
|
max_overflow=settings.db_max_overflow,
|
||||||
|
# MySQL closes idle connections at `wait_timeout` (8h by default,
|
||||||
|
# often far lower behind a proxy). Recycling below that threshold
|
||||||
|
# is what prevents intermittent "MySQL server has gone away".
|
||||||
|
pool_recycle=1800,
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, **kwargs)
|
||||||
|
|
||||||
|
if engine.dialect.name == "mysql":
|
||||||
|
_force_utc(engine)
|
||||||
|
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def _force_utc(engine: AsyncEngine) -> None:
|
||||||
|
"""Pin every MySQL connection to UTC.
|
||||||
|
|
||||||
|
MySQL's DATETIME stores no timezone, so SQLAlchemy's `timezone=True` is a
|
||||||
|
no-op there and `CURRENT_TIMESTAMP` follows the *server's* zone. Without
|
||||||
|
this, timestamps silently depend on wherever the database happens to run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def _set_session_timezone(dbapi_connection: object, _record: object) -> None:
|
||||||
|
cursor = dbapi_connection.cursor() # type: ignore[attr-defined]
|
||||||
|
try:
|
||||||
|
cursor.execute("SET SESSION time_zone = '+00:00'")
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_engine(settings: Settings) -> AsyncEngine:
|
||||||
|
"""Build the process-wide engine and session factory. Called at startup."""
|
||||||
|
global _engine, _sessionmaker
|
||||||
|
_engine = create_engine(settings)
|
||||||
|
_sessionmaker = async_sessionmaker(
|
||||||
|
bind=_engine,
|
||||||
|
expire_on_commit=False,
|
||||||
|
autoflush=False,
|
||||||
|
)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
async def dispose_engine() -> None:
|
||||||
|
"""Close pooled connections. Called at shutdown."""
|
||||||
|
global _engine, _sessionmaker
|
||||||
|
if _engine is not None:
|
||||||
|
await _engine.dispose()
|
||||||
|
_engine = None
|
||||||
|
_sessionmaker = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||||
|
if _sessionmaker is None:
|
||||||
|
raise RuntimeError("Database engine not initialised; is the app lifespan running?")
|
||||||
|
return _sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||||
|
"""Request-scoped session: commit on success, roll back on any exception.
|
||||||
|
|
||||||
|
Endpoints therefore never need to call `commit()` themselves, and a raised
|
||||||
|
exception can never leave a partial write behind.
|
||||||
|
"""
|
||||||
|
async with get_sessionmaker()() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Application factory and ASGI entrypoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, Request, Response
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
|
from starlette.middleware.base import RequestResponseEndpoint
|
||||||
|
|
||||||
|
from v2x_server import __version__
|
||||||
|
from v2x_server.api.router import api_router
|
||||||
|
from v2x_server.api.v1 import health
|
||||||
|
from v2x_server.auth.keycloak import OIDCProvider
|
||||||
|
from v2x_server.core.config import Settings, get_settings
|
||||||
|
from v2x_server.core.errors import register_exception_handlers
|
||||||
|
from v2x_server.core.logging import configure_logging, request_id_ctx
|
||||||
|
from v2x_server.db.session import dispose_engine, init_engine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
REQUEST_ID_HEADER = "X-Request-ID"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
|
settings: Settings = app.state.settings
|
||||||
|
|
||||||
|
init_engine(settings)
|
||||||
|
|
||||||
|
# One shared client for the process: connection reuse against Keycloak
|
||||||
|
# matters, since JWKS refreshes happen on the request path.
|
||||||
|
client = httpx.AsyncClient(timeout=settings.keycloak_timeout_seconds)
|
||||||
|
app.state.http_client = client
|
||||||
|
app.state.oidc_provider = OIDCProvider(settings, client)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Started %s (env=%s, issuer=%s)",
|
||||||
|
settings.app_name,
|
||||||
|
settings.app_env,
|
||||||
|
settings.keycloak_issuer,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
await dispose_engine()
|
||||||
|
logger.info("Shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
|
settings = settings or get_settings()
|
||||||
|
configure_logging(settings.log_level, json_output=not settings.is_local)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="v2x-server",
|
||||||
|
version=__version__,
|
||||||
|
description=(
|
||||||
|
"REST API backed by MySQL. Authentication is delegated to Keycloak: "
|
||||||
|
"send a Bearer access token, or use Authorize below to obtain one."
|
||||||
|
),
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url="/redoc",
|
||||||
|
openapi_url="/openapi.json",
|
||||||
|
# Lets the Authorize button run the authorization-code + PKCE flow
|
||||||
|
# against Keycloak, so /docs is usable without pasting tokens by hand.
|
||||||
|
swagger_ui_init_oauth={
|
||||||
|
"clientId": settings.keycloak_swagger_client_id,
|
||||||
|
"usePkceWithAuthorizationCodeGrant": True,
|
||||||
|
"scopes": "openid profile email",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
app.state.settings = settings
|
||||||
|
|
||||||
|
if settings.cors_origins:
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
expose_headers=[REQUEST_ID_HEADER],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def request_id_middleware(
|
||||||
|
request: Request, call_next: RequestResponseEndpoint
|
||||||
|
) -> Response:
|
||||||
|
"""Adopt the caller's request id or mint one, then echo it back.
|
||||||
|
|
||||||
|
Set before anything else runs so even a 500 from a downstream handler
|
||||||
|
carries an id the client can quote and the logs can be searched by.
|
||||||
|
"""
|
||||||
|
request_id = request.headers.get(REQUEST_ID_HEADER) or uuid.uuid4().hex
|
||||||
|
token = request_id_ctx.set(request_id)
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
finally:
|
||||||
|
request_id_ctx.reset(token)
|
||||||
|
response.headers[REQUEST_ID_HEADER] = request_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
register_exception_handlers(app)
|
||||||
|
|
||||||
|
# Probes stay unversioned at the root; orchestrators shouldn't have to know
|
||||||
|
# about an API version to decide whether a container is alive.
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Model package.
|
||||||
|
|
||||||
|
Every model must be imported here: Alembic's autogenerate only sees tables that
|
||||||
|
have been registered on `Base.metadata` by import time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from v2x_server.db.base import Base
|
||||||
|
from v2x_server.models.device import Device, DeviceStatus
|
||||||
|
|
||||||
|
__all__ = ["Base", "Device", "DeviceStatus"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Placeholder domain model proving the routing -> auth -> ORM -> migration path.
|
||||||
|
|
||||||
|
Replace with the real V2X domain once it is defined; nothing else depends on it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy import String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from v2x_server.db.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceStatus(enum.StrEnum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
MAINTENANCE = "maintenance"
|
||||||
|
|
||||||
|
|
||||||
|
class Device(Base, TimestampMixin):
|
||||||
|
__tablename__ = "devices"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||||
|
serial: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||||
|
status: Mapped[DeviceStatus] = mapped_column(
|
||||||
|
# native_enum=False stores a VARCHAR + CHECK instead of a MySQL ENUM
|
||||||
|
# column: adding a value later is an ordinary migration rather than an
|
||||||
|
# ALTER TABLE that rewrites the whole table.
|
||||||
|
SAEnum(
|
||||||
|
DeviceStatus,
|
||||||
|
native_enum=False,
|
||||||
|
length=32,
|
||||||
|
values_callable=lambda enum_cls: [member.value for member in enum_cls],
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
default=DeviceStatus.ACTIVE,
|
||||||
|
server_default=DeviceStatus.ACTIVE.value,
|
||||||
|
)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Device id={self.id} serial={self.serial!r}>"
|
||||||
Whitespace-only changes.
@@ -0,0 +1,59 @@
|
|||||||
|
"""Data access for devices. Keeps queries out of route handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from v2x_server.models.device import Device, DeviceStatus
|
||||||
|
from v2x_server.schemas.device import DeviceCreate, DeviceUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceRepository:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def get(self, device_id: int) -> Device | None:
|
||||||
|
return await self._session.get(Device, device_id)
|
||||||
|
|
||||||
|
async def get_by_serial(self, serial: str) -> Device | None:
|
||||||
|
result = await self._session.execute(select(Device).where(Device.serial == serial))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
status: DeviceStatus | None = None,
|
||||||
|
) -> tuple[Sequence[Device], int]:
|
||||||
|
filters = [Device.status == status] if status is not None else []
|
||||||
|
|
||||||
|
total = await self._session.scalar(select(func.count()).select_from(Device).where(*filters))
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(Device).where(*filters).order_by(Device.id).limit(limit).offset(offset)
|
||||||
|
)
|
||||||
|
return result.scalars().all(), int(total or 0)
|
||||||
|
|
||||||
|
async def create(self, payload: DeviceCreate) -> Device:
|
||||||
|
device = Device(**payload.model_dump())
|
||||||
|
self._session.add(device)
|
||||||
|
# Flush rather than commit: surfaces constraint violations here, while
|
||||||
|
# leaving the transaction boundary to the session dependency.
|
||||||
|
await self._session.flush()
|
||||||
|
await self._session.refresh(device)
|
||||||
|
return device
|
||||||
|
|
||||||
|
async def update(self, device: Device, payload: DeviceUpdate) -> Device:
|
||||||
|
# exclude_unset keeps a PATCH from nulling fields the caller omitted.
|
||||||
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(device, field, value)
|
||||||
|
await self._session.flush()
|
||||||
|
await self._session.refresh(device)
|
||||||
|
return device
|
||||||
|
|
||||||
|
async def delete(self, device: Device) -> None:
|
||||||
|
await self._session.delete(device)
|
||||||
|
await self._session.flush()
|
||||||
Whitespace-only changes.
@@ -0,0 +1,48 @@
|
|||||||
|
"""Request/response schemas for the devices resource."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from v2x_server.models.device import DeviceStatus
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceBase(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=128)
|
||||||
|
serial: str = Field(min_length=1, max_length=64)
|
||||||
|
status: DeviceStatus = DeviceStatus.ACTIVE
|
||||||
|
description: str | None = Field(default=None, max_length=512)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCreate(DeviceBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceUpdate(BaseModel):
|
||||||
|
"""All fields optional: this is a PATCH payload.
|
||||||
|
|
||||||
|
`model_dump(exclude_unset=True)` at the call site distinguishes "not
|
||||||
|
supplied" from "explicitly set to null".
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
|
serial: str | None = Field(default=None, min_length=1, max_length=64)
|
||||||
|
status: DeviceStatus | None = None
|
||||||
|
description: str | None = Field(default=None, max_length=512)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceRead(DeviceBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
created_at: dt.datetime
|
||||||
|
updated_at: dt.datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DevicePage(BaseModel):
|
||||||
|
items: list[DeviceRead]
|
||||||
|
total: int
|
||||||
|
limit: int
|
||||||
|
offset: int
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,154 @@
|
|||||||
|
"""Token-forging fixtures for exercising the validator against real crypto.
|
||||||
|
|
||||||
|
A locally generated RSA keypair stands in for Keycloak's realm keys, and the
|
||||||
|
provider's JWKS fetch is redirected at it. That makes it possible to mint
|
||||||
|
tokens that are genuinely signed -- and genuinely wrong in one specific way per
|
||||||
|
test -- without a running Keycloak.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from jwt.utils import base64url_encode
|
||||||
|
|
||||||
|
from v2x_server.core.config import Settings
|
||||||
|
|
||||||
|
TEST_KID = "test-key-1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def rsa_key() -> rsa.RSAPrivateKey:
|
||||||
|
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def jwks(rsa_key: rsa.RSAPrivateKey) -> dict[str, Any]:
|
||||||
|
"""The public half, in the JWKS shape Keycloak would serve."""
|
||||||
|
public_numbers = rsa_key.public_key().public_numbers()
|
||||||
|
|
||||||
|
def _b64(value: int) -> str:
|
||||||
|
length = (value.bit_length() + 7) // 8
|
||||||
|
return base64url_encode(value.to_bytes(length, "big")).decode()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"kty": "RSA",
|
||||||
|
"kid": TEST_KID,
|
||||||
|
"use": "sig",
|
||||||
|
"alg": "RS256",
|
||||||
|
"n": _b64(public_numbers.n),
|
||||||
|
"e": _b64(public_numbers.e),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_settings() -> Settings:
|
||||||
|
return Settings(
|
||||||
|
app_env="test",
|
||||||
|
keycloak_issuer="https://idp.test/realms/v2x",
|
||||||
|
keycloak_internal_url="https://idp.test",
|
||||||
|
keycloak_realm="v2x",
|
||||||
|
keycloak_audience="v2x-api",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_token(rsa_key: rsa.RSAPrivateKey, auth_settings: Settings) -> Callable[..., str]:
|
||||||
|
"""Mint a signed token, overriding any claim or header field per test."""
|
||||||
|
|
||||||
|
def _make(
|
||||||
|
*,
|
||||||
|
kid: str | None = TEST_KID,
|
||||||
|
algorithm: str = "RS256",
|
||||||
|
key: Any = None,
|
||||||
|
expires_in: int = 300,
|
||||||
|
drop: tuple[str, ...] = (),
|
||||||
|
**claim_overrides: Any,
|
||||||
|
) -> str:
|
||||||
|
now = dt.datetime.now(tz=dt.UTC)
|
||||||
|
claims: dict[str, Any] = {
|
||||||
|
"sub": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"iss": auth_settings.keycloak_issuer,
|
||||||
|
"aud": auth_settings.keycloak_audience,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + dt.timedelta(seconds=expires_in),
|
||||||
|
"preferred_username": "vera",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"scope": "openid profile email",
|
||||||
|
"realm_access": {"roles": ["viewer"]},
|
||||||
|
"resource_access": {"v2x-api": {"roles": ["device.read"]}},
|
||||||
|
}
|
||||||
|
claims.update(claim_overrides)
|
||||||
|
for claim in drop:
|
||||||
|
claims.pop(claim, None)
|
||||||
|
headers = {"kid": kid} if kid else {}
|
||||||
|
return jwt.encode(
|
||||||
|
claims,
|
||||||
|
key if key is not None else rsa_key,
|
||||||
|
algorithm=algorithm,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def unsigned_token(auth_settings: Settings) -> str:
|
||||||
|
"""An `alg: none` token -- the classic downgrade attempt."""
|
||||||
|
now = dt.datetime.now(tz=dt.UTC)
|
||||||
|
header = base64url_encode(
|
||||||
|
json.dumps({"alg": "none", "typ": "JWT", "kid": TEST_KID}).encode()
|
||||||
|
).decode()
|
||||||
|
payload = base64url_encode(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"sub": "attacker",
|
||||||
|
"iss": auth_settings.keycloak_issuer,
|
||||||
|
"aud": auth_settings.keycloak_audience,
|
||||||
|
"iat": int(now.timestamp()),
|
||||||
|
"exp": int((now + dt.timedelta(minutes=5)).timestamp()),
|
||||||
|
"realm_access": {"roles": ["admin"]},
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
).decode()
|
||||||
|
return f"{header}.{payload}."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider(auth_settings: Settings, jwks: dict[str, Any]) -> Iterator[Any]:
|
||||||
|
"""An OIDCProvider whose network calls are served from the fixtures above."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from v2x_server.auth.keycloak import OIDCProvider
|
||||||
|
|
||||||
|
certs_url = (
|
||||||
|
f"{auth_settings.keycloak_internal_url}"
|
||||||
|
f"/realms/{auth_settings.keycloak_realm}/protocol/openid-connect/certs"
|
||||||
|
)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
url = str(request.url)
|
||||||
|
if url.endswith("/.well-known/openid-configuration"):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"issuer": auth_settings.keycloak_issuer,
|
||||||
|
"jwks_uri": certs_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if url.endswith("/protocol/openid-connect/certs"):
|
||||||
|
return httpx.Response(200, json=jwks)
|
||||||
|
return httpx.Response(404)
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
|
yield OIDCProvider(auth_settings, client)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""The validator must reject every category of bad token, not just expired ones.
|
||||||
|
|
||||||
|
Without these, `get_current_principal` could be accepting anything and the
|
||||||
|
endpoint tests -- which override authentication -- would never notice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
|
||||||
|
from v2x_server.auth.keycloak import OIDCProvider, TokenError
|
||||||
|
from v2x_server.auth.principal import Principal
|
||||||
|
|
||||||
|
|
||||||
|
async def test_accepts_a_valid_token(
|
||||||
|
provider: OIDCProvider, make_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
claims = await provider.decode(make_token())
|
||||||
|
assert claims["sub"] == "11111111-1111-1111-1111-111111111111"
|
||||||
|
assert claims["preferred_username"] == "vera"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejects_expired_token(
|
||||||
|
provider: OIDCProvider, make_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
# Beyond the 30s leeway.
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
await provider.decode(make_token(expires_in=-120))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejects_wrong_issuer(provider: OIDCProvider, make_token: Callable[..., str]) -> None:
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
await provider.decode(make_token(iss="https://evil.test/realms/v2x"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejects_wrong_audience(
|
||||||
|
provider: OIDCProvider, make_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
# This is the failure Keycloak produces by default: without an audience
|
||||||
|
# mapper the token's aud is "account", not the API's client id.
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
await provider.decode(make_token(aud="account"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejects_unknown_key_id(
|
||||||
|
provider: OIDCProvider, make_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
await provider.decode(make_token(kid="rotated-away"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejects_missing_key_id(
|
||||||
|
provider: OIDCProvider, make_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(TokenError, match="no kid"):
|
||||||
|
await provider.decode(make_token(kid=None))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejects_alg_none(provider: OIDCProvider, unsigned_token: str) -> None:
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
await provider.decode(unsigned_token)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejects_token_signed_by_another_key(
|
||||||
|
provider: OIDCProvider, make_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
attacker_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
await provider.decode(make_token(key=attacker_key))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejects_tampered_payload(
|
||||||
|
provider: OIDCProvider, make_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
header, payload, signature = make_token().split(".")
|
||||||
|
# Flip a character in the payload; the signature no longer covers it.
|
||||||
|
mutated = payload[:-2] + ("A" if payload[-2] != "A" else "B") + payload[-1]
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
await provider.decode(f"{header}.{mutated}.{signature}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejects_garbage(provider: OIDCProvider) -> None:
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
await provider.decode("not-a-jwt")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("claim", ["sub", "exp", "iat", "iss", "aud"])
|
||||||
|
async def test_rejects_token_missing_a_required_claim(
|
||||||
|
provider: OIDCProvider, make_token: Callable[..., str], claim: str
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
await provider.decode(make_token(drop=(claim,)))
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrincipalMapping:
|
||||||
|
"""Claim shape -> Principal. The one place that knows Keycloak's layout."""
|
||||||
|
|
||||||
|
def test_maps_realm_and_client_roles(self) -> None:
|
||||||
|
principal = Principal.from_claims(
|
||||||
|
{
|
||||||
|
"sub": "abc",
|
||||||
|
"preferred_username": "vera",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"realm_access": {"roles": ["viewer", "operator"]},
|
||||||
|
"resource_access": {"v2x-api": {"roles": ["device.read"]}},
|
||||||
|
"scope": "openid profile",
|
||||||
|
},
|
||||||
|
client_id="v2x-api",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert principal.realm_roles == frozenset({"viewer", "operator"})
|
||||||
|
assert principal.client_roles == frozenset({"device.read"})
|
||||||
|
assert principal.roles == frozenset({"viewer", "operator", "device.read"})
|
||||||
|
assert principal.has_any_role("operator", "admin")
|
||||||
|
assert not principal.has_all_roles("operator", "admin")
|
||||||
|
|
||||||
|
def test_tolerates_absent_role_blocks(self) -> None:
|
||||||
|
principal = Principal.from_claims({"sub": "abc"}, client_id="v2x-api")
|
||||||
|
assert principal.roles == frozenset()
|
||||||
|
assert principal.username is None
|
||||||
|
|
||||||
|
def test_ignores_roles_for_other_clients(self) -> None:
|
||||||
|
principal = Principal.from_claims(
|
||||||
|
{
|
||||||
|
"sub": "abc",
|
||||||
|
"resource_access": {"some-other-client": {"roles": ["admin"]}},
|
||||||
|
},
|
||||||
|
client_id="v2x-api",
|
||||||
|
)
|
||||||
|
assert principal.client_roles == frozenset()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_kid_triggers_a_refresh_once_the_floor_has_passed(
|
||||||
|
provider: OIDCProvider, make_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
"""Key rotation must be survivable without restarting the process."""
|
||||||
|
await provider.decode(make_token()) # warm the cache
|
||||||
|
before = provider._jwks_fetched_at
|
||||||
|
|
||||||
|
# Age the cache past the minimum refresh interval.
|
||||||
|
provider._jwks_fetched_at -= 3600
|
||||||
|
|
||||||
|
with pytest.raises(TokenError):
|
||||||
|
await provider.decode(make_token(kid="unknown-kid"))
|
||||||
|
|
||||||
|
# A refetch happened: the timestamp moved forward again.
|
||||||
|
assert provider._jwks_fetched_at > before - 3600
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unknown_kid_refresh_is_rate_limited(
|
||||||
|
provider: OIDCProvider, make_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
"""A flood of junk tokens must not become load amplification on Keycloak."""
|
||||||
|
await provider.decode(make_token())
|
||||||
|
|
||||||
|
with pytest.raises(TokenError, match="rate-limited"):
|
||||||
|
await provider.decode(make_token(kid="unknown-kid"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clock_skew_within_leeway_is_accepted(
|
||||||
|
provider: OIDCProvider, make_token: Callable[..., str]
|
||||||
|
) -> None:
|
||||||
|
"""A token issued a few seconds in the future should still pass."""
|
||||||
|
future = dt.datetime.now(tz=dt.UTC) + dt.timedelta(seconds=10)
|
||||||
|
claims = await provider.decode(make_token(iat=future, nbf=future))
|
||||||
|
assert claims["sub"]
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Shared fixtures.
|
||||||
|
|
||||||
|
Tests run against a real MySQL database (`v2x_test`), never SQLite: this project
|
||||||
|
relies on MySQL types, collation and constraint behaviour, and a SQLite-backed
|
||||||
|
suite would happily pass while production broke.
|
||||||
|
|
||||||
|
Isolation strategy: each test runs inside an outer transaction that is rolled
|
||||||
|
back afterwards, so tests share one migrated schema without sharing data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncIterator, Callable, Iterator
|
||||||
|
from contextlib import AbstractAsyncContextManager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic import command
|
||||||
|
from alembic.config import Config
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from v2x_server.auth.deps import get_current_principal
|
||||||
|
from v2x_server.auth.principal import Principal
|
||||||
|
from v2x_server.core.config import Settings
|
||||||
|
from v2x_server.db.session import create_engine, get_session
|
||||||
|
from v2x_server.main import create_app
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def settings() -> Settings:
|
||||||
|
base = Settings()
|
||||||
|
return base.model_copy(
|
||||||
|
update={
|
||||||
|
"app_env": "test",
|
||||||
|
"database_url": base.test_database_url,
|
||||||
|
"cors_origins": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def _migrated_database(settings: Settings) -> Iterator[None]:
|
||||||
|
"""Bring `v2x_test` to head once per session."""
|
||||||
|
config = Config(os.path.join(PROJECT_ROOT, "alembic.ini"))
|
||||||
|
config.set_main_option("script_location", os.path.join(PROJECT_ROOT, "migrations"))
|
||||||
|
os.environ["ALEMBIC_DATABASE_URL"] = settings.database_url
|
||||||
|
|
||||||
|
command.upgrade(config, "head")
|
||||||
|
yield
|
||||||
|
os.environ.pop("ALEMBIC_DATABASE_URL", None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_connection(
|
||||||
|
settings: Settings, _migrated_database: None
|
||||||
|
) -> AsyncIterator[AsyncConnection]:
|
||||||
|
"""A connection with an open outer transaction, rolled back after the test."""
|
||||||
|
engine = create_engine(settings)
|
||||||
|
async with engine.connect() as connection:
|
||||||
|
transaction = await connection.begin()
|
||||||
|
try:
|
||||||
|
yield connection
|
||||||
|
finally:
|
||||||
|
await transaction.rollback()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(db_connection: AsyncConnection) -> AsyncIterator[AsyncSession]:
|
||||||
|
"""Session bound to the test's connection, joined to its outer transaction.
|
||||||
|
|
||||||
|
`join_transaction_mode="create_savepoint"` lets application code call
|
||||||
|
commit() normally -- it releases a savepoint rather than committing the
|
||||||
|
outer transaction, so the rollback above still wipes everything.
|
||||||
|
"""
|
||||||
|
factory = async_sessionmaker(
|
||||||
|
bind=db_connection,
|
||||||
|
expire_on_commit=False,
|
||||||
|
join_transaction_mode="create_savepoint",
|
||||||
|
)
|
||||||
|
async with factory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
class LifespanRunner:
|
||||||
|
"""Minimal async context manager that drives an app's lifespan events."""
|
||||||
|
|
||||||
|
def __init__(self, app: FastAPI) -> None:
|
||||||
|
self._app = app
|
||||||
|
self._context: AbstractAsyncContextManager[None] | None = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> None:
|
||||||
|
self._context = self._app.router.lifespan_context(self._app)
|
||||||
|
await self._context.__aenter__()
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc_info: Any) -> None:
|
||||||
|
assert self._context is not None
|
||||||
|
await self._context.__aexit__(*exc_info)
|
||||||
|
|
||||||
|
|
||||||
|
def make_principal(*roles: str, username: str = "test-user") -> Principal:
|
||||||
|
return Principal(
|
||||||
|
subject="00000000-0000-0000-0000-000000000001",
|
||||||
|
username=username,
|
||||||
|
email=f"{username}@example.com",
|
||||||
|
realm_roles=frozenset(roles),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def app(settings: Settings, db_session: AsyncSession) -> AsyncIterator[FastAPI]:
|
||||||
|
"""App wired to the test session; auth left unauthenticated by default."""
|
||||||
|
application = create_app(settings)
|
||||||
|
|
||||||
|
async def _override_session() -> AsyncIterator[AsyncSession]:
|
||||||
|
yield db_session
|
||||||
|
|
||||||
|
application.dependency_overrides[get_session] = _override_session
|
||||||
|
yield application
|
||||||
|
application.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def as_user(app: FastAPI) -> Callable[..., Principal]:
|
||||||
|
"""Authenticate subsequent requests as a principal holding `roles`."""
|
||||||
|
|
||||||
|
def _apply(*roles: str, username: str = "test-user") -> Principal:
|
||||||
|
principal = make_principal(*roles, username=username)
|
||||||
|
app.dependency_overrides[get_current_principal] = lambda: principal
|
||||||
|
return principal
|
||||||
|
|
||||||
|
return _apply
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(app: FastAPI) -> AsyncIterator[AsyncClient]:
|
||||||
|
"""In-process HTTP client. No live server, but the full middleware stack."""
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
# LifespanRunner is what builds the engine and OIDC provider, so app.state
|
||||||
|
# matches production rather than being half-initialised.
|
||||||
|
async with (
|
||||||
|
LifespanRunner(app),
|
||||||
|
AsyncClient(transport=transport, base_url="http://test") as http_client,
|
||||||
|
):
|
||||||
|
yield http_client
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Endpoint behaviour: authorization gates, CRUD, and error shapes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from v2x_server.auth.principal import Principal
|
||||||
|
|
||||||
|
PREFIX = "/api/v1/devices"
|
||||||
|
|
||||||
|
AsUser = Callable[..., Principal]
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(**overrides: object) -> dict[str, object]:
|
||||||
|
body: dict[str, object] = {
|
||||||
|
"name": "roadside-unit-1",
|
||||||
|
"serial": "RSU-0001",
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
body.update(overrides)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthorization:
|
||||||
|
async def test_anonymous_request_is_401(self, client: AsyncClient) -> None:
|
||||||
|
response = await client.get(PREFIX)
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.headers["www-authenticate"].startswith("Bearer")
|
||||||
|
|
||||||
|
async def test_authenticated_without_role_is_403(
|
||||||
|
self, client: AsyncClient, as_user: AsUser
|
||||||
|
) -> None:
|
||||||
|
# Authenticated, but holds no role this API recognises. 403 rather than
|
||||||
|
# 401: retrying with the same token will never help.
|
||||||
|
as_user()
|
||||||
|
response = await client.get(PREFIX)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
async def test_viewer_can_read(self, client: AsyncClient, as_user: AsUser) -> None:
|
||||||
|
as_user("viewer")
|
||||||
|
response = await client.get(PREFIX)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
async def test_viewer_cannot_write(self, client: AsyncClient, as_user: AsUser) -> None:
|
||||||
|
as_user("viewer")
|
||||||
|
response = await client.post(PREFIX, json=_payload())
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
async def test_operator_can_write(self, client: AsyncClient, as_user: AsUser) -> None:
|
||||||
|
as_user("operator")
|
||||||
|
response = await client.post(PREFIX, json=_payload())
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
async def test_operator_cannot_delete(self, client: AsyncClient, as_user: AsUser) -> None:
|
||||||
|
as_user("operator")
|
||||||
|
created = await client.post(PREFIX, json=_payload())
|
||||||
|
response = await client.delete(f"{PREFIX}/{created.json()['id']}")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
async def test_admin_can_delete(self, client: AsyncClient, as_user: AsUser) -> None:
|
||||||
|
as_user("admin")
|
||||||
|
created = await client.post(PREFIX, json=_payload())
|
||||||
|
response = await client.delete(f"{PREFIX}/{created.json()['id']}")
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrud:
|
||||||
|
async def test_create_then_read_roundtrip(self, client: AsyncClient, as_user: AsUser) -> None:
|
||||||
|
as_user("operator")
|
||||||
|
created = await client.post(PREFIX, json=_payload(description="corner of 5th"))
|
||||||
|
assert created.status_code == 201
|
||||||
|
body = created.json()
|
||||||
|
assert body["serial"] == "RSU-0001"
|
||||||
|
assert body["id"] > 0
|
||||||
|
assert body["created_at"]
|
||||||
|
|
||||||
|
fetched = await client.get(f"{PREFIX}/{body['id']}")
|
||||||
|
assert fetched.status_code == 200
|
||||||
|
assert fetched.json() == body
|
||||||
|
|
||||||
|
async def test_duplicate_serial_is_409(self, client: AsyncClient, as_user: AsUser) -> None:
|
||||||
|
as_user("operator")
|
||||||
|
await client.post(PREFIX, json=_payload())
|
||||||
|
duplicate = await client.post(PREFIX, json=_payload(name="different name"))
|
||||||
|
assert duplicate.status_code == 409
|
||||||
|
assert duplicate.headers["content-type"].startswith("application/problem+json")
|
||||||
|
|
||||||
|
async def test_patch_only_touches_supplied_fields(
|
||||||
|
self, client: AsyncClient, as_user: AsUser
|
||||||
|
) -> None:
|
||||||
|
as_user("operator")
|
||||||
|
created = (await client.post(PREFIX, json=_payload(description="original"))).json()
|
||||||
|
|
||||||
|
patched = await client.patch(f"{PREFIX}/{created['id']}", json={"status": "maintenance"})
|
||||||
|
assert patched.status_code == 200
|
||||||
|
assert patched.json()["status"] == "maintenance"
|
||||||
|
# Untouched fields survive the PATCH.
|
||||||
|
assert patched.json()["description"] == "original"
|
||||||
|
assert patched.json()["name"] == created["name"]
|
||||||
|
|
||||||
|
async def test_missing_device_is_404(self, client: AsyncClient, as_user: AsUser) -> None:
|
||||||
|
as_user("viewer")
|
||||||
|
response = await client.get(f"{PREFIX}/999999")
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json()["title"] == "Not Found"
|
||||||
|
|
||||||
|
async def test_invalid_body_is_422_with_details(
|
||||||
|
self, client: AsyncClient, as_user: AsUser
|
||||||
|
) -> None:
|
||||||
|
as_user("operator")
|
||||||
|
response = await client.post(PREFIX, json={"name": "", "serial": ""})
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert response.json()["errors"]
|
||||||
|
|
||||||
|
async def test_pagination_and_filtering(self, client: AsyncClient, as_user: AsUser) -> None:
|
||||||
|
as_user("admin")
|
||||||
|
for index in range(5):
|
||||||
|
status = "active" if index % 2 == 0 else "inactive"
|
||||||
|
await client.post(
|
||||||
|
PREFIX, json=_payload(name=f"rsu-{index}", serial=f"S-{index}", status=status)
|
||||||
|
)
|
||||||
|
|
||||||
|
page = (await client.get(PREFIX, params={"limit": 2, "offset": 0})).json()
|
||||||
|
assert page["total"] == 5
|
||||||
|
assert len(page["items"]) == 2
|
||||||
|
|
||||||
|
filtered = (await client.get(PREFIX, params={"status": "inactive"})).json()
|
||||||
|
assert filtered["total"] == 2
|
||||||
|
assert {item["status"] for item in filtered["items"]} == {"inactive"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestIdentity:
|
||||||
|
async def test_me_reports_roles(self, client: AsyncClient, as_user: AsUser) -> None:
|
||||||
|
as_user("viewer", "operator", username="vera")
|
||||||
|
response = await client.get("/api/v1/me")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["username"] == "vera"
|
||||||
|
assert sorted(response.json()["realm_roles"]) == ["operator", "viewer"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealth:
|
||||||
|
async def test_healthz_needs_no_auth_and_no_dependencies(self, client: AsyncClient) -> None:
|
||||||
|
response = await client.get("/healthz")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
|
|
||||||
|
async def test_every_response_carries_a_request_id(self, client: AsyncClient) -> None:
|
||||||
|
response = await client.get("/healthz")
|
||||||
|
assert response.headers["x-request-id"]
|
||||||
|
|
||||||
|
async def test_supplied_request_id_is_echoed(self, client: AsyncClient) -> None:
|
||||||
|
response = await client.get("/healthz", headers={"X-Request-ID": "abc123"})
|
||||||
|
assert response.headers["x-request-id"] == "abc123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", ["/openapi.json", "/docs"])
|
||||||
|
async def test_docs_are_reachable(client: AsyncClient, path: str) -> None:
|
||||||
|
assert (await client.get(path)).status_code == 200
|
||||||
Reference in new issue
Block a user