Перейти к основному содержимому

0004. Модель RBAC: хранение роли и доставка её в guard

Копия ADR из репозитория: backend/docs/adr/0004-rbac-role-storage-and-jwt.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-06-01
  • Связанные задачи: OP-29 (F2 · RBAC)

Контекст и проблема​

ТЗ 4 фиксирует три роли с разной ответственностью: Администратор кабинета, Селлер (владелец бизнеса) и Менеджер маркетплейсов. Доступы различаются на уровне эндпоинтов; матрица прав — в docs/rbac-matrix.md.

OP-29 поднимает первую итерацию RBAC поверх auth-инфраструктуры OP-28 (Passport/JWT). Нужны два долгоиграющих решения, влияющих на все будущие модули:

  1. Где хранить роль и насколько богатой делать модель прав — плоский enum или таблица permissions с many-to-many.
  2. Как guard получает роль на горячем пути — из payload JWT или из БД.

Рассмотренные варианты​

Хранение роли​

  1. Колонка role (Postgres enum) в users.
    • Простая нормализация, нативная валидация на стороне БД, миграция тривиальна, RolesGuard сравнивает строки. − Одна роль на пользователя; для матриц «роль × конкретное разрешение» потребуется отдельная сущность в будущем.
  2. Таблица permissions + связь many-to-many с users.
    • Гибко: точечные права (reports:read, pricing:write). − Преждевременная абстракция: ТЗ фиксирует ровно три плоские роли, дополнительных permission-bits нет; обслуживание many-to-many дороже, а выигрыш гипотетический.
  3. JWT-only без хранения в БД (роль выдаёт админ при логине через UI). − Невозможно ревизировать список ролей пользователей, рассинхрон между сессиями и реальным состоянием.

Доставка роли в RolesGuard​

  1. Положить роль в payload access-JWT.
    • Нет лишних чтений БД на горячем пути; guard детерминирован. − Смена роли вступает в силу при следующей выдаче токена (login/refresh), то есть с задержкой ≤ access TTL (по умолчанию 15 мин).
  2. Запрос в БД на каждой защите эндпоинта.
    • Изменение роли применяется немедленно. − Удваивает p99 на горячем пути; усложняет кеширование (инвалидация).

Решение​

Хранение — колонка role enum​

  • Postgres-enum user_role со значениями admin, seller, manager.
  • Колонка users.role NOT NULL DEFAULT 'seller'. Дефолт — seller: целевая аудитория «Оптимы» — селлеры WB.
  • Доступ на уровне бренда (для Manager) — отдельная сущность BrandAccess, заводится в OP-31 (вне scope OP-29).
  • Если позже понадобятся точечные permission-bits — введём таблицу permissions отдельным ADR; сейчас за плоской моделью прятать flexibility было бы преждевременно.

Доставка в RolesGuard — через payload JWT​

  • В access-JWT payload { sub, email, role }. Guard читает req.user.role без обращения к БД.
  • Trade-off: задержка применения смены роли ≤ access TTL (15 мин). Документируется в Swagger-описании эндпоинта смены роли.
  • Принудительный «выход со всех устройств» (revoke всех refresh-токенов пользователя) — отдельная Story; после этого пользователь перелогинится и получит новый access с актуальной ролью.

Bootstrap первого администратора​

  • На первой инсталляции в БД нет ни одного пользователя; /auth/register создаёт всех со ролью seller.
  • Первый Admin поднимается ручным UPDATE во время инициализации окружения:
    UPDATE users SET role = 'admin' WHERE email = '<admin-email>';
    В деплое Coolify шаг выполняет оператор через psql после первого деплоя.
  • Альтернатива — seed-Story (OP-…): сидер читает BOOTSTRAP_ADMIN_EMAIL из env и проставляет роль. Заводится при появлении продакшен-среды.

Последствия​

  • Положительные:
    • RolesGuard детерминирован и быстр (никакой БД на запрос).
    • Миграция тривиальная: новая колонка с дефолтом, без data-migration — все существующие записи становятся селлерами.
    • Энам в БД ловит ошибку записи (UPDATE … SET role = 'pirate' → ошибка БД, а не «тихая» строка).
  • Отрицательные / компромиссы:
    • Смена роли применяется к новым токенам; для немедленного эффекта нужен отзыв refresh-токенов пользователя (отдельная Story).
    • Добавление новой роли — миграция ALTER TYPE user_role ADD VALUE (без обратной миграции для конкретного значения — стандартный Postgres-нюанс).
  • Дальше (вне scope OP-29):
    • OP-31 — доступ на уровне бренда для Manager;
    • «Выход со всех устройств» — отдельная Story (revoke by user_id);
    • Аудит-таблица F6 — сейчас логи через Logger.log('audit.role-changed …'), структурированное хранилище появится в F6.