From 5e11f0d0bf0f39063e254c4b28e93db17c9c3100 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:13:30 +0400 Subject: [PATCH 01/13] =?UTF-8?q?fix:=20seed=20--dev=20=D1=82=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B8=20DEBUG=3Dtrue=20(OQ-60?= =?UTF-8?q?,=20#9=20=D0=BF.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI `python -m scripts.seed --dev` сверяется с `DEBUG` (`check_dev_allowed`) до чтения seed и подключения к БД; при `DEBUG=false` — код возврата 2 и сообщение «--dev доступен только при DEBUG=true (dev-пользователи не для прода)». `run_seed(session, dev=True)` защиты не имеет (её вызывают тесты). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 2 +- README.md | 10 ++++--- api/scripts/seed.py | 56 +++++++++++++++++++++++++++++++------- api/tests/test_seed_dev.py | 34 ++++++++++++++++++++++- 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7a03618..9e62126 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ (`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`. -- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен; `--dev` — только для разработки. +- **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 baac7f3..d943306 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,9 @@ make seed-dev # справочники продукта + dev-пользов ``` `make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без -dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует. +dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует. `make seed-dev` +(`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2 +(«`--dev` доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД. ### 5. Запуск @@ -482,7 +484,7 @@ email без учёта регистра проверяет только сер | `make up` | поднимает compose из `../docs/dev` (PostgreSQL, NATS, MinIO, бакет) | | `make migrate` | `alembic upgrade head` на БД из `api/.env` | | `make seed` | загрузка справочников продукта (идемпотентна) | -| `make seed-dev` | то же и dev-пользователи/филиалы (`--dev`) — только для разработки | +| `make seed-dev` | то же и dev-пользователи/филиалы (`--dev`) — только для разработки, требует `DEBUG=true` | | `make lint` | `ruff check`, `ruff format --check`, `mypy src scripts` | | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | | `make test-integration` | интеграционные тесты: `pytest -m integration`, нужны PostgreSQL (и NATS) | @@ -579,8 +581,8 @@ Dockerfile только для локального compose - Маршрут существует лишь при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`; при любом другом сочетании его нет вовсе (404), а dev-проверяющий токенов не создаётся даже при заданном `AUTH_DEV_SECRET`. В проде `DEBUG=false`, поэтому dev-эмитент там невозможен. -- `AUTH_DEV_SECRET` из `api/.env.example` — публичный пример, в проде не использовать. `make seed-dev` в прод не - запускать. +- `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. ## Соседние документы diff --git a/api/scripts/seed.py b/api/scripts/seed.py index 5d3097f..d8bdb62 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -26,9 +26,10 @@ ASSUMPTION(OQ-46)): `seed_lib/loaders_rules.py` проверяет, что ка Dev-данные (`--dev`, `seed/dev/`, задача 15): 2 региональных филиала (`regional_offices.yaml`, upsert по `name`) и по одному пользователю на каждую системную роль (`users.yaml`, `<роль>@armka.local`, upsert `app_user` по `email`, `user_role` по (user_id, role_id)). Ссылки пользователей на роли и филиалы проверяются до записи. Без `--dev` ни -пользователи, ни dev-филиалы не загружаются. ASSUMPTION: dev-данные — только для локальной разработки и тестовых -стендов; в прод `--dev` не запускать (защиты флага нет, спека её не требует). Удалений нет: роль, убранная из -`users.yaml`, остаётся назначенной. +пользователи, ни dev-филиалы не загружаются. Dev-данные — только для локальной разработки и тестовых стендов: CLI +принимает `--dev` лишь при `DEBUG=true` (`check_dev_allowed`, OQ-60, issue #9), иначе — код возврата 2 до чтения +seed и подключения к БД. `run_seed(session, dev=True)` защиты не имеет (её вызывают тесты). Удалений нет: роль, +убранная из `users.yaml`, остаётся назначенной. Расширение (задачи 13–15): поле в `SeedData` (`seed_lib/model.py`), читатель в `load_seed`, шаг в `apply_seed`. """ @@ -37,14 +38,41 @@ import argparse import asyncio import sys from pathlib import Path +from typing import TYPE_CHECKING from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from scripts.seed_lib.apply import apply_seed from scripts.seed_lib.loaders import DEFAULT_SEED_DIR, load_seed -from scripts.seed_lib.model import SeedData, SeedError, SeedReport +from scripts.seed_lib.model import SeedError, SeedReport -__all__ = ["DEFAULT_SEED_DIR", "SeedError", "SeedReport", "apply_seed", "load_seed", "main", "run_seed"] +if TYPE_CHECKING: + from src.core.config import Settings + +__all__ = [ + "DEFAULT_SEED_DIR", + "DEV_FORBIDDEN_MESSAGE", + "SeedError", + "SeedReport", + "apply_seed", + "check_dev_allowed", + "load_seed", + "main", + "run_seed", +] + +DEV_FORBIDDEN_MESSAGE = "--dev доступен только при DEBUG=true (dev-пользователи не для прода)" +#: код возврата CLI при `--dev` без DEBUG (1 — ошибка файлов seed) +EXIT_DEV_FORBIDDEN = 2 + + +class DevSeedForbiddenError(Exception): + """`--dev` запрошен при `DEBUG=false`.""" + + +def check_dev_allowed(settings: "Settings") -> str | None: + """Можно ли грузить dev-данные: `None` — да, иначе текст отказа (dev-пользователи только при `DEBUG=true`).""" + return None if settings.DEBUG else DEV_FORBIDDEN_MESSAGE async def run_seed(session: AsyncSession, *, dev: bool = False, seed_dir: Path | None = None) -> SeedReport: @@ -55,9 +83,15 @@ async def run_seed(session: AsyncSession, *, dev: bool = False, seed_dir: Path | return await apply_seed(session, load_seed(seed_dir, dev=dev)) -async def _run_cli(data: SeedData) -> SeedReport: - # импорт здесь: настройки читаются из ENV_FILE только при реальном запуске, - # поэтому `--help` и ошибки валидации работают без env +async def _run_cli(seed_dir: Path, *, dev: bool) -> SeedReport: + # импорт настроек — только после проверки файлов seed: модуль конфигурации читает ENV_FILE при импорте, + # поэтому `--help` и ошибки валидации без `--dev` работают без env; `--dev` сначала сверяется с DEBUG + if dev: + from src.core.config import get_settings # noqa: PLC0415 + + if (refusal := check_dev_allowed(get_settings())) is not None: + raise DevSeedForbiddenError(refusal) + data = load_seed(seed_dir, dev=dev) from src.core.config import settings # noqa: PLC0415 engine = create_async_engine( @@ -80,8 +114,10 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--seed-dir", type=Path, default=DEFAULT_SEED_DIR, help="каталог seed (по умолчанию api/seed)") args = parser.parse_args(argv) try: - data = load_seed(args.seed_dir, dev=args.dev) - report = asyncio.run(_run_cli(data)) + report = asyncio.run(_run_cli(args.seed_dir, dev=args.dev)) + except DevSeedForbiddenError as error: + print(f"Ошибка seed: {error}", file=sys.stderr) + return EXIT_DEV_FORBIDDEN except SeedError as error: print(f"Ошибка seed: {error}", file=sys.stderr) return 1 diff --git a/api/tests/test_seed_dev.py b/api/tests/test_seed_dev.py index facc086..1ccaae2 100644 --- a/api/tests/test_seed_dev.py +++ b/api/tests/test_seed_dev.py @@ -1,13 +1,17 @@ """Dev-seed (spec reference-data-seed, требование «Dev-данные только по явному флагу», задача 15).""" +import os import shutil +import subprocess +import sys from pathlib import Path import pytest from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession -from scripts.seed import SeedError, load_seed, run_seed +from scripts.seed import DEV_FORBIDDEN_MESSAGE, SeedError, check_dev_allowed, load_seed, run_seed +from src.core.config import get_settings from src.models.access import AppUser, Permission, Role, RolePermission, UserRole from src.models.directories import RegionalOffice from src.models.enums import BusinessSegment @@ -240,3 +244,31 @@ def test_missing_dev_file_is_seed_error_only_with_dev_flag(tmp_path: Path) -> No load_seed(seed_dir) with pytest.raises(SeedError, match="users.yaml"): load_seed(seed_dir, dev=True) + + +# ---- `--dev` только при DEBUG=true (OQ-60) ---- + + +def test_dev_allowed_only_with_debug() -> None: + settings = get_settings() + + assert check_dev_allowed(settings.model_copy(update={"DEBUG": True})) is None + assert check_dev_allowed(settings.model_copy(update={"DEBUG": False})) == DEV_FORBIDDEN_MESSAGE + assert "DEBUG=true" in DEV_FORBIDDEN_MESSAGE + + +def test_cli_dev_without_debug_exits_with_code_2_before_db() -> None: + """`--dev` при DEBUG=false: код 2 и текст в stderr; до БД дело не доходит (переменная окружения важнее ENV_FILE).""" + env = {**os.environ, "ENV_FILE": ".env.test", "DEBUG": "false"} + result = subprocess.run( # noqa: S603 + [sys.executable, "-m", "scripts.seed", "--dev"], + cwd=API_DIR, + capture_output=True, + text=True, + check=False, + env=env, + ) + + assert result.returncode == 2 # noqa: PLR2004 + assert DEV_FORBIDDEN_MESSAGE in result.stderr + assert "Seed применён" not in result.stdout -- 2.47.2 From 0eedb822e2bfcf6936e41778ef885c255f4f736f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:16:51 +0400 Subject: [PATCH 02/13] =?UTF-8?q?fix:=20/docs=20=D0=B8=20OpenAPI=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=B8=20DEBUG,=20?= =?UTF-8?q?CORS=20=D0=B8=D0=B7=20CORS=5FORIGINS=20=D0=B2=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=20(OQ-65,=20OQ-66,=20#9=20=D0=BF.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Приложение собирает `create_app(settings, web_dir=...)`: при DEBUG=false нет `/docs`, `/redoc`, `/openapi.json` (SPA-заглушка на эти пути отвечает 404, а не index.html); CORS — `cors_options`: DEBUG — `*` с credentials, иначе `CORS_ORIGINS` (пусто — middleware не подключается, credentials только для списка без `*`). Настройка `CORS_ORIGINS` читает JSON-список или строку через запятую. `.env.example` и README «Конфигурация» дополнены. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 5 +- api/.env.example | 4 ++ api/.env.test | 3 ++ api/src/app.py | 87 ++++++++++++++++++++++------------ api/src/core/config.py | 20 +++++++- api/tests/test_app.py | 100 +++++++++++++++++++++++++++++++++++++++ api/tests/test_config.py | 25 ++++++++++ 7 files changed, 209 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index d943306..016a5d3 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ curl -s localhost:8000/api/v1/me -H "Authorization: Bearer " ``` (идентификаторы в вашей БД будут другими). Токен действует 8 часов. Интерактивная документация API — `/docs` -(Swagger UI), схема — `/openapi.json`. +(Swagger UI), схема — `/openapi.json`; обе — только при `DEBUG=true` (в проде их нет, 404). Dev-пользователи (`api/seed/dev/users.yaml`, создаются `make seed-dev`): @@ -438,7 +438,8 @@ email без учёта регистра проверяет только сер | Переменная | По умолчанию | Назначение | |---|---|---| | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | -| `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов; при `false` пакеты РАИСА подгружаются условно | +| `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | +| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*`) | | `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) | | `ROOT_PATH` | `/` | корневой путь за прокси (должен совпадать с `REACT_APP_ROOT_PATH` фронта) | | `VERSION` | `1.0.0` | версия в `/healthcheck` и OpenAPI | diff --git a/api/.env.example b/api/.env.example index 5255f9c..a1ad609 100644 --- a/api/.env.example +++ b/api/.env.example @@ -30,6 +30,10 @@ OPENBAO__SMB__SMB_PATH= OPENBAO__SMB__SMB_LOGIN= OPENBAO__SMB__SMB_PASSWORD= +# CORS при DEBUG=false: источники через запятую или JSON-список; пусто — кросс-доменные запросы запрещены. +# При DEBUG=true не используется (CORS `*`); документация API (/docs, /redoc, /openapi.json) — тоже только при DEBUG=true +CORS_ORIGINS= + # Аутентификация. Dev-эмитент работает только при AUTH_DEV_ISSUER=true И DEBUG=true. AUTH_DEV_ISSUER=true # ТОЛЬКО ДЛЯ РАЗРАБОТКИ: секрет HS256 dev-эмитента, в проде не использовать diff --git a/api/.env.test b/api/.env.test index 59fcd77..8b949e9 100644 --- a/api/.env.test +++ b/api/.env.test @@ -27,6 +27,9 @@ OPENBAO__SMB__SMB_PATH= OPENBAO__SMB__SMB_LOGIN= OPENBAO__SMB__SMB_PASSWORD= +# CORS при DEBUG=false (в тестах DEBUG=true — не используется), как в .env.example +CORS_ORIGINS= + AUTH_DEV_ISSUER=true AUTH_DEV_SECRET=test-only-secret-not-for-production-0123456789 AUTH_JWKS_URL= diff --git a/api/src/app.py b/api/src/app.py index 623a0a2..7651a0d 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -3,8 +3,9 @@ Что здесь есть: настройки, подключение S3 (MinIO; `MINIO_SECURE` — https или http), NATS (`src/core/nats.py`: core NATS без JetStream, подключение в фоне при NATS_ENABLED=true независимо от DEBUG, подписка `recognition.result` в queue group `arm-core`), SMB (горячие папки ИС Сканирование; необязателен — при пустом `SMB_HOST` не подключается), -middleware (gzip, proxy headers, CORS; при DEBUG=False пакет РАИСА лишь условно импортируется, middleware не -подключается), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. +middleware (gzip, proxy headers, CORS по `cors_options`; при DEBUG=False пакет РАИСА лишь условно импортируется, +middleware не подключается), healthcheck (uptime, version, nats: ok|down|off), раздача собранного SPA. Документация API +(`/docs`, `/redoc`, `/openapi.json`) — только при DEBUG (OQ-66). Приложение собирает `create_app(settings)`. Недоступность S3 или NATS при старте не роняет и не задерживает приложение: ошибка логируется, healthcheck показывает состояние. При NATS_ENABLED=true lifespan запускает фоновые задачи обмена `recognition_job` — outbox-публикатор и вахтёр сроков (`src/services/recognition_exchange.py`, design D-6) — и останавливает их до закрытия NATS. Сообщения @@ -21,6 +22,7 @@ from contextlib import asynccontextmanager from datetime import UTC, datetime from functools import partial from http import HTTPStatus +from typing import Any import smbclient from fastapi import FastAPI, Request @@ -32,9 +34,9 @@ from miniopy_async import Minio from starlette.middleware.gzip import GZipMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware -from src.api.router import API_PREFIX, router +from src.api.router import API_PREFIX, build_api_router from src.core.clients import S3Storage -from src.core.config import get_settings +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 @@ -73,9 +75,6 @@ def _normalize_root_path(value: str) -> str: return "/" + value.strip("/") -_APP_ROOT_PATH = _normalize_root_path(settings.ROOT_PATH) - - async def _connect_s3() -> S3Storage | None: """Клиент S3 с проверкой бакета; при недоступности — ``None`` (приложение всё равно стартует).""" s3_client = Minio( @@ -188,36 +187,61 @@ async def lifespan(app: FastAPI): await _shutdown(app) -app = FastAPI(title="АРМ КА", version=VERSION, root_path=settings.ROOT_PATH, lifespan=lifespan) +def cors_options(app_settings: Settings) -> dict[str, Any] | None: + """Источники CORS (OQ-65): DEBUG — `*` с credentials, как в шаблоне; иначе — `CORS_ORIGINS`. + + Пустой `CORS_ORIGINS` вне DEBUG — `None`: middleware не подключается, браузер кросс-доменные запросы не выполнит. + `allow_credentials` вне DEBUG — только для явного списка без `*` (спецификация CORS запрещает `*` с credentials). + """ + if app_settings.DEBUG: + return {"allow_origins": ["*"], "allow_credentials": True} + origins = list(app_settings.CORS_ORIGINS) + if not origins: + return None + return {"allow_origins": origins, "allow_credentials": "*" not in origins} -@app.get("/healthcheck", status_code=200) -async def healthcheck(request: Request): - """Живость приложения. ``nats``: ``off`` — NATS выключен, ``ok`` — подключён, ``down`` — включён, но нет связи.""" - nats_status = "off" - if settings.NATS_ENABLED: - nc = getattr(request.app.state, "nats", None) - nats_status = "ok" if nc is not None and nc.is_connected else "down" - return {"uptime": UP_TIME, "version": VERSION, "nats": nats_status} +#: пути документации API: при DEBUG=false маршрутов нет, а SPA-заглушка отвечает на них 404, а не index.html +DOC_PATHS = ("/docs", "/docs/oauth2-redirect", "/redoc", "/openapi.json") -app.include_router(router) +def create_app(app_settings: Settings, *, web_dir: str = "web") -> FastAPI: + """Приложение под настройки: документация API (`/docs`, `/redoc`, `/openapi.json`) — только при DEBUG (OQ-66), + CORS — `cors_options`, SPA из `web_dir`. Модульный `app` собран по настройкам процесса; тесты собирают свои.""" + docs_enabled = app_settings.DEBUG + application = FastAPI( + title="АРМ КА", + version=VERSION, + root_path=app_settings.ROOT_PATH, + lifespan=lifespan, + docs_url="/docs" if docs_enabled else None, + redoc_url="/redoc" if docs_enabled else None, + openapi_url="/openapi.json" if docs_enabled else None, + ) -register_exception_handlers(app) + @application.get("/healthcheck", status_code=200) + async def healthcheck(request: Request): + """Живость приложения. ``nats``: ``off`` — NATS выключен, ``ok`` — подключён, ``down`` — нет связи.""" + nats_status = "off" + if app_settings.NATS_ENABLED: + nc = getattr(request.app.state, "nats", None) + nats_status = "ok" if nc is not None and nc.is_connected else "down" + return {"uptime": UP_TIME, "version": VERSION, "nats": nats_status} -app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1) -app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) + application.include_router(build_api_router(app_settings)) + register_exception_handlers(application) -# --- Раздача собранного SPA (web/) --------------------------------------------------------------- -os.makedirs("web", exist_ok=True) + application.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1) + application.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) + cors = cors_options(app_settings) + if cors is not None: + application.add_middleware(CORSMiddleware, allow_methods=["*"], allow_headers=["*"], **cors) + + # --- Раздача собранного SPA (web/) --- + os.makedirs(web_dir, exist_ok=True) + application.mount("/", SPAStaticFiles(directory=web_dir, html=True), name="web") + return application def sep_static(path: str, stat_dir: str) -> str: @@ -227,13 +251,14 @@ def sep_static(path: str, stat_dir: str) -> str: API_STATIC_PATH = API_PREFIX.strip("/") +_DOC_STATIC_PATHS = frozenset(path.strip("/") for path in DOC_PATHS) class SPAStaticFiles(StaticFiles): async def get_response(self, path: str, scope): # Несуществующий маршрут API — честный 404 (в т.ч. для POST), а не index.html SPA и не 405 от StaticFiles: # иначе dev-маршруты, которых при выключенных флагах нет, выглядели бы как «существующие» - if path == API_STATIC_PATH or path.startswith(API_STATIC_PATH + "/"): + if path == API_STATIC_PATH or path.startswith(API_STATIC_PATH + "/") or path in _DOC_STATIC_PATHS: raise HTTPException(status_code=HTTPStatus.NOT_FOUND) try: return await super().get_response(path, scope) @@ -247,4 +272,4 @@ class SPAStaticFiles(StaticFiles): raise -app.mount("/", SPAStaticFiles(directory="web", html=True), name="web") +app = create_app(settings) diff --git a/api/src/core/config.py b/api/src/core/config.py index 91fdeb6..f658835 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,7 +1,9 @@ +import json import os +from typing import Annotated, Any -from pydantic import NonNegativeInt, PositiveInt -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import NonNegativeInt, PositiveInt, field_validator +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict class DBSettings(BaseSettings): @@ -56,6 +58,9 @@ class Settings(BaseSettings): server: ServerSettings = ServerSettings() ROOT_PATH: str = "/" VERSION: str = "1.0.0" + # CORS при DEBUG=false: разрешённые источники (JSON-список или строка через запятую); пусто — кросс-доменные + # запросы запрещены. При DEBUG=true — всегда `*` (OQ-65) + CORS_ORIGINS: Annotated[list[str], NoDecode] = [] # Аутентификация: dev-эмитент (только для разработки; роутер подключается при AUTH_DEV_ISSUER и DEBUG одновременно) AUTH_DEV_ISSUER: bool = False @@ -79,6 +84,17 @@ class Settings(BaseSettings): # Импорт справочников из выгрузки ЕЦП (§13.3): предельный размер файла, больше — 413 IMPORT_MAX_BYTES: PositiveInt = 20 * 1024 * 1024 + @field_validator("CORS_ORIGINS", mode="before") + @classmethod + def _split_origins(cls, value: Any) -> Any: + """`https://a,https://b` или JSON-список `["https://a"]` → список без пробелов и пустых элементов.""" + if not isinstance(value, str): + return value + text = value.strip() + if text.startswith("["): + return json.loads(text) + return [item.strip() for item in text.split(",") if item.strip()] + model_config = SettingsConfigDict( extra="ignore", env_file=os.environ.get("ENV_FILE", ".env"), diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 3060c60..da628d0 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -433,3 +433,103 @@ async def test_shutdown_steps_are_independent_and_engine_disposed_last( assert events == ["nats", "s3", "smb", "db"] failed_steps = sorted(r["extra"].get("step") for r in records) assert failed_steps == ["nats", "s3"] + + +# --- /docs и CORS по DEBUG (OQ-66, OQ-65, issue #9 п.2) ----------------------------------------------------------- + +DOC_PATHS = ["/docs", "/redoc", "/openapi.json"] +ORIGIN = "https://arm-ka.example.ru" + + +def _app_with(tmp_path: Any, **update: Any) -> Any: + """Приложение `create_app` поверх копии настроек процесса; SPA — пустой временный каталог.""" + return app_module.create_app(app_module.settings.model_copy(update=update), web_dir=str(tmp_path)) + + +async def _request(application: Any, method: str, path: str, **kwargs: Any) -> httpx.Response: + transport = httpx.ASGITransport(app=application) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + return await c.request(method, path, **kwargs) + + +@pytest.mark.parametrize("path", DOC_PATHS) +async def test_docs_disabled_without_debug(tmp_path: Any, path: str) -> None: + (tmp_path / "index.html").write_text("SPA", encoding="utf-8") + application = _app_with(tmp_path, DEBUG=False) + + response = await _request(application, "GET", path) + + assert response.status_code == HTTPStatus.NOT_FOUND # не схема и не index.html SPA + assert "SPA" not in response.text + + +@pytest.mark.parametrize("path", DOC_PATHS) +async def test_docs_enabled_with_debug(tmp_path: Any, path: str) -> None: + response = await _request(_app_with(tmp_path, DEBUG=True), "GET", path) + + assert response.status_code == HTTPStatus.OK + + +def _preflight_headers() -> dict[str, str]: + return {"Origin": ORIGIN, "Access-Control-Request-Method": "GET"} + + +async def test_cors_debug_allows_any_origin(tmp_path: Any) -> None: + response = await _request(_app_with(tmp_path, DEBUG=True), "OPTIONS", "/healthcheck", headers=_preflight_headers()) + + assert response.status_code == HTTPStatus.OK + assert response.headers["access-control-allow-origin"] in {"*", ORIGIN} + + +async def test_cors_prod_without_origins_denies_cross_origin(tmp_path: Any) -> None: + application = _app_with(tmp_path, DEBUG=False, CORS_ORIGINS=[]) + + preflight = await _request(application, "OPTIONS", "/healthcheck", headers=_preflight_headers()) + simple = await _request(application, "GET", "/healthcheck", headers={"Origin": ORIGIN}) + + assert "access-control-allow-origin" not in preflight.headers + assert simple.status_code == HTTPStatus.OK + assert "access-control-allow-origin" not in simple.headers + + +async def test_cors_prod_allows_listed_origin_with_credentials(tmp_path: Any) -> None: + application = _app_with(tmp_path, DEBUG=False, CORS_ORIGINS=[ORIGIN]) + + allowed = await _request(application, "OPTIONS", "/healthcheck", headers=_preflight_headers()) + other = await _request( + application, + "OPTIONS", + "/healthcheck", + headers={"Origin": "https://evil.example.com", "Access-Control-Request-Method": "GET"}, + ) + + assert allowed.status_code == HTTPStatus.OK + assert allowed.headers["access-control-allow-origin"] == ORIGIN + assert allowed.headers["access-control-allow-credentials"] == "true" + assert "access-control-allow-origin" not in other.headers + + +async def test_cors_prod_wildcard_without_credentials(tmp_path: Any) -> None: + application = _app_with(tmp_path, DEBUG=False, CORS_ORIGINS=["*"]) + + response = await _request(application, "OPTIONS", "/healthcheck", headers=_preflight_headers()) + + assert response.headers["access-control-allow-origin"] == "*" + assert "access-control-allow-credentials" not in response.headers + + +def test_cors_options_rules() -> None: + base = app_module.settings + assert app_module.cors_options(base.model_copy(update={"DEBUG": True, "CORS_ORIGINS": []})) == { + "allow_origins": ["*"], + "allow_credentials": True, + } + assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": []})) is None + assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": [ORIGIN]})) == { + "allow_origins": [ORIGIN], + "allow_credentials": True, + } + assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": ["*", ORIGIN]})) == { + "allow_origins": ["*", ORIGIN], + "allow_credentials": False, + } diff --git a/api/tests/test_config.py b/api/tests/test_config.py index f06f7c6..23cc200 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -123,3 +123,28 @@ def test_env_example_listens_on_loopback(clean_env: pytest.MonkeyPatch) -> None: """Dev-запуск по `.env.example` слушает только 127.0.0.1 (финальное ревью M-11).""" s = Settings(_env_file=API_DIR / ".env.example") # type: ignore[call-arg] assert (s.server.HOST, s.server.PORT) == ("127.0.0.1", 8000) + + +# --- CORS_ORIGINS (OQ-65, issue #9 п.2) --------------------------------------------------------------------------- + + +def test_cors_origins_default_is_empty(clean_env: pytest.MonkeyPatch) -> None: + clean_env.delenv("CORS_ORIGINS", raising=False) + s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg] + assert s.CORS_ORIGINS == [] + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("https://a.example.ru,https://b.example.ru", ["https://a.example.ru", "https://b.example.ru"]), + (" https://a.example.ru , ", ["https://a.example.ru"]), + ('["https://a.example.ru", "https://b.example.ru"]', ["https://a.example.ru", "https://b.example.ru"]), + ("", []), + ], + ids=["comma", "spaces-and-trailing-comma", "json", "empty"], +) +def test_cors_origins_from_env(clean_env: pytest.MonkeyPatch, raw: str, expected: list[str]) -> None: + clean_env.setenv("CORS_ORIGINS", raw) + s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg] + assert s.CORS_ORIGINS == expected -- 2.47.2 From 4b873468fb33afb60f1429eccc54d1513bc21652 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:17:48 +0400 Subject: [PATCH 03/13] =?UTF-8?q?fix:=20GET=20/=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B8=D1=80=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20//,=20405=20=D0=BD=D0=B0=20=D0=BD=D0=B5=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BD=D1=8B=D0=B9=20=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=20/api/v1=20(OQ-61,=20OQ-62,=20#9=20=D0=BF.3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Причина редиректа: `FastAPI(root_path="/")` — путь маршрута для `GET /` после вычитания root_path пуст, монтирование SPA его не находит, и роутер Starlette отвечал 307 на `//`. Теперь `root_path` нормализуется (`/` → "", хвостовой `/` снимается) — `_normalize_root_path` существовал, но не применялся. `SPAStaticFiles` для путей `api/...` сверяется с маршрутами приложения: путь совпал, метод нет (`Match.PARTIAL`) — 405 с заголовком `Allow` и русским `detail`; иначе — прежний 404 (dev-маршруты при выключенных флагах — 404). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 5 ++- api/src/app.py | 43 ++++++++++++++++++++--- api/tests/test_app.py | 80 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 016a5d3..7e3ee9f 100644 --- a/README.md +++ b/README.md @@ -426,7 +426,10 @@ email без учёта регистра проверяет только сер | `LAST_ADMIN` | 409 | операция оставила бы систему без активного `USERS_ADMIN` | Файл импорта больше `IMPORT_MAX_BYTES` — 413 со строкой в `detail`. Недостаточно прав — 403 строкой -(`недостаточно прав: требуется <ПРАВО>`), нет токена или он неверен — 401. +(`недостаточно прав: требуется <ПРАВО>`), нет токена или он неверен — 401. Под `/api/v1` SPA не отвечает: неверный +метод на существующий маршрут — 405 `{"detail": "метод не поддерживается этим маршрутом"}` с заголовком `Allow` +(например, `POST /api/v1/me`), несуществующий путь (и dev-маршрут при выключенных флагах) — 404 `{"detail": "Not Found"}`. +Все остальные пути — SPA: `GET /` и клиентские маршруты (`/admin`) отдают `index.html` без редиректов. ## Конфигурация diff --git a/api/src/app.py b/api/src/app.py index 7651a0d..fc1020b 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -32,6 +32,8 @@ from fastapi.staticfiles import StaticFiles from loguru import logger from miniopy_async import Minio from starlette.middleware.gzip import GZipMiddleware +from starlette.routing import Match, Mount, Route +from starlette.types import Scope from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from src.api.router import API_PREFIX, build_api_router @@ -212,7 +214,9 @@ def create_app(app_settings: Settings, *, web_dir: str = "web") -> FastAPI: application = FastAPI( title="АРМ КА", version=VERSION, - root_path=app_settings.ROOT_PATH, + # `/` → "" (OQ-62): при root_path "/" путь маршрута для `GET /` вычисляется пустым, монтирование SPA его не + # находит, и роутер отвечал редиректом на `//` + root_path=_normalize_root_path(app_settings.ROOT_PATH), lifespan=lifespan, docs_url="/docs" if docs_enabled else None, redoc_url="/redoc" if docs_enabled else None, @@ -254,11 +258,40 @@ API_STATIC_PATH = API_PREFIX.strip("/") _DOC_STATIC_PATHS = frozenset(path.strip("/") for path in DOC_PATHS) +METHOD_NOT_ALLOWED_DETAIL = "метод не поддерживается этим маршрутом" + + +def _allowed_methods(scope: Scope) -> set[str]: + """Методы маршрутов приложения, чей путь совпал с запросом, а метод — нет (`Match.PARTIAL`). + + SPA смонтировано на `/` и совпадает с любым запросом полностью, поэтому роутер Starlette до своего 405 не доходит: + неверный метод на существующий маршрут API попадает сюда. Монтирования (в том числе само SPA) не учитываются. + """ + allowed: set[str] = set() + for route in scope["app"].router.routes: + if isinstance(route, Mount) or not isinstance(route, Route): + continue + match, _ = route.matches(scope) + if match is Match.PARTIAL and route.methods: + allowed |= route.methods + return allowed + + class SPAStaticFiles(StaticFiles): - async def get_response(self, path: str, scope): - # Несуществующий маршрут API — честный 404 (в т.ч. для POST), а не index.html SPA и не 405 от StaticFiles: - # иначе dev-маршруты, которых при выключенных флагах нет, выглядели бы как «существующие» - if path == API_STATIC_PATH or path.startswith(API_STATIC_PATH + "/") or path in _DOC_STATIC_PATHS: + async def get_response(self, path: str, scope: Scope): + # Под /api/v1 SPA не отвечает (OQ-61): путь совпал с маршрутом API, но метод другой — 405 с `Allow`; + # маршрута нет — честный 404 (в т.ч. для POST), а не index.html SPA и не 405 от StaticFiles: иначе dev-маршруты, + # которых при выключенных флагах нет, выглядели бы как «существующие» + if path == API_STATIC_PATH or path.startswith(API_STATIC_PATH + "/"): + allowed = _allowed_methods(scope) + if allowed: + raise HTTPException( + status_code=HTTPStatus.METHOD_NOT_ALLOWED, + detail=METHOD_NOT_ALLOWED_DETAIL, + headers={"Allow": ", ".join(sorted(allowed))}, + ) + raise HTTPException(status_code=HTTPStatus.NOT_FOUND) + if path in _DOC_STATIC_PATHS: raise HTTPException(status_code=HTTPStatus.NOT_FOUND) try: return await super().get_response(path, scope) diff --git a/api/tests/test_app.py b/api/tests/test_app.py index da628d0..549fdf1 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -533,3 +533,83 @@ def test_cors_options_rules() -> None: "allow_origins": ["*", ORIGIN], "allow_credentials": False, } + + +# --- GET / без редиректа на //, 405 и 404 под /api/v1 (OQ-61, OQ-62, issue #9 п.3) --------------------------------- + +SPA_HTML = "АРМ КА SPA" + + +def _spa_app(tmp_path: Any, **update: Any) -> Any: + (tmp_path / "index.html").write_text(SPA_HTML, encoding="utf-8") + return _app_with(tmp_path, **update) + + +@pytest.mark.parametrize("root_path", ["/", ""]) +async def test_root_serves_spa_index_without_redirect(tmp_path: Any, root_path: str) -> None: + response = await _request(_spa_app(tmp_path, ROOT_PATH=root_path), "GET", "/") + + assert response.status_code == HTTPStatus.OK + assert response.text == SPA_HTML + assert "location" not in response.headers + + +async def test_root_path_prefix_serves_spa(tmp_path: Any) -> None: + """ROOT_PATH за прокси (`/armka`, с хвостовым `/` или без): корень приложения — index.html.""" + for root_path in ("/armka", "/armka/"): + response = await _request(_spa_app(tmp_path, ROOT_PATH=root_path), "GET", "/armka/") + assert response.status_code == HTTPStatus.OK, root_path + assert response.text == SPA_HTML + + +async def test_spa_client_route_serves_index(tmp_path: Any) -> None: + response = await _request(_spa_app(tmp_path), "GET", "/admin") + + assert response.status_code == HTTPStatus.OK + assert response.text == SPA_HTML + + +@pytest.mark.parametrize( + ("method", "path", "allowed"), + [ + ("POST", "/api/v1/me", {"GET"}), + ("DELETE", "/api/v1/me", {"GET"}), + ("PATCH", "/api/v1/directories/company-groups", {"GET", "POST"}), + ("GET", "/api/v1/directories/import", {"POST"}), + ("PUT", "/api/v1/directories/company-groups/00000000-0000-0000-0000-000000000001", {"GET", "PATCH"}), + ("GET", "/api/v1/dev/token", {"POST"}), # dev-эмитент включён в .env.test + ], +) +async def test_wrong_method_on_existing_api_route_is_405( + tmp_path: Any, method: str, path: str, allowed: set[str] +) -> None: + response = await _request(_spa_app(tmp_path), method, path) + + assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED + assert {m.strip() for m in response.headers["allow"].split(",")} == allowed + assert response.json() == {"detail": "метод не поддерживается этим маршрутом"} + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("GET", "/api/v1/no-such-route"), + ("POST", "/api/v1/no-such-route"), + ("GET", "/api/v1"), + ("GET", "/api/v1/"), + ("GET", "/api/v1/me/extra"), + ], +) +async def test_unknown_api_path_is_404(tmp_path: Any, method: str, path: str) -> None: + response = await _request(_spa_app(tmp_path), method, path) + + assert response.status_code == HTTPStatus.NOT_FOUND + assert SPA_HTML not in response.text + + +async def test_dev_route_absent_without_debug_is_404_not_405(tmp_path: Any) -> None: + """Поведение Среза 0: при выключенных флагах dev-маршрута нет в таблице — 404 на любой метод.""" + application = _spa_app(tmp_path, DEBUG=False) + for method in ("GET", "POST"): + response = await _request(application, method, "/api/v1/dev/token") + assert response.status_code == HTTPStatus.NOT_FOUND, method -- 2.47.2 From 2017d113791db81c766076579a65dc0316315834 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:18:31 +0400 Subject: [PATCH 04/13] =?UTF-8?q?chore:=20.dockerignore=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D0=B5=D1=82=20api/.env.*=20(OQ-8?= =?UTF-8?q?2,=20#9=20=D0=BF.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `api/.env.test`, `api/.env.example` и прочие env-файлы больше не попадают в образ (как у tabular-parser и scan-gateway): настройки контейнера задаются переменными окружения. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- .dockerignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.dockerignore b/.dockerignore index 336e34d..b3a9986 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,9 @@ setup **/*.pyc api/.venv api/.env +# прочие env-файлы (.env.test, .env.local, .env.example …) — тоже не в образ: настройки контейнера задаёт платформа +# переменными окружения (OQ-82) +api/.env.* **/.mypy_cache **/.ruff_cache **/.pytest_cache -- 2.47.2 From ef7458eddb7632a07bb5cb94ce2efd04244a1db9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:27:29 +0400 Subject: [PATCH 05/13] =?UTF-8?q?feat:=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=20?= =?UTF-8?q?=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20LOGIN=20/=20LOGIN=5FFAILED=20(?= =?UTF-8?q?OQ-64,=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 -- 2.47.2 From b17176f97c127ce44a15926d944714d14e62727c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:29:00 +0400 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=C2=AB=D1=81=D0=B5=D0=B3=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D1=8F=C2=BB=20=D0=B4=D0=BB=D1=8F=20effective=5Fdat?= =?UTF-8?q?e=20=E2=80=94=20=D0=B4=D0=B0=D1=82=D0=B0=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=9C=D0=BE=D1=81=D0=BA=D0=B2=D0=B5=20(#9=20=D0=BF.6,=20follow?= =?UTF-8?q?-up=20#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_today()` в `services/legal_entities.py` берёт московскую дату (`common/clock.py`: `moscow_date`, `MOSCOW_TZ` = Europe/Moscow; без базы часовых поясов — фиксированный UTC+3, Москва без перехода на летнее время с 2014 г.). Тесты на границе суток: 23:30 МСК (20:30 UTC) — ещё 4-е, 00:30 МСК (21:30 UTC того же дня) — уже 5-е, и 4-е становится прошлым. README: даты перевода/вывода — по Москве. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 4 +- api/src/common/clock.py | 18 +++++++- api/src/services/legal_entities.py | 8 ++-- api/tests/test_api_legal_entities.py | 5 ++- api/tests/test_clock.py | 61 ++++++++++++++++++++++++++++ 5 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 api/tests/test_clock.py diff --git a/README.md b/README.md index d818087..14213c5 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `GET /directories/legal-entities` | `DIRECTORY_VIEW` | видимые ЮЛ/ИП: `company_group_id`, `outside_group`, `inn`, `q`, `closed`, `limit`/`offset` | | `POST /directories/legal-entities` | `DIRECTORY_EDIT` | создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12) | | `GET`, `PATCH /directories/legal-entities/{entity_id}` | `DIRECTORY_VIEW` / `DIRECTORY_EDIT` | карточка (с членствами и алиасами) / изменить | -| `POST /directories/legal-entities/{entity_id}/move` | `DIRECTORY_EDIT` | перевести в другую ГК с датой `effective_date` ≥ сегодня; членства ГСК/ГСЗ закрываются | +| `POST /directories/legal-entities/{entity_id}/move` | `DIRECTORY_EDIT` | перевести в другую ГК с датой `effective_date` ≥ сегодня (дата по Москве, Europe/Moscow); членства ГСК/ГСЗ закрываются | | `POST /directories/legal-entities/{entity_id}/withdraw` | `DIRECTORY_EDIT` | вывести из ГК: `CLOSE` (с `close_date`) или `REMOVE_FROM_GROUP` | | `GET /directories/legal-entities/{entity_id}/history` | `DIRECTORY_VIEW` | история ЮЛ/ИП, новые первыми | | `POST /directories/legal-entities/{entity_id}/aliases` | `DIRECTORY_EDIT` | добавить алиас (`normalized_alias` по §3) | @@ -406,7 +406,7 @@ email без учёта регистра проверяет только сер | `OFFICE_NOT_FOUND` | 422 | филиал из тела запроса не существует (ГК, ЮЛ, пользователь) | | `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом | | `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр | -| `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней | +| `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней (по Москве) | | `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) | | `DIFFERENT_COMPANY_GROUP` | 422 | ЮЛ не входит в ГК этой ГСК/ГСЗ | | `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) | diff --git a/api/src/common/clock.py b/api/src/common/clock.py index c7bf2e5..26b42dd 100644 --- a/api/src/common/clock.py +++ b/api/src/common/clock.py @@ -1,8 +1,24 @@ -"""Текущее время сервисов: одно место для «сейчас» (UTC, с часовым поясом) — `valid_from`/`valid_to`, `updated_at`.""" +"""Текущее время сервисов: одно место для «сейчас» (UTC, с часовым поясом) — `valid_from`/`valid_to`, `updated_at`. + +Календарная дата бизнеса («сегодня» для дат перевода/закрытия ЮЛ) — московская: `moscow_date` (issue #9 п.6). +""" import datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +try: + MOSCOW_TZ: datetime.tzinfo = ZoneInfo("Europe/Moscow") +except ZoneInfoNotFoundError: # нет базы часовых поясов (tzdata) в образе: Москва с 2014 г. — постоянно UTC+3 + MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3), "MSK") def utc_now() -> datetime.datetime: """Текущий момент в UTC (aware).""" return datetime.datetime.now(datetime.UTC) + + +def moscow_date(moment: datetime.datetime) -> datetime.date: + """Календарная дата момента `moment` (aware) по Москве: 21:30 UTC 4-го — уже 5-е.""" + if moment.tzinfo is None: + raise ValueError("ожидается момент с часовым поясом (aware datetime)") + return moment.astimezone(MOSCOW_TZ).date() diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py index 404e3ce..e2732e8 100644 --- a/api/src/services/legal_entities.py +++ b/api/src/services/legal_entities.py @@ -14,7 +14,7 @@ from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit -from src.common.clock import utc_now +from src.common.clock import moscow_date, utc_now from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError from src.domain.inn import validate_inn from src.domain.policy import VisibilityScope, office_for_create @@ -41,8 +41,8 @@ TRACKED_FIELDS = ("name", "entity_type", "crm_id", "regional_office_id") # по def _today() -> datetime.date: - """«Сегодня» для проверки `effective_date` — дата UTC (часовой пояс пользователя в Срезе 1 не известен).""" - return utc_now().date() + """«Сегодня» для проверки `effective_date` — дата по Москве (Europe/Moscow, issue #9 п.6).""" + return moscow_date(utc_now()) def snapshot_entity( # noqa: PLR0913 - снимок ЮЛ: сессия, автор, ЮЛ, событие, момент и дата события @@ -290,7 +290,7 @@ class LegalEntityService: @staticmethod def _check_effective_date(effective_date: datetime.date) -> None: - # ASSUMPTION(OQ-21): дата перевода/закрытия не раньше сегодняшней (ANL-046); «сегодня» — по UTC + # ASSUMPTION(OQ-21): дата перевода/закрытия не раньше сегодняшней (ANL-046); «сегодня» — по Москве if effective_date < _today(): raise DomainValidationError("EFFECTIVE_DATE_IN_PAST", "Дата не может быть раньше сегодняшней") diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py index f4b180e..95396f8 100644 --- a/api/tests/test_api_legal_entities.py +++ b/api/tests/test_api_legal_entities.py @@ -11,6 +11,7 @@ from fastapi import FastAPI from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from src.common.clock import moscow_date from src.models.audit_ex import AuditEvent from src.models.directories import ( LegalEntity, @@ -38,8 +39,8 @@ GROUP_NOT_FOUND = "ГК не найдена" def _today() -> datetime.date: - """«Сегодня» сервиса — дата UTC (тест не зависит от часового пояса машины).""" - return datetime.datetime.now(datetime.UTC).date() + """«Сегодня» сервиса — дата по Москве (тест не зависит от часового пояса машины).""" + return moscow_date(datetime.datetime.now(datetime.UTC)) async def _office(session: AsyncSession, name: str) -> RegionalOffice: diff --git a/api/tests/test_clock.py b/api/tests/test_clock.py new file mode 100644 index 0000000..8338cf9 --- /dev/null +++ b/api/tests/test_clock.py @@ -0,0 +1,61 @@ +"""«Сегодня» по Москве для дат перевода/закрытия ЮЛ (issue #9 п.6, follow-up #8): граница суток — московская.""" + +import datetime + +import pytest + +from src.common.clock import MOSCOW_TZ, moscow_date +from src.common.exceptions import DomainValidationError +from src.services import legal_entities +from src.services.legal_entities import LegalEntityService + +UTC = datetime.UTC +# 23:30 МСК 4 октября = 20:30 UTC 4 октября; 00:30 МСК 5 октября = 21:30 UTC 4 октября (UTC ещё 4-е) +LATE_EVENING_MSK = datetime.datetime(2026, 10, 4, 20, 30, tzinfo=UTC) +AFTER_MIDNIGHT_MSK = datetime.datetime(2026, 10, 4, 21, 30, tzinfo=UTC) +OCT_4 = datetime.date(2026, 10, 4) +OCT_5 = datetime.date(2026, 10, 5) + + +def test_moscow_zone_is_utc_plus_3() -> None: + assert LATE_EVENING_MSK.astimezone(MOSCOW_TZ).utcoffset() == datetime.timedelta(hours=3) + + +@pytest.mark.parametrize( + ("moment", "expected"), + [(LATE_EVENING_MSK, OCT_4), (AFTER_MIDNIGHT_MSK, OCT_5)], + ids=["23:30-msk", "00:30-msk"], +) +def test_moscow_date_follows_moscow_midnight(moment: datetime.datetime, expected: datetime.date) -> None: + assert moscow_date(moment) == expected + assert moment.date() == OCT_4 # дата UTC в обоих случаях — 4-е + + +def test_moscow_date_rejects_naive_datetime() -> None: + with pytest.raises(ValueError, match="часов"): + moscow_date(datetime.datetime(2026, 10, 4, 21, 30)) # noqa: DTZ001 - проверяется отказ на naive + + +@pytest.mark.parametrize( + ("moment", "today"), + [(LATE_EVENING_MSK, OCT_4), (AFTER_MIDNIGHT_MSK, OCT_5)], + ids=["23:30-msk", "00:30-msk"], +) +def test_effective_date_today_is_moscow_date( + monkeypatch: pytest.MonkeyPatch, moment: datetime.datetime, today: datetime.date +) -> None: + monkeypatch.setattr(legal_entities, "utc_now", lambda: moment) + + assert legal_entities._today() == today + LegalEntityService._check_effective_date(today) # сегодня по Москве — можно + with pytest.raises(DomainValidationError) as info: + LegalEntityService._check_effective_date(today - datetime.timedelta(days=1)) + assert info.value.code == "EFFECTIVE_DATE_IN_PAST" + + +def test_after_moscow_midnight_utc_date_is_already_past(monkeypatch: pytest.MonkeyPatch) -> None: + """00:30 МСК 5-го: 4-е (ещё «сегодня» по UTC) — уже прошлое.""" + monkeypatch.setattr(legal_entities, "utc_now", lambda: AFTER_MIDNIGHT_MSK) + + with pytest.raises(DomainValidationError): + LegalEntityService._check_effective_date(OCT_4) -- 2.47.2 From 900fdc8ece9d2b9e6fb30e73d84dea2f630f8ed9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:30:09 +0400 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20seed=20=D0=BD=D0=B5=20=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82=20=D1=81=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20?= =?UTF-8?q?=E2=80=94=20=D0=BC=D0=B0=D1=82=D1=80=D0=B8=D1=86=D0=B0=20=D1=82?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=20=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D1=82=D1=83=D1=8E=20role=5Fpermission=20(#9=20=D0=BF.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_apply_role_permissions` (`scripts/seed_lib/apply.py`) загружает матрицу из `role_permissions.yaml` только при пустой `role_permission`; иначе шаг пропускается целиком с причиной в отчёте (`TableStat.skipped_reason`, «role_permission: пропущено — …»). Права, снятые администратором, не возвращаются; `role` и `permission` по-прежнему upsert'ятся. Тесты: повторный прогон — «пропущено», матрица та же; снятая связь после повторного seed не возвращается. README «Администрирование» и раздел seed переписаны под новое поведение. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 3 ++- README.md | 14 +++++++++----- api/scripts/seed.py | 3 ++- api/scripts/seed_lib/apply.py | 14 ++++++++++++++ api/scripts/seed_lib/model.py | 9 +++++++-- api/tests/test_seed_dev.py | 3 ++- api/tests/test_seed_roles.py | 36 ++++++++++++++++++++++++++++++++++- 7 files changed, 71 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 490b0d5..1da6884 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,7 +76,8 @@ 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`). +- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен (матрица `role_permission` — только в + пустую таблицу, иначе шаг пропускается); `--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 14213c5..392afe1 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,9 @@ make seed-dev # справочники продукта + dev-пользов ``` `make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без -dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует. `make seed-dev` +dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав +`role_permission` он загружает только в пустую таблицу (правки администратора не перезаписываются, см. +«Администрирование»). `make seed-dev` (`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2 («`--dev` доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД. @@ -362,10 +364,12 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о - **Email.** В теле запроса — `EmailStr`, поэтому адрес с зарезервированным доменом `*.local` (`x@corp.local`) через API и UI **отклоняется** (422); dev-пользователи `*@armka.local` создаёт `make seed-dev` напрямую в БД. Для ручной проверки заводите пользователей с `example.com`. Email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён. -- **Повторный seed возвращает снятые права.** `make seed` / `make seed-dev` идемпотентны и только **добавляют** - недостающие связи «роль × право» из `api/seed/core/role_permissions.yaml`: право, которое администратор снял с роли - в матрице, следующий запуск seed выдаст снова (`granted_by` — пусто); права, добавленные администратором сверх seed, - seed не отзывает. На стенде с правками матрицы seed повторно не запускайте (follow-up к M-8 Среза 0). +- **Seed не трогает заполненную матрицу прав.** Связи «роль × право» из `api/seed/core/role_permissions.yaml` + `make seed` / `make seed-dev` загружают только в **пустую** `role_permission` (первый запуск на новой БД). Если + связи уже есть, шаг пропускается целиком — в отчёте `role_permission: пропущено — матрица прав уже заполнена…`: + право, снятое администратором, не возвращается, добавленное сверх файла не отзывается. Роли и права (`role`, + `permission`) по-прежнему приводятся к файлам seed. Изменения `role_permissions.yaml` на действующем стенде + (например, права новой роли) seed не применит — их выдаёт администратор (`PUT /admin/roles/{code}/permissions`). - **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите страницу или войдите заново. - **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after` diff --git a/api/scripts/seed.py b/api/scripts/seed.py index d8bdb62..d6aeb49 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -7,7 +7,8 @@ Spec reference-data-seed; design D-4 и решение #18 (R-5): `run_seed(sess 1. `load_seed` (`seed_lib/loaders.py`) читает ВСЕ файлы в память и проверяет их (обязательные поля, enum'ы из `src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается. 2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному - ключу, без удалений. + ключу, без удалений. Исключение — матрица `role_permission`: загружается только в пустую таблицу, иначе шаг + пропускается (в отчёте — «пропущено»), чтобы не вернуть права, снятые администратором (issue #9 п.7). План счетов (`core/chart_of_accounts.csv`, design D-9) — ДОПУЩЕНИЕ OQ-47: полного плана 1С клиента нет, в файле Приказ Минфина № 94н: 62 активных синтетических счёта (01–05, 07–11, 14–16, 19–21, 23, 25, 26, 28, 29, 40–46, diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py index 412f0fd..a69d95b 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -49,7 +49,21 @@ async def _upsert( await session.execute(statement, changed) +MATRIX_SKIPPED = ( + "матрица прав уже заполнена; seed её не меняет, чтобы не вернуть права, снятые администратором " + "(правка — PUT /admin/roles/{code}/permissions)" +) + + async def _apply_role_permissions(session: AsyncSession, data: SeedData, stat: TableStat) -> None: + """Матрица «роль × право» из файла — только в пустую `role_permission` (первичная загрузка, issue #9 п.7). + + Если связи уже есть, матрицей владеет администратор: шаг пропускается целиком (`stat.skipped_reason`), снятые + права не возвращаются, добавленные не отзываются. Роли и права (`role`, `permission`) по-прежнему upsert'ятся. + """ + if await session.scalar(select(RolePermission.role_id).limit(1)) is not None: + stat.skipped_reason = MATRIX_SKIPPED + return role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()} permission_ids = {code: id_ for id_, code in (await session.execute(select(Permission.id, Permission.code))).all()} existing = set((await session.execute(select(RolePermission.role_id, RolePermission.permission_id))).all()) diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py index fce5301..b7be21a 100644 --- a/api/scripts/seed_lib/model.py +++ b/api/scripts/seed_lib/model.py @@ -133,11 +133,13 @@ class TableStat: inserted: int = 0 updated: int = 0 unchanged: int = 0 + #: шаг не выполнялся (например, матрица прав уже заполнена); текст — для отчёта CLI + skipped_reason: str | None = None @dataclass class SeedReport: - """Итог применения: по таблицам — сколько строк вставлено / обновлено / не изменилось.""" + """Итог применения: по таблицам — сколько строк вставлено / обновлено / не изменилось (или почему шаг пропущен).""" tables: dict[str, TableStat] = field(default_factory=dict) @@ -147,6 +149,9 @@ class SeedReport: def format(self) -> str: lines = ["Seed применён (вставлено / обновлено / без изменений):"] lines += [ - f" {name}: {stat.inserted} / {stat.updated} / {stat.unchanged}" for name, stat in self.tables.items() + f" {name}: пропущено — {stat.skipped_reason}" + if stat.skipped_reason is not None + else f" {name}: {stat.inserted} / {stat.updated} / {stat.unchanged}" + for name, stat in self.tables.items() ] return "\n".join(lines) diff --git a/api/tests/test_seed_dev.py b/api/tests/test_seed_dev.py index 1ccaae2..2099ed7 100644 --- a/api/tests/test_seed_dev.py +++ b/api/tests/test_seed_dev.py @@ -110,9 +110,10 @@ async def test_second_dev_run_changes_nothing(db_session: AsyncSession) -> None: assert await _counts(db_session) == before assert before["app_user"] == USER_COUNT assert before["user_role"] == USER_COUNT - for table in ("regional_office", "app_user", "user_role"): + for table in ("regional_office", "app_user", "user_role", "role_permission"): stat = report.tables[table] assert (stat.inserted, stat.updated) == (0, 0) + assert report.tables["role_permission"].skipped_reason is not None # матрица непуста — не трогается db_session.expire_all() assert {u.email: u.updated_at for u in (await db_session.scalars(select(AppUser))).all()} == stamps diff --git a/api/tests/test_seed_roles.py b/api/tests/test_seed_roles.py index 76efd00..bd5a30a 100644 --- a/api/tests/test_seed_roles.py +++ b/api/tests/test_seed_roles.py @@ -10,7 +10,7 @@ from pathlib import Path import pytest import yaml -from sqlalchemy import func, select +from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from scripts.seed import SeedError, SeedReport, load_seed, run_seed @@ -146,6 +146,40 @@ async def test_second_run_does_not_change_row_counts(db_session: AsyncSession) - assert await _counts(db_session) == first assert all(stat.inserted == 0 and stat.updated == 0 for stat in report.tables.values()) + # матрица прав непуста — шаг пропущен целиком (issue #9 п.7) + assert report.tables["role_permission"].skipped_reason is not None + assert report.tables["role_permission"].unchanged == 0 + assert "role_permission: пропущено" in report.format() + + +@pytest.mark.integration +async def test_first_run_fills_matrix_without_skip(db_session: AsyncSession) -> None: + report = await run_seed(db_session) + + assert report.tables["role_permission"].skipped_reason is None + assert "пропущено" not in report.format() + + +@pytest.mark.integration +async def test_reseed_does_not_restore_permission_revoked_by_admin(db_session: AsyncSession) -> None: + """Связь «роль × право», снятая администратором, повторным seed не возвращается; роли и права — upsert.""" + await run_seed(db_session) + expert_view = ( + select(RolePermission) + .join(Role, Role.id == RolePermission.role_id) + .join(Permission, Permission.id == RolePermission.permission_id) + .where(Role.code == "EXPERT", Permission.code == "EX_VIEW") + ) + await db_session.delete((await db_session.execute(expert_view)).scalar_one()) + await db_session.flush() + await db_session.execute(update(Role).where(Role.code == "EXPERT").values(name="Изменено вручную")) + + report = await run_seed(db_session) + + assert (await db_session.execute(expert_view)).scalar_one_or_none() is None + assert await _count(db_session, RolePermission) == GRANT_COUNT - 1 + assert report.tables["role_permission"].skipped_reason is not None + assert report.tables["role"].updated == 1 # роли по-прежнему приводятся к файлу @pytest.mark.integration -- 2.47.2 From ea119ffaa5c49cfe6eb22eaf748dc5d751731781 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:45:29 +0400 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=D1=8E=D1=82=20user=5Fagent=20=D0=B4?= =?UTF-8?q?=D0=BE=20512=20=D0=B8=20description=20=D0=B4=D0=BE=201000=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BC=D0=B2=D0=BE=D0=BB=D0=BE=D0=B2=20(=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E=20R-1,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `clip` в `services/session_audit.py` применяется в `record_login` (общая точка LOGIN/LOGIN_FAILED): заголовок User-Agent и email из токена присылает клиент, без ограничения запись в журнал была бы произвольной длины. Тесты: помощник, запись `SessionAudit` без БД и длинный User-Agent при 401 через API. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 1 + api/src/services/session_audit.py | 17 ++++++++++--- api/tests/test_login_audit.py | 41 ++++++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 392afe1..9f2e099 100644 --- a/README.md +++ b/README.md @@ -602,6 +602,7 @@ Dockerfile только для локального compose если пользователь найден). Запись делается в отдельной транзакции и остаётся, хотя транзакция запроса откатилась. - Не события входа: запрос без заголовка `Authorization` и 403 «недостаточно прав». Ошибка записи аудита входа ответ не меняет (только лог). Выход (`LOGOUT`) не пишется: сессий на сервере нет. + - Поля, которые присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000. ## Соседние документы diff --git a/api/src/services/session_audit.py b/api/src/services/session_audit.py index 1599edc..16264e5 100644 --- a/api/src/services/session_audit.py +++ b/api/src/services/session_audit.py @@ -8,7 +8,8 @@ (пользователь не найден, отключён, неоднозначен: email в `description`, `user_id` — если пользователь найден). Запрос без заголовка `Authorization` попыткой входа не считается; 403 «недостаточно прав» — авторизация, не вход. -Раздел `SESSION`, `object_type = "app_user"`, `object_id = user_id`; IP и user-agent — из запроса. +Раздел `SESSION`, `object_type = "app_user"`, `object_id = user_id`; IP и user-agent — из запроса. Поля, которые +присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000. LOGIN_FAILED и LOGIN первого запроса пишутся в ОТДЕЛЬНОЙ короткой транзакции (`transaction`, в проде — `src.db.db.session_transaction`): транзакция запроса при 401/403 откатывается (R-1), а запись об отказе должна остаться. Ошибка записи аудита входа ответ не меняет: она логируется, LOGIN первого запроса будет повторён следующим запросом. @@ -26,6 +27,9 @@ from src.models.enums import AuditAction, AuditSection from src.services.audit import AuditService OBJECT_TYPE = "app_user" +#: пределы длины полей события входа: заголовок User-Agent и email из токена присылает клиент (ревью R-1) +USER_AGENT_MAX = 512 +DESCRIPTION_MAX = 1000 FIRST_REQUEST_DESCRIPTION = "первый запрос с токеном" DEV_TOKEN_DESCRIPTION = "выдан dev-токен" @@ -33,6 +37,11 @@ DEV_TOKEN_DESCRIPTION = "выдан dev-токен" TransactionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] +def clip(text: str | None, limit: int) -> str | None: + """Строка не длиннее `limit` символов (`None` остаётся `None`).""" + return text if text is None else text[:limit] + + def request_client(request: HTTPConnection) -> tuple[str | None, str | None]: """IP клиента (за прокси — из `ProxyHeadersMiddleware`) и заголовок `User-Agent` запроса.""" return (request.client.host if request.client else None, request.headers.get("user-agent")) @@ -47,16 +56,16 @@ async def record_login( # noqa: PLR0913 - поля события входа б ip_address: str | None, user_agent: str | None, ) -> None: - """Событие входа в транзакции `session` (раздел SESSION, объект — пользователь).""" + """Событие входа в транзакции `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, + description=clip(description, DESCRIPTION_MAX), ip_address=ip_address, - user_agent=user_agent, + user_agent=clip(user_agent, USER_AGENT_MAX), ) diff --git a/api/tests/test_login_audit.py b/api/tests/test_login_audit.py index 22b8153..da3b984 100644 --- a/api/tests/test_login_audit.py +++ b/api/tests/test_login_audit.py @@ -22,7 +22,13 @@ 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 +from src.services.session_audit import ( + DESCRIPTION_MAX, + FIRST_REQUEST_DESCRIPTION, + USER_AGENT_MAX, + SessionAudit, + clip, +) HTTP_OK = 200 HTTP_UNAUTHORIZED = 401 @@ -253,3 +259,36 @@ async def test_failed_login_write_does_not_raise_and_is_retried() -> None: session.fail = False await audit.first_request(user_id, ip_address=None, user_agent=None) assert audit.logged_in == {user_id} + + +# --- ограничение длины полей события входа (ревью R-1) ------------------------------------------------------------ + + +def test_clip_limits_text_length() -> None: + assert clip(None, 5) is None + assert clip("abc", 5) == "abc" + assert clip("a" * 10, 5) == "aaaaa" + + +async def test_login_event_fields_are_clipped() -> None: + session = _RecordingSession(fail=False) + audit = SessionAudit(_fake_transaction(session)) # type: ignore[arg-type] + + await audit.login_failed(None, "x" * 5000, ip_address="10.0.0.1", user_agent="U" * 5000) + + (event,) = session.added + assert isinstance(event, AuditEvent) + assert event.user_agent == "U" * USER_AGENT_MAX + assert event.description == "x" * DESCRIPTION_MAX + + +@pytest.mark.integration +async def test_long_user_agent_is_clipped_in_journal(app: FastAPI, seeded: AsyncSession) -> None: + async with _client(app) as client: + response = await client.get( + "/api/v1/me", headers={**_bearer(GARBAGE_TOKEN), "User-Agent": "A" * (USER_AGENT_MAX * 4)} + ) + assert response.status_code == HTTP_UNAUTHORIZED + + (event,) = await _events(seeded) + assert event.user_agent == "A" * USER_AGENT_MAX -- 2.47.2 From f47cbeeddafbd49290aed1f07be2df856b8e90ef Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:46:17 +0400 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20X-Forwarded-For=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BE=D1=82=20=D0=B4=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D1=85=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=81=D0=B8=20=E2=80=94=20=D0=BD=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B0=20FORWARDED=5FALLOW=5FIPS=20(=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=8C=D1=8E=20R-2,=20OQ-65,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ProxyHeadersMiddleware(trusted_hosts=settings.FORWARDED_ALLOW_IPS)` вместо `["*"]`: по умолчанию `127.0.0.1`, список через запятую или `*`. Иначе любой клиент подставлял свой адрес в `audit_event.ip_address` (в том числе в LOGIN_FAILED). `.env.example`/`.env.test`, README «Конфигурация» и «Безопасность dev-режима» дополнены; compose `docs/dev` не менялся. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 4 ++++ api/.env.example | 4 ++++ api/.env.test | 4 ++++ api/src/app.py | 3 ++- api/src/core/config.py | 3 +++ api/tests/test_app.py | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 51 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f2e099..5b43b1d 100644 --- a/README.md +++ b/README.md @@ -446,6 +446,7 @@ email без учёта регистра проверяет только сер |---|---|---| | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | +| `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) | | `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*`) | | `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) | | `ROOT_PATH` | `/` | корневой путь за прокси (должен совпадать с `REACT_APP_ROOT_PATH` фронта) | @@ -603,6 +604,9 @@ Dockerfile только для локального compose - Не события входа: запрос без заголовка `Authorization` и 403 «недостаточно прав». Ошибка записи аудита входа ответ не меняет (только лог). Выход (`LOGOUT`) не пишется: сессий на сервере нет. - Поля, которые присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000. +- **`audit_event.ip_address` достоверен только за доверенным прокси.** `X-Forwarded-For` принимается лишь от адресов + `FORWARDED_ALLOW_IPS` (по умолчанию `127.0.0.1`); без прокси в журнал попадает адрес TCP-соединения, а + `FORWARDED_ALLOW_IPS=*` позволяет любому клиенту подставить свой адрес — в проде так не настраивать. ## Соседние документы diff --git a/api/.env.example b/api/.env.example index a1ad609..2a5ed79 100644 --- a/api/.env.example +++ b/api/.env.example @@ -34,6 +34,10 @@ OPENBAO__SMB__SMB_PASSWORD= # При DEBUG=true не используется (CORS `*`); документация API (/docs, /redoc, /openapi.json) — тоже только при DEBUG=true CORS_ORIGINS= +# Доверенные прокси (X-Forwarded-For): адреса через запятую или `*`; адрес клиента в audit_event берётся из заголовка +# только от них. По умолчанию — 127.0.0.1 +FORWARDED_ALLOW_IPS=127.0.0.1 + # Аутентификация. Dev-эмитент работает только при AUTH_DEV_ISSUER=true И DEBUG=true. AUTH_DEV_ISSUER=true # ТОЛЬКО ДЛЯ РАЗРАБОТКИ: секрет HS256 dev-эмитента, в проде не использовать diff --git a/api/.env.test b/api/.env.test index 8b949e9..8f8be01 100644 --- a/api/.env.test +++ b/api/.env.test @@ -27,6 +27,10 @@ OPENBAO__SMB__SMB_PATH= OPENBAO__SMB__SMB_LOGIN= OPENBAO__SMB__SMB_PASSWORD= +# Доверенные прокси (X-Forwarded-For): адреса через запятую или `*`; адрес клиента в audit_event берётся из заголовка +# только от них. По умолчанию — 127.0.0.1 +FORWARDED_ALLOW_IPS=127.0.0.1 + # CORS при DEBUG=false (в тестах DEBUG=true — не используется), как в .env.example CORS_ORIGINS= diff --git a/api/src/app.py b/api/src/app.py index b67d7c5..20fad77 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -240,7 +240,8 @@ def create_app(app_settings: Settings, *, web_dir: str = "web") -> FastAPI: register_exception_handlers(application) application.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1) - application.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) + # X-Forwarded-For — только от доверенных прокси: иначе любой клиент подставит свой ip в audit_event (OQ-65) + application.add_middleware(ProxyHeadersMiddleware, trusted_hosts=app_settings.FORWARDED_ALLOW_IPS) cors = cors_options(app_settings) if cors is not None: application.add_middleware(CORSMiddleware, allow_methods=["*"], allow_headers=["*"], **cors) diff --git a/api/src/core/config.py b/api/src/core/config.py index f658835..69d174d 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -61,6 +61,9 @@ class Settings(BaseSettings): # CORS при DEBUG=false: разрешённые источники (JSON-список или строка через запятую); пусто — кросс-доменные # запросы запрещены. При DEBUG=true — всегда `*` (OQ-65) CORS_ORIGINS: Annotated[list[str], NoDecode] = [] + # Доверенные прокси для X-Forwarded-For/-Proto (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Только от + # них берётся адрес клиента (`audit_event.ip_address`); от остальных заголовок игнорируется (OQ-65, ревью R-2) + FORWARDED_ALLOW_IPS: str = "127.0.0.1" # Аутентификация: dev-эмитент (только для разработки; роутер подключается при AUTH_DEV_ISSUER и DEBUG одновременно) AUTH_DEV_ISSUER: bool = False diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 549fdf1..5395491 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -12,9 +12,12 @@ from typing import Any import httpx import pytest +from fastapi import Request +from fastapi.routing import APIRoute from loguru import logger from src.api.router import router +from src.core.config import Settings from src.core.nats import NatsConnection from src.services import recognition_exchange @@ -613,3 +616,34 @@ async def test_dev_route_absent_without_debug_is_404_not_405(tmp_path: Any) -> N for method in ("GET", "POST"): response = await _request(application, method, "/api/v1/dev/token") assert response.status_code == HTTPStatus.NOT_FOUND, method + + +# --- доверенные прокси FORWARDED_ALLOW_IPS (ревью R-2, OQ-65) ------------------------------------------------------- + + +def _with_ip_route(application: Any) -> Any: + """Служебный маршрут `/whoip` перед монтированием SPA: адрес клиента глазами приложения.""" + + async def whoip(request: Request) -> dict[str, str | None]: + return {"ip": request.client.host if request.client else None} + + application.router.routes.insert(0, APIRoute("/whoip", whoip, methods=["GET"])) + return application + + +@pytest.mark.parametrize( + ("trusted", "expected"), + [("127.0.0.1", "203.0.113.7"), ("10.0.0.1,10.0.0.2", "127.0.0.1"), ("*", "203.0.113.7")], + ids=["default-loopback-trusted", "client-not-trusted", "all-trusted"], +) +async def test_forwarded_for_only_from_trusted_proxy(tmp_path: Any, trusted: str, expected: str) -> None: + """X-Forwarded-For принимается только от адресов FORWARDED_ALLOW_IPS (клиент ASGITransport — 127.0.0.1).""" + application = _with_ip_route(_app_with(tmp_path, FORWARDED_ALLOW_IPS=trusted)) + + response = await _request(application, "GET", "/whoip", headers={"X-Forwarded-For": "203.0.113.7"}) + + assert response.json() == {"ip": expected} + + +def test_forwarded_allow_ips_default_is_loopback() -> None: + assert Settings.model_fields["FORWARDED_ALLOW_IPS"].default == "127.0.0.1" -- 2.47.2 From 2c830055644f282c047a5fb12281eb90cae68cdf Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:47:42 +0400 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20seed=20=D0=B2=D1=8B=D0=B4=D0=B0?= =?UTF-8?q?=D1=91=D1=82=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D1=85=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=20=D0=B2=20=D0=B7=D0=B0=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D0=BD=D0=B5=D0=BD=D0=BD=D1=83=D1=8E=20=D0=BC=D0=B0=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D1=86=D1=83=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20R-?= =?UTF-8?q?3,=20R-9,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При непустой `role_permission` вставляются пары, у которых роль или право вставлены этим же прогоном (`_upsert` возвращает вставленные ключи): их администратор снять не мог. Прочие пары по-прежнему не трогаются — снятые права не возвращаются. Убрана мёртвая ветка `unchanged` (R-9): при пустой таблице совпадений быть не может, при заполненной сверка не нужна. Тесты: новое право и новая роль во временном seed-каталоге → повторный seed выдаёт только их пары, снятая связь не возвращается; администратор снял все связи → матрица заливается заново. README, CLAUDE.md, комментарий `role_permissions.yaml` и докстринг `seed.py` уточнены. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 4 +- README.md | 17 ++++--- api/scripts/seed.py | 5 ++- api/scripts/seed_lib/apply.py | 69 ++++++++++++++++++----------- api/scripts/seed_lib/model.py | 16 ++++--- api/seed/core/role_permissions.yaml | 5 ++- api/tests/test_seed_roles.py | 58 +++++++++++++++++++++++- 7 files changed, 127 insertions(+), 47 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1da6884..3dbc7f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,8 +76,8 @@ 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`), идемпотентен (матрица `role_permission` — только в - пустую таблицу, иначе шаг пропускается); `--dev` — только для разработки (CLI отказывает с кодом 2 при `DEBUG=false`). +- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен (матрица `role_permission` — целиком только в + пустую таблицу, в заполненную — лишь пары впервые созданных ролей/прав); `--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 5b43b1d..232b82f 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,8 @@ make seed-dev # справочники продукта + dev-пользов `make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав -`role_permission` он загружает только в пустую таблицу (правки администратора не перезаписываются, см. -«Администрирование»). `make seed-dev` +`role_permission` он загружает целиком только в пустую таблицу, в заполненную — лишь связи новых ролей и прав +(правки администратора не перезаписываются, см. «Администрирование»). `make seed-dev` (`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2 («`--dev` доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД. @@ -365,11 +365,14 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о API и UI **отклоняется** (422); dev-пользователи `*@armka.local` создаёт `make seed-dev` напрямую в БД. Для ручной проверки заводите пользователей с `example.com`. Email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён. - **Seed не трогает заполненную матрицу прав.** Связи «роль × право» из `api/seed/core/role_permissions.yaml` - `make seed` / `make seed-dev` загружают только в **пустую** `role_permission` (первый запуск на новой БД). Если - связи уже есть, шаг пропускается целиком — в отчёте `role_permission: пропущено — матрица прав уже заполнена…`: - право, снятое администратором, не возвращается, добавленное сверх файла не отзывается. Роли и права (`role`, - `permission`) по-прежнему приводятся к файлам seed. Изменения `role_permissions.yaml` на действующем стенде - (например, права новой роли) seed не применит — их выдаёт администратор (`PUT /admin/roles/{code}/permissions`). + `make seed` / `make seed-dev` загружают целиком только в **пустую** `role_permission` (первый запуск на новой БД). + Если связи уже есть, seed добавляет лишь пары, у которых роль **или** право впервые созданы этим же запуском + (новая роль или новое право в файлах seed — их администратор снять не мог); остальные пары не трогаются — в отчёте + `role_permission: пропущено — матрица прав уже заполнена…` (или число добавленных пар новых ролей и прав). Право, + снятое администратором с существующей роли, не возвращается, добавленное сверх файла не отзывается; новые пары + существующих ролей и прав из файла выдаёт администратор (`PUT /admin/roles/{code}/permissions`). Роли и права + (`role`, `permission`) по-прежнему приводятся к файлам seed. Пограничный случай: если администратор снял **все** + связи, таблица пуста, и следующий seed зальёт матрицу файла заново. - **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите страницу или войдите заново. - **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after` diff --git a/api/scripts/seed.py b/api/scripts/seed.py index d6aeb49..4134ada 100644 --- a/api/scripts/seed.py +++ b/api/scripts/seed.py @@ -7,8 +7,9 @@ Spec reference-data-seed; design D-4 и решение #18 (R-5): `run_seed(sess 1. `load_seed` (`seed_lib/loaders.py`) читает ВСЕ файлы в память и проверяет их (обязательные поля, enum'ы из `src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается. 2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному - ключу, без удалений. Исключение — матрица `role_permission`: загружается только в пустую таблицу, иначе шаг - пропускается (в отчёте — «пропущено»), чтобы не вернуть права, снятые администратором (issue #9 п.7). + ключу, без удалений. Исключение — матрица `role_permission`: целиком загружается только в пустую таблицу; в + заполненную — лишь пары ролей и прав, вставленных этим же запуском, остальное пропускается (в отчёте — + «пропущено»), чтобы не вернуть права, снятые администратором (issue #9 п.7, ревью R-3). План счетов (`core/chart_of_accounts.csv`, design D-9) — ДОПУЩЕНИЕ OQ-47: полного плана 1С клиента нет, в файле Приказ Минфина № 94н: 62 активных синтетических счёта (01–05, 07–11, 14–16, 19–21, 23, 25, 26, 28, 29, 40–46, diff --git a/api/scripts/seed_lib/apply.py b/api/scripts/seed_lib/apply.py index a69d95b..43385f1 100644 --- a/api/scripts/seed_lib/apply.py +++ b/api/scripts/seed_lib/apply.py @@ -20,10 +20,13 @@ async def _upsert( key: str, rows: list[dict[str, Any]], stat: TableStat, -) -> None: - """Вставить новые и обновить изменившиеся строки по натуральному ключу `key`; удалений нет.""" +) -> set[Any]: + """Вставить новые и обновить изменившиеся строки по натуральному ключу `key`; удалений нет. + + Возвращает ключи строк, ВСТАВЛЕННЫХ этим прогоном (для матрицы прав: связи новых ролей и прав). + """ if not rows: - return + return set() key_column = getattr(model, key) value_names = [name for name in rows[0] if name != key] existing: dict[Any, tuple[Any, ...]] = { @@ -31,10 +34,12 @@ async def _upsert( for row in (await session.execute(select(key_column, *(getattr(model, name) for name in value_names)))).all() } changed = [] + inserted: set[Any] = set() for row in rows: current = existing.get(row[key]) if current is None: stat.inserted += 1 + inserted.add(row[key]) changed.append(row) elif current != tuple(row[name] for name in value_names): stat.updated += 1 @@ -47,38 +52,48 @@ async def _upsert( index_elements=[key_column], set_={name: statement.excluded[name] for name in value_names} ) await session.execute(statement, changed) + return inserted MATRIX_SKIPPED = ( - "матрица прав уже заполнена; seed её не меняет, чтобы не вернуть права, снятые администратором " - "(правка — PUT /admin/roles/{code}/permissions)" + "матрица прав уже заполнена; seed не возвращает права, снятые администратором, и добавляет только связи ролей " + "и прав, впервые созданных этим запуском (правка — PUT /admin/roles/{code}/permissions)" ) -async def _apply_role_permissions(session: AsyncSession, data: SeedData, stat: TableStat) -> None: - """Матрица «роль × право» из файла — только в пустую `role_permission` (первичная загрузка, issue #9 п.7). +async def _apply_role_permissions( # noqa: PLR0913 - набор, статистика и коды впервые вставленных ролей и прав + session: AsyncSession, + data: SeedData, + stat: TableStat, + *, + new_roles: set[str], + new_permissions: set[str], +) -> None: + """Матрица «роль × право» из файла (issue #9 п.7, ревью R-3). - Если связи уже есть, матрицей владеет администратор: шаг пропускается целиком (`stat.skipped_reason`), снятые - права не возвращаются, добавленные не отзываются. Роли и права (`role`, `permission`) по-прежнему upsert'ятся. + Пустая `role_permission` (первичная загрузка, в том числе после того, как администратор снял все связи) — + вставляется вся матрица файла. Непустая — матрицей владеет администратор: вставляются только пары, у которых + роль ИЛИ право вставлены этим же прогоном (`new_roles`/`new_permissions` — их администратор снять не мог); + остальные пары не трогаются (`stat.skipped_reason`): снятые права не возвращаются, добавленные не отзываются. """ - if await session.scalar(select(RolePermission.role_id).limit(1)) is not None: + matrix_filled = await session.scalar(select(RolePermission.role_id).limit(1)) is not None + if matrix_filled: stat.skipped_reason = MATRIX_SKIPPED - return role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()} permission_ids = {code: id_ for id_, code in (await session.execute(select(Permission.id, Permission.code))).all()} - existing = set((await session.execute(select(RolePermission.role_id, RolePermission.permission_id))).all()) - new_rows = [] - for role_code, codes in data.role_permissions.items(): - for code in codes: - pair = (role_ids[role_code], permission_ids[code]) - if pair in existing: - stat.unchanged += 1 - else: - stat.inserted += 1 - new_rows.append( - {"role_id": pair[0], "permission_id": pair[1], "granted_by": None, "granted_at": func.now()} - ) + new_rows = [ + { + "role_id": role_ids[role_code], + "permission_id": permission_ids[code], + "granted_by": None, + "granted_at": func.now(), + } + for role_code, codes in data.role_permissions.items() + for code in codes + if not matrix_filled or role_code in new_roles or code in new_permissions + ] if new_rows: + stat.inserted += len(new_rows) # .values(): func.now() допустим только во встроенном VALUES, не в executemany statement = ( pg_insert(RolePermission) @@ -226,7 +241,7 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: [{"name": o.name, "is_head_office": o.is_head_office, "is_active": o.is_active} for o in data.regional_offices], report.stat("regional_office"), ) - await _upsert( + new_roles = await _upsert( session, Role, "code", @@ -242,7 +257,7 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: ], report.stat("role"), ) - await _upsert( + new_permissions = await _upsert( session, Permission, "code", @@ -258,7 +273,9 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport: ], report.stat("permission"), ) - await _apply_role_permissions(session, data, report.stat("role_permission")) + await _apply_role_permissions( + session, data, report.stat("role_permission"), new_roles=new_roles, new_permissions=new_permissions + ) await _apply_chart_of_accounts(session, data, report.stat("chart_of_account")) await apply_elimination_rules( session, data, report.stat("elimination_rule"), report.stat("elimination_rule_account") diff --git a/api/scripts/seed_lib/model.py b/api/scripts/seed_lib/model.py index b7be21a..03bca4e 100644 --- a/api/scripts/seed_lib/model.py +++ b/api/scripts/seed_lib/model.py @@ -133,7 +133,7 @@ class TableStat: inserted: int = 0 updated: int = 0 unchanged: int = 0 - #: шаг не выполнялся (например, матрица прав уже заполнена); текст — для отчёта CLI + #: шаг выполнен не полностью или пропущен (матрица прав уже заполнена); текст — для отчёта CLI skipped_reason: str | None = None @@ -148,10 +148,12 @@ class SeedReport: def format(self) -> str: lines = ["Seed применён (вставлено / обновлено / без изменений):"] - lines += [ - f" {name}: пропущено — {stat.skipped_reason}" - if stat.skipped_reason is not None - else f" {name}: {stat.inserted} / {stat.updated} / {stat.unchanged}" - for name, stat in self.tables.items() - ] + for name, stat in self.tables.items(): + counts = f"{stat.inserted} / {stat.updated} / {stat.unchanged}" + if stat.skipped_reason is None: + lines.append(f" {name}: {counts}") + elif stat.inserted: + lines.append(f" {name}: {counts} (только связи новых ролей и прав; {stat.skipped_reason})") + else: + lines.append(f" {name}: пропущено — {stat.skipped_reason}") return "\n".join(lines) diff --git a/api/seed/core/role_permissions.yaml b/api/seed/core/role_permissions.yaml index 0684198..8c5559f 100644 --- a/api/seed/core/role_permissions.yaml +++ b/api/seed/core/role_permissions.yaml @@ -2,8 +2,9 @@ # ASSUMPTION(OQ-16): в ТЗ матрица помечена «будет разработана» (сноска 29) — это ЧЕРНОВИК; администратор правит её в UI. # Пояснения в скобках в таблице §10 («свой РФ», «состав ГСК», «INITIATED…CONSOLIDATION_RF» и т. п.) — условия # policy-слоя, а не отдельные права, поэтому здесь не представлены. -# Ключ — role.code из roles.yaml, значения — permission.code из permissions.yaml. Upsert по (роль, право); -# granted_by = NULL (выдано seed'ом). Права, которых в файле нет, seed не отзывает. +# Ключ — role.code из roles.yaml, значения — permission.code из permissions.yaml; granted_by = NULL (выдано seed'ом). +# Матрица загружается целиком только в ПУСТУЮ role_permission; в заполненную — лишь пары ролей/прав, впервые созданных +# этим запуском seed (снятые администратором права не возвращаются). Права, которых в файле нет, seed не отзывает. ADMIN: - DIRECTORY_VIEW - DIRECTORY_EDIT diff --git a/api/tests/test_seed_roles.py b/api/tests/test_seed_roles.py index bd5a30a..64fd184 100644 --- a/api/tests/test_seed_roles.py +++ b/api/tests/test_seed_roles.py @@ -10,7 +10,7 @@ from pathlib import Path import pytest import yaml -from sqlalchemy import func, select, update +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from scripts.seed import SeedError, SeedReport, load_seed, run_seed @@ -370,3 +370,59 @@ def test_cli_help() -> None: assert result.returncode == 0 assert "--dev" in result.stdout + + +@pytest.mark.integration +async def test_reseed_grants_only_pairs_of_new_roles_and_permissions(db_session: AsyncSession, tmp_path: Path) -> None: + """Матрица непуста: добавляются только связи ролей/прав, вставленных этим прогоном (их снять не могли, ревью R-3); + снятая администратором связь существующих роли и права не возвращается.""" + seed_dir = _copy_seed(tmp_path) + await run_seed(db_session, seed_dir=seed_dir) + expert_view = ( + select(RolePermission) + .join(Role, Role.id == RolePermission.role_id) + .join(Permission, Permission.id == RolePermission.permission_id) + .where(Role.code == "EXPERT", Permission.code == "EX_VIEW") + ) + await db_session.delete((await db_session.execute(expert_view)).scalar_one()) + await db_session.flush() + core = seed_dir / "core" + with (core / "permissions.yaml").open("a", encoding="utf-8") as file: + file.write('- code: AUDIT_EXPORT\n object: AUDIT\n action: EXPORT\n name: "Аудит: выгрузка"\n') + with (core / "roles.yaml").open("a", encoding="utf-8") as file: + file.write("- code: AUDITOR\n name: Аудитор\n is_system: true\n is_active: true\n") + matrix_path = core / "role_permissions.yaml" + matrix = yaml.safe_load(matrix_path.read_text(encoding="utf-8")) + matrix["ADMIN"].append("AUDIT_EXPORT") # существующая роль × новое право + matrix["AUDITOR"] = ["AUDIT_VIEW"] # новая роль × существующее право + matrix_path.write_text(yaml.safe_dump(matrix, allow_unicode=True), encoding="utf-8") + + report = await run_seed(db_session, seed_dir=seed_dir) + + rows = ( + await db_session.execute( + select(Role.code, Permission.code) + .join(RolePermission, RolePermission.role_id == Role.id) + .join(Permission, Permission.id == RolePermission.permission_id) + .where(Role.code.in_(["ADMIN", "AUDITOR"]), Permission.code.in_(["AUDIT_EXPORT", "AUDIT_VIEW"])) + ) + ).all() + assert set(rows) == {("ADMIN", "AUDIT_EXPORT"), ("ADMIN", "AUDIT_VIEW"), ("AUDITOR", "AUDIT_VIEW")} + assert (await db_session.execute(expert_view)).scalar_one_or_none() is None + assert await _count(db_session, RolePermission) == GRANT_COUNT - 1 + 2 + stat = report.tables["role_permission"] + assert (stat.inserted, stat.updated, stat.unchanged) == (2, 0, 0) + assert stat.skipped_reason is not None + + +@pytest.mark.integration +async def test_reseed_refills_matrix_when_admin_removed_all_links(db_session: AsyncSession) -> None: + """Пограничный случай: пустая `role_permission` — первичная загрузка, матрица заливается заново.""" + await run_seed(db_session) + await db_session.execute(delete(RolePermission)) + + report = await run_seed(db_session) + + assert await _count(db_session, RolePermission) == GRANT_COUNT + assert report.tables["role_permission"].inserted == GRANT_COUNT + assert report.tables["role_permission"].skipped_reason is None -- 2.47.2 From e8d14edfbb0e3cdf5a3da8968051ff4e0153b628 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:48:15 +0400 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20CORS=20=D0=BF=D1=80=D0=B8=20DEBUG?= =?UTF-8?q?=20=E2=80=94=20`*`=20=D0=B1=D0=B5=D0=B7=20allow=5Fcredentials?= =?UTF-8?q?=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20R-11,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `*` вместе с credentials спецификация CORS запрещает (Starlette тогда отражает Origin запроса); фронт передаёт токен заголовком Authorization, cookie не нужны. README «Конфигурация» уточнён. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- README.md | 2 +- api/src/app.py | 5 +++-- api/tests/test_app.py | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 232b82f..6c8ba81 100644 --- a/README.md +++ b/README.md @@ -450,7 +450,7 @@ email без учёта регистра проверяет только сер | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | | `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) | -| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*`) | +| `CORS_ORIGINS` | пусто | при `DEBUG=false` — источники, которым разрешены кросс-доменные запросы: строка через запятую (`https://a.example.ru,https://b.example.ru`) или JSON-список. Пусто — кросс-доменные запросы запрещены (middleware CORS не подключается); `allow_credentials` — только для явного списка без `*`. При `DEBUG=true` не используется (всегда `*` без credentials: токен передаётся заголовком, cookie не нужны) | | `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес и порт сервера (`run_server.py`); в `.env.example` — `127.0.0.1` (dev-запуск только на loopback) | | `ROOT_PATH` | `/` | корневой путь за прокси (должен совпадать с `REACT_APP_ROOT_PATH` фронта) | | `VERSION` | `1.0.0` | версия в `/healthcheck` и OpenAPI | diff --git a/api/src/app.py b/api/src/app.py index 20fad77..83fac09 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -191,13 +191,14 @@ async def lifespan(app: FastAPI): def cors_options(app_settings: Settings) -> dict[str, Any] | None: - """Источники CORS (OQ-65): DEBUG — `*` с credentials, как в шаблоне; иначе — `CORS_ORIGINS`. + """Источники CORS (OQ-65): DEBUG — `*` без credentials (ревью R-11; токен — в заголовке, cookie не нужны); + иначе — `CORS_ORIGINS`. Пустой `CORS_ORIGINS` вне DEBUG — `None`: middleware не подключается, браузер кросс-доменные запросы не выполнит. `allow_credentials` вне DEBUG — только для явного списка без `*` (спецификация CORS запрещает `*` с credentials). """ if app_settings.DEBUG: - return {"allow_origins": ["*"], "allow_credentials": True} + return {"allow_origins": ["*"], "allow_credentials": False} origins = list(app_settings.CORS_ORIGINS) if not origins: return None diff --git a/api/tests/test_app.py b/api/tests/test_app.py index 5395491..842dbcc 100644 --- a/api/tests/test_app.py +++ b/api/tests/test_app.py @@ -481,7 +481,8 @@ async def test_cors_debug_allows_any_origin(tmp_path: Any) -> None: response = await _request(_app_with(tmp_path, DEBUG=True), "OPTIONS", "/healthcheck", headers=_preflight_headers()) assert response.status_code == HTTPStatus.OK - assert response.headers["access-control-allow-origin"] in {"*", ORIGIN} + assert response.headers["access-control-allow-origin"] == "*" + assert "access-control-allow-credentials" not in response.headers # `*` без credentials (ревью R-11) async def test_cors_prod_without_origins_denies_cross_origin(tmp_path: Any) -> None: @@ -525,7 +526,7 @@ def test_cors_options_rules() -> None: base = app_module.settings assert app_module.cors_options(base.model_copy(update={"DEBUG": True, "CORS_ORIGINS": []})) == { "allow_origins": ["*"], - "allow_credentials": True, + "allow_credentials": False, } assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": []})) is None assert app_module.cors_options(base.model_copy(update={"DEBUG": False, "CORS_ORIGINS": [ORIGIN]})) == { -- 2.47.2 From 7b28554817bda4f79fc70b8ee2861f87abf146da Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:48:52 +0400 Subject: [PATCH 12/13] =?UTF-8?q?docs:=20=C2=AB=D0=BD=D0=B5=D1=82=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D0=B0=20Authorizatio?= =?UTF-8?q?n=20=D0=B8=D0=BB=D0=B8=20=D1=81=D1=85=D0=B5=D0=BC=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20Bearer=C2=BB=20=E2=80=94=20=D0=BD=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B0=20=D0=B2=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B0=20(=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20R-7,=20#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Формулировка уточнена в докстрингах `services/session_audit.py` и `api/dependencies/auth.py`, README «Безопасность dev-режима» и CLAUDE.md; тест событий входа дополнен запросом со схемой Basic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 3 ++- README.md | 3 ++- api/src/api/dependencies/auth.py | 3 ++- api/src/services/session_audit.py | 3 ++- api/tests/test_login_audit.py | 9 ++++++--- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3dbc7f0..eb239a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,8 @@ - **Аутентификация** — `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`). + `LOGIN_FAILED` (`AuthenticationFailedError` 401/403 → обработчик пишет в отдельной транзакции `session_transaction`); + нет заголовка `Authorization` или схема не `Bearer`, 403 «недостаточно прав» — не события входа. - **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен (матрица `role_permission` — целиком только в пустую таблицу, в заполненную — лишь пары впервые созданных ролей/прав); `--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 6c8ba81..afcb388 100644 --- a/README.md +++ b/README.md @@ -604,7 +604,8 @@ Dockerfile только для локального compose - `LOGIN_FAILED` — 401 (токен не принят: `user_id` пуст, в `description` причина, сам токен не пишется) и 403 аутентификации (пользователь не зарегистрирован, отключён или неоднозначен: email в `description`, `user_id` — если пользователь найден). Запись делается в отдельной транзакции и остаётся, хотя транзакция запроса откатилась. - - Не события входа: запрос без заголовка `Authorization` и 403 «недостаточно прав». Ошибка записи аудита входа + - Не события входа: запрос без заголовка `Authorization` или со схемой не `Bearer` (401 «требуется заголовок…») и + 403 «недостаточно прав». Ошибка записи аудита входа ответ не меняет (только лог). Выход (`LOGOUT`) не пишется: сессий на сервере нет. - Поля, которые присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000. - **`audit_event.ip_address` достоверен только за доверенным прокси.** `X-Forwarded-For` принимается лишь от адресов diff --git a/api/src/api/dependencies/auth.py b/api/src/api/dependencies/auth.py index 9ad2839..e988fc8 100644 --- a/api/src/api/dependencies/auth.py +++ b/api/src/api/dependencies/auth.py @@ -10,7 +10,8 @@ Аудит входа (OQ-64, `services/session_audit.py`): отказ токена и 403 аутентификации — `AuthenticationFailedError`, обработчик пишет LOGIN_FAILED в отдельной транзакции; первый успешный `current_user` пользователя после старта процесса -пишет LOGIN. Нет заголовка `Authorization` и 403 «недостаточно прав» — не события входа. +пишет LOGIN. Нет заголовка `Authorization` или схема не `Bearer` (`parse_bearer`) и 403 «недостаточно прав» — не +события входа. """ import uuid diff --git a/api/src/services/session_audit.py b/api/src/services/session_audit.py index 16264e5..f623661 100644 --- a/api/src/services/session_audit.py +++ b/api/src/services/session_audit.py @@ -6,7 +6,8 @@ и сбрасывается при рестарте, у каждого экземпляра приложения — своё); - LOGIN_FAILED — 401 (токен не принят: `user_id` пуст, в `description` причина без самого токена) и 403 аутентификации (пользователь не найден, отключён, неоднозначен: email в `description`, `user_id` — если пользователь найден). - Запрос без заголовка `Authorization` попыткой входа не считается; 403 «недостаточно прав» — авторизация, не вход. + Запрос без заголовка `Authorization` или со схемой не `Bearer` попыткой входа не считается (токен не предъявлен); + 403 «недостаточно прав» — авторизация, не вход. Раздел `SESSION`, `object_type = "app_user"`, `object_id = user_id`; IP и user-agent — из запроса. Поля, которые присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000. diff --git a/api/tests/test_login_audit.py b/api/tests/test_login_audit.py index da3b984..e5b17e3 100644 --- a/api/tests/test_login_audit.py +++ b/api/tests/test_login_audit.py @@ -2,8 +2,9 @@ LOGIN — успешная выдача dev-токена и первый успешный запрос пользователя с токеном после старта процесса; LOGIN_FAILED — 401 (токен не принят) и 403 аутентификации (пользователь не найден, отключён, неоднозначен). -Запрос без заголовка `Authorization` и 403 «недостаточно прав» входом не считаются. LOGIN_FAILED пишется в отдельной -транзакции и переживает откат транзакции запроса — проверяется на `committed_app` (сессия запроса как в проде). +Запрос без заголовка `Authorization` или со схемой не `Bearer` и 403 «недостаточно прав» входом не считаются. +LOGIN_FAILED пишется в отдельной транзакции и переживает откат транзакции запроса — проверяется на `committed_app` +(сессия запроса как в проде). """ import asyncio @@ -170,12 +171,14 @@ async def test_disabled_user_records_login_failed_with_user_id(app: FastAPI, see async def test_missing_header_and_missing_permission_are_not_login_events( committed_app: FastAPI, committed_sessionmaker: async_sessionmaker[AsyncSession] ) -> None: - """Без заголовка — не попытка входа; 403 «недостаточно прав» — авторизация, не аутентификация. + """Без заголовка или со схемой не Bearer — не попытка входа; 403 «недостаточно прав» — авторизация, не вход. LOGIN первого запроса expert записан, хотя сам запрос (403 по праву) откатил свою транзакцию. """ async with _client(committed_app) as client: assert (await client.get("/api/v1/me")).status_code == HTTP_UNAUTHORIZED + basic = {"Authorization": "Basic dXNlcjpwYXNz"} + assert (await client.get("/api/v1/me", headers=basic)).status_code == HTTP_UNAUTHORIZED expert = _bearer(_token(EXPERT)) assert (await client.get("/api/v1/admin/users", headers=expert)).status_code == HTTP_FORBIDDEN -- 2.47.2 From 8bc76c69a06fd1def82d133e47b5b8bb543475ad Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:49:46 +0400 Subject: [PATCH 13/13] =?UTF-8?q?docs(openspec):=20=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20=D1=81=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=BE=D0=BC=20issue=20#9=20(=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D1=8C=D1=8E=20R-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reference-data-seed: матрица role_permission целиком — только в пустую таблицу, в заполненную — лишь пары ролей/прав, вставленных тем же запуском (сценарии: снятое право, новые роль и право, все связи сняты); `--dev` только при DEBUG=true (код 2 до обращения к БД). - audit-trail: требование «События входа» (LOGIN/LOGIN_FAILED, раздел SESSION) — исключение из «той же транзакции» с обоснованием, обрезка полей, доверенные прокси; строки в таблице событий. - authn-authz: ссылка на аудит входа; требование «Прод-конфигурация HTTP-поверхности» — /docs, /redoc, /openapi.json только при DEBUG, CORS по CORS_ORIGINS, 405 на неверный метод, 404 под /api/v1, GET / без редиректа. `openspec validate --specs`: 9 passed, 0 failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- openspec/specs/audit-trail/spec.md | 40 +++++++++++++++++++++- openspec/specs/authn-authz/spec.md | 31 +++++++++++++++++ openspec/specs/reference-data-seed/spec.md | 30 ++++++++++++++-- 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/openspec/specs/audit-trail/spec.md b/openspec/specs/audit-trail/spec.md index 74ab0e0..edc2db9 100644 --- a/openspec/specs/audit-trail/spec.md +++ b/openspec/specs/audit-trail/spec.md @@ -10,7 +10,8 @@ Сервис аудита SHALL предоставлять метод записи события с полями `action` (из enum `audit_action`), `section` (из `audit_section`), `object_type`, `object_id`, `user_id` (NULL для системных действий), `consolidation_version_id`, `description`, `before_value`, `after_value` (jsonb), `comment`, `result`, `changed_field`, `ip_address`, `user_agent`; -`occurred_at` SHALL проставляться сервером (UTC). Запись SHALL выполняться в той же транзакции, что и изменение данных. +`occurred_at` SHALL проставляться сервером (UTC). Запись SHALL выполняться в той же транзакции, что и изменение данных; +единственное исключение — события входа раздела `SESSION` (требование «События входа»). NOTE: В Срезе 0 обмен через recognition_job аудит не пишет (рулинг R-7, design #20); сервис готов к использованию Срезом 2+. @@ -44,6 +45,11 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD | пользователь создан/изменён/деактивирован | `USER_CHANGE` | `USERS` | `app_user` | | роли пользователя | `USER_ROLE_CHANGE` | `USERS` | `app_user` | | права роли | `ROLE_PERMISSION_CHANGE` | `ACCESS` | `role` | +| вход: выдача dev-токена, первый запрос с токеном | `LOGIN` | `SESSION` | `app_user` | +| отказ во входе (401 токена, 403 аутентификации) | `LOGIN_FAILED` | `SESSION` | `app_user` | + +Строки раздела `SESSION` подчиняются требованию «События входа» (отдельная транзакция), остальные — пишутся в той же +транзакции, что и мутация. `before_value`/`after_value` SHALL содержать изменённые поля (для списков — весь набор до и после); `changed_field` — имя поля при изменении одного поля. @@ -55,3 +61,35 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD #### Scenario: Откат мутации справочника - **WHEN** транзакция перевода ЮЛ откатывается - **THEN** строки `LEGAL_ENTITY_CHANGE` и `MEMBERSHIP_CHANGE` в `audit_event` отсутствуют + +### Requirement: События входа +Ядро SHALL писать через сервис аудита события входа раздела `SESSION` с `object_type = "app_user"`, +`object_id = user_id`, `ip_address` и `user_agent` запроса (OQ-64): +- `LOGIN` — при выдаче dev-токена (`POST /api/v1/dev/token`, в транзакции запроса) и при первом успешном запросе + пользователя с токеном после старта процесса (учёт — в памяти процесса; после рестарта первый запрос пишется снова); +- `LOGIN_FAILED` — при 401 (токен предъявлен, но не принят: `user_id = NULL`, в `description` причина, сам токен MUST NOT + попадать в журнал) и при 403 аутентификации (пользователь не зарегистрирован, отключён или неоднозначен: email в + `description`, `user_id` — если пользователь найден). + +Запрос без заголовка `Authorization` или со схемой не `Bearer` и 403 «недостаточно прав» событиями входа MUST NOT +считаться. `LOGIN_FAILED` и `LOGIN` первого запроса SHALL записываться в ОТДЕЛЬНОЙ короткой транзакции и фиксироваться, +даже если транзакция запроса откатывается: при 401/403 она откатывается всегда, а отказ во входе — то событие, ради +которого журнал ведётся. `user_agent` SHALL обрезаться до 512 символов, `description` — до 1000. Ошибка записи события +входа MUST NOT менять ответ запроса (только лог). `ip_address` достоверен только за доверенным прокси: +`X-Forwarded-For` принимается лишь от адресов `FORWARDED_ALLOW_IPS` (по умолчанию `127.0.0.1`). + +#### Scenario: Первый запрос пользователя +- **WHEN** пользователь трижды запрашивает `/api/v1/me` с валидным токеном после старта процесса +- **THEN** в `audit_event` одна строка `LOGIN` с его `user_id` и описанием «первый запрос с токеном» + +#### Scenario: Невалидный токен +- **WHEN** запрос несёт подделанный токен +- **THEN** ответ 401, в `audit_event` строка `LOGIN_FAILED` с `user_id = NULL` и причиной отказа без токена + +#### Scenario: Отказ переживает откат запроса +- **WHEN** запрос с токеном незарегистрированного пользователя получает 403, и транзакция запроса откатывается +- **THEN** строка `LOGIN_FAILED` с email в `description` зафиксирована в `audit_event` + +#### Scenario: Не попытка входа +- **WHEN** запрос пришёл без заголовка `Authorization` или пользователь получил 403 «недостаточно прав» +- **THEN** строки `LOGIN_FAILED` нет diff --git a/openspec/specs/authn-authz/spec.md b/openspec/specs/authn-authz/spec.md index 6d6c6ec..74b1267 100644 --- a/openspec/specs/authn-authz/spec.md +++ b/openspec/specs/authn-authz/spec.md @@ -12,6 +12,8 @@ настроенным проверяющим (dev HS256 при `AUTH_DEV_ISSUER=true`, иначе JWKS/публичный ключ по `AUTH_*`), извлечь email из claim `AUTH_EMAIL_CLAIM` (по умолчанию `email`) и найти `app_user` с этим `email` и `is_active = true`. Нет или невалидный токен → 401; пользователь не найден или неактивен → 403. Тело ошибки — `{"detail": "<причина по-русски>"}`. +Отказы в аутентификации (401 при предъявленном токене, 403 «не найден / отключён / неоднозначен») и вход SHALL +фиксироваться в журнале по требованию «События входа» спеки `audit-trail` (`LOGIN_FAILED` / `LOGIN`). #### Scenario: Валидный токен активного пользователя - **WHEN** запрос несёт корректный токен с `email = ka_rf@armka.local`, и такой активный пользователь есть @@ -64,3 +66,32 @@ claim `AUTH_EMAIL_CLAIM` (по умолчанию `email`) и найти `app_us #### Scenario: Прод-конфигурация - **WHEN** `DEBUG=false` - **THEN** `POST /api/v1/dev/token` возвращает 404, токен, подписанный dev-секретом, отвергается с 401 + +### Requirement: Прод-конфигурация HTTP-поверхности +Документация API (`/docs`, `/redoc`, `/openapi.json`) SHALL существовать только при `DEBUG=true`; при `DEBUG=false` +эти пути MUST отвечать 404 (не схемой и не страницей SPA) (OQ-66). CORS при `DEBUG=true` SHALL разрешать любой источник +`*` без `allow_credentials`; при `DEBUG=false` — только источники из `CORS_ORIGINS` (JSON-список или строка через +запятую), пустой список SHALL запрещать кросс-доменные запросы, `allow_credentials` — только для явного списка без `*` +(OQ-65). Под `/api/v1` SPA MUST NOT отвечать: неверный метод на существующий маршрут SHALL давать 405 с заголовком +`Allow` и `detail` по-русски, несуществующий путь (в том числе dev-маршрут при выключенных флагах) — 404 (OQ-61); +`GET /` SHALL отдавать `index.html` SPA без редиректа при любом `ROOT_PATH` (OQ-62). + +#### Scenario: Документация в проде +- **WHEN** `DEBUG=false` и запрошен `/openapi.json` +- **THEN** ответ 404 + +#### Scenario: Кросс-доменный запрос в проде без настроенных источников +- **WHEN** `DEBUG=false`, `CORS_ORIGINS` пуст, браузер шлёт preflight с `Origin: https://other.example` +- **THEN** в ответе нет `Access-Control-Allow-Origin` + +#### Scenario: Неверный метод +- **WHEN** выполнен `POST /api/v1/me` +- **THEN** ответ 405, заголовок `Allow: GET` + +#### Scenario: Несуществующий путь API +- **WHEN** выполнен `GET /api/v1/no-such-route` +- **THEN** ответ 404, не `index.html` + +#### Scenario: Корень приложения +- **WHEN** при `ROOT_PATH=/` выполнен `GET /` +- **THEN** ответ 200 с `index.html`, без редиректа на `//` diff --git a/openspec/specs/reference-data-seed/spec.md b/openspec/specs/reference-data-seed/spec.md index d4b9d92..7699fbd 100644 --- a/openspec/specs/reference-data-seed/spec.md +++ b/openspec/specs/reference-data-seed/spec.md @@ -14,6 +14,12 @@ dev-данные загружаются из файлов репозитория `elimination_rule` + `elimination_rule_account` версии 1 для ББ (§4), `required_document_rule` для `BASIC` и `EXTENDED`. Записи SHALL вставляться или обновляться по натуральному ключу; удаление MUST NOT выполняться. +Исключение — матрица `role_permission` (ей после первичной загрузки владеет администратор, issue #9): в ПУСТУЮ таблицу +матрица файла SHALL загружаться целиком; в заполненную SHALL вставляться только пары, у которых роль или право +вставлены этим же запуском seed (их администратор снять не мог), а остальные пары файла MUST NOT добавляться — право, +снятое администратором, не возвращается, добавленное сверх файла не отзывается. Отчёт команды SHALL сообщать, что +матрица пропущена (или сколько добавлено пар новых ролей и прав). + #### Scenario: Первый запуск на пустой схеме - **WHEN** после `alembic upgrade head` выполняется `python -m scripts.seed` - **THEN** в `role` 7 строк, в `permission` 16, в `role_permission` — число строк равно сумме прав по матрице §10, @@ -22,7 +28,21 @@ dev-данные загружаются из файлов репозитория #### Scenario: Повторный запуск - **WHEN** `python -m scripts.seed` выполняется второй раз без изменения файлов -- **THEN** число строк во всех затронутых таблицах не меняется, команда завершается с кодом 0 +- **THEN** число строк во всех затронутых таблицах не меняется, команда завершается с кодом 0, в отчёте + `role_permission` помечена как пропущенная + +#### Scenario: Право, снятое администратором +- **WHEN** администратор снял с роли `EXPERT` право `EX_VIEW`, после чего seed запущен снова +- **THEN** связь `EXPERT × EX_VIEW` не восстановлена, остальная матрица не изменилась + +#### Scenario: Новые роль и право в файлах seed +- **WHEN** матрица заполнена, а в `permissions.yaml` добавлено новое право, в `roles.yaml` — новая роль, и в + `role_permissions.yaml` — их пары (новое право существующей роли, существующее право новой роли) +- **THEN** seed вставляет ровно эти пары; снятые ранее администратором связи существующих ролей и прав не возвращаются + +#### Scenario: Администратор снял все связи +- **WHEN** таблица `role_permission` пуста и seed запущен снова +- **THEN** матрица файла загружается целиком, как при первом запуске #### Scenario: Изменение названия роли в файле - **WHEN** в `roles.yaml` изменено `name` роли `EXPERT` и seed запущен снова @@ -47,7 +67,9 @@ dev-данные загружаются из файлов репозитория ### Requirement: Dev-данные только по явному флагу `python -m scripts.seed --dev` SHALL дополнительно загрузить из `api/seed/dev/`: примеры региональных филиалов и по одному `app_user` на каждую системную роль (`@armka.local`, активные, с назначенной ролью через `user_role`). Без флага -`--dev` эти данные MUST NOT загружаться. +`--dev` эти данные MUST NOT загружаться. Флаг `--dev` SHALL приниматься только при `DEBUG=true`: при `DEBUG=false` +команда MUST завершиться с кодом 2 и сообщением «--dev доступен только при DEBUG=true (dev-пользователи не для прода)» +до чтения файлов seed и обращения к БД (OQ-60). #### Scenario: Dev-пользователи - **WHEN** выполнен `python -m scripts.seed --dev` @@ -56,3 +78,7 @@ dev-данные загружаются из файлов репозитория #### Scenario: Прод-режим - **WHEN** выполнен `python -m scripts.seed` без `--dev` - **THEN** таблица `app_user` пуста, `regional_office` содержит только Головной офис + +#### Scenario: `--dev` без DEBUG +- **WHEN** при `DEBUG=false` выполнен `python -m scripts.seed --dev` +- **THEN** код возврата 2, в stderr — сообщение об отказе, БД не затронута -- 2.47.2