diff --git a/tradein-mvp/backend/app/core/auth.py b/tradein-mvp/backend/app/core/auth.py index 0906875b..512b924a 100644 --- a/tradein-mvp/backend/app/core/auth.py +++ b/tradein-mvp/backend/app/core/auth.py @@ -180,6 +180,15 @@ def _registry_role(username: str) -> str | None: пустая, либо реестр вообще недоступен. Во всех трёх случаях решение остаётся за roles.yaml — падение БД не имеет права выключить legacy-вход. + ⚠️ Осознанный компромисс (#3316 review): последняя ветка — недоступный + реестр — на время сбоя ВОЗВРАЩАЕТ авторитетность roles.yaml, то есть ровно + то состояние, которое этот фикс и лечит. Сегодня это безопасно: коллизий + имён между реестром и YAML на проде нет, а новые закрыты 409-гвардом в + `team.create_employee`. Если коллизия всё же появится (ручной INSERT в + реестр, расширение roles.yaml) — сбой БД станет окном эскалации, и тогда + эту ветку надо менять на fail-closed (отказ вместо YAML-роли), а не + дописывать проверки у вызывающих. + Имя таблицы берётся из фиксированного словаря `identity_schema()`, значение едет bind-параметром: снаружи в SQL не попадает ничего. """ diff --git a/tradein-mvp/backend/tests/test_role_single_source.py b/tradein-mvp/backend/tests/test_role_single_source.py index da358dcd..c60e00a8 100644 --- a/tradein-mvp/backend/tests/test_role_single_source.py +++ b/tradein-mvp/backend/tests/test_role_single_source.py @@ -129,6 +129,52 @@ def test_legacy_yaml_users_keep_their_roles(registry: Callable[..., Any]) -> Non assert {username: auth.get_role(username) for username in users} == users +def _yaml_pilot() -> str: + for username, role in _yaml_users().items(): + if role == "pilot": + return username + pytest.skip("в auth/roles.yaml нет ни одного pilot — тест неприменим") + + +def test_prod_config_pilot_in_yaml_employee_in_registry(registry: Callable[..., Any]) -> None: + """Прод-конфигурация 12 из 13 аккаунтов: строка в реестре ЕСТЬ и роль там иная. + + Реестр главнее (`employee`), а объём прав от этого не меняется: scope + DB-роли `employee` обязан совпадать с вчерашним YAML-scope роли `pilot`. + Списки сверяются целиком — дрейф ЛЮБОГО из двух ловится здесь, а не + тихой потерей/выдачей раздела в проде. + """ + from app.services.auth_session import DB_ROLE_PATHS + + registry("employee") + assert auth.get_role(_yaml_pilot()) == "employee" + + pilot = auth._load_roles_config()["roles"]["pilot"] + allow, deny = DB_ROLE_PATHS["employee"] + assert sorted(allow) == sorted(pilot["paths"]) + assert sorted(deny) == sorted(pilot["deny"] or []) + + +def test_prod_config_pilot_in_yaml_manager_in_registry(registry: Callable[..., Any]) -> None: + """Конфигурация kopylov: YAML pilot + реестр manager → manager. + + Лишних путей на tradein-периметре это не даёт: manager отличается от + employee ровно одним префиксом `/api/v1/team/**` (дашборд «Команды», + ВНЕ `/trade-in/**`), а deny-списки совпадают. + """ + from app.services.auth_session import DB_ROLE_PATHS + + registry("manager") + assert auth.get_role(_yaml_pilot()) == "manager" + + emp_allow, emp_deny = DB_ROLE_PATHS["employee"] + mgr_allow, mgr_deny = DB_ROLE_PATHS["manager"] + extra = set(mgr_allow) - set(emp_allow) + assert extra == {"/api/v1/team/**"} + assert not any(p.startswith("/trade-in") for p in extra) + assert sorted(mgr_deny) == sorted(emp_deny) + + def test_registry_failure_falls_back_to_yaml(registry: Callable[..., Any]) -> None: """Реестр недоступен → legacy-вход продолжает работать по YAML.""" registry(_MISSING)