BR 1.4.4: Единая permission-based ролевая модель (удаление enum)

Зависит от:

  • BR 1.4.3 — ввёл permissions-роли как второй слой поверх enum
  • BR 1.4.2 — механика создания владельца партнёра (переиспользуется)
  • BR 1.1legal_entities.owner_user_id — ключ нового scope

Отменяет часть BR 1.4.3

Двухслойная модель «multi-tenancy enum + permissions» из BR 1.4.3 заменяется на единый слой. Секция «Multi-tenancy роль» в спеке Роли и форма создания сотрудника — переделываются.


Контекст

BR 1.4.3 ввёл permissions-роли (Yuma-стиль) как второй слой поверх существующего enum employees.role (admin_franchise/admin_franchisee/manager/cashier). На момент реализации это было техническим компромиссом: все 5 сервисов (Store, Catalog, Warehouse, Order, User) использовали switch (user.getRole()) для авторизации, и разом переписать их на permissions-checks было большим скоупом.

Проблема выявленная при тестировании:

  • При создании сотрудника пользователь видит два селекта ролей: системный dropdown «Менеджер / Кассир» (enum) и отдельный блок «Permissions-роли» с созданными им ролями («Бариста», «Курьер» и т.п.).
  • Пользователь не понимает зачем два селекта. Интуиция: «я создал роль Бариста — вот она должна быть в dropdown ролей сотрудника».

Ресёрч аналогов общепита:

  • iiko — иерархия Корпорация → Организация → Заведение. Роли кастомные, enum отсутствует. Админ корпорации видит всё, админ организации — только свою через контекст текущей выбранной организации.
  • Poster — SaaS, плоский multi-location. Роли кастомные, нет enum.
  • R-Keeper — enterprise иерархия, тоже без enum-ролей.

Вывод: ни у кого нет enum-роли. Scope определяется через владение юрлицом + назначенные заведения, а не через жёсткий enum.

Цель BR: перейти на единую permission-модель. После этой BR в системе один слой ролей (permissions-роли) и понятный UX.


Требования

1. Удаление enum employees.role

  • В БД employees.role удаляется (DROP COLUMN) — больше нет никакого enum-разделения ролей.
  • Все сервисы (Store, Catalog, Warehouse, Order, User) переводят switch(role)-проверки на:
    • проверки scope (см. §2) — где применимо (фильтрация данных, доступ к ТТ/ЮЛ/сотрудникам)
    • проверки permissions (см. Роли) — где применимо (операции: edit/delete/publish/…)

2. Единое правило scope сотрудника (во всех сервисах)

Как сервис определяет «что видит сотрудник»:

УсловиеScope
user = legal_entities.owner_user_id и этого ЮЛ type=franchiseВся франшиза — все ТТ, все ЮЛ, все сотрудники в рамках franchise_id
user = legal_entities.owner_user_id и этого ЮЛ type=franchiseeСвои ЮЛ + их ТТ + их сотрудники (может быть несколько ЮЛ у одного владельца)
user — обычный сотрудникТТ из employee_role_stores (объединение всех его permissions-ролей)

3. Флаг типа франшизы

Новое поле на уровне франшизы:

franchises.type: 'corporate' | 'individual'
  • corporate — полноценная франшиза: доступен раздел «Юр. лица», можно создавать партнёров (ЮЛ type=franchisee), есть иерархия владения
  • individual (ИП) — одно главное ЮЛ, раздел «Юр. лица» скрыт в UI. API: попытка создать ЮЛ type=franchisee403 FRANCHISE_TYPE_INDIVIDUAL
  • Default при создании новой франшизы: corporate (можно изменить вручную перед запуском клиента)
  • Текущая (единственная) франшиза при миграции → corporate

4. POS PIN-логин

Ранее POS определял «можно ли логиниться по PIN» через employees.role = 'cashier'.

Новое правило:

  • POST /internal/users/validate-pin возвращает успех, если:
    1. PIN совпадает с pin_hash сотрудника ТТ
    2. У сотрудника в permissions (агрегат permissions-ролей) есть pos.access
  • Иначе — 403 POS_ACCESS_DENIED

Permission pos.access уже существует в каталоге (BR 1.4.3).

5. Системная роль «Администратор» и настраиваемые права владельца партнёра

5.1 Системная роль «Администратор»

  • Остаётся одна системная роль «Администратор» (как в BR 1.4.3) — автосоздаётся при создании франшизы
  • Полный набор permissions включая pos.access (чтобы владелец мог зайти на POS своей ТТ)
  • Выдаётся владельцу франшизы при ручном bootstrap (seed / SQL при подписании договора)

5.2 Настраиваемые права владельца партнёра (новое)

При создании ЮЛ партнёра (type=franchisee) главный админ франшизы сам выбирает, какие права дать владельцу партнёра. Форма создания ЮЛ расширяется блоком «Права владельца»:

РежимЧто происходит
«Полный доступ» (default, рекомендуется)Владельцу партнёра назначается системная роль «Администратор». Получает все permissions, scope ограничен его ЮЛ (через §2)
«Настроенные права»Админ вручную выбирает permissions из каталога. Система создаёт скрытую персональную роль для этого ЮЛ с выбранными permissions и назначает её владельцу

UX ключевое: роли для владельцев партнёров — не видны в /admin/roles.

Саша управляет правами партнёра через карточку ЮЛ партнёра /admin/legal-entities/{id}, вкладка «Права»:

ИП Петров
[Реквизиты] [Владелец] [Права] [ТТ]

─ Права владельца ──────────
  ○ Полный доступ (системная роль «Администратор»)
  ● Настроенные права

  Разделы Бэк-офиса:
    Меню             ☑ чтение ☑ редакт.
    Прейскуранты     ☑ чтение ☐ редакт.
    Публикация ТТ    ☐
    ...
  Функции POS:
    ☑ pos.access
    ...

В списке /admin/roles Саша видит только настоящие роли (системную «Администратор» + созданные им роли сотрудников: «Бариста», «Курьер» и т.п.). Скрытые роли-владельцев туда не попадают.

Примеры «Настроенные права»:

Саша создаёт партнёра «Петров» и хочет, чтобы Петров:

  • мог управлять каталогом, складом, сотрудниками своих ТТ — ✅
  • не мог менять цены (изменения прейскуранта) — ❌
  • не мог публиковать/снимать ТТ с публикации — ❌

Саша заходит в карточку ЮЛ Петрова → вкладка «Права» → снимает галки → сохраняет.

Что всегда выдаётся автоматически (независимо от выбора):

  • pos.access — чтобы владелец мог логиниться на POS по PIN (иначе теряется смысл «владелец»)
  • stores.read, employees.read — минимум чтобы видеть свои данные

Переключение режима: если Саша из «Настроенные» переключает на «Полный доступ» → скрытая роль отсоединяется и на её место назначается системная «Администратор». При обратном переключении — заново создаётся скрытая роль (с галками по умолчанию «все включены» или пустая).

Техническая реализация скрытых ролей: в таблице roles добавить служебное поле owner_legal_entity_id (UUID NULL FK → legal_entities.id). Если задано — роль скрыта из /admin/roles списка и редактируется только через карточку соответствующего ЮЛ.

5.3 Разница между владельцами франшизы и партнёра

Определяется не ролью (у обоих может быть «Администратор»), а:

  • Владением ЮЛ (owner_user_id + type ЮЛ — см. §2 про scope)
  • franchises.type (§3 — individual ИП не может иметь партнёров вообще)

6. UX формы создания сотрудника

  • Убрать dropdown «Менеджер / Кассир» — больше нет enum
  • Оставить только единый раздел «Роли» — multi-select из созданных permissions-ролей + для каждой свой набор магазинов (как сейчас в «Permissions-роли» блоке)
  • Магазины для назначения ограничены scope’ом текущего пользователя:
    • Владелец франшизы → любая ТТ
    • Владелец партнёра → только свои ТТ
    • Обычный сотрудник → обычно не создаёт других сотрудников (нет employees.edit)

7. UI для франшиз type=individual (ИП)

  • Пункт меню «Юр. лица» скрыт
  • На дашборде — нет виджета «Юр. лица»
  • Раздел «Торговые точки» — остаётся (у ИП тоже могут быть несколько ТТ на одном ЮЛ)
  • Форма создания сотрудника — не меняется (ЮЛ в ней не упоминается)

8. Bootstrap новой франшизы — вручную

  • MVP-подход: когда клиент подписывает договор, администратор системы вручную делает SQL:
    1. Создаёт запись в franchises (с нужным type)
    2. Создаёт главное ЮЛ (legal_entities с type=franchise, is_primary=true)
    3. Создаёт сотрудника-владельца (email вида ivan@example.com, пароль — временный, передать клиенту)
    4. Связывает ЮЛ и сотрудника через owner_user_id
    5. Присваивает сотруднику системную роль «Администратор» (через employee_roles)
  • Автоматическая регистрация через UI — отдельная BR в будущем

9. Миграция при выкатке

  • ALTER TABLE franchises ADD COLUMN type VARCHAR(20) DEFAULT ‘corporate’ NOT NULL
  • ALTER TABLE employees DROP COLUMN role
  • Все сервисы пересобираются и деплоятся синхронно (enum из JWT убираем с зависимостями)
  • admin@erp.local уже:
    • привязан к системной роли «Администратор» (через миграцию 017 в BR 1.4.3)
    • является owner_user_id главного ЮЛ → его scope после миграции = вся франшиза, permissions = полный набор. Работает.
  • Других сотрудников нет (миграция 017 их удалила) — рисков не создаёт

Бизнес-правила

  • Нет enum-ролей. Сотрудник не имеет «роли» как отдельного поля — только набор назначенных permissions-ролей
  • Scope определяется по правилам §2, в следующем приоритете: владение type=franchise → владение type=franchisee → ТТ из employee_role_stores
  • Системная роль «Администратор» неделима: у владельца франшизы и у владельца партнёра permissions одинаковые, разница только в scope
  • franchises.type=individual: API блокирует создание партнёров, UI прячет соответствующие разделы. Изменение типа франшизы после старта (individual ↔ corporate) — вручную SQL, отдельной UI-операции нет
  • POS PIN-login: разрешён только сотрудникам с pos.access в permissions + заданным pin_hash. У владельца франшизы/партнёра (системная «Администратор») — разрешено (в системной роли pos.access=true)
  • Backward compat JWT: старые токены содержат поле role. Сервисы не полагаются на него (всё через permissions и scope). Поле в новых JWT — удаляется (или остаётся null для плавности)

Затронутые сервисы

СервисЧто меняется
User ServiceDROP employees.role; ADD franchises.type; переписывается scope-логика в EmployeeService, LegalEntityService, StoreService(internal); форма создания ЮЛ партнёра принимает либо режим «полные права» → системная роль, либо «настроить» → создаётся новая роль «Владелец {name}» с переданными permissions в одной транзакции; форма создания сотрудника — без enum-поля; новый internal endpoint GET /internal/users/{id}/scope (возвращает either {type:all_franchise}, {type:legal_entity_ids, ids:[]}, или {type:store_ids, ids:[]})
Auth ServiceJWT payload: убрать role (или null); /auth/me — убрать role из response user; /internal/auth/validate — убрать role из response
Store / Catalog / Warehouse / Order ServicesПеревод всех switch(role) на scope-check (через новый User Service internal endpoint с кэшем, либо включить scope в response /internal/auth/validate) + permission-check для операций edit/delete/publish
Admin BFFНикаких серверных правок
Admin WebУбрать dropdown multi-tenancy роли в форме сотрудника; единый блок «Роли»; скрыть «Юр. лица» при franchise.type=individual; PermissionContext получает franchise.type; форма создания ЮЛ партнёра получает блок «Права владельца» (radio «Полные / Настроить» + checkboxes permissions при «Настроить»)
POS mobile (erp-pos)Никаких клиентских правок (логика PIN-login на бэке)

Ролевой доступ (итоговая упрощённая матрица)

ДействиеВладелец франшизы (corporate)Владелец ЮЛ-партнёраОбычный сотрудник
Определяется черезowner_user_id в главном ЮЛowner_user_id в ЮЛ type=franchiseeemployee_role_stores
Сист. роль «Администратор»ДаДаОбычно нет
Видит ТТВсе в франшизеТолько своиТолько свои назначенные
Видит сотрудниковВсехТолько в своих ТТТолько в своих ТТ (если employees.read permission)
Создаёт ЮЛ партнёраДа (если type=corporate)Нет (scope)Нет (scope + permission)
Логин на POS по PINДа (у системной роли есть pos.access)ДаЕсли у permissions-роли есть pos.access + есть pin_hash

Открытые вопросы

  1. Scope в internal API: оставить role в /internal/auth/validate response пустым или удалить совсем? Решение влияет на обратную совместимость старых клиентов.
  2. Новый endpoint vs расширение существующего: возвращать scope в /internal/auth/validate или создать отдельный /internal/users/{id}/scope? Первый вариант проще интегрировать с существующим кэшем Auth Service.
  3. Performance: каждый сервис на каждый запрос делает scope-check. Нужен Redis-кэш user_scope:{user_id} аналогично user_permissions:{user_id} (60s TTL). Проработать на этапе контрактов.
  4. Permission pos.access: оставить текущее имя или переименовать на pos.login? (текущее устоявшееся — оставляем)
  5. Изменение franchise.type после старта: нужен ли API/UI или вечно SQL? MVP — SQL.
  6. Именование персональной роли владельца партнёрарешено: роль скрыта от пользователя, управляется через карточку ЮЛ. Имя в БД — техническое (владелец его не видит).
  7. Минимум permissions для владельца партнёра (§5.2): жёстко форсить pos.access + stores.read + employees.read (нельзя снять через UI) или позволить полностью выбирать и хардкодить только serverside-валидацию? Влияет на безопасность.
  8. Шаблоны ролей владельцев партнёров — возможность заранее создать «Партнёр (базовый)» и переиспользовать. Вне скоупа BR 1.4.4, отдельная BR в будущем.

Ссылки