Перейти к содержанию

Админка ГК, Личный кабинет франчайзи и доступ сотрудников

Статус документа

Документ фиксирует согласованную целевую модель. В iam реализованы учётные записи, password credentials, членство сотрудника ГК, bootstrap первого администратора, внутренний сценарий входа в Админку ГК, обязательная смена временного пароля и серверные refresh-сессии аудиторий ADMIN и FRANCHISE_OFFICE. Также реализованы выпуск и проверка access JWT аудиторий admin и franchise-office, каркас модуля admin, HTTP-вход сотрудника ГК, приглашение сотрудника ФЧ и внутренняя аутентификация сотрудника ФЧ. В модуле franchiseoffice реализован полный минимальный HTTP auth-контур: отдельная security chain, login, refresh, bearer-аутентификация и logout. Учётные записи разделены по контурам занятости: HEAD_COMPANY и FRANCHISE — независимые identity с уникальностью email внутри контура. Также реализовано HTTP-управление iiko-подключениями с ownership-проверкой. Реализация выполняется отдельными принимаемыми этапами.

Терминология

Понятие Имя в коде Назначение
Админка ГК admin управление всей франчайзинговой сетью сотрудниками головной компании
Личный кабинет франчайзи franchiseoffice работа сотрудников конкретного ФЧ со своей частью сети
Управление доступом сотрудников iam учётные записи, credentials, членства, роли, приглашения и сессии
Франчайзи Franchisee, сокращённо ФЧ партнёр головной компании

Термин backoffice в коде и API не используется: он не показывает, идёт ли речь об Админке ГК или Личном кабинете ФЧ.

Отдельный контур от покупателей

iam в этом документе обслуживает только сотрудников ГК и ФЧ. Покупатели сайта и приложения — отдельный сетевой контур customer, описанный в модели доступа покупателей.

customer ── покупатели витрины, телефон и одноразовый код
iam      ── сотрудники ГК и ФЧ, email, пароль, приглашения и RBAC

Токен покупателя не принимается административными API. Учётная запись покупателя не становится учётной записью сотрудника автоматически, даже если совпали телефон или email. Разделение оправдано разными способами входа, рисками и жизненными циклами. Покупатель использует CustomerPrincipal и токен с aud=storefront; он не связан с конкретным ФЧ.

Админка ГК и Личный кабинет ФЧ используют общий модуль iam и общую механику паролей, сессий и токенов, но не общую учётную запись: сотрудник ГК и сотрудник ФЧ — независимые identity разных контуров. Совпадение email между контурами не объединяет их, как и совпадение email между сотрудником и покупателем.

Модули

iam

Владеет:

  • учётными записями сотрудников;
  • email и password hash;
  • приглашениями и одноразовыми setup token;
  • членствами в ГК и ФЧ;
  • ролями и разрешениями;
  • refresh-сессиями и отзывом доступа;
  • выпуском access token для конкретной аудитории;
  • блокировками контекста доступа.

Не владеет франчайзи, юридическими лицами, подключениями iiko и ресторанами.

admin

HTTP/application-модуль Админки ГК. Расположен в Gradle-модуле web. Реализованный публичный login не требует principal; будущие прикладные endpoints будут принимать только AdminPrincipal, проверять разрешения и координировать публичные API доменных модулей.

Минимальные зависимости:

admin ──▶ iam::api
admin ──▶ franchise::api

Позже могут добавиться зависимости на iiko::api и restaurant::api для поддержки и просмотра всей сети. Модуль не владеет бизнес-таблицами.

franchiseoffice

HTTP/application-модуль Личного кабинета ФЧ. Принимает только FranchisePrincipal, ограниченный одним franchiseeId.

franchiseoffice ──▶ iam::api
franchiseoffice ──▶ franchise::api
franchiseoffice ──▶ iiko::api

Зависимость на restaurant::api появится вместе с первым ресторанным сценарием. Модуль не владеет бизнес-таблицами и не читает их напрямую.

Учётная запись контура и её членство

UserAccount(HEAD_COMPANY) ── HeadCompanyMembership
UserAccount(FRANCHISE)    ── FranchiseMembership(franchiseeId)

UserAccount

id
context: HEAD_COMPANY | FRANCHISE
email
display_name
status: INVITED | ACTIVE | DISABLED
created_at
updated_at
version

context задаётся при создании и неизменяем. Email нормализуется, уникален без учёта регистра внутри контура и всегда ищется парой (context, email). Поэтому один и тот же адрес может принадлежать сотруднику ГК и сотруднику ФЧ: это две учётные записи с разными идентификаторами, разными паролями и раздельным жизненным циклом.

Принадлежность членства контуру закреплена схемой БД: составной уникальный ключ user_account (id, context) и составные внешние ключи из таблиц членств не позволяют привязать членство ГК к учётной записи ФЧ и наоборот. Инвариант держится на ограничениях, а не на проверках в сервисном коде, поэтому новый сценарий не может его обойти по забывчивости.

Разделение выбрано потому, что у контуров расходятся траектории безопасности: ГК движется к корпоративным SSO и MFA, а внешние партнёры-франчайзи остаются на email и пароле. Плата — неатомарный офбординг человека, работающего в обоих контурах; случай редкий и при необходимости решается отдельным признаком, группирующим учётные записи одного человека.

Пароль в открытом виде не хранится и не передаётся создавшему приглашение сотруднику. Password credential хранится отдельно и отсутствует до принятия приглашения.

PasswordCredential

user_id
password_hash
password_changed_at
password_change_required
version

Текущий алгоритм — bcrypt через DelegatingPasswordEncoder, поэтому hash содержит идентификатор алгоритма и допускает будущую миграцию. Пароль ограничен диапазоном 12–72 UTF-8 байта: верхняя граница предотвращает незаметное усечение bcrypt. Пока password_change_required=true, сотрудник может только сменить пароль или завершить сессию и не получает административных permissions.

HeadCompanyMembership

user_id
role: AdminRole
status: INVITED | ACTIVE | DISABLED
created_at
updated_at

FranchiseMembership

user_id
franchisee_id
role: FranchiseRole
status: INVITED | ACTIVE | DISABLED
operational_block
created_at
updated_at

Используются две таблицы, а не универсальная таблица с scope_type, scope_id и строковой ролью. Это не позволяет создать бессмысленную комбинацию роли и области доступа и упрощает ограничения БД.

Один пользователь технически может иметь членство ГК и не более одного членства ФЧ. Несколько FranchiseMembership для одного UserAccount запрещены уникальным ограничением: система не поддерживает работу одного человека в разных ФЧ. Access token всегда содержит ровно один контекст.

Типизированные principals

Защита от смешивания пользователей обеспечивается разными Kotlin-типами:

data class AdminPrincipal(
    val userId: UUID,
    val permissions: Set<AdminPermission>,
)

data class FranchisePrincipal(
    val userId: UUID,
    val franchiseeId: UUID,
    val permissions: Set<FranchisePermission>,
)

Контроллеры и application services не принимают общий UserPrincipal:

fun createFranchisee(actor: AdminPrincipal, request: CreateFranchiseeRequest)

fun createIikoConnection(actor: FranchisePrincipal, request: CreateIikoConnectionRequest)

Передать FranchisePrincipal в операцию Админки ГК невозможно на уровне компиляции.

Роли и разрешения

На первом этапе нужны две роли с одинаковым отображаемым названием «Администратор», но разными типами:

enum class AdminRole {
    ADMIN,
}

enum class FranchiseRole {
    ADMIN,
}

Роли не сравниваются строковыми константами. Авторизация прикладных операций выполняется по типизированным разрешениям.

AdminPermission

FRANCHISE_VIEW_ALL
FRANCHISE_CREATE
FRANCHISE_EDIT
LEGAL_ENTITY_MANAGE_ALL
FIRST_FRANCHISE_ADMIN_INVITE

AdminRole.ADMIN получает весь этот набор.

FranchisePermission

FRANCHISE_VIEW_OWN
LEGAL_ENTITY_MANAGE
EMPLOYEE_INVITE
IIKO_CONNECTION_MANAGE
IIKO_IMPORT_RUN
RESTAURANT_MANAGE

FranchiseRole.ADMIN получает весь этот набор внутри одного franchiseeId.

Новые роли вводятся как новые отображения «роль → permissions», без изменения проверок в прикладном коде.

HTTP-пространства и JWT audience

/api/admin/v1/**       aud = admin
/api/franchise/v1/**   aud = franchise-office
/api/storefront/v1/**  aud = storefront

Токен Админки ГК:

{
  "sub": "user-id",
  "aud": "admin",
  "typ": "access",
  "membership": "HEAD_COMPANY",
  "permissions": ["FRANCHISE_CREATE", "FRANCHISE_EDIT"]
}

Токен Личного кабинета ФЧ:

{
  "sub": "user-id",
  "aud": "franchise-office",
  "typ": "access",
  "membership": "FRANCHISE",
  "franchisee_id": "franchisee-id",
  "permissions": ["IIKO_CONNECTION_MANAGE", "RESTAURANT_MANAGE"]
}

Помимо стандартных iss, iat, nbf, exp, jti и sid, обязательна проверка aud, typ, статуса пользователя, членства и состояния серверной сессии. Access token действует не более 15 минут.

Для аудитории admin применяется HS256 с секретом не короче 32 байт. После проверки подписи и claims IAM загружает серверную сессию по sid; token принимается только пока сессия, аккаунт и членство ГК активны. Поэтому серверный отзыв применяется немедленно, а не после истечения 15 минут.

Токен franchise-office не принимается /api/admin/**, а токен admin — /api/franchise/**. Даже пользователь с обоими членствами получает два разных токена и явно выбирает интерфейс входа. Покупательский токен storefront не принимается ни одним из этих административных пространств.

Refresh token хранится только в HttpOnly; Secure; SameSite cookie, ротируется при использовании и связан с серверной сессией. Cookie имеют разные path для двух контуров, чтобы браузер не отправлял их в чужой API namespace.

Для обоих контуров каждый вход на устройстве создаёт стабильную iam.login_session; её ID остаётся claim sid при всех refresh. Ротируемые поколения находятся в iam.refresh_credential, где хранится только SHA-256 hash. Successor token воспроизводится через HMAC с секретом аудитории. Повтор старого token в течение 30 секунд идемпотентно возвращает тот же successor, а reuse после этого окна отзывает login-session целиком. Поэтому вкладки одного браузерного профиля безопасно делят cookie, а разные устройства имеют независимые login-session. Обычный logout отзывает текущее устройство; смена пароля и блокировка доступа отзывают все его сессии соответствующего контура.

Вход

Минимальные endpoints:

POST /api/admin/v1/auth/login
POST /api/admin/v1/auth/refresh
POST /api/admin/v1/auth/logout

POST /api/franchise/v1/auth/login
POST /api/franchise/v1/auth/refresh
POST /api/franchise/v1/auth/logout

Оба интерфейса используют один password hash и общий механизм защиты от перебора. После проверки credentials iam проверяет наличие активного членства требуемого типа и выпускает токен нужной аудитории.

Доменная часть входа сотрудника ГК возвращает AdminPrincipal с permissions роли только для активного аккаунта и активного членства ГК. При временном пароле principal не получает permissions до обязательной смены пароля. Все причины отказа представлены одной ошибкой и не раскрывают наличие аккаунта или членства.

Смена временного пароля повторно проверяет текущие credentials и активный доступ, запрещает совпадение нового пароля с текущим, атомарно заменяет hash и снимает password_change_required. После этого AdminPrincipal получает permissions роли.

У пользователя может быть только одно членство ФЧ, поэтому выбирать ФЧ при входе не требуется. Реализованный FranchiseAuthentication берёт franchiseeId из единственного активного членства и возвращает отдельный FranchisePrincipal. Вход отклоняется при блокировке членства состоянием ФЧ. Все причины отказа объединены в одну ошибку. При временном пароле principal не получает permissions.

Первый сотрудник ГК создаётся отдельным одноразовым bootstrap-процессом. Временный пароль передаётся через Kubernetes/Docker Compose Secret либо интерактивную локальную команду и требует обязательной смены. Признаком выполненного bootstrap служит наличие любого членства ГК. Flyway не создаёт известные credentials. Обычный публичный endpoint регистрации сотрудников отсутствует. Процесс изолирован от основного приложения и описан в архитектуре bootstrap.

Приглашение первого администратора ФЧ

Сотрудник ГК не создаёт и не видит пароль сотрудника ФЧ.

AdminPrincipal
      │
      ▼
admin
      ├── franchise::api.createFranchisee(...)
      ├── franchise::api.createLegalEntity(...)
      └── iam::api.inviteFranchiseAdmin(franchiseeId, email)

iam:

  1. Находит UserAccount контура FRANCHISE парой (FRANCHISE, email) или создаёт его в статусе INVITED. Учётная запись сотрудника ГК с тем же email сценарию не видна и не изменяется.
  2. Создаёт FranchiseMembership с ролью FranchiseRole.ADMIN.
  3. Генерирует криптографически случайный одноразовый setup token.
  4. Сохраняет только hash токена и срок действия.
  5. Отправляет ссылку через модуль уведомлений после commit.

Принятие приглашения:

POST /api/franchise/v1/invitations/accept

Пользователь задаёт пароль, токен погашается в одной транзакции, аккаунт и членство активируются. Повторное использование, истёкший или отозванный токен отклоняются. Токен действует 72 часа, и на момент погашения повторно проверяется актуальность состояния: учётная запись должна оставаться INVITED, а ФЧ — ACTIVE. Поэтому приглашение не реактивирует отключённый аккаунт, не открывает доступ к отключённому ФЧ и не может заменить пароль уже действующего сотрудника.

Создание ФЧ и отправка приглашения не маскируются под одну распределённую транзакцию. Если отправка или создание приглашения не удались, ФЧ остаётся созданным, а Админка ГК показывает незавершённую настройку и позволяет повторить приглашение.

Работа администратора ФЧ

После входа FranchisePrincipal.franchiseeId является обязательной областью каждой операции. Клиент не передаёт franchiseeId в body для операций «своего» кабинета.

Создание iiko-подключения

  1. Сотрудник выбирает юридическое лицо своего ФЧ.
  2. franchiseoffice проверяет IIKO_CONNECTION_MANAGE.
  3. Через franchise::api проверяется принадлежность legalEntityId текущему franchiseeId.
  4. Через iiko::api создаётся подключение с неизменяемым владельцем legalEntityId.
  5. apiLogin сохраняется в БД зашифрованным и больше не возвращается через API (требования — в модели франчайзинга, раздел «Секреты юридического лица»).
  6. Запускается импорт организаций.

Передать юридическое лицо другого ФЧ невозможно: проверка выполняется сервером до вызова iiko.

Создание ресторана

  1. Сотрудник выбирает импортированную организацию своего iiko-подключения.
  2. Сервер проверяет цепочку владения organization → connection → legalEntity → franchisee.
  3. restaurant::api создаёт или возвращает существующий DRAFT для пары подключения и организации.
  4. Сотрудник заполняет обязательные настройки.
  5. Ресторан активируется отдельной командой.

Связь ресторана с организацией неизменяема. Смена юридического лица оформляется закрытием старого и созданием нового ресторана, как описано в модели франчайзинга.

Проверка владения и защита от IDOR

Наличие permission недостаточно. Каждая операция Личного кабинета ФЧ проверяет принадлежность объекта FranchisePrincipal.franchiseeId.

LegalEntity      → franchiseeId
IikoConnection   → legalEntityId → franchiseeId
IikoOrganization → connectionId → legalEntityId → franchiseeId
Restaurant       → organization → connection → legalEntity → franchiseeId

Правила:

  • franchiseeId берётся из проверенного principal, а не из request body;
  • публичные API модулей предоставляют операции проверки владения без прямых SQL join из presentation-модулей;
  • bulk-операции проверяют все ID до изменения хотя бы одного объекта;
  • отсутствие объекта и отсутствие доступа наружу могут возвращать одинаковый 404, чтобы не раскрывать существование чужих данных;
  • frontend-фильтры не считаются механизмом безопасности.

Архитектура отдельных SPA, отображение навигации по permissions и контракт allowedActions для действий, зависящих от состояния объекта, описаны в документе об административных frontend-приложениях.

Деактивация и операционные блокировки

Пользователь

UserAccount.DISABLED отзывает все refresh-сессии этой учётной записи и запрещает выпуск новых токенов её контура. Человек, работающий в обоих контурах, имеет две учётные записи, и отключать нужно каждую.

Членство

Отключение HeadCompanyMembership закрывает только доступ в Админку ГК. Отключение конкретного FranchiseMembership закрывает только доступ к соответствующему ФЧ.

Франчайзи

FranchiseeStatusChanged(DISABLED) не перезаписывает собственный статус членства. iam ставит операционную блокировку FRANCHISEE_DISABLED на членства этого ФЧ и отзывает их активные сессии. После активации ФЧ блокировка снимается, а вручную отключённые членства остаются отключёнными. Обработка выполняется синхронно, в одной транзакции со сменой статуса ФЧ, чтобы между commit и блокировкой доступа не оставалось окна.

Тот же принцип применяется к ресторанам и импорту iiko: собственный статус объекта и внешняя операционная блокировка хранятся раздельно.

Аудит

Каждая изменяющая операция записывает:

actor_user_id
actor_context: ADMIN | FRANCHISE
actor_franchisee_id (для кабинета ФЧ)
permission
action
target_type
target_id
before / after или структурированный diff
request_id
ip
user_agent
occurred_at
result

Обязательны аудит входа, неуспешного входа, приглашений, изменения ролей, отключения пользователей, создания ФЧ, юридических лиц, iiko-подключений и ресторанов. Пароли, setup token, refresh token, apiLogin и их hash в аудит не попадают.

Владельцем общей инфраструктуры аудита должен быть будущий модуль platform; presentation-модули передают ему типизированный actor context.

Правила безопасности

  • нет публичной самостоятельной регистрации сотрудников;
  • пароль хранится только через адаптивный PasswordEncoder;
  • setup и refresh token хранятся только в виде hash;
  • логин, refresh и приглашения защищены rate limit;
  • после изменения пароля или ролей старые сессии отзываются;
  • чувствительные операции в будущем требуют MFA, для ГК MFA имеет более высокий приоритет;
  • access и refresh token различаются по typ и не взаимозаменяемы;
  • JWT algorithm, issuer и audience задаются конфигурацией, а не выбираются из входного токена;
  • authorization выполняется на сервере по permission и ownership;
  • секрет iiko не возвращается после сохранения и не попадает в логи;
  • ошибки не раскрывают наличие чужих пользователей или объектов.

Отложенные возможности

На первом этапе не нужны:

  • произвольный редактор ролей;
  • SSO/SAML/OIDC головной компании;
  • обязательная MFA;
  • временный доступ поддержки;
  • impersonation сотрудника ФЧ;
  • несколько брендов;
  • сложные должности и организационная иерархия;
  • географическая модель городов, пригородов и микрорайонов;
  • мобильное приложение для сотрудников.

Поэтапная реализация

Каждый этап реализуется и принимается отдельно.

  1. Создать каркас iam и его публичный API без таблиц. Выполнено.
  2. Реализовать UserAccount и отдельные password credentials. Выполнено.
  3. Реализовать HeadCompanyMembership, AdminRole.ADMIN и bootstrap первого администратора ГК. Выполнено.
  4. Реализовать проверку credentials и активного членства для входа сотрудника ГК без HTTP и сессий. Выполнено.
  5. Реализовать обязательную смену временного пароля. Выполнено.
  6. Реализовать FranchiseMembership, типизированные роли и permissions. Выполнено.
  7. Реализовать создание и отзыв приглашения первого FranchiseRole.ADMIN. Выполнено.
  8. Реализовать принятие приглашения сотрудником ФЧ. Выполнено.
  9. Реализовать проверку credentials и активного членства для входа сотрудника ФЧ без HTTP и сессий. Выполнено.
  10. Реализовать серверные refresh-сессии аудитории ADMIN. Выполнено.
  11. Реализовать выпуск access JWT аудитории admin. Выполнено.
  12. Создать каркас admin, security chain /api/admin/v1/** и endpoint login; использовать AdminPrincipal из iam::api. Выполнено.
  13. Реализовать endpoint refresh с одноразовой ротацией cookie. Выполнено.
  14. Добавить bearer-аутентификацию и endpoint обязательной смены временного пароля. Выполнено.
  15. Реализовать logout. Выполнено.
  16. Реализовать сценарий создания ФЧ, юридического лица и приглашения первого администратора.
  17. Реализовать серверные refresh-сессии аудитории FRANCHISE_OFFICE. Выполнено.
  18. Реализовать выпуск и проверку access JWT аудитории franchise-office. Выполнено.
  19. Создать каркас franchiseoffice, security chain /api/franchise/v1/** и HTTP login с FranchisePrincipal. Выполнено.
  20. Реализовать HTTP refresh с одноразовой ротацией cookie. Выполнено.
  21. Добавить bearer-аутентификацию и logout Личного кабинета ФЧ. Выполнено.
  22. Добавить первый ownership-сценарий: список юридических лиц из FranchisePrincipal. Выполнено.
  23. Разделить учётные записи по контурам занятости: UserAccount.context, уникальность email внутри контура и составные FK на членствах. Выполнено.
  24. Реализовать создание, список и отключение iiko-подключений сотрудником ФЧ с ownership-проверкой и без возврата apiLogin. Выполнено.
  25. Реализовать создание и настройку ресторана сотрудником ФЧ.
  26. Добавить аудит изменяющих операций и security-интеграционные тесты обоих контуров.

На каждом этапе обновляется этот документ и документация затронутых модулей.