Some checks failed
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
Admin dashboard traffic to /api/v1/admin/* no longer logged as api_request (would pollute behavior analytics); login events kept for IP audit.
71 lines
3.5 KiB
Python
71 lines
3.5 KiB
Python
"""RequestAuditMiddleware — пишет `api_request` (+ дедуплицированный `login`)
|
||
события в `user_events` для каждого аутентифицированного `/api/*` запроса.
|
||
|
||
Foundation для Feature 2 (login/IP audit) и базы Feature 3 (behavior analytics).
|
||
Логирование выполняется ПОСЛЕ `call_next` (не задерживает и не ветвит реальный
|
||
ответ клиенту) и через fire-and-forget `schedule_event` — сбой аудита никогда
|
||
не влияет на HTTP-ответ.
|
||
"""
|
||
|
||
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"})
|
||
|
||
|
||
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")
|
||
|
||
# Общий behavior/activity-поток — каждый authenticated API-запрос.
|
||
# /api/v1/admin/* исключаем: это ops-действия (просмотр самих
|
||
# дашбордов аудита/аналитики), а не поведение пилота — иначе
|
||
# запросы дашборда зашумляют top_paths и счётчики активности.
|
||
# login ниже логируем всегда (вход админа с IP — валидный аудит).
|
||
if not path.startswith("/api/v1/admin/"):
|
||
schedule_event(
|
||
event_type="api_request",
|
||
username=username,
|
||
ip=ip,
|
||
user_agent=ua,
|
||
path=path,
|
||
method=request.method,
|
||
)
|
||
|
||
# Дедуплицированный login/IP-audit сигнал — максимум раз в день
|
||
# на (юзер, IP, устройство).
|
||
if should_log_login(username, ip, ua):
|
||
schedule_event(
|
||
event_type="login",
|
||
username=username,
|
||
ip=ip,
|
||
user_agent=ua,
|
||
path=path,
|
||
method=request.method,
|
||
)
|
||
except Exception:
|
||
logger.warning("RequestAuditMiddleware: failed to record event", exc_info=True)
|
||
|
||
return response
|