All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 48s
Deploy Trade-In / build-backend (push) Successful in 1m0s
Deploy Trade-In / deploy (push) Successful in 51s
RequestAuditMiddleware logs api_request + deduped login per authenticated /api/* request; estimate() logs estimate_request with address. Fire-and-forget, never blocks/breaks the request. Writes to user_events (Feature 2/3 capture).
66 lines
2.9 KiB
Python
66 lines
2.9 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-запрос.
|
||
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
|