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:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user