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:
parent
2017d11379
commit
ef7458eddb
@ -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`).
|
||||
|
||||
|
||||
10
README.md
10
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`) не пишется: сессий на сервере нет.
|
||||
|
||||
## Соседние документы
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
113
api/src/services/session_audit.py
Normal file
113
api/src/services/session_audit.py
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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
|
||||
|
||||
255
api/tests/test_login_audit.py
Normal file
255
api/tests/test_login_audit.py
Normal 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}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user