gendesign/backend/app/core/audit_middleware.py
Light1YT 86828d0388
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
feat(rbac): add analyst role + §19 audit-log middleware (#962)
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.
2026-06-08 12:16:19 +05:00

172 lines
7.2 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'
``/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