"""§19 audit log middleware (#962, EPIC18). Best-effort аудит ключевых/чувствительных действий через FastAPI HTTP-middleware. Регистрируется в ``app/main.py`` ПОСЛЕ ``rbac_guard`` (Starlette applies middleware в обратном порядке регистрации, поэтому rbac_guard должен оставаться внешним — аудитим только запросы, которые RBAC уже пропустил). Аудируем sensitive actions из ТЗ §19: * ``POST /api/v1/parcels/{cad}/analyze`` → action='analyze' * ``GET /api/v1/parcels/{cad}/forecast`` → action='forecast' * ``GET /api/v1/parcels/{cad}/forecast/export`` → action='export' * ``POST|PUT|DELETE /api/v1/insights[/{id}]`` → action='insight_write' (#948) Insight WRITES (POST/PUT/DELETE) — чувствительные ручные записи (§19); их аудируем. GET-чтения insight'ов НЕ аудируются (read-only, не мутируют). ``/health``, статика, любые non-matched пути НЕ аудируются. Гарантии: * Сбой записи в ``audit_log`` НИКОГДА не ломает и не замедляет-фейлит реальный запрос — write обёрнут в try/except, ошибка логируется, отдаётся настоящий response. * Запись делается ПОСЛЕ ``call_next`` (когда response уже готов и status_code известен) — off the critical latency path. * В ``settings.testing`` middleware пропускает аудит (как rbac_guard) — юнит-тесты бьют по app мимо Caddy; аудит-логика покрыта напрямую через ``classify_path`` / ``write_audit_row`` в tests/test_audit_middleware.py. DB-write использует свежий ``SessionLocal()`` (сессия запроса в middleware недоступна), commit + close — всё в try/except. """ from __future__ import annotations import logging import re from collections.abc import Awaitable, Callable from fastapi import Request from fastapi.concurrency import run_in_threadpool from fastapi.responses import Response from sqlalchemy import text from app.core.auth import get_role from app.core.config import settings from app.core.db import SessionLocal logger = logging.getLogger(__name__) # Кадастровый номер в пути: 66:41:0204016:10 (цифры/двоеточия). Берём «жадный» # сегмент между /parcels/ и следующим действием — не парсим формат строго, # чтобы не ломаться на нестандартных кад. номерах. _CAD = r"(?P[^/]+)" # Порядок ВАЖЕН: forecast/export проверяем раньше forecast, иначе export # заматчится как forecast. Каждый паттерн якорный ($) → точное совпадение пути. # Эти пути аудируются НЕЗАВИСИМО от HTTP-метода (parcels-действия из §19). _AUDIT_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( ("export", re.compile(rf"^/api/v1/parcels/{_CAD}/forecast/export$")), ("analyze", re.compile(rf"^/api/v1/parcels/{_CAD}/analyze$")), ("forecast", re.compile(rf"^/api/v1/parcels/{_CAD}/forecast$")), ) # Insight WRITES (#948, §7.13/§8.9): /api/v1/insights и /api/v1/insights/{id}. # Аудируем ТОЛЬКО мутации (POST/PUT/DELETE) — GET-чтения insight'ов пропускаем. _INSIGHT_PATH = re.compile(r"^/api/v1/insights(?:/[^/]+)?$") _INSIGHT_WRITE_METHODS = frozenset({"POST", "PUT", "DELETE"}) def classify_path(path: str, method: str | None = None) -> tuple[str, str | None] | None: """Сопоставить *path* (+ опционально *method*) с аудируемым действием. Returns ``(action, cad_num)`` если путь — sensitive endpoint, иначе ``None``. ``cad_num`` извлекается из parcels-путей (для insight'ов — ``None``). *method* нужен только для insight-эндпоинтов (аудируем POST/PUT/DELETE, но не GET). Parcels-паттерны от метода не зависят. Если *method* не передан (``None``), insight-ветка пропускается — обратная совместимость со старыми вызовами ``classify_path(path)``. """ for action, pattern in _AUDIT_PATTERNS: m = pattern.match(path) if m: return action, m.group("cad") if method is not None and method in _INSIGHT_WRITE_METHODS and _INSIGHT_PATH.match(path): return "insight_write", None return None def write_audit_row( *, username: str, action: str, method: str, path: str, status_code: int, cad_num: str | None, ) -> None: """Best-effort вставка одной строки в ``audit_log``. Открывает свежий ``SessionLocal()``, commit, close — всё в try/except. Любая ошибка логируется и ГЛОТАЕТСЯ (re-raise здесь сломал бы основной запрос) — это намеренное исключение из «no silent except»: аудит вторичен по отношению к запросу пользователя. """ role: str | None try: role = get_role(username) except KeyError: # Юзер прошёл сюда через rbac_guard, так что обычно известен; но # на гонке reload roles.yaml / прямом вызове — пишем role=None, не падаем. role = None db = SessionLocal() try: db.execute( text( "INSERT INTO audit_log " "(username, role, action, method, path, cad_num, status_code) " "VALUES (:username, :role, :action, :method, :path, :cad_num, " ":status_code)" ), { "username": username, "role": role, "action": action, "method": method, "path": path, "cad_num": cad_num, "status_code": status_code, }, ) db.commit() except Exception: # Best-effort: НЕ re-raise — аудит не должен ломать запрос. logger.exception( "audit_log write failed (user=%s action=%s path=%s) — request unaffected", username, action, path, ) try: db.rollback() except Exception: logger.exception("audit_log rollback failed") finally: db.close() async def audit_log_middleware( request: Request, call_next: Callable[[Request], Awaitable[Response]], ) -> Response: """Аудируем sensitive actions ПОСЛЕ выполнения запроса (best-effort).""" # Test-mode bypass (как rbac_guard). Аудит-логика покрыта напрямую в # tests/test_audit_middleware.py через classify_path / write_audit_row. if settings.testing: return await call_next(request) matched = classify_path(request.url.path, request.method) # Выполняем сам запрос ВСЕГДА (даже non-matched) — сначала получаем response. response = await call_next(request) if matched is None: return response action, cad_num = matched username = request.headers.get("X-Authenticated-User") if not username: # Без auth-header (локальный curl мимо Caddy) rbac_guard уже отдал бы 401, # но если мы тут — нечего атрибутировать, аудит пропускаем. return response # Write после готового response — off the critical path для САМОГО клиента # (response уже отдан выше), но БЛОКИРУЕТ event loop пока идёт sync DB # round-trip (SessionLocal/execute/commit). Это останавливает ВСЕ другие # запросы на loop'е до завершения insert'а — а при исчерпании connection # pool checkout ждёт `pool_timeout=30s` НА loop'е, замораживая весь API # включая /health (#1202). Выполняем write в threadpool через # `run_in_threadpool` — sync IO в worker-потоке, event loop свободен. try: await run_in_threadpool( write_audit_row, username=username, action=action, method=request.method, path=request.url.path, status_code=response.status_code, cad_num=cad_num, ) except Exception: logger.exception("audit middleware unexpected error — request unaffected") return response