fix: seed выдаёт права новых ролей и прав в заполненную матрицу (ревью R-3, R-9, #9)

При непустой `role_permission` вставляются пары, у которых роль или право
вставлены этим же прогоном (`_upsert` возвращает вставленные ключи): их
администратор снять не мог. Прочие пары по-прежнему не трогаются — снятые
права не возвращаются. Убрана мёртвая ветка `unchanged` (R-9): при пустой
таблице совпадений быть не может, при заполненной сверка не нужна.

Тесты: новое право и новая роль во временном seed-каталоге → повторный seed
выдаёт только их пары, снятая связь не возвращается; администратор снял все
связи → матрица заливается заново. README, CLAUDE.md, комментарий
`role_permissions.yaml` и докстринг `seed.py` уточнены.

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:47:42 +04:00
parent f47cbeedda
commit 2c83005564
7 changed files with 127 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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