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