| """Audit middleware — every state-mutating request gets a row in audit_log. |
| |
| Owner: Person C. Read-only GET/HEAD requests are skipped to keep the table lean. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import time |
| import uuid |
| from typing import Awaitable, Callable |
|
|
| import structlog |
| from starlette.middleware.base import BaseHTTPMiddleware |
| from starlette.requests import Request |
| from starlette.responses import Response |
|
|
| log = structlog.get_logger(__name__) |
|
|
| _READONLY = {"GET", "HEAD", "OPTIONS"} |
|
|
|
|
| class AuditMiddleware(BaseHTTPMiddleware): |
| async def dispatch( |
| self, request: Request, call_next: Callable[[Request], Awaitable[Response]] |
| ) -> Response: |
| rid = uuid.uuid4().hex[:12] |
| t0 = time.perf_counter() |
| request.state.request_id = rid |
|
|
| response = await call_next(request) |
|
|
| if request.method not in _READONLY: |
| dt_ms = round((time.perf_counter() - t0) * 1000, 1) |
| actor = request.headers.get("X-API-Key", "anonymous")[:8] |
| log.info( |
| "audit", |
| rid=rid, |
| actor=actor, |
| method=request.method, |
| path=request.url.path, |
| status=response.status_code, |
| ms=dt_ms, |
| ) |
| |
| |
| response.headers["X-Request-ID"] = rid |
| return response |
|
|