"""Аудит-лог авторизации и запросов API (JSONL, ротация по дням). Пишет строку JSON на каждый запрос /api/v1/*: ts, ip, method, path, api_key_prefix, user_id, status, success, latency_ms, error. Параметры из env: - AUDIT_LOG_DIR — каталог для логов (по умолчанию "logs"). """ from __future__ import annotations import json import logging import os import time from datetime import date, datetime, timezone from pathlib import Path from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request logger = logging.getLogger("md2vk.audit") class AuditMiddleware(BaseHTTPMiddleware): """Логирует каждый запрос /api/v1/* в JSONL с ротацией по дням.""" def __init__(self, app, log_dir: str | None = None): super().__init__(app) self.log_dir = Path(log_dir or os.getenv("AUDIT_LOG_DIR", "logs")) self.log_dir.mkdir(parents=True, exist_ok=True) self._fh = None self._fh_date: date | None = None def _ensure_file(self) -> None: today = date.today() if self._fh is None or self._fh_date != today: if self._fh is not None: try: self._fh.close() except Exception: pass path = self.log_dir / f"access.{today.isoformat()}.log" self._fh = open(path, "a", encoding="utf-8") self._fh_date = today def _write(self, record: dict) -> None: try: self._ensure_file() self._fh.write(json.dumps(record, ensure_ascii=False, default=str) + "\n") self._fh.flush() except Exception: # Логгер не должен ронять API logger.exception("audit write failed") async def dispatch(self, request: Request, call_next): start = time.monotonic() response = None error = None try: response = await call_next(request) return response except Exception as exc: # noqa: BLE001 error = str(exc) raise finally: path = request.url.path if path.startswith("/api/v1"): try: latency_ms = round((time.monotonic() - start) * 1000, 1) status = response.status_code if response is not None else 500 auth = request.headers.get("authorization", "") # api_key может быть в теле (POST) — пытаемся достать api_key_prefix = "" api_key_hash_short = "" user_id = None if auth.startswith("Bearer "): api_key_prefix = auth[len("Bearer "):][:12] elif request.method == "POST": api_key_prefix = self._api_key_from_body(request) if "md2vk_" in api_key_prefix: # вычислим короткий хэш для привязки к user (без хранения ключа) import hashlib api_key_hash_short = hashlib.sha256( api_key_prefix.encode() ).hexdigest()[:12] record = { "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), "ip": (request.client.host if request.client else ""), "method": request.method, "path": path, "query": str(request.url.query) or "", "api_key_prefix": api_key_prefix, "api_key_hash_short": api_key_hash_short, "user_id": user_id, "status": status, "success": status < 400, "latency_ms": latency_ms, "error": error, } self._write(record) except Exception: # noqa: BLE001 logger.exception("audit dispatch failed") @staticmethod def _api_key_from_body(request: Request) -> str: """Достаёт api_key из JSON-тела, не ломая повторное чтение.""" try: # starlette кэширует _body — повторное чтение в роутере безопасно body = getattr(request, "_body", None) if body is None: body = request.body() if hasattr(request, "body") else b"" if isinstance(body, bytes) and body: data = json.loads(body) key = data.get("api_key", "") return str(key)[:12] except Exception: return "" return ""