[meta][auth][mcp] API-ключи — фаза 3: api_key как единственная авторизация агентов + копируемый ключ + персона «MCP внешний» #556

Open
opened 2026-07-12 17:49:16 +03:00 by agent_vscode · 0 comments
Collaborator

Проблема / Цель

После фаз 1 (#501, сервер, замержено) и 2 (#506, UI, открыто) api_key стал одним из способов авторизации агентов на /mcp, но рядом остались костыли, ради устранения которых всё и затевалось:

  1. kill-switch API_KEYS_ENABLED — лишняя сложность (строгий парс, boot-лог, 404-ветки). Ключи должны быть включены безусловно.
  2. сервис-аккаунт MCP_DOCMOST_EMAIL/PASSWORD — «адский костыль», просочившийся в код: он и inbound-фолбэк на /mcp, и одновременно outbound-identity, под которой встроенный MCP «ходит обратно» в Docmost, костыльно привязывая правки к нужному пользователю. Плюс Basic-логин (bcrypt + анти-брутфорс-лимитер) и приём ACCESS-сессии человека на /mcp.
  3. однократный показ токена — неудобно; нужна возможность скопировать существующий ключ, сохранив немедленный revoke.
  4. правки/комменты внешних MCP-агентов отображаются как будто их сделал человек — хотя инфраструктура персон для агентов уже есть.

Цель фазы 3: /mcp — только для агентов, агент аутентифицируется исключительно api_key, который ему выпускает человек. Никаких Basic/сервис-аккаунтов/ACCESS на /mcp, ни следа сервис-аккаунта в коде. Ключ копируемый (детерминированный re-mint под step-up). Правки и комменты и внешних, и встроенных агентов идут через настоящую identity, а внешний MCP показывается отдельной персоной «MCP внешний» — по аналогии с тем, как встроенные агенты показывают имя своей роли.

Контекст: единственный человек, homelab, single-workspace, агенты живут годами. Владелец: @vvzvlad.

Дочерние фазы (порядок)

  • #557 — фаза 3A: снятие kill-switch API_KEYS_ENABLED + копируемый ключ (детерминированный re-mint + reveal под step-up). Правит UI #506.
  • #558 — фаза 3B: /mcp только api_key — полное удаление сервис-аккаунта, Basic-логина и ACCESS-пути + наблюдаемость на deny. Деплой только после миграции агентов на ключи.
  • #559 — фаза 3C: персона «MCP внешний» для правок и комментов внешних агентов (+ аддитивная миграция колонок *_api_key_id).

Технически A/B/C независимы (все опираются на уже смерженный #501), но все blocked by #506. Рекомендуемый порядок выката: сначала #557 и/или #559, затем #558 (breaking-удаление — только после того, как все агенты переведены на Bearer-ключи).

Контекст и исследование

Проверено по develop (коммит фазы 1 — 74d212c, PR #526):

  • Inbound-авторизация /mcpresolveMcpSessionConfig в mcp-auth.helpers.ts, приоритет: 1) Basic email:password, 2) Bearer (allowlist {ACCESS, API_KEY}), 3) env service-account, 4) 401. Обвязка — mcp.service.ts.
  • Сервис-аккаунт двулик: mcp.service.ts:163-183 — inbound-фолбэк И outbound-identity (ветка {email,password} в DocmostMcpConfig, клиент делает performLogin).
  • Встроенный in-app AI-агент НЕ ходит через /mcp: он строит свой loopback-DocmostClient через generateAccessToken/generateCollabToken против /api (ai-chat-tools.service.ts:307-353), не касается ни сервис-аккаунта, ни ACCESS-на-/mcp — удаление их его не ломает (подтверждено разведкой).
  • api_key-токен мятится без exp через выделенный apiKeyJwtService (token.service.ts:150-177); ApiKeyService.validate (api-key.service.ts) — единый валидатор для REST и /mcp, ревокация немедленная (row-check deletedAt на каждом запросе).
  • Инфраструктура персон агентов уже сквозная: resolveAgentProvenance (agent-provenance.ts:62-93) уже имеет внешнюю-MCP ветку; AgentAvatarStack (agent-avatar-stack.tsx) умеет режим внешнего MCP; collab несёт principal:'api_key'+apiKeyId. Единственное недостающее звено: resolveProvenance метит actor='agent' только при user.isAgent (auth-provenance.decorator.ts:30-38), поэтому api_key-правка обычного пользователя проваливается в 'user'.
  • Аудит-синк реальный (не Noop): с #496 привязан DatabaseAuditService (audit.module.ts) — события API_KEY_* персистятся.

Дизайн (целиком; реализация фазируется)

Три PR-размерных изменения + этот мета-тикет. Полный жизненный цикл (выдача → использование → копирование → отзыв → атрибуция → удаление владельца) спроектирован здесь до старта любой фазы.

Фаза A (#557) — ядро api_key: снятие kill-switch + копируемый ключ. Убрать API_KEYS_ENABLED целиком (без остаточных проверок и boot-WARN — торчащий =false просто игнорируется). Сделать api_key-токен детерминированным (noTimestamp:true → нет iat → токен = чистая функция (apiKeyId, sub, workspaceId, APP_SECRET)). Новый POST /api-keys/reveal под step-up (повторный ввод пароля), только владелец, rejectApiKeyPrincipal, throttle, аудит. UI #506: вместо «показать один раз» — кнопка «скопировать».

Фаза B (#558) — /mcp только api_key + удаление сервис-аккаунта/Basic/ACCESS. resolveMcpSessionConfig: оставить только Bearer, allowlist сузить до {API_KEY}. Вычистить весь мёртвый код (parseBasicAuth, FailedLoginLimiter, decideBasicGate, verifyBearerAccess, enforceBasicLoginGate, getEmail/getPassword и т.д.), env-переменные, доки, тесты — ни следа сервис-аккаунта. Оставить: пакет packages/mcp {email,password} + stdio-CLI (отдельный транспорт), всю провенанс/collab-машинерию (становится основным путём). Добавить наблюдаемость на deny (rate-limited WARN / счётчик) взамен удаляемого лимитера.

Фаза C (#559) — персона «MCP внешний». Метить api_key-правки (principal='api_key') как внешне-MCP-агента, протащить apiKeyId до места записи (REST и collab), персистить новые колонки created_api_key_id / last_updated_api_key_id, резолвить имя через join api_keys.name (фолбэк «MCP внешний»).

Модель состояний ключа (дополнение к #501)

Состояние Вход Проверка reveal Атрибуция прошлых правок
active create row есть, deletedAt IS NULL, не истёк (step-up, владелец) «MCP внешний / »
expired expiresAt < now row-check uniform 404 сохраняется (join к строке)
revoked soft-delete row invisible uniform 404 сохраняется (row ещё есть, join без фильтра deletedAt)
orphaned каскад удаления creator/workspace (hard-delete, api_keys.creator_id onDelete cascade) строки нет вырождается в фолбэк «MCP внешний» (join → null)

Границы scope

  • Фаза A / B / C — три отдельных ишьи (#557 / #558 / #559). Каждая самостоятельно шиппится и тестируется.
  • Не входит: per-key scopes/права; ротация APP_SECRET как процесс; правка stdio-CLI пакета packages/mcp (отдельный транспорт, сервис-аккаунт там легитимен — не трогаем); новые продуктовые метрики сверх счётчика deny.

Критерии приёмки (высокоуровнево; детальные — в фазах)

  1. /mcp c Basic / ACCESS-сессией / без заголовка (в т.ч. при выставленных MCP_DOCMOST_*) → 401; c валидным api_key Bearer → работает.
  2. Встроенный AI-агент (веб-чат) после удаления сервис-аккаунта/ACCESS по-прежнему выполняет правку end-to-end (регрессия).
  3. grep по MCP_DOCMOST_EMAIL|MCP_DOCMOST_PASSWORD|API_KEYS_ENABLED|FailedLoginLimiter|parseBasicAuth|enforceBasicLoginGate|verifyBearerAccess в apps/server → пусто (кроме исторического CHANGELOG).
  4. Декодированный api_key-JWT не содержит ни exp, ни iat; два reveal одного ключа → байт-в-байт одинаковый токен.
  5. reveal требует step-up; api_key-принципал на reveal403; чужой/revoked/expired ключ → uniform 404.
  6. Правка/коммент внешнего MCP-агента показывается персоной «MCP внешний / <имя ключа>», а не именем человека; встроенный агент не затронут; правки отозванного ключа сохраняют его имя.

Отказы и наблюдаемость

  • Все отказы авторизации /mcp — единый generic-401 (анти-enumeration инвариант #501 сохраняется).
  • Взамен удаляемого Basic-лимитера — rate-limited WARN / счётчик deny на (apiKeyId, reason) в validate() или на /mcp-bearer-catch (иначе мёртвый ключ, долбящий /mcp, невидим). Детали в #558.
  • reveal — durable audit (DatabaseAuditService, реальный с #496) + throttle; step-up отсекает угон через компрометированную сессию.
  • Break-glass при инциденте (осознанно, kill-switch удалён): немедленный revoke ключей + workspace-тоггл ai.mcp гасит /mcp. Глобального рубильника для REST-api_key нет — принято владельцем (homelab, ротация APP_SECRET/рестарт — крайняя мера).

Совместимость и миграции

  • BREAKING: удаление Basic + сервис-аккаунта с /mcp ломает существующие MCP-сетапы на email:password и на MCP_DOCMOST_*. Порядок выката (обязателен): (1) #506 смержен (UI выдачи ключей) → (2) выпустить ключи всем агентам и переключить их конфиги на Authorization: Bearer <key> → (3) выкатывать фазу B (#558). Приём api_key Bearer уже работает с #501, поэтому окна 401 при переключении конфигов до деплоя B нет.
  • Фаза C (#559) добавляет колонки created_api_key_id (comments), last_updated_api_key_id (pages, page_history) с onDelete('set null'). Миграция аддитивная, без блокировок горячих таблиц (nullable-колонки без бэкофилла).
  • API_KEYS_ENABLED удаляется целиком; деплой на инстанс, где стоял =false, молча включит ключи — приемлемо (homelab, дефолт всегда был ON).

Решённые развилки

  • Kill-switch → удалить полностью, без остаточного boot-WARN (владелец: рубильник не нужен, homelab; штатный ai.mcp-тоггл уже гасит /mcp).
  • /mcp ACCESS-сессия человека → удалить (/mcp только для агентов; человек при желании заводит себе api_key).
  • reveal-механизм → детерминированный re-mint (noTimestamp), токен-материал в БД не хранится; revoke по-прежнему soft-delete (владелец).
  • reveal-безопасность → step-up (пароль), а не «в любой момент» (адверсариальный ревью: копируемый бессрочный токен + компрометированная сессия = утечка всех ключей; владелец выбрал step-up).
  • reveal-scope → только свои ключи (даже админ; list/revoke остаются workspace-wide) — закрывает импёрсонацию (владелец).
  • Персона: новый ProvenanceSource vs переиспользовать 'agent'переиспользовать 'agent' + дискриминатор по api_key_id (внешняя ветка резолвера уже существует; клиент уже ветвится на source==='agent'). Деталь — в #559.

Связи

  • Наследует: #501 (фаза 1, сервер — замержено), #506 (фаза 2, UI — открыто).
  • Blocked by: #501 (merged) и #506 — приступать к фазам #557/#558/#559 можно только после мержа обоих.
  • Дочерние: #557 (A), #558 (B), #559 (C).
  • Зонтик стабилизации: #497 (волна E).
## Проблема / Цель После фаз 1 (#501, сервер, замержено) и 2 (#506, UI, открыто) api_key стал **одним из** способов авторизации агентов на `/mcp`, но рядом остались костыли, ради устранения которых всё и затевалось: 1. **kill-switch `API_KEYS_ENABLED`** — лишняя сложность (строгий парс, boot-лог, 404-ветки). Ключи должны быть включены безусловно. 2. **сервис-аккаунт `MCP_DOCMOST_EMAIL/PASSWORD`** — «адский костыль», просочившийся в код: он и inbound-фолбэк на `/mcp`, и одновременно outbound-identity, под которой встроенный MCP «ходит обратно» в Docmost, костыльно привязывая правки к нужному пользователю. Плюс Basic-логин (bcrypt + анти-брутфорс-лимитер) и приём ACCESS-сессии человека на `/mcp`. 3. **однократный показ токена** — неудобно; нужна возможность скопировать существующий ключ, сохранив немедленный revoke. 4. **правки/комменты внешних MCP-агентов отображаются как будто их сделал человек** — хотя инфраструктура персон для агентов уже есть. **Цель фазы 3:** `/mcp` — только для агентов, агент аутентифицируется **исключительно api_key**, который ему выпускает человек. Никаких Basic/сервис-аккаунтов/ACCESS на `/mcp`, ни следа сервис-аккаунта в коде. Ключ **копируемый** (детерминированный re-mint под step-up). Правки и комменты и внешних, и встроенных агентов идут через настоящую identity, а внешний MCP показывается отдельной персоной **«MCP внешний»** — по аналогии с тем, как встроенные агенты показывают имя своей роли. Контекст: единственный человек, homelab, single-workspace, агенты живут годами. Владелец: @vvzvlad. ## Дочерние фазы (порядок) - **#557 — фаза 3A**: снятие kill-switch `API_KEYS_ENABLED` + копируемый ключ (детерминированный re-mint + `reveal` под step-up). Правит UI #506. - **#558 — фаза 3B**: `/mcp` только api_key — полное удаление сервис-аккаунта, Basic-логина и ACCESS-пути + наблюдаемость на deny. **Деплой только после миграции агентов на ключи.** - **#559 — фаза 3C**: персона «MCP внешний» для правок и комментов внешних агентов (+ аддитивная миграция колонок `*_api_key_id`). Технически A/B/C независимы (все опираются на уже смерженный #501), но все **blocked by #506**. Рекомендуемый порядок выката: сначала #557 и/или #559, затем #558 (breaking-удаление — только после того, как все агенты переведены на Bearer-ключи). ## Контекст и исследование Проверено по `develop` (коммит фазы 1 — `74d212c`, PR #526): - **Inbound-авторизация `/mcp`** — `resolveMcpSessionConfig` в [mcp-auth.helpers.ts](apps/server/src/integrations/mcp/mcp-auth.helpers.ts), приоритет: 1) Basic `email:password`, 2) Bearer (allowlist `{ACCESS, API_KEY}`), 3) env service-account, 4) 401. Обвязка — [mcp.service.ts](apps/server/src/integrations/mcp/mcp.service.ts). - **Сервис-аккаунт двулик**: [mcp.service.ts:163-183](apps/server/src/integrations/mcp/mcp.service.ts#L163-L183) — inbound-фолбэк И outbound-identity (ветка `{email,password}` в `DocmostMcpConfig`, клиент делает `performLogin`). - **Встроенный in-app AI-агент НЕ ходит через `/mcp`**: он строит свой loopback-`DocmostClient` через `generateAccessToken`/`generateCollabToken` против `/api` ([ai-chat-tools.service.ts:307-353](apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts#L307-L353)), не касается ни сервис-аккаунта, ни ACCESS-на-`/mcp` — удаление их его не ломает (подтверждено разведкой). - **api_key-токен** мятится без `exp` через выделенный `apiKeyJwtService` ([token.service.ts:150-177](apps/server/src/core/auth/services/token.service.ts#L150-L177)); `ApiKeyService.validate` ([api-key.service.ts](apps/server/src/core/api-key/api-key.service.ts)) — единый валидатор для REST и `/mcp`, ревокация немедленная (row-check `deletedAt` на каждом запросе). - **Инфраструктура персон агентов уже сквозная**: `resolveAgentProvenance` ([agent-provenance.ts:62-93](apps/server/src/database/repos/agent-provenance.ts#L62-L93)) уже имеет внешнюю-MCP ветку; `AgentAvatarStack` ([agent-avatar-stack.tsx](apps/client/src/components/ui/agent-avatar-stack.tsx)) умеет режим внешнего MCP; collab несёт `principal:'api_key'`+`apiKeyId`. **Единственное недостающее звено**: `resolveProvenance` метит `actor='agent'` только при `user.isAgent` ([auth-provenance.decorator.ts:30-38](apps/server/src/common/decorators/auth-provenance.decorator.ts#L30-L38)), поэтому api_key-правка обычного пользователя проваливается в `'user'`. - **Аудит-синк реальный** (не Noop): с #496 привязан `DatabaseAuditService` (`audit.module.ts`) — события `API_KEY_*` персистятся. ## Дизайн (целиком; реализация фазируется) Три PR-размерных изменения + этот мета-тикет. Полный жизненный цикл (выдача → использование → копирование → отзыв → атрибуция → удаление владельца) спроектирован здесь до старта любой фазы. **Фаза A (#557) — ядро api_key: снятие kill-switch + копируемый ключ.** Убрать `API_KEYS_ENABLED` целиком (без остаточных проверок и boot-WARN — торчащий `=false` просто игнорируется). Сделать api_key-токен детерминированным (`noTimestamp:true` → нет `iat` → токен = чистая функция `(apiKeyId, sub, workspaceId, APP_SECRET)`). Новый `POST /api-keys/reveal` **под step-up** (повторный ввод пароля), **только владелец**, `rejectApiKeyPrincipal`, throttle, аудит. UI #506: вместо «показать один раз» — кнопка «скопировать». **Фаза B (#558) — `/mcp` только api_key + удаление сервис-аккаунта/Basic/ACCESS.** `resolveMcpSessionConfig`: оставить только Bearer, allowlist сузить до `{API_KEY}`. Вычистить весь мёртвый код (parseBasicAuth, FailedLoginLimiter, decideBasicGate, verifyBearerAccess, enforceBasicLoginGate, getEmail/getPassword и т.д.), env-переменные, доки, тесты — ни следа сервис-аккаунта. **Оставить**: пакет `packages/mcp` `{email,password}` + stdio-CLI (отдельный транспорт), всю провенанс/collab-машинерию (становится основным путём). Добавить наблюдаемость на deny (rate-limited WARN / счётчик) взамен удаляемого лимитера. **Фаза C (#559) — персона «MCP внешний».** Метить api_key-правки (`principal='api_key'`) как внешне-MCP-агента, протащить `apiKeyId` **до места записи** (REST и collab), персистить новые колонки `created_api_key_id` / `last_updated_api_key_id`, резолвить имя через join `api_keys.name` (фолбэк «MCP внешний»). ### Модель состояний ключа (дополнение к #501) | Состояние | Вход | Проверка | reveal | Атрибуция прошлых правок | |---|---|---|---|---| | active | create | row есть, `deletedAt IS NULL`, не истёк | ✅ (step-up, владелец) | «MCP внешний / <name>» | | expired | `expiresAt < now` | row-check | ❌ uniform 404 | сохраняется (join к строке) | | revoked | soft-delete | row invisible | ❌ uniform 404 | сохраняется (row ещё есть, join без фильтра `deletedAt`) | | orphaned | каскад удаления creator/workspace (**hard-delete**, `api_keys.creator_id onDelete cascade`) | строки нет | ❌ | вырождается в фолбэк «MCP внешний» (join → null) | ## Границы scope - **Фаза A / B / C** — три отдельных ишьи (#557 / #558 / #559). Каждая самостоятельно шиппится и тестируется. - **Не входит**: per-key scopes/права; ротация `APP_SECRET` как процесс; правка stdio-CLI пакета `packages/mcp` (отдельный транспорт, сервис-аккаунт там легитимен — **не трогаем**); новые продуктовые метрики сверх счётчика deny. ## Критерии приёмки (высокоуровнево; детальные — в фазах) 1. `/mcp` c Basic / ACCESS-сессией / без заголовка (в т.ч. при выставленных `MCP_DOCMOST_*`) → `401`; c валидным api_key Bearer → работает. 2. Встроенный AI-агент (веб-чат) после удаления сервис-аккаунта/ACCESS по-прежнему выполняет правку end-to-end (регрессия). 3. `grep` по `MCP_DOCMOST_EMAIL|MCP_DOCMOST_PASSWORD|API_KEYS_ENABLED|FailedLoginLimiter|parseBasicAuth|enforceBasicLoginGate|verifyBearerAccess` в `apps/server` → пусто (кроме исторического CHANGELOG). 4. Декодированный api_key-JWT не содержит ни `exp`, ни `iat`; два `reveal` одного ключа → байт-в-байт одинаковый токен. 5. `reveal` требует step-up; api_key-принципал на `reveal` → `403`; чужой/revoked/expired ключ → uniform `404`. 6. Правка/коммент внешнего MCP-агента показывается персоной «MCP внешний / <имя ключа>», а не именем человека; встроенный агент не затронут; правки отозванного ключа сохраняют его имя. ## Отказы и наблюдаемость - Все отказы авторизации `/mcp` — единый generic-401 (анти-enumeration инвариант #501 сохраняется). - Взамен удаляемого Basic-лимитера — rate-limited WARN / счётчик deny на `(apiKeyId, reason)` в `validate()` или на `/mcp`-bearer-catch (иначе мёртвый ключ, долбящий `/mcp`, невидим). Детали в #558. - `reveal` — durable audit (`DatabaseAuditService`, реальный с #496) + throttle; step-up отсекает угон через компрометированную сессию. - **Break-glass при инциденте** (осознанно, kill-switch удалён): немедленный `revoke` ключей + workspace-тоггл `ai.mcp` гасит `/mcp`. Глобального рубильника для REST-api_key нет — принято владельцем (homelab, ротация `APP_SECRET`/рестарт — крайняя мера). ## Совместимость и миграции - **BREAKING**: удаление Basic + сервис-аккаунта с `/mcp` ломает существующие MCP-сетапы на `email:password` и на `MCP_DOCMOST_*`. **Порядок выката (обязателен)**: (1) #506 смержен (UI выдачи ключей) → (2) выпустить ключи всем агентам и переключить их конфиги на `Authorization: Bearer <key>` → (3) выкатывать фазу B (#558). Приём api_key Bearer уже работает с #501, поэтому окна 401 при переключении конфигов до деплоя B нет. - Фаза C (#559) добавляет колонки `created_api_key_id` (comments), `last_updated_api_key_id` (pages, page_history) с `onDelete('set null')`. Миграция аддитивная, без блокировок горячих таблиц (nullable-колонки без бэкофилла). - `API_KEYS_ENABLED` удаляется целиком; деплой на инстанс, где стоял `=false`, **молча включит** ключи — приемлемо (homelab, дефолт всегда был ON). ## Решённые развилки - Kill-switch → **удалить полностью**, без остаточного boot-WARN (владелец: рубильник не нужен, homelab; штатный `ai.mcp`-тоггл уже гасит `/mcp`). - `/mcp` ACCESS-сессия человека → **удалить** (`/mcp` только для агентов; человек при желании заводит себе api_key). - reveal-механизм → **детерминированный re-mint (`noTimestamp`)**, токен-материал в БД не хранится; revoke по-прежнему soft-delete (владелец). - reveal-безопасность → **step-up (пароль)**, а не «в любой момент» (адверсариальный ревью: копируемый бессрочный токен + компрометированная сессия = утечка всех ключей; владелец выбрал step-up). - reveal-scope → **только свои ключи** (даже админ; list/revoke остаются workspace-wide) — закрывает импёрсонацию (владелец). - Персона: новый `ProvenanceSource` vs переиспользовать `'agent'` → **переиспользовать `'agent'` + дискриминатор по `api_key_id`** (внешняя ветка резолвера уже существует; клиент уже ветвится на `source==='agent'`). Деталь — в #559. ## Связи - **Наследует**: #501 (фаза 1, сервер — замержено), #506 (фаза 2, UI — открыто). - **Blocked by**: #501 (merged) **и** #506 — приступать к фазам #557/#558/#559 можно только после мержа обоих. - **Дочерние**: #557 (A), #558 (B), #559 (C). - Зонтик стабилизации: #497 (волна E).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#556