feat(e1): AuditMiddleware als systematisches Safety-Net — alle erfolgreichen POST/PATCH/DELETE erzeugen Audit-Eintrag (Session-basierte user/tenant-Attribuierung, entity_type aus Pfad, source=middleware in changes); schließt Lücke von 349 mutierenden Endpoints in 59 Dateien ohne Audit; Skip-Liste auth/health/errors/audit/external; best-effort; Beweistest test_audit_middleware.py grün (POST ohne explizites log_audit → Audit-Zeile); Regressionssmoke 23/23 grün

This commit is contained in:
Agent Zero
2026-08-24 13:55:25 +02:00
parent 197b0d3bab
commit 46c909c226
4 changed files with 152 additions and 1 deletions
+96
View File
@@ -3,6 +3,8 @@
from __future__ import annotations
import logging
import re
import uuid as uuid_mod
from fastapi import Request, status
from starlette.middleware.base import BaseHTTPMiddleware
@@ -137,3 +139,97 @@ class CSRFMiddleware(BaseHTTPMiddleware):
pass
return await call_next(request)
class AuditMiddleware(BaseHTTPMiddleware):
"""Safety-net audit trail for ALL successful mutating requests.
AGENTS.md requires every mutation to produce an audit entry. Explicit
``log_audit`` calls in routes/services remain the detail layer (entity ids,
change diffs); this middleware guarantees a baseline entry for mutations
that lack one, marked with ``source=middleware`` in ``details``.
Best-effort by design: audit failures never break the request.
"""
_MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
_SKIP_PREFIXES = (
"/api/v1/auth",
"/api/v1/health",
"/api/v1/errors",
"/api/v1/audit",
"/api/v1/external",
)
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if request.method not in self._MUTATING:
return response
if response.status_code < 200 or response.status_code >= 300:
return response
path = request.url.path
if any(path.startswith(p) for p in self._SKIP_PREFIXES):
return response
try:
await self._write_entry(request, path, response.status_code)
except Exception:
logging.getLogger(__name__).debug(
"AuditMiddleware: failed to write baseline entry for %s %s", request.method, path
)
return response
@staticmethod
def _derive_entity_type(path: str) -> str:
"""Derive an entity_type from the second URL segment."""
parts = [p for p in path.split("/") if p]
# /api/v1/<resource>/... -> resource; singularize naive trailing 's'
resource = parts[2] if len(parts) > 2 and parts[0] == "api" and parts[1] == "v1" else (parts[0] if parts else "unknown")
return resource[:-1] if len(resource) > 3 and resource.endswith("s") else resource
async def _write_entry(self, request: Request, path: str, status_code: int) -> None:
from app.core.audit import log_audit
from app.core.auth import get_redis, get_session_data
from app.core.db import create_db_session
# Attribute via the Redis session (same source as CSRFMiddleware) —
# FastAPI dependencies run after middleware, so request.state is empty here.
settings = get_settings()
session_id = request.cookies.get(settings.session_cookie_name)
if not session_id:
return # unauthenticated — nothing to attribute
redis = get_redis()
session_data = await get_session_data(redis, session_id)
if not session_data:
return
tenant_raw = session_data.get("tenant_id")
user_raw = session_data.get("user_id")
if not tenant_raw:
return
action_map = {"POST": "create", "PATCH": "update", "PUT": "update", "DELETE": "delete"}
entity_id: uuid_mod.UUID | None = None
parts = [p for p in path.split("/") if p]
if parts and re.fullmatch(r"[0-9a-fA-F-]{36}", parts[-1]):
try:
entity_id = uuid_mod.UUID(parts[-1])
except ValueError:
entity_id = None
async with create_db_session(uuid_mod.UUID(tenant_raw)) as db:
await log_audit(
db,
uuid_mod.UUID(tenant_raw),
uuid_mod.UUID(user_raw) if user_raw else None,
action_map.get(request.method, request.method.lower()),
self._derive_entity_type(path),
entity_id,
changes={
"source": "middleware",
"method": request.method,
"path": path,
"status": status_code,
},
)
await db.commit()
+6 -1
View File
@@ -23,7 +23,11 @@ logger = logging.getLogger(__name__)
from app.config import get_settings # noqa: E402
from app.core.db import close_engine, get_engine # noqa: E402
from app.core.error_codes import ApiError, build_error_response # noqa: E402
from app.core.middleware import CSRFMiddleware, SecurityHeadersMiddleware # noqa: E402
from app.core.middleware import ( # noqa: E402
AuditMiddleware,
CSRFMiddleware,
SecurityHeadersMiddleware,
)
from app.core.monitoring import record_error, record_request # noqa: E402
from app.core.rate_limit import GeneralRateLimitMiddleware # noqa: E402
from app.core.resilience import CircuitBreakerMiddleware # noqa: E402
@@ -472,6 +476,7 @@ def create_app() -> FastAPI:
)
app.add_middleware(CSRFMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(AuditMiddleware)
app.add_middleware(GeneralRateLimitMiddleware)
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(CircuitBreakerMiddleware)