"""RequestAuditMiddleware — пишет `api_request` / `admin_action` (+ дедуплицированный `login`/`login_failed`) события в `user_events` для каждого аутентифицированного `/api/*` запроса. Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics). Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный ответ клиенту) и через fire-and-forget `schedule_event` — сбой аудита никогда не влияет на HTTP-ответ. Порядок middleware-стека (см. `app/main.py`: `rbac_guard` — `@app.middleware("http")`, объявлен ДО `app.add_middleware(RequestAuditMiddleware)`) делает `RequestAudit` ВНЕШНИМ по отношению к `rbac_guard` (Starlette строит стек в обратном порядке регистрации — последний `add_middleware` оборачивает предыдущие). Поэтому к моменту, когда код ниже читает `response.status_code`, в нём уже отражён исход rbac_guard (401/403 short-circuit) ИЛИ реального хендлера — статус несёт реальный смысл "успех/отказ", а не только "запрос дошёл до хендлера". Заведомо неаутентифицированный трафик (сканеры, долбящиеся в /wp-login.php и т.п. без валидного basic_auth) сюда вообще не попадает: Caddy гейтит basic_auth ПЕРЕД проксированием, так что `X-Authenticated-User` в таких запросах нет — условие `if username and ...` ниже их уже отсекает. Поэтому шум сканеров не нужно дополнительно фильтровать в этом файле — тот класс проблемы («сигнал тонет в шуме сканера») здесь структурно невозможен: событие может появиться только для запроса, прошедшего Caddy basic_auth. """ from __future__ import annotations import logging from fastapi import Request from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response from app.core.ratelimit import _client_ip from app.services.user_events import schedule_event, should_log_login logger = logging.getLogger(__name__) # Зеркалит app.main._PUBLIC_PATHS. Не импортируем напрямую из app.main — оно # импортирует этот модуль (регистрирует middleware), обратный импорт дал бы # циклическую зависимость. _PUBLIC_PATHS = frozenset({"/health", "/docs", "/redoc", "/openapi.json"}) # Методы, меняющие состояние — для /api/v1/admin/* именно они должны попадать в # аудит с атрибуцией (кто именно загрузил куки / включил авто-логин / поправил # прокси / изменил настройки скрапера / выполнил bulk-операцию). GET/HEAD/OPTIONS # на /admin/* остаются вне аудита (см. комментарий ниже — это просмотр дашбордов, # не действие). _MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) class RequestAuditMiddleware(BaseHTTPMiddleware): """Логирует активность аутентифицированных пользователей в `user_events`.""" async def dispatch(self, request: Request, call_next): # type: ignore[no-untyped-def] response: Response = await call_next(request) try: username = request.headers.get("x-authenticated-user") path = request.url.path if username and path.startswith("/api/") and path not in _PUBLIC_PATHS: ip = _client_ip(request) ua = request.headers.get("user-agent") method = request.method success = response.status_code < 400 is_admin_path = path.startswith("/api/v1/admin/") # Общий behavior/activity-поток — каждый authenticated API-запрос. # /api/v1/admin/* исключаем из `api_request`: это ops-действия # (просмотр дашбордов аудита/аналитики), а не поведение пилота — # иначе запросы дашборда зашумляют top_paths и счётчики активности. if not is_admin_path: schedule_event( event_type="api_request", username=username, ip=ip, user_agent=ua, path=path, method=method, payload={"status_code": response.status_code}, ) elif method in _MUTATING_METHODS: # Admin-аудит (security-audit fix): раньше ЛЮБОЙ запрос под # /api/v1/admin/* (включая меняющие состояние — загрузка кук, # авто-логин, правка прокси, настройки скраперов, bulk-операции) # полностью исключался из `user_events` тем же условием, что и # шумные GET-дашборды — установить, КТО совершил действие, было # невозможно. Пишем факт действия + атрибуцию (username/ip/path/ # method/статус) — БЕЗ тела запроса (там куки/пароли/секреты # правки прокси), это НЕ payload-лог, а событие "что произошло". schedule_event( event_type="admin_action", username=username, ip=ip, user_agent=ua, path=path, method=method, payload={"status_code": response.status_code, "success": success}, ) # Дедуплицированный login/IP-audit сигнал — максимум раз в день # на (юзер, IP, устройство). Security-audit fix: раньше событие # всегда писалось как `login` независимо от исхода запроса — # отражённая RBAC-попытка (валидный Caddy basic_auth, но # 401/403 от rbac_guard: протухший X-Internal-Auth-Secret, # неизвестная роль, scope-блок) была неотличима от настоящего # входа. Теперь тип события расходится по `response.status_code`: # `login` — успех, `login_failed` — otказ. Дедуп-бакет (once per # user+ip+ua+day) НЕ разбит отдельно на success/fail (это # потребовало бы менять `should_log_login` в user_events.py — # вне scope этого фикса): если в рамках одного дня с этого же # устройства сначала случился отказ, а затем реальный успешный # вход, второе событие в тот же день не запишется — тот же # компромисс дедупа, что был и раньше, разница только в том, что # теперь ЕДИНСТВЕННОЕ событие дня корректно отражает, чем оно было. if should_log_login(username, ip, ua): schedule_event( event_type="login" if success else "login_failed", username=username, ip=ip, user_agent=ua, path=path, method=method, payload={"status_code": response.status_code}, ) except Exception: logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True) return response