- FastAPI + SQLAlchemy async + Alembic + Postgres backend - Auth: JWT access + rotating refresh tokens, argon2, roles, audit log - React 19 + Vite frontend: login page, protected route, auth context - Docker Compose: postgres, redis, migrate, api, worker (placeholder), frontend/nginx Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
73 lines
2.4 KiB
Python
73 lines
2.4 KiB
Python
"""Запись событий в журнал аудита."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Any
|
|
|
|
from fastapi import Request
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models.audit import AuditLog
|
|
from app.db.models.user import User
|
|
|
|
# Ключи, значения которых не должны попасть в журнал даже случайно.
|
|
_REDACTED_KEYS = {
|
|
"password",
|
|
"new_password",
|
|
"current_password",
|
|
"token",
|
|
"access_token",
|
|
"refresh_token",
|
|
"secret",
|
|
"license_key",
|
|
"api_key",
|
|
}
|
|
|
|
|
|
def _sanitize(payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Журнал аудита читают люди и он живёт годами — секретам там не место."""
|
|
return {
|
|
key: ("***" if key.lower() in _REDACTED_KEYS else value) for key, value in payload.items()
|
|
}
|
|
|
|
|
|
def client_ip(request: Request | None) -> str | None:
|
|
if request is None:
|
|
return None
|
|
# За nginx реальный адрес приходит в X-Forwarded-For; первый элемент — клиент.
|
|
forwarded = request.headers.get("x-forwarded-for")
|
|
if forwarded:
|
|
return forwarded.split(",")[0].strip()
|
|
return request.client.host if request.client else None
|
|
|
|
|
|
async def record(
|
|
session: AsyncSession,
|
|
*,
|
|
action: str,
|
|
user: User | None = None,
|
|
actor_label: str | None = None,
|
|
entity_type: str | None = None,
|
|
entity_id: str | uuid.UUID | None = None,
|
|
payload: dict[str, Any] | None = None,
|
|
request: Request | None = None,
|
|
) -> AuditLog:
|
|
"""Добавляет запись в журнал.
|
|
|
|
Без commit: вызывающий код сам решает границы транзакции, чтобы событие
|
|
и изменение состояния фиксировались вместе либо не фиксировались вовсе.
|
|
"""
|
|
entry = AuditLog(
|
|
user_id=user.id if user else None,
|
|
actor_label=actor_label or (user.email if user else None),
|
|
action=action,
|
|
entity_type=entity_type,
|
|
entity_id=str(entity_id) if entity_id is not None else None,
|
|
payload=_sanitize(payload or {}),
|
|
ip=client_ip(request),
|
|
user_agent=(request.headers.get("user-agent") if request else None),
|
|
)
|
|
session.add(entry)
|
|
return entry
|