add logging
All checks were successful
Deploy / deploy (push) Successful in 19s

This commit is contained in:
2026-03-12 14:20:45 +00:00
parent d8db8e2c16
commit 5cc5efd2e0
6 changed files with 208 additions and 7 deletions

120
backend/core/middleware.py Normal file
View File

@@ -0,0 +1,120 @@
"""
AuditLogMiddleware
──────────────────
Перехватывает мутирующие HTTP-запросы (POST / PUT / PATCH / DELETE),
пишет запись в таблицу action_logs после того как ответ уже отправлен клиенту.
Таким образом, запись в БД не блокирует основной поток ответа.
Что логируется:
• Метод + путь → action ("POST /api/tickets/book")
• IP-адрес → ip_address (X-Forwarded-For → client.host)
• user_id из JWT-токена → user_id (None для анонимных запросов)
• HTTP-статус ответа → details["status_code"]
Что пропускается:
• GET, HEAD, OPTIONS
• /docs, /redoc, /openapi.json, /metrics, /api/health
"""
import logging
from typing import Any
import jwt
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from core.security import SECRET_KEY, ALGORITHM
from database.models import ActionLog
from database.session import async_session
log = logging.getLogger("audit")
# ─── Paths that are never interesting to audit ────────────────────────────────
_SKIP_PREFIXES: tuple[str, ...] = (
"/docs",
"/redoc",
"/openapi.json",
"/metrics",
"/api/health",
)
# Only log requests that can change state
_AUDIT_METHODS: frozenset[str] = frozenset({"POST", "PUT", "PATCH", "DELETE"})
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _extract_user_id(request: Request) -> int | None:
"""
Try to decode the Bearer JWT and return the subject as int.
Returns None if the header is absent, malformed, or expired.
"""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header.removeprefix("Bearer ").strip()
try:
payload: dict[str, Any] = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
sub = payload.get("sub")
return int(sub) if sub is not None else None
except (jwt.PyJWTError, ValueError, TypeError):
return None
def _extract_ip(request: Request) -> str | None:
"""
Prefer X-Forwarded-For (set by Traefik / Nginx reverse proxy).
Fall back to the direct TCP peer address.
"""
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# X-Forwarded-For can be a comma-separated list; the first entry is the client IP
return forwarded_for.split(",")[0].strip()
if request.client:
return request.client.host
return None
# ─── Middleware ───────────────────────────────────────────────────────────────
class AuditLogMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: Any) -> Response:
# 1. Let the request go through and collect the response first
response: Response = await call_next(request)
# 2. Decide whether to log
method = request.method.upper()
path = request.url.path
if method not in _AUDIT_METHODS:
return response
if any(path.startswith(prefix) for prefix in _SKIP_PREFIXES):
return response
# 3. Extract metadata (cheap, no DB involved)
user_id = _extract_user_id(request)
ip_address = _extract_ip(request)
action = f"{method} {path}"
details: dict[str, Any] = {"status_code": response.status_code}
# 4. Write the audit record asynchronously — after the response is ready,
# so latency is not affected. Errors here must not propagate to the client.
try:
async with async_session() as session:
session.add(
ActionLog(
user_id=user_id,
action=action,
ip_address=ip_address,
details=details,
)
)
await session.commit()
except Exception:
# Audit failure must never break the API
log.exception("Failed to write audit log for %s %s", method, path)
return response