CI-красное на голове ветки: 4 теста в tests/test_rbac.py ждали YAML-роль
(kopylov=pilot, user1=pilot), а в CI-базе реестр засеян миграцией 193
(kopylov=manager, user*=employee) — DB-first резолвер честно отдавал роль из БД.
Локально те же тесты были зелёными ровно потому, что БД нет и работал
YAML-fallback: результат файла зависел от ОКРУЖЕНИЯ, а такой тест не проверяет
ничего.
Чинится не подгонкой чисел в ассертах, а изоляцией: файл проверяет ИМЕННО
legacy-путь roles.yaml (разбор файла, globs, guard и /me на trusted-header), и
теперь заявляет это явно — autouse-фикстура `_legacy_yaml_only` глушит реестр
(`_registry_role` → None). Ассерты на YAML-роли после этого законны в любом
окружении. Приоритет реестра, эквивалентность scope employee↔pilot и
конфигурация kopylov (DB manager + YAML pilot) покрыты отдельно —
tests/test_role_single_source.py.
Проверено обоими способами: полный `pytest tests` без сида и он же с
плагином-имитацией засеянного реестра (подменяется тот же шов, что и в проде,
`identity_store.identity_session`) — 5290 passed, 35 skipped в обоих.
Review PR #3331: приёмка «роли не изменились» гонялась с ПУСТЫМ реестром, а в
проде строка в БД есть у 12 из 13 юзеров и DB-роль ИНАЯ (kopylov: manager при
YAML pilot, user1: employee при YAML pilot). Добавлены два кейса именно этой
конфигурации:
* YAML pilot + реестр employee → employee, и scope не поехал: allow/deny
DB_ROLE_PATHS['employee'] сверяются со списками роли pilot из roles.yaml
целиком — дрейф ЛЮБОГО из двух списков теперь красный тест, а не тихо
потерянный/выданный раздел в проде;
* YAML pilot + реестр manager → manager, и лишних путей на tradein-периметре
нет: manager отличается от employee ровно префиксом /api/v1/team/** (вне
/trade-in/**), deny-списки совпадают.
Докстринг `_registry_role`: зафиксирован компромисс — при недоступном реестре
фолбэк временно возвращает авторитетность roles.yaml, то есть состояние, которое
фикс и лечит. Сегодня безопасно (прод-коллизий имён нет, новые закрыты
409-гвардом create_employee); появится коллизия — ветку менять на fail-closed.
Роль жила в двух местах сразу: люди заводятся в БД (`tradein_users.role`),
а `get_role` читал ТОЛЬКО `auth/roles.yaml` — и никто эти два источника не
сверял. Дефект двусторонний:
* вверх: менеджер заводил сотрудника с именем, которое уже числится в
roles.yaml админом (проверялись лишь regex и уникальность в БД) — на
входе тот получал admin из YAML, то есть чтение ЛЮБОЙ чужой оценки
(admin проходит мимо ownership-check в trade_in.py) и безлимитную квоту;
* вниз: сотрудник, которого в roles.yaml нет, ловил KeyError → 403 на
СОБСТВЕННУЮ оценку.
Источник теперь один и лечится один раз — в `app.core.auth.get_role`:
реестр (`tradein_users.role` / `auth.users.role`) спрашивается первым,
roles.yaml остаётся fallback для legacy-юзеров, у которых строки в реестре
нет. Реестр недоступен → тоже fallback: падение БД не выключает legacy-вход.
Вызывающие (rbac, trade_in, team, account_quota) не менялись.
Сопутствующее, чтобы поведение существующих аккаунтов не поехало:
* rbac_guard выбирает матчер путей по РОДУ роли (роль реестра → DB_ROLE_PATHS),
иначе employee/manager на legacy-пути получил бы 403 на всё;
* get_user_scope отдаёт scope роли реестра из того же DB_ROLE_PATHS;
* право на персональный `unlimited` осталось за roles.yaml (account_quota +
_batch_quota_status) — фикс убирает эскалацию, а не раздаёт новую;
* `_batch_quota_status` берёт роли из уже прочитанных строк — иначе список
«Команды» снова стал бы N+1.
Defense-in-depth: create_employee отдаёт 409 на username, за которым в
roles.yaml числится не-employee роль.