fix(tradein/auth): единый источник ролей — БД first, YAML только legacy-fallback (закрывает эскалацию до admin и 403 своим) #3331

Merged
bot-backend merged 3 commits from fix/3316-role-single-source into main 2026-09-05 17:37:38 +00:00
2 changed files with 55 additions and 0 deletions
Showing only changes of commit cde95aa9f6 - Show all commits

View file

@ -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 не попадает ничего.
"""

View file

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