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`. dev-эмитент (`POST /api/v1/dev/token`) существует только при `AUTH_DEV_ISSUER=true` **и** `DEBUG=true`.
Аудит входа — `services/session_audit.py` (`app.state.session_audit`): `LOGIN` (dev-токен; первый запрос после старта), Аудит входа — `services/session_audit.py` (`app.state.session_audit`): `LOGIN` (dev-токен; первый запрос после старта),
`LOGIN_FAILED` (`AuthenticationFailedError` 401/403 → обработчик пишет в отдельной транзакции `session_transaction`). `LOGIN_FAILED` (`AuthenticationFailedError` 401/403 → обработчик пишет в отдельной транзакции `session_transaction`).
- **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен (матрица `role_permission` — только в - **Seed** — `python -m scripts.seed [--dev]` (`make seed|seed-dev`), идемпотентен (матрица `role_permission` — целиком только в
пустую таблицу, иначе шаг пропускается); `--dev` — только для разработки (CLI отказывает с кодом 2 при `DEBUG=false`). пустую таблицу, в заполненную — лишь пары впервые созданных ролей/прав); `--dev` — только для разработки (CLI отказывает с кодом 2 при `DEBUG=false`).
**Сверка ORM↔DBML** — `scripts/check_dbml_vs_orm.py` (`make dbml-check`). **Сверка ORM↔DBML** — `scripts/check_dbml_vs_orm.py` (`make dbml-check`).
## Зависимости от соседей ## Зависимости от соседей

View File

@ -88,8 +88,8 @@ make seed-dev # справочники продукта + dev-пользов
`make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без `make seed` загружает только справочники (роли, права, филиалы, план счетов, правила элиминации, чек-лист), без
dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав dev-пользователей. Seed идемпотентен: повторный запуск обновляет данные и ничего не дублирует; матрицу прав
`role_permission` он загружает только в пустую таблицу (правки администратора не перезаписываются, см. `role_permission` он загружает целиком только в пустую таблицу, в заполненную — лишь связи новых ролей и прав
«Администрирование»). `make seed-dev` (правки администратора не перезаписываются, см. «Администрирование»). `make seed-dev`
(`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2 (`--dev`) работает только при `DEBUG=true` в `api/.env`; при `DEBUG=false` загрузчик отказывает с кодом 2
(«`--dev` доступен только при DEBUG=true (dev-пользователи не для прода)»), не трогая БД. («`--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` напрямую в БД. Для ручной API и UI **отклоняется** (422); dev-пользователи `*@armka.local` создаёт `make seed-dev` напрямую в БД. Для ручной
проверки заводите пользователей с `example.com`. Email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён. проверки заводите пользователей с `example.com`. Email уникален без учёта регистра (409 `EMAIL_EXISTS`), хранится как введён.
- **Seed не трогает заполненную матрицу прав.** Связи «роль × право» из `api/seed/core/role_permissions.yaml` - **Seed не трогает заполненную матрицу прав.** Связи «роль × право» из `api/seed/core/role_permissions.yaml`
`make seed` / `make seed-dev` загружают только в **пустую** `role_permission` (первый запуск на новой БД). Если `make seed` / `make seed-dev` загружают целиком только в **пустую** `role_permission` (первый запуск на новой БД).
связи уже есть, шаг пропускается целиком — в отчёте `role_permission: пропущено — матрица прав уже заполнена…`: Если связи уже есть, seed добавляет лишь пары, у которых роль **или** право впервые созданы этим же запуском
право, снятое администратором, не возвращается, добавленное сверх файла не отзывается. Роли и права (`role`, (новая роль или новое право в файлах seed — их администратор снять не мог); остальные пары не трогаются — в отчёте
`permission`) по-прежнему приводятся к файлам seed. Изменения `role_permissions.yaml` на действующем стенде `role_permission: пропущено — матрица прав уже заполнена…` (или число добавленных пар новых ролей и прав). Право,
(например, права новой роли) seed не применит — их выдаёт администратор (`PUT /admin/roles/{code}/permissions`). снятое администратором с существующей роли, не возвращается, добавленное сверх файла не отзывается; новые пары
существующих ролей и прав из файла выдаёт администратор (`PUT /admin/roles/{code}/permissions`). Роли и права
(`role`, `permission`) по-прежнему приводятся к файлам seed. Пограничный случай: если администратор снял **все**
связи, таблица пуста, и следующий seed зальёт матрицу файла заново.
- **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите - **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите
страницу или войдите заново. страницу или войдите заново.
- **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after` - **Имя и филиал ГК одним 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'ы из 1. `load_seed` (`seed_lib/loaders.py`) читает ВСЕ файлы в память и проверяет их (обязательные поля, enum'ы из
`src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается. `src.models.enums`, ссылки внутри наборов); любая ошибка — `SeedError`, база при этом не затрагивается.
2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному 2. `apply_seed` (`seed_lib/apply.py`) применяет набор к БД шагами в порядке зависимостей: upsert по натуральному
ключу, без удалений. Исключение — матрица `role_permission`: загружается только в пустую таблицу, иначе шаг ключу, без удалений. Исключение — матрица `role_permission`: целиком загружается только в пустую таблицу; в
пропускается (в отчёте — «пропущено»), чтобы не вернуть права, снятые администратором (issue #9 п.7). заполненную — лишь пары ролей и прав, вставленных этим же запуском, остальное пропускается (в отчёте —
«пропущено»), чтобы не вернуть права, снятые администратором (issue #9 п.7, ревью R-3).
План счетов (`core/chart_of_accounts.csv`, design D-9) — ДОПУЩЕНИЕ OQ-47: полного плана 1С клиента нет, в файле План счетов (`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, Приказ Минфина № 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, key: str,
rows: list[dict[str, Any]], rows: list[dict[str, Any]],
stat: TableStat, stat: TableStat,
) -> None: ) -> set[Any]:
"""Вставить новые и обновить изменившиеся строки по натуральному ключу `key`; удалений нет.""" """Вставить новые и обновить изменившиеся строки по натуральному ключу `key`; удалений нет.
Возвращает ключи строк, ВСТАВЛЕННЫХ этим прогоном (для матрицы прав: связи новых ролей и прав).
"""
if not rows: if not rows:
return return set()
key_column = getattr(model, key) key_column = getattr(model, key)
value_names = [name for name in rows[0] if name != key] value_names = [name for name in rows[0] if name != key]
existing: dict[Any, tuple[Any, ...]] = { 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() for row in (await session.execute(select(key_column, *(getattr(model, name) for name in value_names)))).all()
} }
changed = [] changed = []
inserted: set[Any] = set()
for row in rows: for row in rows:
current = existing.get(row[key]) current = existing.get(row[key])
if current is None: if current is None:
stat.inserted += 1 stat.inserted += 1
inserted.add(row[key])
changed.append(row) changed.append(row)
elif current != tuple(row[name] for name in value_names): elif current != tuple(row[name] for name in value_names):
stat.updated += 1 stat.updated += 1
@ -47,38 +52,48 @@ async def _upsert(
index_elements=[key_column], set_={name: statement.excluded[name] for name in value_names} index_elements=[key_column], set_={name: statement.excluded[name] for name in value_names}
) )
await session.execute(statement, changed) await session.execute(statement, changed)
return inserted
MATRIX_SKIPPED = ( MATRIX_SKIPPED = (
"матрица прав уже заполнена; seed её не меняет, чтобы не вернуть права, снятые администратором " "матрица прав уже заполнена; seed не возвращает права, снятые администратором, и добавляет только связи ролей "
"(правка — PUT /admin/roles/{code}/permissions)" "и прав, впервые созданных этим запуском (правка — PUT /admin/roles/{code}/permissions)"
) )
async def _apply_role_permissions(session: AsyncSession, data: SeedData, stat: TableStat) -> None: async def _apply_role_permissions( # noqa: PLR0913 - набор, статистика и коды впервые вставленных ролей и прав
"""Матрица «роль × право» из файла — только в пустую `role_permission` (первичная загрузка, issue #9 п.7). 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` (первичная загрузка, в том числе после того, как администратор снял все связи) —
права не возвращаются, добавленные не отзываются. Роли и права (`role`, `permission`) по-прежнему upsert'ятся. вставляется вся матрица файла. Непустая — матрицей владеет администратор: вставляются только пары, у которых
роль ИЛИ право вставлены этим же прогоном (`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 stat.skipped_reason = MATRIX_SKIPPED
return
role_ids = {code: id_ for id_, code in (await session.execute(select(Role.id, Role.code))).all()} 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()} 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 = [
new_rows = [] {
for role_code, codes in data.role_permissions.items(): "role_id": role_ids[role_code],
for code in codes: "permission_id": permission_ids[code],
pair = (role_ids[role_code], permission_ids[code]) "granted_by": None,
if pair in existing: "granted_at": func.now(),
stat.unchanged += 1 }
else: for role_code, codes in data.role_permissions.items()
stat.inserted += 1 for code in codes
new_rows.append( if not matrix_filled or role_code in new_roles or code in new_permissions
{"role_id": pair[0], "permission_id": pair[1], "granted_by": None, "granted_at": func.now()} ]
)
if new_rows: if new_rows:
stat.inserted += len(new_rows)
# .values(): func.now() допустим только во встроенном VALUES, не в executemany # .values(): func.now() допустим только во встроенном VALUES, не в executemany
statement = ( statement = (
pg_insert(RolePermission) 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], [{"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"), report.stat("regional_office"),
) )
await _upsert( new_roles = await _upsert(
session, session,
Role, Role,
"code", "code",
@ -242,7 +257,7 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport:
], ],
report.stat("role"), report.stat("role"),
) )
await _upsert( new_permissions = await _upsert(
session, session,
Permission, Permission,
"code", "code",
@ -258,7 +273,9 @@ async def apply_seed(session: AsyncSession, data: SeedData) -> SeedReport:
], ],
report.stat("permission"), 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_chart_of_accounts(session, data, report.stat("chart_of_account"))
await apply_elimination_rules( await apply_elimination_rules(
session, data, report.stat("elimination_rule"), report.stat("elimination_rule_account") session, data, report.stat("elimination_rule"), report.stat("elimination_rule_account")

View File

@ -133,7 +133,7 @@ class TableStat:
inserted: int = 0 inserted: int = 0
updated: int = 0 updated: int = 0
unchanged: int = 0 unchanged: int = 0
#: шаг не выполнялся (например, матрица прав уже заполнена); текст — для отчёта CLI #: шаг выполнен не полностью или пропущен (матрица прав уже заполнена); текст — для отчёта CLI
skipped_reason: str | None = None skipped_reason: str | None = None
@ -148,10 +148,12 @@ class SeedReport:
def format(self) -> str: def format(self) -> str:
lines = ["Seed применён (вставлено / обновлено / без изменений):"] lines = ["Seed применён (вставлено / обновлено / без изменений):"]
lines += [ for name, stat in self.tables.items():
f" {name}: пропущено — {stat.skipped_reason}" counts = f"{stat.inserted} / {stat.updated} / {stat.unchanged}"
if stat.skipped_reason is not None if stat.skipped_reason is None:
else f" {name}: {stat.inserted} / {stat.updated} / {stat.unchanged}" lines.append(f" {name}: {counts}")
for name, stat in self.tables.items() elif stat.inserted:
] lines.append(f" {name}: {counts} (только связи новых ролей и прав; {stat.skipped_reason})")
else:
lines.append(f" {name}: пропущено — {stat.skipped_reason}")
return "\n".join(lines) return "\n".join(lines)

View File

@ -2,8 +2,9 @@
# ASSUMPTION(OQ-16): в ТЗ матрица помечена «будет разработана» (сноска 29) — это ЧЕРНОВИК; администратор правит её в UI. # ASSUMPTION(OQ-16): в ТЗ матрица помечена «будет разработана» (сноска 29) — это ЧЕРНОВИК; администратор правит её в UI.
# Пояснения в скобках в таблице §10 («свой РФ», «состав ГСК», «INITIATED…CONSOLIDATION_RF» и т. п.) — условия # Пояснения в скобках в таблице §10 («свой РФ», «состав ГСК», «INITIATED…CONSOLIDATION_RF» и т. п.) — условия
# policy-слоя, а не отдельные права, поэтому здесь не представлены. # policy-слоя, а не отдельные права, поэтому здесь не представлены.
# Ключ — role.code из roles.yaml, значения — permission.code из permissions.yaml. Upsert по (роль, право); # Ключ — role.code из roles.yaml, значения — permission.code из permissions.yaml; granted_by = NULL (выдано seed'ом).
# granted_by = NULL (выдано seed'ом). Права, которых в файле нет, seed не отзывает. # Матрица загружается целиком только в ПУСТУЮ role_permission; в заполненную — лишь пары ролей/прав, впервые созданных
# этим запуском seed (снятые администратором права не возвращаются). Права, которых в файле нет, seed не отзывает.
ADMIN: ADMIN:
- DIRECTORY_VIEW - DIRECTORY_VIEW
- DIRECTORY_EDIT - DIRECTORY_EDIT

View File

@ -10,7 +10,7 @@ from pathlib import Path
import pytest import pytest
import yaml import yaml
from sqlalchemy import func, select, update from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from scripts.seed import SeedError, SeedReport, load_seed, run_seed from scripts.seed import SeedError, SeedReport, load_seed, run_seed
@ -370,3 +370,59 @@ def test_cli_help() -> None:
assert result.returncode == 0 assert result.returncode == 0
assert "--dev" in result.stdout 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