From ef7458eddb7632a07bb5cb94ce2efd04244a1db9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:27:29 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20=D0=B2?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=B0=20LOGIN=20/=20LOGIN=5FFAILED=20(OQ-64,?= =?UTF-8?q?=20#9=20=D0=BF.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раздел 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 2 + README.md | 10 + api/src/api/dependencies/audit.py | 7 +- api/src/api/dependencies/auth.py | 40 +++- api/src/api/endpoints/dev_auth.py | 20 +- api/src/app.py | 5 +- api/src/common/exceptions.py | 24 +++ api/src/core/exception_handlers.py | 15 +- api/src/db/db.py | 13 ++ api/src/services/session_audit.py | 113 +++++++++++ api/tests/conftest.py | 29 ++- api/tests/test_api_directory_import.py | 11 +- api/tests/test_import_full_export.py | 12 +- api/tests/test_login_audit.py | 255 +++++++++++++++++++++++++ api/tests/test_me_and_dev_token.py | 8 +- 15 files changed, 535 insertions(+), 29 deletions(-) create mode 100644 api/src/services/session_audit.py create mode 100644 api/tests/test_login_audit.py diff --git a/CLAUDE.md b/CLAUDE.md index 9e62126..490b0d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). diff --git a/README.md b/README.md index 7e3ee9f..d818087 100644 --- a/README.md +++ b/README.md @@ -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`) не пишется: сессий на сервере нет. ## Соседние документы diff --git a/api/src/api/dependencies/audit.py b/api/src/api/dependencies/audit.py index 2d41694..fb43279 100644 --- a/api/src/api/dependencies/audit.py +++ b/api/src/api/dependencies/audit.py @@ -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 ) diff --git a/api/src/api/dependencies/auth.py b/api/src/api/dependencies/auth.py index 5fd7b8e..9ad2839 100644 --- a/api/src/api/dependencies/auth.py +++ b/api/src/api/dependencies/auth.py @@ -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 `; иначе 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 diff --git a/api/src/api/endpoints/dev_auth.py b/api/src/api/endpoints/dev_auth.py index 681d9a9..3b0cab2 100644 --- a/api/src/api/endpoints/dev_auth.py +++ b/api/src/api/endpoints/dev_auth.py @@ -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 diff --git a/api/src/app.py b/api/src/app.py index fc1020b..b67d7c5 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -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) diff --git a/api/src/common/exceptions.py b/api/src/common/exceptions.py index 8e7360f..2ddca46 100644 --- a/api/src/common/exceptions.py +++ b/api/src/common/exceptions.py @@ -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 diff --git a/api/src/core/exception_handlers.py b/api/src/core/exception_handlers.py index 3c480a7..8c8aa8f 100644 --- a/api/src/core/exception_handlers.py +++ b/api/src/core/exception_handlers.py @@ -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. diff --git a/api/src/db/db.py b/api/src/db/db.py index b32ef1a..0eba4f1 100644 --- a/api/src/db/db.py +++ b/api/src/db/db.py @@ -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 diff --git a/api/src/services/session_audit.py b/api/src/services/session_audit.py new file mode 100644 index 0000000..1599edc --- /dev/null +++ b/api/src/services/session_audit.py @@ -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 diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 7109763..7687153 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -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 diff --git a/api/tests/test_api_directory_import.py b/api/tests/test_api_directory_import.py index fc8d60c..8e3bab8 100644 --- a/api/tests/test_api_directory_import.py +++ b/api/tests/test_api_directory_import.py @@ -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]: diff --git a/api/tests/test_import_full_export.py b/api/tests/test_import_full_export.py index 1cd3c44..5ca257f 100644 --- a/api/tests/test_import_full_export.py +++ b/api/tests/test_import_full_export.py @@ -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 diff --git a/api/tests/test_login_audit.py b/api/tests/test_login_audit.py new file mode 100644 index 0000000..22b8153 --- /dev/null +++ b/api/tests/test_login_audit.py @@ -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} diff --git a/api/tests/test_me_and_dev_token.py b/api/tests/test_me_and_dev_token.py index 22e051b..ccb9ed2 100644 --- a/api/tests/test_me_and_dev_token.py +++ b/api/tests/test_me_and_dev_token.py @@ -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