arm-core/api/src/services/session_audit.py
Andrey Epifancev 7b28554817 docs: «нет заголовка Authorization или схема не Bearer» — не попытка входа (ревью R-7, #9)
Формулировка уточнена в докстрингах `services/session_audit.py` и
`api/dependencies/auth.py`, README «Безопасность dev-режима» и CLAUDE.md; тест
событий входа дополнен запросом со схемой Basic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 13:48:52 +04:00

124 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Аудит входа: 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