fix: seed --dev только при DEBUG=true (OQ-60, #9 п.1)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:13:30 +04:00
parent 7e550246b5
commit 5e11f0d0bf
4 changed files with 86 additions and 16 deletions

View File

@ -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`).
## Зависимости от соседей

View File

@ -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.
## Соседние документы

View File

@ -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

View File

@ -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