Админка ГК, Личный кабинет франчайзи и доступ сотрудников¶
Статус документа¶
Документ фиксирует согласованную целевую модель. В 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 доменных модулей.
Минимальные зависимости:
Позже могут добавиться зависимости на iiko::api и restaurant::api для поддержки и просмотра
всей сети. Модуль не владеет бизнес-таблицами.
franchiseoffice¶
HTTP/application-модуль Личного кабинета ФЧ. Принимает только FranchisePrincipal, ограниченный
одним franchiseeId.
Зависимость на 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¶
Текущий алгоритм — bcrypt через DelegatingPasswordEncoder, поэтому hash содержит идентификатор
алгоритма и допускает будущую миграцию. Пароль ограничен диапазоном 12–72 UTF-8 байта: верхняя
граница предотвращает незаметное усечение bcrypt. Пока password_change_required=true, сотрудник
может только сменить пароль или завершить сессию и не получает административных permissions.
HeadCompanyMembership¶
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 в операцию Админки ГК невозможно на уровне компиляции.
Роли и разрешения¶
На первом этапе нужны две роли с одинаковым отображаемым названием «Администратор», но разными типами:
Роли не сравниваются строковыми константами. Авторизация прикладных операций выполняется по типизированным разрешениям.
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:
- Находит
UserAccountконтураFRANCHISEпарой(FRANCHISE, email)или создаёт его в статусеINVITED. Учётная запись сотрудника ГК с тем же email сценарию не видна и не изменяется. - Создаёт
FranchiseMembershipс рольюFranchiseRole.ADMIN. - Генерирует криптографически случайный одноразовый setup token.
- Сохраняет только hash токена и срок действия.
- Отправляет ссылку через модуль уведомлений после commit.
Принятие приглашения:
Пользователь задаёт пароль, токен погашается в одной транзакции, аккаунт и членство активируются.
Повторное использование, истёкший или отозванный токен отклоняются. Токен действует 72 часа, и на
момент погашения повторно проверяется актуальность состояния: учётная запись должна оставаться
INVITED, а ФЧ — ACTIVE. Поэтому приглашение не реактивирует отключённый аккаунт, не открывает
доступ к отключённому ФЧ и не может заменить пароль уже действующего сотрудника.
Создание ФЧ и отправка приглашения не маскируются под одну распределённую транзакцию. Если отправка или создание приглашения не удались, ФЧ остаётся созданным, а Админка ГК показывает незавершённую настройку и позволяет повторить приглашение.
Работа администратора ФЧ¶
После входа FranchisePrincipal.franchiseeId является обязательной областью каждой операции.
Клиент не передаёт franchiseeId в body для операций «своего» кабинета.
Создание iiko-подключения¶
- Сотрудник выбирает юридическое лицо своего ФЧ.
franchiseofficeпроверяетIIKO_CONNECTION_MANAGE.- Через
franchise::apiпроверяется принадлежностьlegalEntityIdтекущемуfranchiseeId. - Через
iiko::apiсоздаётся подключение с неизменяемым владельцемlegalEntityId. apiLoginсохраняется в БД зашифрованным и больше не возвращается через API (требования — в модели франчайзинга, раздел «Секреты юридического лица»).- Запускается импорт организаций.
Передать юридическое лицо другого ФЧ невозможно: проверка выполняется сервером до вызова iiko.
Создание ресторана¶
- Сотрудник выбирает импортированную организацию своего iiko-подключения.
- Сервер проверяет цепочку владения
organization → connection → legalEntity → franchisee. restaurant::apiсоздаёт или возвращает существующийDRAFTдля пары подключения и организации.- Сотрудник заполняет обязательные настройки.
- Ресторан активируется отдельной командой.
Связь ресторана с организацией неизменяема. Смена юридического лица оформляется закрытием старого и созданием нового ресторана, как описано в модели франчайзинга.
Проверка владения и защита от 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 сотрудника ФЧ;
- несколько брендов;
- сложные должности и организационная иерархия;
- географическая модель городов, пригородов и микрорайонов;
- мобильное приложение для сотрудников.
Поэтапная реализация¶
Каждый этап реализуется и принимается отдельно.
- Создать каркас
iamи его публичный API без таблиц. Выполнено. - Реализовать
UserAccountи отдельные password credentials. Выполнено. - Реализовать
HeadCompanyMembership,AdminRole.ADMINи bootstrap первого администратора ГК. Выполнено. - Реализовать проверку credentials и активного членства для входа сотрудника ГК без HTTP и сессий. Выполнено.
- Реализовать обязательную смену временного пароля. Выполнено.
- Реализовать
FranchiseMembership, типизированные роли и permissions. Выполнено. - Реализовать создание и отзыв приглашения первого
FranchiseRole.ADMIN. Выполнено. - Реализовать принятие приглашения сотрудником ФЧ. Выполнено.
- Реализовать проверку credentials и активного членства для входа сотрудника ФЧ без HTTP и сессий. Выполнено.
- Реализовать серверные refresh-сессии аудитории
ADMIN. Выполнено. - Реализовать выпуск access JWT аудитории
admin. Выполнено. - Создать каркас
admin, security chain/api/admin/v1/**и endpoint login; использоватьAdminPrincipalизiam::api. Выполнено. - Реализовать endpoint refresh с одноразовой ротацией cookie. Выполнено.
- Добавить bearer-аутентификацию и endpoint обязательной смены временного пароля. Выполнено.
- Реализовать logout. Выполнено.
- Реализовать сценарий создания ФЧ, юридического лица и приглашения первого администратора.
- Реализовать серверные refresh-сессии аудитории
FRANCHISE_OFFICE. Выполнено. - Реализовать выпуск и проверку access JWT аудитории
franchise-office. Выполнено. - Создать каркас
franchiseoffice, security chain/api/franchise/v1/**и HTTP login сFranchisePrincipal. Выполнено. - Реализовать HTTP refresh с одноразовой ротацией cookie. Выполнено.
- Добавить bearer-аутентификацию и logout Личного кабинета ФЧ. Выполнено.
- Добавить первый ownership-сценарий: список юридических лиц из
FranchisePrincipal. Выполнено. - Разделить учётные записи по контурам занятости:
UserAccount.context, уникальность email внутри контура и составные FK на членствах. Выполнено. - Реализовать создание, список и отключение iiko-подключений сотрудником ФЧ с ownership-проверкой
и без возврата
apiLogin. Выполнено. - Реализовать создание и настройку ресторана сотрудником ФЧ.
- Добавить аудит изменяющих операций и security-интеграционные тесты обоих контуров.
На каждом этапе обновляется этот документ и документация затронутых модулей.