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
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.
188 lines
8.5 KiB
Python
188 lines
8.5 KiB
Python
"""§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
|