# G7-B — Дверь «вход под клиентом» + машинный доступ для ИИ — ДИЗАЙН (спека)

**Дата:** 2026-06-19. **Статус:** дизайн согласован с владельцем в брейншторме, готов к writing-plans.
**Ветка/HEAD:** `gitea/main` @ `734ed08c`. **Предшественник:** хэндофф `docs/superpowers/specs/2026-06-19-g7b-impersonation-handoff.md`.
**Go-live находка:** G7-B (после неё остаётся только G5 — ждёт ООО/Б-1).

---

## 0. Одной фразой

Достраиваем уже наполовину собранный режим «Войти как клиент» (ТЗ §22.7 / Ю-1) до **реально работающей, безопасной, аудируемой двери** в кабинет клиента — **на стаб-админе** (без настоящего SSO, который ждёт ООО). Дверь делаем **operator-agnostic**: в неё может войти и человек (браузер), и **будущий ИИ-агент** (машинный ключ доступа). Сам ИИ-агент-«мозг» и интеграция каналов (чат/почта) — **следующий проект**, сегодня не строим.

---

## 1. Контекст и решения владельца (зафиксировано в брейншторме)

- **«Доработать» режим** (не убирать, не оставлять как есть).
- **Строить реальную сессию сейчас, на стаб-админе** (override рекомендации «после ООО»). Смягчающий контроль: 6-значный код-согласие уходит на почту **самого клиента** — без него входа нет.
- **Права человека-оператора в кабинете — полный доступ** (осознанный выбор владельца, видел предупреждение про 152-ФЗ).
- **За руль садим ИИ-агента, не человека** (стратегическая цель). Поэтому дверь обязана быть машинно-проходимой → **машинный ключ доступа (`Bearer`-токен)**.
- **ИИ должен решать проблемы клиентов через каналы:** форма/почта «Помощь» (наша БД) и чат на сайте. **Чат JivoSite (внешний бот) — ОТКЛОНЁН владельцем как неконтролируемый.** Целимся в свой ИИ-чат внутри кабинета. **Вопрос канала-чата заморожен — вернёмся позже** (см. §8).
- **Сегодня главное — допилить портал (дверь).** Каналы и «мозг» — следующий проект.

---

## 2. Что уже собрано (переиспользуем, не переписываем)

- `ImpersonationController` — `init` (генерит 6-зн. код, хеш в `impersonation_tokens`, TTL 15 мин, audit), `verify` (Hash::check, 5 попыток, ставит `used_at`), `end` (ставит `session_ended_at`), `active`/`recent`. Всё через `pgsql_supplier` (BYPASSRLS).
- `ImpersonationToken` (поле `session_id` уже есть, оставляем NULL), `ImpersonationAuditService` (recordInit/Verify/End, стаб-атрибуция `super_admin` через `ResolvesAdminUserId`).
- Фронт: `ImpersonationDialog.vue` (3-step), админский `ImpersonationBanner.vue` (счётчик активных — это НЕ клиентская плашка), привязки в Admin-вьюхах.
- Роуты `/api/admin/impersonation/{init,verify,end,active,recent}` под `saas-admin` (на проде дверь админки держит **nginx HTTP Basic Auth**, `EnsureSaasAdmin` = passthrough).
- Механика входа в кабинет: **Sanctum SPA cookie-сессия**, web-guard. `Auth::login($user)` → сессия. RLS-периметр ставит `SetTenantContext` из `auth()->user()->tenant_id`. Один Vue-SPA (shell `welcome.blade`), клиентские роуты (layout `app`) и админские (layout `admin`).
- Образец письма-кода: `RegistrationService::sendCode` + `EmailVerificationCodeMail` + `emails/email_verification_code.blade.php` (очередь + try/catch + без ПДн в логах).
- Образец машинного ключа: `ApiKey` + `ApiKeyAuth` middleware (G6) — `Authorization: Bearer lpkapi_...`, bcrypt-хеш + префикс. Sanctum personal access tokens в проекте **не используются** — идём по образцу `ApiKeyAuth`.

---

## 3. Объём (что строим сегодня)

### 3.1. Два письма клиенту (закрываем TODO в коде)
- **`init`:** отправить 6-зн. код на `tenant.contact_email`. Новый mailable `ImpersonationCodeMail` + blade `emails/impersonation_code.blade.php`. Текст — про **согласие**: «Поддержка Лидерры запросила вход в ваш кабинет. Если это вы инициировали — передайте код сотруднику. Если нет — игнорируйте письмо, вход без кода невозможен.» Очередь + try/catch (best-effort, паттерн `sendCode`). Заменяет TODO `ImpersonationController.php:137`.
- **`end`:** уведомление клиенту о завершении сессии. Новый mailable `ImpersonationEndedMail` + blade `emails/impersonation_ended.blade.php`. Заменяет TODO `:235`. Срабатывает и на `end` (из админки), и на `leave` (из кабинета).

### 3.2. Реальная сессия — путь ЧЕЛОВЕКА (браузер)
- На `verify` success (после `used_at = NOW()`): резолвим **целевого пользователя тенанта** = самый ранний активный пользователь тенанта (`is_active = true`, `ORDER BY id ASC`) через `pgsql_supplier`.
- `Auth::login($targetUser)` (web-guard) + кладём в сессию **маркер impersonation**: `{token_id, tenant_id, target_user_id, started_at}`.
- **Лимит 1 час:** `session_expires_at = used_at + 60 мин` (вычисляемо, без новой колонки).
- Фронт `ImpersonationDialog`: на success **редирект всей страницы на `/dashboard`** (браузер пере-бутстрапит SPA уже как клиент). + показать **копируемый машинный ключ** (для ИИ/интеграции; на dev/testing — всегда, на prod — по флагу).

### 3.3. Машинный ключ доступа — путь ИИ
- На `verify` генерим высокоэнтропийный bearer **`lpimp_<token_id>_<secret>`**; храним **только хеш** в **новой колонке** `impersonation_tokens.session_token_hash VARCHAR(255)` (см. §5).
- Возвращаем **plaintext ключ один раз** в ответе `verify` (поле `machine_token`). На prod — тоже (его забирает оператор/интеграция), т.к. это и есть цель «готово под ИИ»; **в логи ключ не пишем**.
- Кастомный guard `impersonation` (через `Auth::viaRequest`): парсит `token_id` из ключа → грузит строку (`pgsql_supplier`) → проверяет **активность гранта** (`used_at != null`, `session_ended_at == null`, `invalidated_at == null`, `now() < used_at + 60 мин`) → `Hash::check(secret, session_token_hash)` → возвращает целевого `User` с флагом impersonation. Префикс-парсинг по `token_id` = точечная загрузка одной строки (без full-scan bcrypt).
- `end`/`leave`/истечение → ключ перестаёт авторизовать (грант неактивен).

### 3.4. Изоляция и envelope (защита)
- **Запрет эскалации:** пока активен impersonation (маркер в сессии ИЛИ машинный ключ) — **доступ в saas-admin зону `/api/admin/*` запрещён** (нельзя прыгнуть к другим клиентам / в админку). Реализация: проверка в `saas-admin` группе (маркер сессии → 403). Машинный ключ на `/api/admin/*` не подаётся в принципе.
- **Envelope машинного ключа (УЖЕ машины, не человека):** ключ ИИ принимается **только на «рабочих» эндпоинтах кабинета**, и **НЕ** на опасных. Реализуется выбором, к каким группам добавить guard `impersonation` (`auth:sanctum,impersonation` — мульти-guard: человек проходит по Sanctum-cookie, ИИ — по ключу):
  - **Разрешено ИИ:** `/api/deals` (+bulk/export), `/api/projects`, `/api/reports/jobs`, `/api/imports`, `/api/dashboard`, `/api/managers`, `/api/lead-statuses`, `/api/notifications` (чтение/отметка), `/api/support-requests`, `/api/billing/charges` (чтение+экспорт).
  - **Запрещено ИИ (остаётся `auth:sanctum` only):** профиль/`me`/смена пароля/2FA, `/api/billing` (пополнение/кошелёк — содержит `topup`), `/api/api-keys` (перевыпуск), webhook-настройки, `/api/tenant/requisites` (правка юр./платёжных реквизитов).
  - Смешанную группу `/api/billing` (там `topup` + чтения) **целиком исключаем** из ИИ-envelope; чтение трат ИИ получает через отдельную группу `/api/billing/charges`.
- **Человек-браузер — полный доступ** (выбор владельца): его сессия проходит по Sanctum-cookie на всех группах, включая опасные. Envelope сужает **только** машинный ключ ИИ — это почти бесплатно и сильно безопаснее для автономного оператора и 152-ФЗ.
- **Авто-истечение 1 ч:** middleware на web/клиентских роутах: если маркер есть и `now() > started_at + 60 мин` → завершить (`session_ended_at`, письмо, `Auth::logout`) → 401 (SPA уводит на /login). Для машинного ключа — guard сам отвергает по тому же сроку.

### 3.5. Завершение из кабинета + плашка
- Новый роут `POST /api/impersonation/leave` (под `auth:sanctum`, читает маркер сессии): ставит `session_ended_at`, шлёт `ImpersonationEndedMail`, `Auth::logout`, чистит маркер. (Админский `end` остаётся для удалённого завершения.)
- `/api/auth/me`: добавить поле `impersonation` = `{active: bool, tenant_name, started_at, expires_at}` (заполнено только при активном маркере).
- Новый клиентский компонент **`ImpersonationSessionBanner.vue`** в `AppLayout` (над `RouterView`): красная плашка «Вы в кабинете клиента «X» — режим поддержки. **Выйти**» → `leave` → редирект. Реактивен на поле `me.impersonation`.

### 3.6. Аудит
- `ImpersonationAuditService` (есть) пишет init/verify/end со стаб-атрибуцией `super_admin`. Путь `leave` тоже вызывает `recordEnd`. **Стаб-атрибуция — известное ограничение до Б-1 (реальный SSO)**, документируется.

---

## 4. Что НЕ делаем сегодня (вне объёма, осознанно)

1. **«Мозг» ИИ-агента** (рассуждение, выполнение задач, guardrails) — отдельный проект.
2. **Интеграция каналов с ИИ** (чтение чата/почты, авто-ответы) — отдельный проект (§8).
3. **Настоящий saas-admin SSO** (Yandex 360) — заблокирован Б-1 + DO-4. Дверь живёт за nginx-паролем.
4. **Two-person approval** (CTO-15/Ю-9) и **hard-блок restricted-тенантов** (`processing_restricted`/`chargeback>0`) — нужна роль `compliance`, которой нет. **Документируем как ограничение**; до появления роли дверь доступна владельцу-единственному-админу за nginx-паролем.
5. **Как именно ИИ получает код-согласие** от клиента — часть проекта «мозг»/каналы.

---

## 5. Изменение схемы (минимальное)

- **+1 поле:** `impersonation_tokens.session_token_hash VARCHAR(255)` (NULL; хеш машинного ключа, NULL пока ключ не выдан).
- В `db/schema.sql` (bump до **v8.49**) + запись в `db/CHANGELOG_schema.md` + **guard'нутая миграция** (`ALTER TABLE impersonation_tokens ADD COLUMN IF NOT EXISTS session_token_hash VARCHAR(255)`).
- После — `migrate:fresh` зелёный + **ZERO_DRIFT** (скан pg_tables/колонок vs schema.sql).
- `session_id` (FK `saas_admin_sessions`) — **остаётся NULL** (материализацию saas_admin_sessions не делаем, YAGNI; «активные сессии» считаются по `used_at`/`session_ended_at`, как сейчас).

---

## 6. Стратегия тестов (vitest сломан G8 → Pest + живой Playwright)

**Pest (backend):**
- `init` шлёт `ImpersonationCodeMail` на `tenant.contact_email` (`Mail::fake`), audit-запись, dev-код в ответе только на local/testing.
- `verify` валидный код → `used_at`, маркер сессии, `Auth::login` целевого юзера, в ответе есть `machine_token`; неверный код → счётчик/инвалидация (как сейчас).
- Машинный ключ: валидный bearer GET `/api/deals` → 200 в периметре тенанта; протухший/завершённый/чужой/битый → 401; ключ на опасной группе (`/api/billing` topup, `/api/api-keys`) → 401; ключ на `/api/admin/*` → не проходит.
- Обычная Sanctum-cookie сессия по-прежнему работает на всех группах (envelope не сломал клиентов).
- Изоляция: при активном маркере `/api/admin/*` → 403.
- `leave`/`end` → `session_ended_at`, `ImpersonationEndedMail`, logout; авто-истечение через 60 мин.
- Команды: `composer --working-dir=app test -- <path>`; миграции тестовой БД `DB_DATABASE=liderra_testing php app/artisan migrate:fresh --force`.

**Живой Playwright (dev, демо `admin@demo.local`/`password`, tenant 1):**
- Полный путь: админка → «Войти как клиент» → reason → код (dev-баннер) → verify → редирект в `/dashboard` → **красная плашка** → данные именно этого клиента → «Выйти» → плашка исчезла. 0 ошибок в консоли.

**Larastan:** 0 (Pest-ложноположительные — в baseline тем же паттерном). **Линт/тайпчек/билд фронта** зелёные.

---

## 7. Деплой-гейт (для прод-выката, владельцу)

- **Дверь обязана оставаться за nginx HTTP Basic Auth** (`/admin` + `/api/admin/*`) — session-takeover нельзя оголять в интернет. Зафиксировать в чек-листе выката.
- Машинный ключ `machine_token` возвращается в ответе `verify` — следить, чтобы прод-логи nginx/Laravel **не логировали тело ответа** этого эндпоинта.
- Перед мержем — **прогон skill `threat-model` / `security-go-live`** по impersonation (точки входа, эскалация, 152-ФЗ).

---

## 8. Будущие каналы ИИ (заморожено — карта на потом)

```
  Клиент пишет            →   ┌─────────────┐   →   действует в кабинете
  • чат на сайте (?)           │  ИИ-АГЕНТ    │       через ДВЕРЬ + машинный ключ
  • форма/почта «Помощь»       │  («мозг»)    │       (строим СЕГОДНЯ)
    → таблица support_requests └─────────────┘
        ↑ следующий проект ───────┘
```

- **Почта/форма «Помощь» — наш канал, готов под ИИ:** заявки уже ложатся в `support_requests` (`SupportRequestController`). ИИ-«мозг» сможет читать новые строки, решать, действовать дверью, отвечать.
- **Чат на сайте — решение отложено владельцем.** **JivoSite-бот ОТКЛОНЁН как неконтролируемый** (переписки в их облаке, чужая логика). Рассмотренные альтернативы (на потом): **(A) свой ИИ-чат внутри кабинета — рекомендуется** (всё наше, клиент залогинен, ИИ рядом с дверью); (B) свой чат на публичном сайте для гостей; (C) self-hosted (Chatwoot). **Не фиксируем сейчас** — дверь+ключ подходят под любой выбор.

---

## 9. Открытые точки для ревью владельца

1. **Envelope машинного ключа ИИ:** в спеке — **узкий** (рабочие действия да; пароль/деньги/ключи/webhook/реквизиты — нет). Владелец говорил «ИИ полностью решает проблемы» — если нужно **шире**, расширим список групп (но я рекомендую узкий старт, расширять при сборке «мозга» с реальными тестами).
2. **Целевой пользователь тенанта** = самый ранний активный пользователь. Если у тенанта несколько — устраивает?
3. **Машинный ключ на prod в ответе `verify`** — подтвердить, что это ок (нужно для «готово под ИИ»).
```
