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:
gnickensandClaude Opus 5 committed 2026-09-10 12:46:52 -04:00
commit 0526d34e42
52 files changed
+4433

No files matched your search

+16
View File
@@ -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
+44
View File
@@ -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:
+41
View File
@@ -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" }
}
}
}
}
}
+19
View File
@@ -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
+25
View File
@@ -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"]
+54
View File
@@ -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
View File
@@ -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
+35
View File
@@ -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
View File
@@ -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"]
+60
View File
@@ -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."
+192
View File
@@ -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
View File
@@ -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
+75
View File
@@ -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:
+135
View File
@@ -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"]
}
]
}
+13
View File
@@ -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;
+82
View File
@@ -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()
+25
View File
@@ -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"}
View File
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")
+91
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
View File
Whitespace-only changes.
+9
View File
@@ -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)
View File
Whitespace-only changes.
+97
View File
@@ -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)
+54
View File
@@ -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}
+22
View File
@@ -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),
}
View File
Whitespace-only changes.
+102
View File
@@ -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
+184
View File
@@ -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
+53
View File
@@ -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,
)
View File
Whitespace-only changes.
+82
View File
@@ -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()
+128
View File
@@ -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")
+67
View File
@@ -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
View File
Whitespace-only changes.
+43
View File
@@ -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,
)
+104
View File
@@ -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()
+121
View File
@@ -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()
+10
View File
@@ -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"]
+46
View File
@@ -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.
+59
View File
@@ -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()
View File
Whitespace-only changes.
+48
View File
@@ -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
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+154
View File
@@ -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)
+170
View File
@@ -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"]
+150
View File
@@ -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
+161
View File
@@ -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
Generated
+1485
View File
File diff suppressed because it is too large. Load diff