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). Нужны два долгоиграющих решения, влияющих на все будущие модули:
- Где хранить роль и насколько богатой делать модель прав —
плоский enum или таблица
permissionsс many-to-many. - Как guard получает роль на горячем пути — из payload JWT или из БД.
Рассмотренные варианты
Хранение роли
- Колонка
role(Postgres enum) вusers.- Простая нормализация, нативная валидация на стороне БД, миграция тривиальна,
RolesGuardсравнивает строки. − Одна роль на пользователя; для матриц «роль × конкретное разрешение» потребуется отдельная сущность в будущем.
- Простая нормализация, нативная валидация на стороне БД, миграция тривиальна,
- Таблица
permissions+ связь many-to-many сusers.- Гибко: точечные права (
reports:read,pricing:write). − Преждевременная абстракция: ТЗ фиксирует ровно три плоские роли, дополнительных permission-bits нет; обслуживание many-to-many дороже, а выигрыш гипотетический.
- Гибко: точечные права (
- JWT-only без хранения в БД (роль выдаёт админ при логине через UI). − Невозможно ревизировать список ролей пользователей, рассинхрон между сессиями и реальным состоянием.
Доставка роли в RolesGuard
- Положить роль в payload access-JWT.
- Нет лишних чтений БД на горячем пути; guard детерминирован. − Смена роли вступает в силу при следующей выдаче токена (login/refresh), то есть с задержкой ≤ access TTL (по умолчанию 15 мин).
- Запрос в БД на каждой защите эндпоинта.
- Изменение роли применяется немедленно. − Удваивает 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во время инициализации окружения:В деплое Coolify шаг выполняет оператор через psql после первого деплоя.UPDATE users SET role = 'admin' WHERE email = '<admin-email>'; - Альтернатива — 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.