feat(rbac): add analyst role + §19 audit-log middleware (#962)
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
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.
This commit is contained in:
parent
25e21c2bff
commit
86828d0388
8 changed files with 652 additions and 4 deletions
|
|
@ -39,7 +39,26 @@ roles:
|
|||
- "/admin/**"
|
||||
- "/api/v1/admin/**"
|
||||
- "/trade-in/api/v1/admin/**"
|
||||
analyst:
|
||||
# #962 (EPIC18, ТЗ §19): analyst видит ВСЁ (deals, insights, exports,
|
||||
# site-finder, analytics, concept) КРОМЕ admin/data-management.
|
||||
# paths "/**" = доступ ко всему; deny = админ-управление (scraper-триггеры,
|
||||
# sync-таски, data-management, user/role management).
|
||||
# Backend hard-gate: app/main.py rbac_guard уже блокирует /api/v1/admin/*
|
||||
# для любого role != "admin" → analyst авто-403 на admin-API без доп. кода.
|
||||
# deny ниже драйвит фронтовый RouteGuard (deny_paths из /me) для UI-gating
|
||||
# /admin/** страниц.
|
||||
paths:
|
||||
- "/**"
|
||||
deny:
|
||||
- "/admin/**"
|
||||
- "/api/v1/admin/**"
|
||||
- "/trade-in/api/v1/admin/**"
|
||||
|
||||
# NB: реальные analyst-логины ДОЛЖНЫ быть добавлены и в
|
||||
# caddy/users.caddy.snippet (Caddy basic_auth) силами devops — иначе Caddy не
|
||||
# пропустит юзера и не выставит X-Authenticated-User. Здесь только role-mapping;
|
||||
# Caddy НЕ редактируем из этого таска.
|
||||
users:
|
||||
admin: admin
|
||||
kopylov: pilot
|
||||
|
|
@ -56,3 +75,4 @@ users:
|
|||
praktika: pilot # пилот-аккаунт агентства «Практика» 2026-05-29
|
||||
admintest: admin # temp QA 2026-05-26
|
||||
pilottest: pilot # temp QA 2026-05-26
|
||||
analysttest: analyst # temp QA 2026-06-07 (#962)
|
||||
|
|
|
|||
172
backend/app/core/audit_middleware.py
Normal file
172
backend/app/core/audit_middleware.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""§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
|
||||
|
|
@ -30,7 +30,7 @@ import yaml
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Role = Literal["admin", "pilot"]
|
||||
Role = Literal["admin", "pilot", "analyst"]
|
||||
|
||||
|
||||
class UserScope(TypedDict):
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from app.api.v1 import (
|
|||
trade_in,
|
||||
users,
|
||||
)
|
||||
from app.core.audit_middleware import audit_log_middleware
|
||||
from app.core.auth import get_role
|
||||
from app.core.config import settings
|
||||
from app.observability.sentry_scrub import scrub_sensitive_query
|
||||
|
|
@ -77,6 +78,15 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|||
|
||||
app = FastAPI(title="GenDesign API", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
# §19 audit log (#962, EPIC18): аудит sensitive actions (analyze/forecast/export).
|
||||
# Регистрируем ПЕРВЫМ → Starlette применяет HTTP-middleware в LIFO-порядке
|
||||
# (add_middleware insert(0); build_middleware_stack wraps reversed), поэтому
|
||||
# rbac_guard (зарегистрирован НИЖЕ) оказывается ВНЕШНИМ слоем, а audit — внутри:
|
||||
# request flow = rbac_guard → audit → router. Аудитим ТОЛЬКО запросы, которые
|
||||
# rbac_guard уже пропустил (его 401/403 short-circuit'ы не доходят до audit).
|
||||
# Best-effort: сбой записи не ломает запрос (см. app/core/audit_middleware.py).
|
||||
app.middleware("http")(audit_log_middleware)
|
||||
|
||||
# RBAC: defense-in-depth поверх Caddy basic_auth + X-Authenticated-User
|
||||
# (см. app/core/auth.py + auth/roles.yaml). Правила:
|
||||
# 1) Любой non-public path требует X-Authenticated-User — иначе 401
|
||||
|
|
|
|||
319
backend/tests/test_audit_middleware.py
Normal file
319
backend/tests/test_audit_middleware.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
"""§19 audit-log middleware tests (#962, EPIC18).
|
||||
|
||||
Coverage:
|
||||
- classify_path: analyze / forecast / forecast-export матчатся, cad_num
|
||||
извлекается; forecast/export НЕ путается с forecast; non-matched → None.
|
||||
- write_audit_row: best-effort INSERT — ровно одна строка с верными полями;
|
||||
сбой записи (execute кидает) проглатывается, не re-raise; rollback вызван.
|
||||
- middleware (копия, минующая settings.testing-bypass, как в test_rbac.py):
|
||||
* matched request → ровно один audit write с верными полями + реальный
|
||||
status_code из ответа.
|
||||
* non-matched path → НИ одной записи.
|
||||
* сбой audit write → запрос всё равно отдаёт настоящий response.
|
||||
|
||||
DB не нужен: SessionLocal патчится фейковой сессией, ловящей execute()/commit().
|
||||
auth.get_role патчится, чтобы не зависеть от roles.yaml-кэша.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import Response
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core import audit_middleware as audit_mod
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake DB session — записывает execute()/commit()/rollback()/close().
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, *, fail_on_execute: bool = False) -> None:
|
||||
self.executed: list[tuple[str, dict[str, Any]]] = []
|
||||
self.committed = 0
|
||||
self.rolled_back = 0
|
||||
self.closed = 0
|
||||
self._fail_on_execute = fail_on_execute
|
||||
|
||||
def execute(self, stmt: Any, params: dict[str, Any]) -> None:
|
||||
if self._fail_on_execute:
|
||||
raise RuntimeError("simulated DB failure")
|
||||
# stmt — SQLAlchemy TextClause; .text несёт сырой SQL.
|
||||
self.executed.append((str(getattr(stmt, "text", stmt)), params))
|
||||
|
||||
def commit(self) -> None:
|
||||
self.committed += 1
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rolled_back += 1
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_session(monkeypatch: pytest.MonkeyPatch) -> _FakeSession:
|
||||
"""Патчит SessionLocal в audit-модуле, возвращает единственную сессию."""
|
||||
sess = _FakeSession()
|
||||
monkeypatch.setattr(audit_mod, "SessionLocal", lambda: sess)
|
||||
# get_role детерминированно → не зависим от roles.yaml lru_cache.
|
||||
monkeypatch.setattr(audit_mod, "get_role", lambda u: "analyst")
|
||||
return sess
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# classify_path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_classify_path_analyze() -> None:
|
||||
assert audit_mod.classify_path("/api/v1/parcels/66:41:0204016:10/analyze") == (
|
||||
"analyze",
|
||||
"66:41:0204016:10",
|
||||
)
|
||||
|
||||
|
||||
def test_classify_path_forecast() -> None:
|
||||
assert audit_mod.classify_path("/api/v1/parcels/66:41:0204016:10/forecast") == (
|
||||
"forecast",
|
||||
"66:41:0204016:10",
|
||||
)
|
||||
|
||||
|
||||
def test_classify_path_forecast_export_not_confused_with_forecast() -> None:
|
||||
"""forecast/export должен дать action='export', НЕ 'forecast'."""
|
||||
assert audit_mod.classify_path(
|
||||
"/api/v1/parcels/66:41:0204016:10/forecast/export"
|
||||
) == ("export", "66:41:0204016:10")
|
||||
|
||||
|
||||
def test_classify_path_unmatched_returns_none() -> None:
|
||||
assert audit_mod.classify_path("/api/v1/parcels/66:41:0204016:10") is None
|
||||
assert audit_mod.classify_path("/api/v1/parcels/dummy/runs") is None
|
||||
assert audit_mod.classify_path("/api/v1/analytics/dashboard") is None
|
||||
assert audit_mod.classify_path("/health") is None
|
||||
assert audit_mod.classify_path("/api/v1/admin/jobs/settings") is None
|
||||
# Похожие, но не точные пути (якорь $) не матчатся.
|
||||
assert audit_mod.classify_path("/api/v1/parcels/X/analyze/extra") is None
|
||||
assert audit_mod.classify_path("/api/v1/parcels/X/forecast/export/extra") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# write_audit_row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_write_audit_row_inserts_one_row(fake_session: _FakeSession) -> None:
|
||||
audit_mod.write_audit_row(
|
||||
username="analysttest",
|
||||
action="analyze",
|
||||
method="POST",
|
||||
path="/api/v1/parcels/66:41:0204016:10/analyze",
|
||||
status_code=200,
|
||||
cad_num="66:41:0204016:10",
|
||||
)
|
||||
assert len(fake_session.executed) == 1
|
||||
sql, params = fake_session.executed[0]
|
||||
assert "INSERT INTO audit_log" in sql
|
||||
# psycopg v3 CAST-конвенция: никаких ':param::type' в SQL.
|
||||
assert "::" not in sql
|
||||
assert params == {
|
||||
"username": "analysttest",
|
||||
"role": "analyst",
|
||||
"action": "analyze",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/parcels/66:41:0204016:10/analyze",
|
||||
"cad_num": "66:41:0204016:10",
|
||||
"status_code": 200,
|
||||
}
|
||||
assert fake_session.committed == 1
|
||||
assert fake_session.closed == 1
|
||||
assert fake_session.rolled_back == 0
|
||||
|
||||
|
||||
def test_write_audit_row_failure_is_swallowed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Сбой execute НЕ должен пробрасываться — best-effort. rollback + close."""
|
||||
sess = _FakeSession(fail_on_execute=True)
|
||||
monkeypatch.setattr(audit_mod, "SessionLocal", lambda: sess)
|
||||
monkeypatch.setattr(audit_mod, "get_role", lambda u: "analyst")
|
||||
|
||||
# Не должно бросить.
|
||||
audit_mod.write_audit_row(
|
||||
username="analysttest",
|
||||
action="forecast",
|
||||
method="GET",
|
||||
path="/api/v1/parcels/66:41:0204016:10/forecast",
|
||||
status_code=500,
|
||||
cad_num="66:41:0204016:10",
|
||||
)
|
||||
assert sess.committed == 0
|
||||
assert sess.rolled_back == 1
|
||||
assert sess.closed == 1
|
||||
|
||||
|
||||
def test_write_audit_row_unknown_user_role_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_role KeyError → role=None, запись всё равно делается."""
|
||||
sess = _FakeSession()
|
||||
monkeypatch.setattr(audit_mod, "SessionLocal", lambda: sess)
|
||||
|
||||
def _raise(_: str) -> str:
|
||||
raise KeyError("unknown")
|
||||
|
||||
monkeypatch.setattr(audit_mod, "get_role", _raise)
|
||||
|
||||
audit_mod.write_audit_row(
|
||||
username="ghost",
|
||||
action="export",
|
||||
method="GET",
|
||||
path="/api/v1/parcels/66:41:0204016:10/forecast/export",
|
||||
status_code=403,
|
||||
cad_num="66:41:0204016:10",
|
||||
)
|
||||
assert len(sess.executed) == 1
|
||||
_, params = sess.executed[0]
|
||||
assert params["role"] is None
|
||||
assert params["username"] == "ghost"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware — копия audit_log_middleware БЕЗ settings.testing-bypass (как
|
||||
# rbac_guard в test_rbac.py: прод-middleware на settings.testing смотрит,
|
||||
# поэтому здесь гоняем «развязанную» копию, идентичную по логике).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_audited_app(captured: list[dict[str, Any]]) -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def audit_mw(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
matched = audit_mod.classify_path(request.url.path)
|
||||
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:
|
||||
return response
|
||||
captured.append(
|
||||
{
|
||||
"username": username,
|
||||
"action": action,
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": response.status_code,
|
||||
"cad_num": cad_num,
|
||||
}
|
||||
)
|
||||
return response
|
||||
|
||||
@app.post("/api/v1/parcels/{cad}/analyze")
|
||||
async def analyze(cad: str) -> dict:
|
||||
return {"ok": True, "cad": cad}
|
||||
|
||||
@app.get("/api/v1/parcels/{cad}/forecast")
|
||||
async def forecast(cad: str) -> dict:
|
||||
return {"ok": True, "cad": cad}
|
||||
|
||||
@app.get("/api/v1/parcels/{cad}/forecast/export")
|
||||
async def forecast_export(cad: str) -> dict:
|
||||
return {"ok": True, "cad": cad}
|
||||
|
||||
@app.get("/api/v1/analytics/dashboard")
|
||||
async def analytics() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def test_middleware_audits_matched_request() -> None:
|
||||
captured: list[dict[str, Any]] = []
|
||||
client = TestClient(_build_audited_app(captured))
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/parcels/66:41:0204016:10/analyze",
|
||||
headers={"X-Authenticated-User": "analysttest"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(captured) == 1
|
||||
row = captured[0]
|
||||
assert row["username"] == "analysttest"
|
||||
assert row["action"] == "analyze"
|
||||
assert row["method"] == "POST"
|
||||
assert row["cad_num"] == "66:41:0204016:10"
|
||||
assert row["status_code"] == 200
|
||||
|
||||
|
||||
def test_middleware_does_not_audit_unmatched_paths() -> None:
|
||||
captured: list[dict[str, Any]] = []
|
||||
client = TestClient(_build_audited_app(captured))
|
||||
|
||||
r1 = client.get(
|
||||
"/api/v1/analytics/dashboard",
|
||||
headers={"X-Authenticated-User": "analysttest"},
|
||||
)
|
||||
r2 = client.get("/health")
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert captured == []
|
||||
|
||||
|
||||
def test_middleware_export_action_distinct_from_forecast() -> None:
|
||||
captured: list[dict[str, Any]] = []
|
||||
client = TestClient(_build_audited_app(captured))
|
||||
|
||||
client.get(
|
||||
"/api/v1/parcels/66:41:0204016:10/forecast",
|
||||
headers={"X-Authenticated-User": "analysttest"},
|
||||
)
|
||||
client.get(
|
||||
"/api/v1/parcels/66:41:0204016:10/forecast/export",
|
||||
headers={"X-Authenticated-User": "analysttest"},
|
||||
)
|
||||
assert [r["action"] for r in captured] == ["forecast", "export"]
|
||||
|
||||
|
||||
def test_middleware_audit_failure_does_not_break_request(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Сбой записи аудита НЕ ломает основной запрос — real response отдан.
|
||||
|
||||
Здесь гоняем реальный audit_log_middleware (settings.testing выключаем
|
||||
для этого app), но write_audit_row патчим на бросающий — проверяем что
|
||||
клиент всё равно получает 200 от handler'а."""
|
||||
from app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "testing", False)
|
||||
|
||||
def _boom(**_: Any) -> None:
|
||||
raise RuntimeError("audit exploded")
|
||||
|
||||
monkeypatch.setattr(audit_mod, "write_audit_row", _boom)
|
||||
|
||||
app = FastAPI()
|
||||
app.middleware("http")(audit_mod.audit_log_middleware)
|
||||
|
||||
@app.post("/api/v1/parcels/{cad}/analyze")
|
||||
async def analyze(cad: str) -> dict:
|
||||
return {"ok": True, "cad": cad}
|
||||
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/parcels/66:41:0204016:10/analyze",
|
||||
headers={"X-Authenticated-User": "analysttest"},
|
||||
)
|
||||
# write_audit_row взорвался, но middleware проглотил → handler-ответ дошёл.
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["ok"] is True
|
||||
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
Coverage:
|
||||
- get_role / get_user_scope happy + error paths
|
||||
- is_path_allowed glob semantics (admin everywhere, pilot blocked from /admin/**)
|
||||
- is_path_allowed glob semantics (admin everywhere, pilot blocked from /admin/**,
|
||||
analyst sees /** except admin/data-management — #962)
|
||||
- GET /me — header missing / unknown user / pilot / admin
|
||||
- rbac_guard middleware:
|
||||
* любой non-public path требует X-Authenticated-User (no-header → 401)
|
||||
* неизвестный юзер → 403 на ВСЁ (включая non-admin paths) —
|
||||
«человек без ролей вообще ничего не видит» (decided 2026-05-25)
|
||||
* /api/v1/admin/* — только role=admin (pilot → 403)
|
||||
* /api/v1/admin/* — только role=admin (pilot/analyst → 403; #962)
|
||||
* public /health пропускаем без проверки
|
||||
|
||||
Тесты используют изолированный FastAPI app (см. _build_test_app), чтобы не
|
||||
|
|
@ -87,6 +88,11 @@ def _build_test_app() -> FastAPI:
|
|||
async def admin_dummy() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
# Non-admin /api/v1/* endpoint — analyst/pilot/admin должны доходить до него.
|
||||
@app.get("/api/v1/parcels/dummy")
|
||||
async def parcels_dummy() -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
|
@ -158,6 +164,28 @@ def test_is_path_allowed_pilot_only_tradein() -> None:
|
|||
assert not auth_mod.is_path_allowed("pilot", "/trade-in/api/v1/admin/scrape")
|
||||
|
||||
|
||||
def test_is_path_allowed_analyst_everything_except_admin() -> None:
|
||||
"""Analyst (#962) видит ВСЁ кроме admin/data-management.
|
||||
paths=/** ; deny=/admin/**, /api/v1/admin/**, /trade-in/api/v1/admin/**."""
|
||||
# Analyst ALLOWED — данные, аналитика, site-finder, concept, trade-in
|
||||
assert auth_mod.is_path_allowed("analyst", "/")
|
||||
assert auth_mod.is_path_allowed("analyst", "/analytics/dashboard")
|
||||
assert auth_mod.is_path_allowed("analyst", "/site-finder/123")
|
||||
assert auth_mod.is_path_allowed("analyst", "/concept/abc")
|
||||
assert auth_mod.is_path_allowed("analyst", "/trade-in/123")
|
||||
assert auth_mod.is_path_allowed("analyst", "/api/v1/parcels/66:41:0204016:10/analyze")
|
||||
assert auth_mod.is_path_allowed("analyst", "/api/v1/parcels/66:41:0204016:10/forecast")
|
||||
assert auth_mod.is_path_allowed("analyst", "/api/v1/analytics/dashboard")
|
||||
|
||||
# Analyst DENIED — admin/data-management (scraper, sync, user/role mgmt)
|
||||
assert not auth_mod.is_path_allowed("analyst", "/admin")
|
||||
assert not auth_mod.is_path_allowed("analyst", "/admin/jobs")
|
||||
assert not auth_mod.is_path_allowed("analyst", "/admin/scrape/runs/42")
|
||||
assert not auth_mod.is_path_allowed("analyst", "/api/v1/admin/jobs/settings")
|
||||
assert not auth_mod.is_path_allowed("analyst", "/api/v1/admin/scrape/status")
|
||||
assert not auth_mod.is_path_allowed("analyst", "/trade-in/api/v1/admin/scrape")
|
||||
|
||||
|
||||
def test_is_path_allowed_unknown_role_denied() -> None:
|
||||
assert not auth_mod.is_path_allowed("ghost", "/")
|
||||
assert not auth_mod.is_path_allowed("ghost", "/admin")
|
||||
|
|
@ -189,6 +217,18 @@ def test_get_user_scope_pilot() -> None:
|
|||
assert "/api/v1/admin/**" in scope["deny_paths"]
|
||||
|
||||
|
||||
def test_get_user_scope_analyst() -> None:
|
||||
"""analysttest (#962) → analyst scope: allowed /** + admin deny_paths."""
|
||||
scope = auth_mod.get_user_scope("analysttest")
|
||||
assert scope["username"] == "analysttest"
|
||||
assert scope["role"] == "analyst"
|
||||
assert "/**" in scope["allowed_paths"]
|
||||
# deny_paths драйвит фронтовый RouteGuard для /admin/** страниц.
|
||||
assert "/admin/**" in scope["deny_paths"]
|
||||
assert "/api/v1/admin/**" in scope["deny_paths"]
|
||||
assert "/trade-in/api/v1/admin/**" in scope["deny_paths"]
|
||||
|
||||
|
||||
def test_get_user_scope_unknown_raises() -> None:
|
||||
with pytest.raises(KeyError):
|
||||
auth_mod.get_user_scope("nobody")
|
||||
|
|
@ -255,6 +295,28 @@ def test_rbac_guard_admin_allowed(client: TestClient) -> None:
|
|||
assert resp.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_rbac_guard_analyst_blocked_on_admin_api(client: TestClient) -> None:
|
||||
"""#962 backend-hard: existing rbac_guard АВТО-блокирует analyst на
|
||||
/api/v1/admin/* (role != admin) — без доп. кода, просто потому что роль
|
||||
добавлена. Подтверждаем что 403 'admin only'."""
|
||||
resp = client.get(
|
||||
"/api/v1/admin/dummy",
|
||||
headers={"X-Authenticated-User": "analysttest"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == "admin only"
|
||||
|
||||
|
||||
def test_rbac_guard_analyst_allowed_on_non_admin_api(client: TestClient) -> None:
|
||||
"""Analyst свободно ходит в обычные /api/v1/* (не admin) — 200."""
|
||||
resp = client.get(
|
||||
"/api/v1/parcels/dummy",
|
||||
headers={"X-Authenticated-User": "analysttest"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
|
||||
def test_rbac_guard_no_header_on_admin_path_returns_401(client: TestClient) -> None:
|
||||
"""Локальный curl без Caddy → /api/v1/admin/* отдаёт 401."""
|
||||
resp = client.get("/api/v1/admin/dummy")
|
||||
|
|
|
|||
65
data/sql/144_audit_log.sql
Normal file
65
data/sql/144_audit_log.sql
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
-- 144_audit_log.sql
|
||||
-- #962 (EPIC18, ТЗ §19): аудит-лог чувствительных действий аналитика/пользователей.
|
||||
--
|
||||
-- Пишется best-effort FastAPI-middleware (app/core/audit_middleware.py) ПОСЛЕ
|
||||
-- ответа на запрос для ключевых действий:
|
||||
-- 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'
|
||||
-- Прочие matched-нестандартные пути → action='other'. /health, static,
|
||||
-- non-matched пути НЕ аудируются.
|
||||
--
|
||||
-- Best-effort: сбой записи в audit_log НИКОГДА не ломает основной запрос
|
||||
-- (middleware оборачивает write в try/except).
|
||||
--
|
||||
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз NN=144).
|
||||
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блок для CHECK.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ── 1. Таблица audit_log ──────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL, -- из X-Authenticated-User
|
||||
role TEXT, -- get_role(username) на момент запроса
|
||||
action TEXT NOT NULL, -- analyze | forecast | export | other
|
||||
method TEXT NOT NULL, -- HTTP-метод (GET/POST/...)
|
||||
path TEXT NOT NULL, -- request.url.path
|
||||
cad_num TEXT, -- кадастровый номер из пути (NULL если нет)
|
||||
status_code INTEGER, -- HTTP-статус ответа
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
detail JSONB -- произвольный контекст (резерв)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE audit_log IS
|
||||
'Аудит чувствительных действий (#962, ТЗ §19). Пишется best-effort '
|
||||
'middleware app/core/audit_middleware.py ПОСЛЕ ответа на запрос. '
|
||||
'Сбой записи не влияет на основной запрос.';
|
||||
COMMENT ON COLUMN audit_log.action IS
|
||||
'analyze | forecast | export | other — выводится из request path в middleware.';
|
||||
COMMENT ON COLUMN audit_log.cad_num IS
|
||||
'Кадастровый номер из /parcels/{cad}/... (напр. 66:41:0204016:10), NULL если нет.';
|
||||
COMMENT ON COLUMN audit_log.detail IS
|
||||
'Резервное JSONB-поле под доп. контекст (пока не заполняется).';
|
||||
|
||||
-- ── 2. CHECK на допустимые action (guarded — idempotent при ре-apply) ────────
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'ck_audit_log_action'
|
||||
) THEN
|
||||
ALTER TABLE audit_log
|
||||
ADD CONSTRAINT ck_audit_log_action
|
||||
CHECK (action IN ('analyze', 'forecast', 'export', 'other'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- ── 3. Индексы: lookup по юзеру во времени + по кадастру ──────────────────────
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_username_created
|
||||
ON audit_log (username, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_cad_num
|
||||
ON audit_log (cad_num);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -31,7 +31,7 @@ import yaml
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Role = Literal["admin", "pilot"]
|
||||
Role = Literal["admin", "pilot", "analyst"]
|
||||
|
||||
|
||||
class UserScope(TypedDict):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue