# ADR-0013. Сотрудники: учётные записи, членство, роли, сессии

- Статус: принято; реализуется на этапе 6 — ✅ 6a (ядро: учётные записи, членство, доступ, сеансы),
  ✅ 6b-1 (вход в кабинеты с TOTP, профиль, «Мои сеансы», ADR-0014), ✅ 6b-2 (администрирование учётных записей
  из консоли), ✅ 6b-3 (почта сотрудникам, забытый пароль), ✅ 6b-4 (приглашения в консоль и в команды мерчантов)
- Дата: 2026-10-01

## Контекст

Кабинеты этапа 6 (консоль платформы, кабинет партнёра, кабинет мерчанта) открывают систему людям: сотрудникам
платформы, партнёров и мерчантов. Один человек может работать у нескольких мерчантов (агентство, сотрудник
партнёра), у партнёра десятки мерчантов, а захват учётной записи сотрудника опаснее захвата одного ключа API:
через кабинет видны данные участников и выпускаются ключи. Стандартные таблицы Laravel (`users` с открытым e-mail,
`password_reset_tokens` по e-mail, remember-me) этим требованиям не отвечают.

## Решение

- **Одна глобальная учётная запись на человека** (модуль `identity`, таблица `users`, UUIDv7). E-mail — ПДн и
  хранится как контакты участников (ADR-0008): шифртекст ключом ПДн с привязкой к записи, слепой индекс
  `BlindIndex::of('platform', 'user.email', …)` для поиска и маска для интерфейсов. Наружу модуль отдаёт только маску.
  Токены сброса пароля лежат в кэше и ключатся id записи, а не e-mail; смена пароля отменяет выданную ссылку сброса.
- **Три уровня членства:** операторы платформы (`platform_staff`), команды партнёров (`partner_memberships`) и
  команды мерчантов (`tenant_memberships`, данные тенанта под RLS). До выбора мерчанта членства одного человека во
  всех тенантах читает узкая функция `staff_tenant_memberships(user_id)` (SECURITY DEFINER, владелец схемы, право
  выполнения — только у групп runtime-ролей).
- **Фиксированные роли и права-перечисления.** Мерчант: Владелец, Администратор, Маркетолог, Поддержка, Аналитик.
  Партнёр: Владелец, Администратор, Разработчик, Поддержка, Наблюдатель. Платформа: Администратор, Поддержка.
  Матрица прав закреплена тестом `RoleMatrixTest`; её изменение — решение владельца продукта. Чувствительные права
  (выпуск учётных данных, публикация правил и текстов согласий, раскрытие и выгрузка ПДн и кодов, команда,
  обезличивание и объединение участников) требуют свежего подтверждения 2FA (6b) и никогда не доступны через доступ
  поддержки.
- **Доступ сотрудников партнёра к его мерчантам без приглашения:** Владелец и Администратор партнёра работают у
  мерчанта как Администратор, Поддержка — как Поддержка, Наблюдатель — как Аналитик, Разработчик в кабинеты
  мерчантов не входит; только пока партнёр активен. **Командой мерчанта такой доступ не управляет**: команда —
  дело самого мерчанта, иначе сотрудники партнёра могли бы снять его запрет с коллеги. Прямое членство в команде
  мерчанта важнее роли партнёра, а **отключённое членство — явный запрет**: мерчант может закрыть доступ
  конкретному сотруднику партнёра. Первого владельца мерчанта назначает приглашение (6b) или командная строка.
- **Правила команд — в домене:** изменения делает тот, у кого есть `team.manage`; всё, что касается владельцев, —
  только с `team.manage_owners`; свою роль и своё членство никто не меняет; владелец с работающей учётной записью
  остаётся всегда (отключённая или обезличенная запись владельцем не считается). Строки владельцев блокируются в
  порядке id, поэтому два параллельных понижения не проходят оба и не дают взаимоблокировки (интеграционный тест).
  То же для операторов: последний активный администратор платформы остаётся. Командная строка (`staff:*`)
  действует как система: пропускает проверки прав и «своего» членства, но не правило последнего владельца.
- **Разделение обязанностей:** оператор платформы не состоит в командах партнёров и мерчантов, член команды не
  становится оператором — для этого нужна вторая учётная запись. Проверка идёт под блокировкой записи
  (advisory lock), поэтому параллельные выдачи не обходят её.
- **`StaffAccess` решает на каждом запросе** и ничего не кэширует: отозванное членство действует со следующего
  запроса. Он не читает и не меняет контекст тенанта, поэтому безопасен внутри транзакций. Порядок: запись активна
  → мерчант существует и не закрыт → прямое членство (отключённое запрещает) → членство в команде активного
  партнёра мерчанта → (6d) доступ поддержки. У приостановленного мерчанта или партнёра доступ только на чтение — кроме
  отзыва ключей мерчанта (`keys.revoke`): он только защищает, и утёкший ключ можно остановить и у приостановленного.
- **Пароли:** Argon2id; 12–128 символов; список распространённых паролей и раскладочных последовательностей в
  репозитории (латиница и кириллица), без обращений во внешние сервисы; запрет на части e-mail и имени от 4 символов
  и на пароли из одного-двух символов; без правил состава и принудительной смены (NIST SP 800-63B). Смена пароля
  завершает остальные сеансы, сброс — все. Пересчёт хеша при входе (новые параметры Argon2id) заменяет только тот
  хеш, который проверялся, и не может вернуть старый пароль после смены.
- **Вход:** попытки считаются до проверки пароля (атомарный счётчик в кэше, поэтому параллельные запросы не
  проскакивают одну проверку): 5 в минуту на e-mail с одного адреса и 20 в минуту с одного адреса на любые e-mail
  (распыление паролей, нагрузка хешированием); успешный вход возвращает свои попытки. **Счётчика на e-mail отовсюду
  нет:** он позволил бы кому угодно заблокировать известного сотрудника, а подобранный пароль без второго фактора
  бесполезен; неудачи видны в журнале безопасности. Ключи счётчиков — HMAC e-mail, не сам адрес и не его простой
  хеш. Неизвестная, отключённая или ещё без пароля запись стоит той же одной проверки хеша, что и настоящая, а
  неудачная проверка длится не меньше 400 мс (`auth.timebox_duration`). Второй фактор: 5 попыток за 15 минут и не
  больше 20 неудач в сутки на запись (вход, подтверждения в профиле, коды восстановления). Код принимается в свои 30
  секунд и по шагу до и после — одновременно действуют три кода, и шестизначный код за год угадывается с вероятностью
  около 2 %. Remember-me нет. TOTP обязателен всем с первого входа (ADR-0014).
- **Реестр сеансов `staff_sessions`** поверх хранилища сессий Laravel: строка на вход, абсолютный срок 12 часов,
  простой — 30 минут в консоли и 120 в кабинетах; активность консоли считается отдельно, поэтому работа в кабинете
  мерчанта не продлевает консоль. Опросы (`passive`) активностью не считаются. Middleware
  `identity.session:<panel>` выходит из сеанса, строка которого закрыта, простаивает или истекла, очищает сессию и
  отправляет на страницу входа кабинета (`PanelGate::loginUrl`), без неё — 401. Вход поверх открытого сеанса (в том
  числе другой учётной записи) закрывает прежний. Пользователь видит свои сеансы и завершает их.
- **Забытый пароль** (`PasswordResets`): «Забыли пароль?» на странице входа каждого кабинета. Ответ один и тот же
  для любого адреса и приходит за одно время: учётную запись ищет и письмо отправляет задача очереди (зашифрованная,
  без повторов), поэтому вне разработки очередь должна быть асинхронной (`QUEUE_CONNECTION` не `sync`). Ссылка уходит
  только на адрес самой записи и только если запись может входить в этот кабинет, не больше 3 ссылок в час на
  запись, кто бы ни просил, и не больше 2 запросов в минуту с одного адреса; поток запросов с множества адресов (в
  том числе IPv6 из одной сети /64) останавливает пограничный прокси. Ссылка действует 60 минут и один раз
  (`auth.passwords.users.expire`); новая ссылка или смена пароля отменяют прежнюю, отключение и обезличивание —
  навсегда (включённая снова запись старую ссылку не принимает). Ссылка подписана, называет запись по id, а не по
  e-mail, и строится от `APP_URL` и `CONSOLE_DOMAIN`, а не от заголовка `Host` запроса, который мог подделать кто
  угодно. Ссылка применяется под блокировкой строки записи: два применения одной ссылки или применение и одновременное
  изменение записи вместе не проходят. Новый пароль проверяется политикой и завершает все сеансы; второй фактор при
  следующем входе спрашивается как обычно. Отправка ссылки со страницы входа и сброс пишутся в журнал безопасности с
  адресом и браузером того, кто просил; ссылка администратора — с его id (`initiated_by`), адрес администратора — в
  журнале аудита.
- **Приглашения** (`Invitations`): в консоль (операторы) и в команду мерчанта; приглашения в команды партнёров
  появятся с кабинетом партнёра (6i). Пригласить может тот, кому команда разрешает добавлять людей: `operators.manage`
  в консоли, `team.manage` в команде мерчанта и `team.manage_owners` для владельца (и чтобы заменить ждущее
  приглашение владельца); не больше 20 приглашений в час от
  одного человека (`identity.invitations.per_hour`), чтобы почтой платформы нельзя было рассылать что угодно. Ссылка
  уходит только письмом на приглашённый адрес и действует 72 часа и один раз; новое приглашение на тот же адрес в ту
  же команду заменяет прежнее, приглашение можно отозвать. Пригласивший ничего не узнаёт об адресе: ответ один и тот
  же, есть ли у адреса учётная запись. Адрес хранится как адрес учётной записи (шифртекст, слепой индекс, маска) и
  стирается, когда приглашение принято или отозвано; старые приглашения удаляются через 30 дней
  (`identity:prune-invitations`). Ссылка — `<id>.<секрет>`: 12 символов, по которым приглашение находится, и 256-битный
  секрет, от которого хранится только SHA-256; приглашения мерчантов лежат под RLS, до выбора мерчанта их находит
  узкая функция `tenant_invitation_candidates(token_id)`. Принимает приглашение страница по ссылке: новый человек
  задаёт имя и пароль (политика платформы), у кого учётная запись уже есть — принимает как есть (ссылку получает
  только владелец адреса, как и ссылку сброса пароля; без пароля и второго фактора принятое ничего не открывает).
  Отключённая учётная запись принять не может, оператор платформы не меняет так свою роль (это делается в «Сотрудниках
  платформы»), а снятие с роли оператора отзывает его ждущие приглашения в консоль; в закрытый мерчант не вступают.
  Человека добавляет в команду её же сервис от имени пригласившего: роли,
  владельцы и правило «операторы платформы не работают в командах» проверяются при принятии, и если пригласивший к
  этому времени потерял право добавлять людей, приглашение не действует. Принятие пишется в журнал безопасности
  (`invitation_accepted`) и в журнал аудита команды (`invitations.accept`, исполнитель — тот, кто присоединился).
- **Письма сотрудникам** — уведомления Laravel на русском: в очереди, зашифрованы (ссылка и адрес не лежат в
  таблицах очереди открыто), уходят только после фиксации транзакции изменения. Вне локальной разработки, тестов и
  локальной песочницы они не отдаются почтовым драйверам `log` и `array`, которые оставили бы ссылку и адрес в
  журнале или в памяти: такое письмо не уходит, задача падает с ошибкой без личных данных.
- **Администрирование учётных записей** (`AccountAdministration`): отключить, включить, сбросить второй фактор,
  отправить ссылку для смены пароля (она уходит на адрес самого человека — в консоль, если человек оператор, иначе в
  его кабинет; администратор её не видит; ограничения «3 в час» у неё нет — действие пишется в журнал аудита). Запись
  без кабинета, где есть смена пароля, получает отказ, и ссылка, которую человек запросил сам, остаётся в силе. О
  сбросе второго фактора человек узнаёт из письма.
  Нужно право `accounts.manage`, свою учётную запись администратор так не меняет (`own_account_change`),
  последнего действующего администратора платформы отключить нельзя (`last_owner`) — и командой `staff:disable` тоже.
  Сброс второго фактора удаляет приложение и коды восстановления и завершает сеансы; при следующем входе человек
  подключит приложение заново. Изменения из консоли подтверждаются вторым фактором не старше 10 минут. Блокировки
  берутся в одном порядке — учётная запись, которую меняют, затем администраторы платформы с их учётными записями, — и
  права действующего оператора проверяются ещё раз уже под ними: изменение, которое за это время отняло у него роль
  или учётную запись, не даёт ему довести своё.
- **Журнал безопасности учётных записей** `user_security_events` (append-only, только id в контексте): входы,
  ошибки, смены пароля и 2FA, отправленные ссылки для смены пароля, завершения сеансов, отключение и обезличивание. Он отделён от журнала аудита
  (ADR-0010) и хранится 12 месяцев (помесячные партиции на этапе эксплуатации).
- **Обезличивание сотрудника** стирает e-mail, имя, пароль и секреты 2FA, навсегда отключает запись, снимает роль
  оператора, отключает членство в командах партнёров и удаляет его сеансы с адресами и браузерами (членство у
  мерчантов остаётся записью, но отключённой учётной записи ничего не даёт). Id остаётся, чтобы записи аудита и
  проводки леджера сохраняли смысл; журнал безопасности хранит адреса до истечения срока.
- **Песочница:** сотрудники интеграторов — клиенты платформы, поэтому в песочнице принимаются их настоящие e-mail и
  письма им уходят (поправка к ADR-0012); правило «только тестовые данные» остаётся для участников программ.
- **Конфигурация вне локальной разработки и тестов:** страницы с сессией (группа `web` и панели кабинетов) не
  обслуживаются, если данные сессии не шифруются (`SESSION_ENCRYPT`), cookie не `Secure` или не `HttpOnly`,
  отправляется другим сайтам (`SameSite` не `lax`/`strict`), разделена с поддоменами, не называется `__Host-…` или
  включён `APP_DEBUG`.
  Проверка идёт на запросе, а не при загрузке: команды (`composer install`, миграции) работают до настройки
  окружения, а API без сессий от неё не зависят. Доверенные прокси (`TRUSTED_PROXIES`) настраиваются явно: без них
  за прокси все запросы шли бы с его адреса. Страницы входа на уровне приложения нет: неаутентифицированный запрос
  получает 401, кабинеты отправляют на свои страницы входа.

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

- Кабинеты (6b–6i) строятся на контрактах `Modules\Identity\Contracts`: `StaffUsers`, `StaffAccess`,
  `TenantMemberships`, `PartnerMemberships`, `PlatformOperators`, `StaffSessions`, `SecurityEvents`, `LoginThrottle`,
  `MfaThrottle`, `PanelGate`, `AccountAdministration`, `PasswordResets`, `Invitations`.
- Коды ошибок модуля пока внутренние (`Tests\Support\ProblemCodes::INTERNAL`): ни один API ими не отвечает.
- **Остаточный риск:** runtime-группы ролей БД по умолчанию могут писать в глобальные таблицы сотрудников, и
  SQL-инъекция в публичном API теоретически могла бы изменить учётную запись. Отзыв прав на часть таблиц не помогает
  (вход сам пишет в `users`), поэтому настоящая защита — отдельная роль БД для трафика публичных API без прав на
  таблицы сотрудников; это пункт 11.4 дорожной карты.
- Удалить учётную запись нельзя (на неё ссылаются журнал безопасности и аудит) — только обезличить.
