Some checks failed
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy Trade-In / changes (push) Successful in 7s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 36s
Deploy / build-backend (push) Successful in 1m39s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / build-backend (push) Successful in 48s
Deploy / build-worker (push) Successful in 2m41s
Deploy / deploy (push) Failing after 4s
Deploy Trade-In / deploy (push) Successful in 47s
CI / backend-tests (push) Successful in 6m38s
CI / backend-tests (pull_request) Successful in 6m30s
EPIC18/§19. analyst sees everything (deals, insights, exports, site-finder, analytics, concept) EXCEPT admin/data-management. Enforcement is backend-hard (the existing rbac_guard already 403s any non-admin role on /api/v1/admin/*, so adding the role auto-blocks it) + frontend (deny_paths via /me) + audit. §19 audit: new best-effort HTTP middleware logs the sensitive actions (analyze / forecast / forecast-export) to a new audit_log table after the response. Audit failures never break or delay-fail the request (2-layer try/except + finally close). Registered INNER to rbac_guard so only authorized requests are audited (a 403 short-circuits before audit). classify_path matches export before forecast (anchored). - auth/roles.yaml: analyst role (paths /**, deny admin-mgmt) + analysttest QA user - core/auth.py (+ tradein mirror): Role Literal += analyst - core/audit_middleware.py (new) + main.py registration - data/sql/144_audit_log.sql (idempotent; auto-applies) - tests: analyst rbac (403 admin / 200 parcels) + 11 audit cases No data-level ACL (analyst sees data per policy) -> no #948 dependency. No new deps. parcels.py untouched. Real analyst logins still need adding to caddy/users.caddy.snippet (devops). Refs #962.
172 lines
7.2 KiB
Python
172 lines
7.2 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'
|
||
|
||
``/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. Каждый паттерн якорный ($) → точное совпадение пути.
|
||
_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$")),
|
||
)
|
||
|
||
|
||
def classify_path(path: str) -> tuple[str, str | None] | None:
|
||
"""Сопоставить *path* с аудируемым действием.
|
||
|
||
Returns ``(action, cad_num)`` если путь — один из sensitive endpoints,
|
||
иначе ``None`` (путь не аудируется). ``cad_num`` извлекается из пути.
|
||
"""
|
||
for action, pattern in _AUDIT_PATTERNS:
|
||
m = pattern.match(path)
|
||
if m:
|
||
return action, m.group("cad")
|
||
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)
|
||
|
||
# Выполняем сам запрос ВСЕГДА (даже 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
|