[feat][auth][mcp] API-ключи фаза 3B: /mcp только api_key — эксгумация сервис-аккаунта, Basic-логина и ACCESS-пути #558

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

Фаза B мета-ишьи #556. Blocked by #501 (merged) и #506. Деплой — только после миграции агентов на ключи (см. «Совместимость»).

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

/mcp ходят только агенты, и агент должен аутентифицироваться исключительно api_key, который ему выпустил человек. Сейчас на /mcp четыре пути (Basic email:password, Bearer ACCESS-сессия человека, Bearer api_key, env service-account MCP_DOCMOST_EMAIL/PASSWORD). Сервис-аккаунт — «адский костыль»: он и inbound-фолбэк, и outbound-identity, под которой встроенный MCP костыльно «ходит обратно» в Docmost, привязывая правки к пользователю. Цель: оставить на /mcp только Bearer api_key, а весь остальной путь и сервис-аккаунт выпилить без следа — ни мёртвого кода, ни env, ни доков, ни намёка, что сервис-аккаунт когда-либо был.

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

Inbound-приоритет resolveMcpSessionConfig (mcp-auth.helpers.ts): 1) Basic, 2) Bearer {ACCESS, API_KEY}, 3) service-account, 4) 401. Обвязка — mcp.service.ts. Разведка дала полный file:line инвентарь (ниже). Ключевые границы:

  • Встроенный AI-агент НЕ ломается: он не ходит через /mcp (loopback /api через generateAccessToken/generateCollabToken, ai-chat-tools.service.ts:307-353).
  • Приём api_key Bearer уже работает (allowlist уже {ACCESS, API_KEY}), поэтому переключение конфигов агентов на Bearer до деплоя B не даёт окна 401.
  • НЕ ТРОГАТЬ: пакет packages/mcp (context.ts ветка {email,password} + performLogin + stdio-CLI) — это отдельный транспорт, сервис-аккаунт там легитимен; всю провенанс/collab-машинерию (resolveProvenance, JwtCollabPayload.principal/apiKeyId, authentication.extension, generateCollabToken) — она становится основным путём; ядровые примитивы AuthService.verifyUserCredentials, CREDENTIALS_MISMATCH_MESSAGE, TokenService.verifyJwtOneOf; в helpers — sharedTokenMatches, mapAuthResultToResponse, extractBearer, onModuleDestroy.

Дизайн

1. resolveMcpSessionConfig → только Bearer api_key

  • Удалить блок «1) Basic login/password» (mcp-auth.helpers.ts:634-794) и блок «3) service-account» (:821-827). Bearer-блок (:796-819) становится единственным путём; финальный 401 (:830-833) переписать (сообщение: только api_key).
  • bindMcpBearerVerifier (:336-341) — allowlist сузить с {ACCESS, API_KEY} до {API_KEY}.
  • verifyMcpBearer (:371-403) — оставить только ветку API_KEY, удалить ACCESS-ветку (вызов verifyBearerAccess). McpBearerDeps схлопнуть до { verifyJwtOneOf, validateApiKey, expectedWorkspaceId } (убрать Omit<BearerVerifyDeps,'verifyJwt'>, findUser, findActiveSession).

2. Удалить мёртвый код (эксгумация)

mcp-auth.helpers.ts — DELETE: parseBasicAuth (:19-38), FailedLoginLimiter (класс целиком :51-131), isCredentialsFailure (:222-248), ClientIpRequest+clientIp (:275-305), isInitializeRequestBody (:487-507), BasicGateMfaResult (:568-573), decideBasicGate (:575-612), BearerVerifyDeps (:405-434), verifyBearerAccess (:436-485), ветка {apiUrl;email;password} серверного DocmostMcpConfig (:137-139), поля email?/password?/enforceBasicGate?/login/verifyCredentials/limiter/clientIp/isSessionInit в McpAuthDeps (:174-220). Мёртвый импорт CREDENTIALS_MISMATCH_MESSAGE (:10) и isInitializeRequest из SDK (:8). Комментарии/JSDoc про precedence и service-account (:134, :623-633) — переписать.

mcp.service.ts — DELETE: warnedMissingCreds (:88), getEmail/getPassword/credsConfigured (:166-183), warn-блок про сервис-аккаунт (:491-500), failedLogins+sweepIntervalMs+sweepTimer+setInterval в конструкторе (:93,98-100,116-124) и clearInterval (:128), enforceBasicLoginGate (метод :284-349), в resolveSessionConfigisSessionInit и deps enforceBasicGate/login/verifyCredentials/limiter/clientIp/isSessionInit/email/password (оставить apiUrl, findWorkspace, verifyAccessJwt), в verifyMcpBearer-обвязке — findUser/findActiveSession (:229-232). Мёртвые импорты: FailedLoginLimiter(:21), isInitializeRequestBody(:24), clientIp(:26), decideBasicGate(:28), validateSsoEnforcement(:16), Workspace(:18), ModuleRef(:7). Из DI-конструктора убрать userRepo(:106), userSessionRepo(:107), moduleRef(:108), authService(:104) — разведка подтвердила: после удаления ACCESS/Basic каждый из них используется только в удаляемых путях (workspaceRepo/tokenService/apiKeyService/sandboxStore остаются). Проверить и почистить провайдеры в mcp.module.ts.

3. Наблюдаемость на deny (замена удаляемого лимитера)

Важно (адверсариальный ревью): обещанного в #501 «rate-limited WARN per (apiKeyId, class)» в коде нетApiKeyService.validate кидает UnauthorizedException без лога и метрики, а McpService.handle намеренно не логирует UnauthorizedException. После удаления Basic-лимитера мёртвый/отозванный ключ, долбящий /mcp, станет полностью невидим. Добавить: rate-limited структурный WARN и/или счётчик api_key_auth_denied_total{reason} в ApiKeyService.validate (или на /mcp-bearer-catch), ключ агрегации — (apiKeyId, reason), apiKeyId в лог-путь попадает только после верификации подписи (кардинальность ограничена числом выданных ключей). Generic-401 в теле ответа сохраняется (WARN — только в логах оператора).

4. Env / доки

.env.example: удалить MCP_DOCMOST_EMAIL/MCP_DOCMOST_PASSWORD (:116-117) и переписать описание способа аутентификации (:105-110) под «только api_key Bearer». Оставить MCP_DOCMOST_API_URL (:118, outbound loopback URL) и MCP_TOKEN. AGENTS.md: переписать :453 и :325 (убрать creds-переменные из списка). CHANGELOG — добавить Breaking-запись.

5. Тесты

DELETE целиком: mcp-basic-login-gate.spec.ts, mcp-login-gate-coupling.contract.spec.ts. В mcp.service.spec.ts — удалить describe-блоки parseBasicAuth, isCredentialsFailure+coupling, FailedLoginLimiter, verifyBearerAccess, Basic-кейсы и service-account-кейс в resolveMcpSessionConfig, isInitializeRequestBody/isSessionInit/non-initialize-side-effects, clientIp, decideBasicGate; переписать bindMcpBearerVerifier-тест на {API_KEY}, оставить только API_KEY-кейсы verifyMcpBearer, поправить onModuleDestroy-тест (нет sweepTimer). KEEP: sharedTokenMatches, mapAuthResultToResponse, extractBearer, провенанс/collab-специи (в authentication.extension.spec.ts поправить комментарий про «MCP/service-account collab edit» на :235). Добавить тест: /mcp с Basic / с ACCESS-JWT / без заголовка → 401; с api_key → ок.

Границы scope

  • Только inbound-авторизация встроенного /mcp + вычистка. Ядро api_key (reveal, kill-switch) — #557. Персона — #PHASE_C.
  • Не входит: packages/mcp stdio-CLI.

Критерии приёмки

  1. /mcp c Authorization: Basic ...401; c валидным ACCESS-сессионным JWT → 401; без заголовка при выставленных MCP_DOCMOST_EMAIL/PASSWORD401 (нет service-account фолбэка); c валидным api_key Bearer → работает.
  2. Встроенный AI-агент (веб-чат) выполняет правку страницы end-to-end (регрессия — путь не через /mcp).
  3. grep -rE 'MCP_DOCMOST_EMAIL|MCP_DOCMOST_PASSWORD|FailedLoginLimiter|parseBasicAuth|enforceBasicLoginGate|verifyBearerAccess|decideBasicGate|service-account' apps/server → пусто (кроме CHANGELOG).
  4. Все негативы /mcp — единый generic-401 (анти-enumeration).
  5. Отозванный ключ, долбящий /mcp N раз → в логах оператора rate-limited WARN с apiKeyId и/или счётчик deny инкрементится (не тишина).
  6. Сборка/линт/тесты зелёные, нет unused-import/DI-провайдеров (проверка компилятором/линтером).

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

  • Generic-401 на всех отказах; WARN/counter на deny — единственная замена удаляемому лимитеру.
  • Брутфорса на api_key нет (HMAC-подписанный JWT неугадываем) — лимитер не нужен.

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

  • BREAKING: ломает MCP-клиентов на email:password и деплои на MCP_DOCMOST_*. Порядок (обязателен): #506 смержен → выпустить ключи всем агентам (UI/create) и переключить их конфиги на Authorization: Bearer <key>затем деплой B. Приём api_key Bearer работает с #501 → окна 401 нет.
  • Миграций БД нет.

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

  • /mcp ACCESS-сессия человека → удалить (только агенты; человек заводит api_key). Разведка подтвердила отделимость от service-account/Basic-вычистки; здесь удаляем вместе (единая цель «только api_key»).
  • Наблюдаемость → добавить WARN/counter (в #501 задизайнено, но не реализовано — адверсариальный ревью).

Связи

  • Мета: #556. Blocked by #501 (merged), #506. Смежно правит поведение, заявленное в #501 (Basic «для людей» и лимитер — отменяются: модель изменилась, /mcp = агенты-only).
Фаза B мета-ишьи #556. **Blocked by #501 (merged) и #506.** Деплой — только после миграции агентов на ключи (см. «Совместимость»). ## Проблема / Цель `/mcp` ходят **только агенты**, и агент должен аутентифицироваться **исключительно api_key**, который ему выпустил человек. Сейчас на `/mcp` четыре пути (Basic `email:password`, Bearer ACCESS-сессия человека, Bearer api_key, env service-account `MCP_DOCMOST_EMAIL/PASSWORD`). Сервис-аккаунт — «адский костыль»: он и inbound-фолбэк, и outbound-identity, под которой встроенный MCP костыльно «ходит обратно» в Docmost, привязывая правки к пользователю. Цель: оставить на `/mcp` **только Bearer api_key**, а весь остальной путь и сервис-аккаунт **выпилить без следа** — ни мёртвого кода, ни env, ни доков, ни намёка, что сервис-аккаунт когда-либо был. ## Контекст и исследование Inbound-приоритет `resolveMcpSessionConfig` ([mcp-auth.helpers.ts](apps/server/src/integrations/mcp/mcp-auth.helpers.ts)): 1) Basic, 2) Bearer `{ACCESS, API_KEY}`, 3) service-account, 4) 401. Обвязка — [mcp.service.ts](apps/server/src/integrations/mcp/mcp.service.ts). Разведка дала полный file:line инвентарь (ниже). **Ключевые границы:** - **Встроенный AI-агент НЕ ломается**: он не ходит через `/mcp` (loopback `/api` через `generateAccessToken`/`generateCollabToken`, [ai-chat-tools.service.ts:307-353](apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts#L307-L353)). - **Приём api_key Bearer уже работает** (allowlist уже `{ACCESS, API_KEY}`), поэтому переключение конфигов агентов на Bearer до деплоя B не даёт окна 401. - **НЕ ТРОГАТЬ**: пакет `packages/mcp` (`context.ts` ветка `{email,password}` + `performLogin` + stdio-CLI) — это **отдельный транспорт**, сервис-аккаунт там легитимен; всю провенанс/collab-машинерию (`resolveProvenance`, `JwtCollabPayload.principal/apiKeyId`, `authentication.extension`, `generateCollabToken`) — она становится **основным** путём; ядровые примитивы `AuthService.verifyUserCredentials`, `CREDENTIALS_MISMATCH_MESSAGE`, `TokenService.verifyJwtOneOf`; в helpers — `sharedTokenMatches`, `mapAuthResultToResponse`, `extractBearer`, `onModuleDestroy`. ## Дизайн ### 1. `resolveMcpSessionConfig` → только Bearer api_key - Удалить блок «1) Basic login/password» ([mcp-auth.helpers.ts:634-794](apps/server/src/integrations/mcp/mcp-auth.helpers.ts#L634-L794)) и блок «3) service-account» ([:821-827](apps/server/src/integrations/mcp/mcp-auth.helpers.ts#L821-L827)). Bearer-блок ([:796-819](apps/server/src/integrations/mcp/mcp-auth.helpers.ts#L796-L819)) становится единственным путём; финальный 401 ([:830-833](apps/server/src/integrations/mcp/mcp-auth.helpers.ts#L830-L833)) переписать (сообщение: только api_key). - `bindMcpBearerVerifier` ([:336-341](apps/server/src/integrations/mcp/mcp-auth.helpers.ts#L336-L341)) — allowlist сузить с `{ACCESS, API_KEY}` до **`{API_KEY}`**. - `verifyMcpBearer` ([:371-403](apps/server/src/integrations/mcp/mcp-auth.helpers.ts#L371-L403)) — оставить только ветку API_KEY, удалить ACCESS-ветку (вызов `verifyBearerAccess`). `McpBearerDeps` схлопнуть до `{ verifyJwtOneOf, validateApiKey, expectedWorkspaceId }` (убрать `Omit<BearerVerifyDeps,'verifyJwt'>`, `findUser`, `findActiveSession`). ### 2. Удалить мёртвый код (эксгумация) **`mcp-auth.helpers.ts`** — DELETE: `parseBasicAuth` (:19-38), `FailedLoginLimiter` (класс целиком :51-131), `isCredentialsFailure` (:222-248), `ClientIpRequest`+`clientIp` (:275-305), `isInitializeRequestBody` (:487-507), `BasicGateMfaResult` (:568-573), `decideBasicGate` (:575-612), `BearerVerifyDeps` (:405-434), `verifyBearerAccess` (:436-485), ветка `{apiUrl;email;password}` серверного `DocmostMcpConfig` (:137-139), поля `email?/password?/enforceBasicGate?/login/verifyCredentials/limiter/clientIp/isSessionInit` в `McpAuthDeps` (:174-220). Мёртвый импорт `CREDENTIALS_MISMATCH_MESSAGE` (:10) и `isInitializeRequest` из SDK (:8). Комментарии/JSDoc про precedence и service-account (:134, :623-633) — переписать. **`mcp.service.ts`** — DELETE: `warnedMissingCreds` (:88), `getEmail/getPassword/credsConfigured` (:166-183), warn-блок про сервис-аккаунт (:491-500), `failedLogins`+`sweepIntervalMs`+`sweepTimer`+`setInterval` в конструкторе (:93,98-100,116-124) и `clearInterval` (:128), `enforceBasicLoginGate` (метод :284-349), в `resolveSessionConfig` — `isSessionInit` и deps `enforceBasicGate/login/verifyCredentials/limiter/clientIp/isSessionInit/email/password` (оставить `apiUrl`, `findWorkspace`, `verifyAccessJwt`), в `verifyMcpBearer`-обвязке — `findUser`/`findActiveSession` (:229-232). Мёртвые импорты: `FailedLoginLimiter`(:21), `isInitializeRequestBody`(:24), `clientIp`(:26), `decideBasicGate`(:28), `validateSsoEnforcement`(:16), `Workspace`(:18), `ModuleRef`(:7). Из DI-конструктора убрать `userRepo`(:106), `userSessionRepo`(:107), `moduleRef`(:108), `authService`(:104) — **разведка подтвердила: после удаления ACCESS/Basic каждый из них используется только в удаляемых путях** (`workspaceRepo`/`tokenService`/`apiKeyService`/`sandboxStore` остаются). Проверить и почистить провайдеры в `mcp.module.ts`. ### 3. Наблюдаемость на deny (замена удаляемого лимитера) **Важно (адверсариальный ревью):** обещанного в #501 «rate-limited WARN per (apiKeyId, class)» в коде **нет** — `ApiKeyService.validate` кидает `UnauthorizedException` без лога и метрики, а `McpService.handle` намеренно не логирует `UnauthorizedException`. После удаления Basic-лимитера мёртвый/отозванный ключ, долбящий `/mcp`, станет полностью невидим. Добавить: **rate-limited структурный WARN** и/или счётчик `api_key_auth_denied_total{reason}` в `ApiKeyService.validate` (или на `/mcp`-bearer-catch), ключ агрегации — `(apiKeyId, reason)`, `apiKeyId` в лог-путь попадает только после верификации подписи (кардинальность ограничена числом выданных ключей). Generic-401 в теле ответа сохраняется (WARN — только в логах оператора). ### 4. Env / доки `.env.example`: удалить `MCP_DOCMOST_EMAIL`/`MCP_DOCMOST_PASSWORD` (:116-117) и переписать описание способа аутентификации (:105-110) под «только api_key Bearer». **Оставить** `MCP_DOCMOST_API_URL` (:118, outbound loopback URL) и `MCP_TOKEN`. `AGENTS.md`: переписать :453 и :325 (убрать creds-переменные из списка). `CHANGELOG` — добавить Breaking-запись. ### 5. Тесты DELETE целиком: `mcp-basic-login-gate.spec.ts`, `mcp-login-gate-coupling.contract.spec.ts`. В `mcp.service.spec.ts` — удалить describe-блоки `parseBasicAuth`, `isCredentialsFailure`+coupling, `FailedLoginLimiter`, `verifyBearerAccess`, Basic-кейсы и service-account-кейс в `resolveMcpSessionConfig`, `isInitializeRequestBody`/`isSessionInit`/non-initialize-side-effects, `clientIp`, `decideBasicGate`; переписать `bindMcpBearerVerifier`-тест на `{API_KEY}`, оставить только API_KEY-кейсы `verifyMcpBearer`, поправить `onModuleDestroy`-тест (нет `sweepTimer`). KEEP: `sharedTokenMatches`, `mapAuthResultToResponse`, `extractBearer`, провенанс/collab-специи (в `authentication.extension.spec.ts` поправить комментарий про «MCP/service-account collab edit» на :235). Добавить тест: `/mcp` с Basic / с ACCESS-JWT / без заголовка → 401; с api_key → ок. ## Границы scope - Только inbound-авторизация встроенного `/mcp` + вычистка. Ядро api_key (reveal, kill-switch) — #557. Персона — #PHASE_C. - Не входит: `packages/mcp` stdio-CLI. ## Критерии приёмки 1. `/mcp` c `Authorization: Basic ...` → `401`; c валидным ACCESS-сессионным JWT → `401`; без заголовка при выставленных `MCP_DOCMOST_EMAIL/PASSWORD` → `401` (нет service-account фолбэка); c валидным api_key Bearer → работает. 2. Встроенный AI-агент (веб-чат) выполняет правку страницы end-to-end (регрессия — путь не через `/mcp`). 3. `grep -rE 'MCP_DOCMOST_EMAIL|MCP_DOCMOST_PASSWORD|FailedLoginLimiter|parseBasicAuth|enforceBasicLoginGate|verifyBearerAccess|decideBasicGate|service-account' apps/server` → пусто (кроме CHANGELOG). 4. Все негативы `/mcp` — единый generic-401 (анти-enumeration). 5. Отозванный ключ, долбящий `/mcp` N раз → в логах оператора rate-limited WARN с `apiKeyId` и/или счётчик deny инкрементится (не тишина). 6. Сборка/линт/тесты зелёные, нет unused-import/DI-провайдеров (проверка компилятором/линтером). ## Отказы и наблюдаемость - Generic-401 на всех отказах; WARN/counter на deny — единственная замена удаляемому лимитеру. - Брутфорса на api_key нет (HMAC-подписанный JWT неугадываем) — лимитер не нужен. ## Совместимость и миграции - **BREAKING**: ломает MCP-клиентов на `email:password` и деплои на `MCP_DOCMOST_*`. **Порядок (обязателен)**: #506 смержен → выпустить ключи всем агентам (UI/`create`) и переключить их конфиги на `Authorization: Bearer <key>` → **затем** деплой B. Приём api_key Bearer работает с #501 → окна 401 нет. - Миграций БД нет. ## Решённые развилки - `/mcp` ACCESS-сессия человека → удалить (только агенты; человек заводит api_key). Разведка подтвердила отделимость от service-account/Basic-вычистки; здесь удаляем вместе (единая цель «только api_key»). - Наблюдаемость → добавить WARN/counter (в #501 задизайнено, но не реализовано — адверсариальный ревью). ## Связи - Мета: #556. Blocked by #501 (merged), #506. Смежно правит поведение, заявленное в #501 (Basic «для людей» и лимитер — отменяются: модель изменилась, `/mcp` = агенты-only).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#558