feat: аудит входа LOGIN / LOGIN_FAILED (OQ-64, #9 п.5)

Раздел SESSION, object_type app_user, IP и user-agent запроса
(`services/session_audit.py`):
- LOGIN — выдача dev-токена (в транзакции запроса) и первый успешный
  `current_user` пользователя после старта процесса (`SessionAudit.logged_in`,
  в памяти процесса; параллельные первые запросы — одно событие);
- LOGIN_FAILED — 401 (токен не принят: user_id пуст, причина без токена) и 403
  аутентификации (не зарегистрирован / отключён / неоднозначен: email в
  description, user_id — если найден). `AuthenticationFailedError` (подкласс
  HTTPException) обрабатывается после отката транзакции запроса, запись — в
  отдельной транзакции `session_transaction`, поэтому остаётся в журнале.
Без заголовка Authorization и 403 «недостаточно прав» — не события входа;
ошибка записи аудита входа ответ не меняет.

Тесты: `test_login_audit.py` (в т.ч. фиксация при откате запроса на
`committed_app`); счётчики следов импорта не учитывают события SESSION.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:27:29 +04:00
parent 2017d11379
commit ef7458eddb
15 changed files with 535 additions and 29 deletions

View File

@ -74,6 +74,8 @@
(`LAST_ADMIN` с блокировкой строки права). Порядок: маршрут (`require(code)`) → policy → сервис → аудит.
- **Аутентификация** — `core/auth.py` (проверка JWT), `src/api/dependencies/auth.py` (`current_user`, `require(code)`);
dev-эмитент (`POST /api/v1/dev/token`) существует только при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`.
Аудит входа — `services/session_audit.py` (`app.state.session_audit`): `LOGIN` (dev-токен; первый запрос после старта),
`LOGIN_FAILED` (`AuthenticationFailedError` 401/403 → обработчик пишет в отдельной транзакции `session_transaction`).
- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен; `--dev` — только для разработки (CLI отказывает с кодом 2 при `DEBUG=false`).
**Сверка ORM↔DBML** — `scripts/check_dbml_vs_orm.py` (`make dbml-check`).

View File

@ -588,6 +588,16 @@ Dockerfile только для локального compose
- `AUTH_DEV_SECRET` из `api/.env.example` — публичный пример, в проде не использовать. `make seed-dev`
(`python -m scripts.seed --dev`) при `DEBUG=false` отказывает (код 2), dev-пользователи в прод не попадут.
- Порты compose по умолчанию публикуются только на `127.0.0.1` (`BIND_ADDR`); пароли `armka`/`minioadmin` — только dev.
- **Аудит входа** (OQ-64; `src/services/session_audit.py`), раздел `SESSION`, `object_type = app_user`, IP и
user-agent запроса:
- `LOGIN` — выдача dev-токена (`description` «выдан dev-токен», в транзакции запроса) и первый успешный запрос
пользователя с токеном после старта процесса («первый запрос с токеном»; учёт — в памяти процесса, после рестарта
и на каждом экземпляре приложения первый запрос пишется снова);
- `LOGIN_FAILED` — 401 (токен не принят: `user_id` пуст, в `description` причина, сам токен не пишется) и 403
аутентификации (пользователь не зарегистрирован, отключён или неоднозначен: email в `description`, `user_id` —
если пользователь найден). Запись делается в отдельной транзакции и остаётся, хотя транзакция запроса откатилась.
- Не события входа: запрос без заголовка `Authorization` и 403 «недостаточно прав». Ошибка записи аудита входа
ответ не меняет (только лог). Выход (`LOGOUT`) не пишется: сессий на сервере нет.
## Соседние документы

View File

@ -16,6 +16,7 @@ from src.api.dependencies.db import SessionDep
from src.models.audit_ex import AuditEvent
from src.models.enums import AuditAction, AuditSection
from src.services.audit import AuditService
from src.services.session_audit import request_client
@dataclass(frozen=True)
@ -65,9 +66,7 @@ async def get_request_audit(
principal: Annotated[Principal, Depends(current_user)],
) -> RequestAudit:
"""Зависимость: аудит в сессии запроса; IP — адрес клиента (за прокси — из `ProxyHeadersMiddleware`)."""
ip_address, user_agent = request_client(request)
return RequestAudit(
service=AuditService(session),
user_id=principal.user.id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent"),
service=AuditService(session), user_id=principal.user.id, ip_address=ip_address, user_agent=user_agent
)

View File

@ -7,17 +7,24 @@
Ответы: 401 — нет/невалидный токен (с `WWW-Authenticate: Bearer`); 403 — пользователь не найден, неоднозначен,
отключён либо нет права. Тело — `{"detail": "<причина по-русски>"}` (форматирует обработчик `HTTPException`).
Права проверяются только по кодам прав: код роли в логике не используется.
Аудит входа (OQ-64, `services/session_audit.py`): отказ токена и 403 аутентификации — `AuthenticationFailedError`,
обработчик пишет LOGIN_FAILED в отдельной транзакции; первый успешный `current_user` пользователя после старта процесса
пишет LOGIN. Нет заголовка `Authorization` и 403 «недостаточно прав» — не события входа.
"""
import uuid
from collections.abc import Awaitable, Callable
from typing import Annotated
from fastapi import Depends, Header, HTTPException, Request, status
from src.api.dependencies.db import SessionDep
from src.common.exceptions import AuthenticationFailedError
from src.core.auth import AuthError, Claims, TokenVerifier, build_verifier
from src.core.config import get_settings
from src.repositories.users import AmbiguousUserError, UserWithAccess, load_principal_data
from src.services.session_audit import SessionAudit, request_client
# Текущий пользователь вместе с филиалом, ролями и эффективными правами (то, что нужно `GET /me` и `require`)
Principal = UserWithAccess
@ -36,6 +43,23 @@ def _forbidden(detail: str) -> HTTPException:
return HTTPException(status.HTTP_403_FORBIDDEN, detail=detail)
def _token_rejected(detail: str) -> AuthenticationFailedError:
"""401 с LOGIN_FAILED: токен предъявлен, но не принят (в журнал — причина, токен не пишется)."""
return AuthenticationFailedError(
status.HTTP_401_UNAUTHORIZED,
detail,
audit_description=f"токен не принят: {detail}",
headers=dict(_UNAUTHORIZED_HEADERS),
)
def _login_forbidden(detail: str, email: str, user_id: uuid.UUID | None = None) -> AuthenticationFailedError:
"""403 аутентификации с LOGIN_FAILED: email из токена — в журнал, `user_id` — если пользователь найден."""
return AuthenticationFailedError(
status.HTTP_403_FORBIDDEN, detail, audit_description=f"{detail}: {email}", user_id=user_id
)
def parse_bearer(authorization: str | None) -> str:
"""Токен из значения заголовка `Authorization: Bearer <jwt>`; иначе 401 (схема без учёта регистра)."""
if authorization is not None:
@ -64,14 +88,15 @@ async def verified_claims(
"""Claims проверенного токена. Не обращается к БД: все 401 решаются до неё."""
token = parse_bearer(authorization)
if verifier is None:
raise _unauthorized("аутентификация не настроена")
raise _token_rejected("аутентификация не настроена")
try:
return await verifier.verify(token)
except AuthError as error:
raise _unauthorized(str(error)) from error
raise _token_rejected(str(error)) from error
async def current_user(
request: Request,
claims: Annotated[Claims, Depends(verified_claims)],
session: SessionDep,
) -> Principal:
@ -79,15 +104,20 @@ async def current_user(
`Claims.email` уже нормализован (`strip().lower()`), сравнение в БД — по `lower(app_user.email)` (R-17).
403: пользователя нет в АРМ; учётная запись неоднозначна (несколько email, различающихся регистром) или отключена.
Первый успешный вызов для пользователя после старта процесса пишет LOGIN (`SessionAudit.first_request`).
"""
try:
principal = await load_principal_data(session, claims.email)
except AmbiguousUserError as error:
raise _forbidden("неоднозначная учётная запись") from error
raise _login_forbidden("неоднозначная учётная запись", claims.email) from error
if principal is None:
raise _forbidden("пользователь не зарегистрирован в АРМ")
raise _login_forbidden("пользователь не зарегистрирован в АРМ", claims.email)
if not principal.user.is_active:
raise _forbidden("учётная запись отключена")
raise _login_forbidden("учётная запись отключена", claims.email, principal.user.id)
session_audit = getattr(request.app.state, "session_audit", None)
if isinstance(session_audit, SessionAudit):
ip_address, user_agent = request_client(request)
await session_audit.first_request(principal.user.id, ip_address=ip_address, user_agent=user_agent)
return principal

View File

@ -7,13 +7,15 @@
from typing import Annotated
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field, field_validator
from src.api.dependencies.db import SessionDep
from src.core.auth import DevHs256Verifier
from src.core.config import Settings
from src.models.enums import AuditAction
from src.repositories.users import AmbiguousUserError, load_principal_data
from src.services.session_audit import DEV_TOKEN_DESCRIPTION, record_login, request_client
USER_NOT_FOUND = "пользователь не найден или отключён"
USER_AMBIGUOUS = "неоднозначная учётная запись"
@ -54,14 +56,24 @@ def build_dev_router(settings: Settings) -> APIRouter:
router = APIRouter(tags=["dev"])
@router.post("/dev/token", response_model=DevTokenResponse, summary="Dev-токен по email (только DEBUG)")
async def issue_dev_token(body: DevTokenRequest, session: SessionDep) -> DevTokenResponse:
"""HS256 JWT на 8 часов для существующего активного пользователя."""
async def issue_dev_token(body: DevTokenRequest, request: Request, session: SessionDep) -> DevTokenResponse:
"""HS256 JWT на 8 часов для существующего активного пользователя; LOGIN в транзакции запроса (OQ-64)."""
try:
principal = await load_principal_data(session, body.email)
except AmbiguousUserError as error:
raise HTTPException(status.HTTP_409_CONFLICT, detail=USER_AMBIGUOUS) from error
if principal is None or not principal.user.is_active:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=USER_NOT_FOUND)
return DevTokenResponse(access_token=issuer.issue_token(body.email))
token = issuer.issue_token(body.email)
ip_address, user_agent = request_client(request)
await record_login(
session,
action=AuditAction.LOGIN,
user_id=principal.user.id,
description=DEV_TOKEN_DESCRIPTION,
ip_address=ip_address,
user_agent=user_agent,
)
return DevTokenResponse(access_token=token)
return router

View File

@ -41,8 +41,9 @@ from src.core.clients import S3Storage
from src.core.config import Settings, get_settings
from src.core.exception_handlers import register_exception_handlers
from src.core.nats import MessageHandler, NatsConnection
from src.db.db import async_session_maker, engine
from src.db.db import async_session_maker, engine, session_transaction
from src.services.recognition_exchange import ExchangeTasks, NoopMapper, handle_message
from src.services.session_audit import SessionAudit
settings = get_settings()
@ -233,6 +234,8 @@ def create_app(app_settings: Settings, *, web_dir: str = "web") -> FastAPI:
return {"uptime": UP_TIME, "version": VERSION, "nats": nats_status}
application.include_router(build_api_router(app_settings))
# аудит входа (OQ-64): LOGIN первого запроса и LOGIN_FAILED — в отдельной транзакции, множество входов — на процесс
application.state.session_audit = SessionAudit(session_transaction)
register_exception_handlers(application)

View File

@ -4,8 +4,10 @@
ответа (например, `legal_entity_id` существующей записи). Ответы `HTTPException` (401/403 строкой) — отдельно.
"""
import uuid
from typing import Any
from fastapi import HTTPException
from fastapi import status as http_status
_RESERVED_KEYS = frozenset({"code", "message"})
@ -45,3 +47,25 @@ class DomainValidationError(DomainError):
def __init__(self, code: str, message: str, **extra: Any) -> None:
super().__init__(http_status.HTTP_422_UNPROCESSABLE_CONTENT, code, message, extra)
class AuthenticationFailedError(HTTPException):
"""401/403 АУТЕНТИФИКАЦИИ (токен не принят; пользователь не найден, отключён, неоднозначен) — не авторизации.
Ответ — как у `HTTPException` (`{"detail": "<строка>"}`); обработчик дополнительно пишет LOGIN_FAILED в отдельной
транзакции (`services/session_audit.py`, OQ-64). `audit_description` — текст для журнала (без токена),
`user_id` — найденный пользователь (например, отключённый) или `None`.
"""
def __init__(
self,
status_code: int,
detail: str,
*,
audit_description: str,
user_id: uuid.UUID | None = None,
headers: dict[str, str] | None = None,
) -> None:
super().__init__(status_code, detail=detail, headers=headers)
self.audit_description = audit_description
self.user_id = user_id

View File

@ -18,7 +18,8 @@ from loguru import logger
from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError
from src.common.constraint_conflicts import resolve_unique_conflict
from src.common.exceptions import DomainError
from src.common.exceptions import AuthenticationFailedError, DomainError
from src.services.session_audit import SessionAudit, request_client
LOG_STATEMENT_LIMIT = 200 # символов SQL-текста (с плейсхолдерами) в записи лога
@ -89,6 +90,18 @@ def register_exception_handlers(app: FastAPI) -> None:
content={"detail": "ошибка базы данных"},
)
@app.exception_handler(AuthenticationFailedError)
async def authentication_failed_handler(request: Request, exc: AuthenticationFailedError):
# Обработчик работает после отката транзакции запроса: LOGIN_FAILED пишется в отдельной (OQ-64). Приложение
# без `state.session_audit` (тестовые мини-приложения) отвечает так же, но без записи в журнал
session_audit = getattr(request.app.state, "session_audit", None)
if isinstance(session_audit, SessionAudit):
ip_address, user_agent = request_client(request)
await session_audit.login_failed(
exc.user_id, exc.audit_description, ip_address=ip_address, user_agent=user_agent
)
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}, headers=exc.headers)
@app.exception_handler(HTTPException)
async def http_exception_handler(_: Request, exc: HTTPException):
# Не ломаем существующие ответы endpoint-ов и прокидываем исходный detail.

View File

@ -1,4 +1,5 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
@ -19,3 +20,15 @@ async def get_async_session() -> AsyncIterator[AsyncSession]:
"""
async with async_session_maker() as session, session.begin():
yield session
@asynccontextmanager
async def session_transaction(
maker: async_sessionmaker[AsyncSession] | None = None,
) -> AsyncIterator[AsyncSession]:
"""Отдельная короткая транзакция вне сессии запроса (аудит входа): commit на выходе, rollback при исключении.
`maker` по умолчанию — `async_session_maker` приложения (читается при вызове: тесты его подменяют).
"""
async with (maker or async_session_maker)() as session, session.begin():
yield session

View File

@ -0,0 +1,113 @@
"""Аудит входа: 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` попыткой входа не считается; 403 «недостаточно прав» — авторизация, не вход.
Раздел `SESSION`, `object_type = "app_user"`, `object_id = user_id`; IP и user-agent — из запроса.
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"
FIRST_REQUEST_DESCRIPTION = "первый запрос с токеном"
DEV_TOKEN_DESCRIPTION = "выдан dev-токен"
#: фабрика отдельной транзакции: `async with transaction() as session` — сессия в открытой транзакции, commit на выходе
TransactionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
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=description,
ip_address=ip_address,
user_agent=user_agent,
)
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

View File

@ -14,7 +14,8 @@ S3 — `FakeStorage` (объекты в словаре).
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
from functools import partial
from pathlib import Path
from types import SimpleNamespace
from typing import Any
@ -37,7 +38,8 @@ from src.api.router import build_api_router
from src.core.auth import build_verifier
from src.core.config import get_settings
from src.core.exception_handlers import register_exception_handlers
from src.db.db import get_async_session
from src.db.db import get_async_session, session_transaction
from src.services.session_audit import SessionAudit
from tests.fixtures_helpers import fixtures_skip_reason, resolve_fixtures_dir
from tests.pg_helpers import ( # scratch_db_url — реэкспорт фикстуры, чтобы pytest её нашёл
alembic_async,
@ -118,6 +120,17 @@ async def db_session(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
AuthClientFactory = Callable[[str], Awaitable[httpx.AsyncClient]]
@asynccontextmanager
async def nested_transaction(session: AsyncSession) -> AsyncIterator[AsyncSession]:
"""«Отдельная транзакция» аудита входа поверх `db_session`: SAVEPOINT в той же внешней транзакции теста.
LOGIN_FAILED пишется обработчиком исключений ПОСЛЕ отката SAVEPOINT запроса, поэтому запись видна тесту; что
в проде она переживает откат транзакции запроса, проверяют тесты на `committed_app`.
"""
async with session.begin_nested():
yield session
@pytest_asyncio.fixture
async def seeded(db_session: AsyncSession) -> AsyncSession:
"""`db_session` с засеянными справочниками и dev-пользователями (`run_seed(dev=True)`)."""
@ -127,10 +140,13 @@ async def seeded(db_session: AsyncSession) -> AsyncSession:
def _build_application(
session_dependency: Callable[[], AsyncIterator[AsyncSession]], monkeypatch: pytest.MonkeyPatch
session_dependency: Callable[[], AsyncIterator[AsyncSession]],
monkeypatch: pytest.MonkeyPatch,
audit_transaction: Callable[[], AbstractAsyncContextManager[AsyncSession]],
) -> FastAPI:
"""Приложение на `build_api_router` с dev-эмитентом (`DEBUG` и `AUTH_DEV_ISSUER`) и переопределённой сессией БД.
`audit_transaction` — отдельная транзакция аудита входа (`SessionAudit`, LOGIN/LOGIN_FAILED) на БД теста.
Предупреждение «Включён dev-эмитент…» из `build_verifier` (`src.core.auth.logger`) подавлено (T3.1).
"""
monkeypatch.setattr("src.core.auth.logger", SimpleNamespace(warning=lambda *args, **kwargs: None))
@ -141,6 +157,7 @@ def _build_application(
application.dependency_overrides[get_async_session] = session_dependency
application.state.token_verifier = build_verifier(settings)
application.state.storage = None
application.state.session_audit = SessionAudit(audit_transaction)
return application
@ -156,7 +173,7 @@ def app(db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch) -> FastAPI:
async with db_session.begin_nested(): # как транзакция запроса: ошибка → откат до SAVEPOINT
yield db_session
return _build_application(override_session, monkeypatch)
return _build_application(override_session, monkeypatch, partial(nested_transaction, db_session))
async def _client_factory(application: FastAPI, stack: AsyncExitStack, email: str) -> httpx.AsyncClient:
@ -223,7 +240,9 @@ def committed_app(committed_sessionmaker: async_sessionmaker[AsyncSession], monk
async with committed_sessionmaker() as session, session.begin():
yield session
application = _build_application(production_like_session, monkeypatch)
application = _build_application(
production_like_session, monkeypatch, partial(session_transaction, committed_sessionmaker)
)
application.state.storage = FakeStorage()
return application

View File

@ -136,10 +136,13 @@ async def _report(client: httpx.AsyncClient, filename: str, content: bytes, **kw
async def _counts(
session: AsyncSession, models: tuple[type[Any], ...] = DIRECTORY_TABLES + TRACE_TABLES
) -> dict[str, int]:
return {
model.__tablename__: (await session.execute(select(func.count()).select_from(model))).scalar_one()
for model in models
}
counts = {}
for model in models:
statement = select(func.count()).select_from(model)
if model is AuditEvent: # события входа (LOGIN первого запроса, OQ-64) к следам импорта не относятся
statement = statement.where(AuditEvent.section != AuditSection.SESSION)
counts[model.__tablename__] = (await session.execute(statement)).scalar_one()
return counts
def _finals(report: dict[str, Any]) -> dict[int, str]:

View File

@ -25,6 +25,7 @@ from src.models.directories import (
RelatedGroup,
RelatedGroupMember,
)
from src.models.enums import AuditSection
from src.models.files import S3Object
from tests.conftest import AuthClientFactory, FakeStorage
@ -56,10 +57,13 @@ def _export_path() -> Path:
async def _counts(session: AsyncSession) -> dict[str, int]:
return {
model.__tablename__: (await session.execute(select(func.count()).select_from(model))).scalar_one()
for model in TOUCHED_TABLES
}
counts = {}
for model in TOUCHED_TABLES:
statement = select(func.count()).select_from(model)
if model is AuditEvent: # события входа (LOGIN первого запроса, OQ-64) к следам импорта не относятся
statement = statement.where(AuditEvent.section != AuditSection.SESSION)
counts[model.__tablename__] = (await session.execute(statement)).scalar_one()
return counts
@pytest.mark.integration

View File

@ -0,0 +1,255 @@
"""Аудит входа LOGIN / LOGIN_FAILED (OQ-64, issue #9 п.5): раздел SESSION, `object_type = "app_user"`.
LOGIN — успешная выдача dev-токена и первый успешный запрос пользователя с токеном после старта процесса;
LOGIN_FAILED — 401 (токен не принят) и 403 аутентификации (пользователь не найден, отключён, неоднозначен).
Запрос без заголовка `Authorization` и 403 «недостаточно прав» входом не считаются. LOGIN_FAILED пишется в отдельной
транзакции и переживает откат транзакции запроса — проверяется на `committed_app` (сессия запроса как в проде).
"""
import asyncio
import uuid
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
import httpx
import pytest
from fastapi import FastAPI
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.core.auth import DevHs256Verifier
from src.core.config import get_settings
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.enums import AuditAction, AuditSection
from src.services.session_audit import FIRST_REQUEST_DESCRIPTION, SessionAudit
HTTP_OK = 200
HTTP_UNAUTHORIZED = 401
HTTP_FORBIDDEN = 403
KA_RF = "ka_rf@armka.local"
EXPERT = "expert@armka.local"
USER_AGENT = "login-audit-test/1.0"
GARBAGE_TOKEN = "garbage.token.value" # noqa: S105 - заведомо неверный токен, не секрет
def _client(app: FastAPI) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test", headers={"User-Agent": USER_AGENT}
)
def _token(email: str) -> str:
return DevHs256Verifier(get_settings().AUTH_DEV_SECRET).issue_token(email)
def _bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
async def _events(session: AsyncSession) -> list[AuditEvent]:
rows = await session.scalars(
select(AuditEvent)
.where(AuditEvent.action.in_([AuditAction.LOGIN, AuditAction.LOGIN_FAILED]))
.order_by(AuditEvent.occurred_at)
)
return list(rows.all())
async def _user_id(session: AsyncSession, email: str) -> uuid.UUID:
user_id = await session.scalar(select(AppUser.id).where(AppUser.email == email))
assert user_id is not None
return user_id
def _assert_session_event(event: AuditEvent, action: AuditAction, user_id: uuid.UUID | None) -> None:
assert event.action is action
assert event.section is AuditSection.SESSION
assert event.object_type == "app_user"
assert event.user_id == user_id
assert event.object_id == user_id
assert event.ip_address == "127.0.0.1"
assert event.user_agent == USER_AGENT
# --- LOGIN ------------------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_dev_token_issue_records_login(app: FastAPI, seeded: AsyncSession) -> None:
async with _client(app) as client:
response = await client.post("/api/v1/dev/token", json={"email": KA_RF})
assert response.status_code == HTTP_OK
(event,) = await _events(seeded)
_assert_session_event(event, AuditAction.LOGIN, await _user_id(seeded, KA_RF))
assert event.description is not None
assert "dev-токен" in event.description
assert response.json()["access_token"] not in event.description
@pytest.mark.integration
async def test_first_request_with_token_records_login_once_per_user(app: FastAPI, seeded: AsyncSession) -> None:
async with _client(app) as client:
for _ in range(3):
assert (await client.get("/api/v1/me", headers=_bearer(_token(KA_RF)))).status_code == HTTP_OK
assert (await client.get("/api/v1/me", headers=_bearer(_token(EXPERT)))).status_code == HTTP_OK
events = await _events(seeded)
assert [(e.action, e.user_id) for e in events] == [
(AuditAction.LOGIN, await _user_id(seeded, KA_RF)),
(AuditAction.LOGIN, await _user_id(seeded, EXPERT)),
]
for event in events:
_assert_session_event(event, AuditAction.LOGIN, event.user_id)
assert event.description == FIRST_REQUEST_DESCRIPTION
assert app.state.session_audit.logged_in == {e.user_id for e in events}
@pytest.mark.integration
async def test_login_set_is_per_process(app: FastAPI, seeded: AsyncSession) -> None:
"""Рестарт процесса (новый `SessionAudit`) — первый запрос снова пишет LOGIN."""
async with _client(app) as client:
await client.get("/api/v1/me", headers=_bearer(_token(KA_RF)))
app.state.session_audit = SessionAudit(app.state.session_audit.transaction)
await client.get("/api/v1/me", headers=_bearer(_token(KA_RF)))
assert [e.action for e in await _events(seeded)] == [AuditAction.LOGIN, AuditAction.LOGIN]
# --- LOGIN_FAILED -----------------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_invalid_token_records_login_failed_without_token(app: FastAPI, seeded: AsyncSession) -> None:
async with _client(app) as client:
response = await client.get("/api/v1/me", headers=_bearer(GARBAGE_TOKEN))
assert response.status_code == HTTP_UNAUTHORIZED
(event,) = await _events(seeded)
_assert_session_event(event, AuditAction.LOGIN_FAILED, None)
assert event.description is not None
assert response.json()["detail"] in event.description # причина отказа
assert GARBAGE_TOKEN not in event.description
@pytest.mark.integration
async def test_unknown_user_records_login_failed_with_email(app: FastAPI, seeded: AsyncSession) -> None:
async with _client(app) as client:
response = await client.get("/api/v1/me", headers=_bearer(_token("ghost@example.com")))
assert response.status_code == HTTP_FORBIDDEN
(event,) = await _events(seeded)
_assert_session_event(event, AuditAction.LOGIN_FAILED, None)
assert event.description is not None
assert "ghost@example.com" in event.description
assert response.json()["detail"] in event.description
@pytest.mark.integration
async def test_disabled_user_records_login_failed_with_user_id(app: FastAPI, seeded: AsyncSession) -> None:
user_id = await _user_id(seeded, EXPERT)
await seeded.execute(update(AppUser).where(AppUser.id == user_id).values(is_active=False))
async with _client(app) as client:
response = await client.get("/api/v1/me", headers=_bearer(_token(EXPERT)))
assert response.status_code == HTTP_FORBIDDEN
(event,) = await _events(seeded)
_assert_session_event(event, AuditAction.LOGIN_FAILED, user_id)
assert event.description is not None
assert EXPERT in event.description
@pytest.mark.integration
async def test_missing_header_and_missing_permission_are_not_login_events(
committed_app: FastAPI, committed_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
"""Без заголовка — не попытка входа; 403 «недостаточно прав» — авторизация, не аутентификация.
LOGIN первого запроса expert записан, хотя сам запрос (403 по праву) откатил свою транзакцию.
"""
async with _client(committed_app) as client:
assert (await client.get("/api/v1/me")).status_code == HTTP_UNAUTHORIZED
expert = _bearer(_token(EXPERT))
assert (await client.get("/api/v1/admin/users", headers=expert)).status_code == HTTP_FORBIDDEN
async with committed_sessionmaker() as session:
events = await _events(session)
expert_id = await _user_id(session, EXPERT)
assert [(e.action, e.user_id) for e in events] == [(AuditAction.LOGIN, expert_id)]
# --- отдельная транзакция: LOGIN_FAILED переживает откат запроса -------------------------------------------------
@pytest.mark.integration
async def test_login_failed_is_committed_although_request_rolls_back(
committed_app: FastAPI, committed_sessionmaker: async_sessionmaker[AsyncSession]
) -> None:
async with _client(committed_app) as client:
unauthorized = await client.get("/api/v1/me", headers=_bearer(GARBAGE_TOKEN))
forbidden = await client.get("/api/v1/me", headers=_bearer(_token("ghost@example.com")))
ok = await client.get("/api/v1/me", headers=_bearer(_token(KA_RF)))
assert (unauthorized.status_code, forbidden.status_code, ok.status_code) == (
HTTP_UNAUTHORIZED,
HTTP_FORBIDDEN,
HTTP_OK,
)
async with committed_sessionmaker() as session:
events = await _events(session)
ka_rf = await _user_id(session, KA_RF)
assert [(e.action, e.user_id) for e in events] == [
(AuditAction.LOGIN_FAILED, None),
(AuditAction.LOGIN_FAILED, None),
(AuditAction.LOGIN, ka_rf),
]
# --- SessionAudit без БД ----------------------------------------------------------------------------------------
class _RecordingSession:
def __init__(self, fail: bool) -> None: # noqa: FBT001
self.fail = fail
self.added: list[object] = []
def add(self, obj: object) -> None:
self.added.append(obj)
async def flush(self) -> None:
if self.fail:
raise RuntimeError("подставная ошибка БД")
def _fake_transaction(session: _RecordingSession): # noqa: ANN202
@asynccontextmanager
async def transaction() -> AsyncIterator[_RecordingSession]:
await asyncio.sleep(0) # точка переключения: параллельные первые запросы
yield session
return transaction
async def test_first_request_is_recorded_once_even_for_concurrent_requests() -> None:
session = _RecordingSession(fail=False)
audit = SessionAudit(_fake_transaction(session)) # type: ignore[arg-type]
user_id = uuid.uuid4()
await asyncio.gather(*(audit.first_request(user_id, ip_address="10.0.0.1", user_agent=None) for _ in range(5)))
assert len(session.added) == 1
assert audit.logged_in == {user_id}
async def test_failed_login_write_does_not_raise_and_is_retried() -> None:
session = _RecordingSession(fail=True)
audit = SessionAudit(_fake_transaction(session)) # type: ignore[arg-type]
user_id = uuid.uuid4()
await audit.first_request(user_id, ip_address=None, user_agent=None) # ошибка записи — в лог, не в ответ
assert audit.logged_in == set() # следующий запрос попробует записать LOGIN снова
session.fail = False
await audit.first_request(user_id, ip_address=None, user_agent=None)
assert audit.logged_in == {user_id}

View File

@ -7,6 +7,7 @@
import importlib
from collections.abc import AsyncIterator
from functools import partial
from types import SimpleNamespace
from typing import Any
@ -25,7 +26,8 @@ from src.core.exception_handlers import register_exception_handlers
from src.db.db import get_async_session
from src.models.access import AppUser
from src.schemas.me import MeResponse
from tests.conftest import AuthClientFactory
from src.services.session_audit import SessionAudit
from tests.conftest import AuthClientFactory, nested_transaction
HTTP_OK = 200
HTTP_UNAUTHORIZED = 401
@ -334,6 +336,9 @@ async def test_full_app_me_dev_token_and_openapi(seeded: AsyncSession) -> None:
# проверяющий приложения строится лениво по `.env.test`; сбрасываем возможный кэш предыдущих тестов
if hasattr(full_app.state, "token_verifier"):
del full_app.state.token_verifier
# аудит входа приложения пишет через свой пул в БД из `.env.test`; в тесте — в БД теста (OQ-64)
production_audit = full_app.state.session_audit
full_app.state.session_audit = SessionAudit(partial(nested_transaction, seeded))
try:
async with _client(full_app) as client:
token = await _dev_token(client, "ka_rf@armka.local")
@ -341,6 +346,7 @@ async def test_full_app_me_dev_token_and_openapi(seeded: AsyncSession) -> None:
health = await client.get("/healthcheck")
openapi = (await client.get("/openapi.json")).json()
finally:
full_app.state.session_audit = production_audit
full_app.dependency_overrides.pop(get_async_session, None)
if hasattr(full_app.state, "token_verifier"):
del full_app.state.token_verifier