"""Аудит входа: LOGIN / LOGIN_FAILED (OQ-64, issue #9 п.5; spec audit-trail — запись только через `AuditService`). Что считается входом (вход внешний — JWT РАИСА, отдельного «логина» в АРМ нет): - LOGIN — успешная выдача dev-токена (`POST /api/v1/dev/token`, пишется в транзакции запроса) и первый успешный запрос пользователя с токеном после старта процесса (`first_request`; множество `logged_in` живёт в памяти процесса и сбрасывается при рестарте, у каждого экземпляра приложения — своё); - LOGIN_FAILED — 401 (токен не принят: `user_id` пуст, в `description` причина без самого токена) и 403 аутентификации (пользователь не найден, отключён, неоднозначен: email в `description`, `user_id` — если пользователь найден). Запрос без заголовка `Authorization` или со схемой не `Bearer` попыткой входа не считается (токен не предъявлен); 403 «недостаточно прав» — авторизация, не вход. Раздел `SESSION`, `object_type = "app_user"`, `object_id = user_id`; IP и user-agent — из запроса. Поля, которые присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000. LOGIN_FAILED и LOGIN первого запроса пишутся в ОТДЕЛЬНОЙ короткой транзакции (`transaction`, в проде — `src.db.db.session_transaction`): транзакция запроса при 401/403 откатывается (R-1), а запись об отказе должна остаться. Ошибка записи аудита входа ответ не меняет: она логируется, LOGIN первого запроса будет повторён следующим запросом. """ import uuid from collections.abc import Callable from contextlib import AbstractAsyncContextManager from loguru import logger from sqlalchemy.ext.asyncio import AsyncSession from starlette.requests import HTTPConnection from src.models.enums import AuditAction, AuditSection from src.services.audit import AuditService OBJECT_TYPE = "app_user" #: пределы длины полей события входа: заголовок User-Agent и email из токена присылает клиент (ревью R-1) USER_AGENT_MAX = 512 DESCRIPTION_MAX = 1000 FIRST_REQUEST_DESCRIPTION = "первый запрос с токеном" DEV_TOKEN_DESCRIPTION = "выдан dev-токен" #: фабрика отдельной транзакции: `async with transaction() as session` — сессия в открытой транзакции, commit на выходе TransactionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] def clip(text: str | None, limit: int) -> str | None: """Строка не длиннее `limit` символов (`None` остаётся `None`).""" return text if text is None else text[:limit] def request_client(request: HTTPConnection) -> tuple[str | None, str | None]: """IP клиента (за прокси — из `ProxyHeadersMiddleware`) и заголовок `User-Agent` запроса.""" return (request.client.host if request.client else None, request.headers.get("user-agent")) async def record_login( # noqa: PLR0913 - поля события входа без значений по умолчанию session: AsyncSession, *, action: AuditAction, user_id: uuid.UUID | None, description: str, ip_address: str | None, user_agent: str | None, ) -> None: """Событие входа в транзакции `session` (раздел SESSION, объект — пользователь); длинные поля обрезаются.""" await AuditService(session).record( action=action, section=AuditSection.SESSION, object_type=OBJECT_TYPE, object_id=user_id, user_id=user_id, description=clip(description, DESCRIPTION_MAX), ip_address=ip_address, user_agent=clip(user_agent, USER_AGENT_MAX), ) class SessionAudit: """LOGIN первого запроса и LOGIN_FAILED в отдельной транзакции; хранится в `app.state.session_audit`.""" def __init__(self, transaction: TransactionFactory) -> None: self.transaction = transaction #: пользователи, для которых LOGIN «первого запроса» уже записан этим процессом self.logged_in: set[uuid.UUID] = set() async def first_request(self, user_id: uuid.UUID, *, ip_address: str | None, user_agent: str | None) -> None: """LOGIN при первом успешном запросе пользователя после старта процесса. Id попадает в `logged_in` до записи (без `await` между проверкой и добавлением), поэтому параллельные первые запросы пишут одно событие; при ошибке записи id убирается — LOGIN попробует записать следующий запрос. """ if user_id in self.logged_in: return self.logged_in.add(user_id) if not await self._record( AuditAction.LOGIN, user_id, FIRST_REQUEST_DESCRIPTION, ip_address=ip_address, user_agent=user_agent ): self.logged_in.discard(user_id) async def login_failed( self, user_id: uuid.UUID | None, description: str, *, ip_address: str | None, user_agent: str | None ) -> None: """LOGIN_FAILED (401/403 аутентификации); вызывается обработчиком исключений после отката запроса.""" await self._record(AuditAction.LOGIN_FAILED, user_id, description, ip_address=ip_address, user_agent=user_agent) async def _record( self, action: AuditAction, user_id: uuid.UUID | None, description: str, *, ip_address: str | None, user_agent: str | None, ) -> bool: try: async with self.transaction() as session: await record_login( session, action=action, user_id=user_id, description=description, ip_address=ip_address, user_agent=user_agent, ) except Exception: logger.exception("Не удалось записать событие входа в audit_event. action={action}", action=action) return False return True