gendesign/backend/app/core/audit_middleware.py
Light1YT 6400ebde24
All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m50s
Deploy / deploy (push) Successful in 1m22s
CI / backend-tests (push) Successful in 6m35s
CI / backend-tests (pull_request) Successful in 6m32s
feat(insights): manual-entry Insight entity CRUD + §19 audit (#948 part A)
Insight = аналитик пишет free-form intel про локацию/участок с пометкой
«непублично» (is_confidential, §7.13/§8.9). CRUD под /api/v1/insights;
created_by из X-Authenticated-User (не из тела — спуфинг автора невозможен).
Зеркало custom_pois: raw-SQL service + Pydantic v2 schemas, sync def handlers
(threadpool, off event loop).

- data/sql/145_insight.sql: table insight (idempotent, geom GENERATED из lon/lat,
  индексы district/cad_num/is_confidential/created_by/GIST); аддитивно расширяет
  CHECK audit_log.action += insight_write (тот же constraint ck_audit_log_action,
  to_regclass-guarded, superset — не ломает #962-аудит; НЕ правит applied 144).
- audit_middleware.classify_path: method-aware insight_write для POST/PUT/DELETE
  /api/v1/insights (GET-чтения не аудируются); обратно совместимо с parcels.
- ACL: backend rbac_guard hard-блокирует только /api/v1/admin/*; pilot отсекается
  frontend RouteGuard + Caddy (как parcels/custom_pois). is_confidential — стораемая
  пометка без per-record backend-гейта (analyst видит, per #962-политике).
- tests: 28 insight (CRUD+filters+required+#261 commit) + 5 audit. 86 зелёных, ruff.

Part B (Location first-class, §8.2) — отдельный follow-up.

Refs #948.
2026-06-08 12:41:39 +05:00

188 lines
8.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""§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.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<cad>[^/]+)"
# Порядок ВАЖЕН: 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. Любой сбой внутри
# write_audit_row проглатывается там же; здесь дополнительная страховка.
try:
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