[feat][auth][mcp] API-ключи для агентов — фаза 1 (сервер): выдача + приём api_key на /mcp вместо bcrypt-Basic #501

Open
opened 2026-07-11 05:09:03 +03:00 by agent_vscode · 0 comments
Collaborator

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

Агенты ходят в /mcp под Basic-auth (login/password в конфиге). Серверный Basic-путь (mcp-auth.helpers.ts) на каждый запрос делает bcrypt-проверку пароля (~50–100 мс CPU, by design медленно) и резервирует слот в анти-брутфорс-лимитере. Следствие из агентского фидбека: 6–8 параллельных валидных чтений исчерпывают бюджет лимитера друг друга → «Too many failed MCP login attempts», скан ~300 страниц идёт только последовательно (один раз — 2-минутный таймаут).

Цель: агенты аутентифицируются на /mcp API-ключом (Authorization: Bearer <key>) — HMAC-верификация (микросекунды) вместо bcrypt, и это не «попытка логина», лимитер не участвует. Параллелизм агентов перестаёт душиться. Basic-путь и лимитер — не трогаем (остаются для людей/простых сетапов).

Эта ишью — фаза 1 (сервер): выдача ключей (REST) + приём на /mcp и REST + закрытие путей отмывания. Фаза 2 (UI-страница) — отдельный тикет, ссылка в «Связях». Проектирование обеих фаз — здесь (полный жизненный цикл спроектирован до фазы 1, R1).

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

Что уже есть (наследие апстрима Docmost, проверено на develop):

Есть Файл
Таблица api_keys (id uuid pk gen_uuid_v7(), name, creator_id→users cascade, workspace_id→workspaces cascade, expires_at, last_used_at, created/updated, deleted_at) миграция 20250912T101500-api-keysновых миграций фича не вводит
Kysely-типы ApiKeys/InsertableApiKey/UpdatableApiKey db.d.ts, entity.types.ts
JwtApiKeyPayload = {sub, workspaceId, apiKeyId, type:'api_key'} jwt-payload.ts
Чеканка: TokenService.generateApiToken({apiKeyId, user, workspaceId, expiresIn?}) token.service.ts:122-141
Аудит-константы API_KEY_CREATED/DELETED, AuditResource.API_KEY audit-events.ts
CASL: WorkspaceCaslSubject.API, админ держит Manage на нём workspace-ability.factory.ts:43, workspace-ability.type.ts:24

Чего нет: эндпоинтов выдачи, приёма api_key на /mcp. Ключевые факты, найденные при adversarial-ревью (каждый влияет на дизайн):

  • EE-модуль отсутствует: jwt.strategy.ts:104 validateApiKey динамически require('./../../../ee/api-key/api-key.service') → ветки ee/ в форке нет → сегодня api_key на любом /api/* кидает «Enterprise API Key module missing». То есть api_key-принципал сейчас не долетает ни до одного эндпоинта — базовый уровень «до».
  • /mcp-Bearer жёстко пинит тип к ACCESS: bindAccessJwtVerifier (mcp-auth.helpers.ts:329) — «единственное место, где /mcp-Bearer пинит тип». Type-routing без нового примитива невозможен: TokenService.verifyJwt(token, type) бросает на несовпадении типа.
  • generateApiToken уже молча чеканит exp = now+90d для «бессрочных» ключей (эмпирически проверено на установленных @nestjs/jwt/jsonwebtoken): глобальный signOptions.expiresIn='90d' из token.module.ts мержится в вызов даже при пустых per-call опциях, а {expiresIn: undefined}бросает. Это живой баг, не гипотеза.
  • resolveProvenance читает user.isAgent, а userRepo.findById его НЕ грузит без {includeIsAgent:true} (user.repo.ts:53,64) — и ветка api_key в jwt.strategy return'ит до блока стампинга провенанса (jwt.strategy.ts:46-48 перед :91-99).
  • AUDIT_SERVICE в форке = NoopAuditService (audit.module.ts) — аудит-события молча глотаются; реального синка нет (лид про «#486 ELK-воркер» — ложный, там elkjs-граф).
  • /auth/collab-token (auth.controller.ts:204, под JwtAuthGuard) чеканит 24-часовой COLLAB-токен; его единственный потребитель AuthenticationExtension.doAuthenticate не сверяется с api_keys. MCP-клиент чеканит collab-токены для ВСЕХ правок контента (packages/mcp/src/client/context.ts:41-46).

Смежное: зонтик стабилизации #497 (эта ишью — волна E), провенанс-фикс пересекается с #486 (коммит 11).

Дизайн

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

Состояние Как входит Как проверяется Выход
active create строка есть, deleted_at IS NULL, (expires_at IS NULL OR > now()), юзер активен → expired / revoked
expired время прошло expires_at row-check (НЕ exp-клейм JWT) только пересоздание
revoked deleted_at set (delete-эндпоинт) row-check терминальное
orphaned каскад удаления юзера/воркспейса строки нет → deny терминальное

Владелец истины о сроке и отзыве — строка api_keys, не JWT. Это единственный источник (R3).

1. Модуль core/api-key

ApiKeyRepo (kysely, по образцу user.repo.ts): insert, findById, findByCreator, findAllInWorkspace, softDelete, touchLastUsed. Фильтрация deleted_at IS NULL — конвенция репо.

ApiKeyService:

  • create(user, name, expiresAt?) → { token, key }:
    1. Генерирует apiKeyId = uuid7() (import { v7 as uuid7 } from 'uuid' — прайор-арт page.service.ts:26) — id нужен в payload ДО строки.
    2. Чеканит JWT ПЕРВЫМ (mint-then-insert: сбой чеканки → строки нет → инертно; потерянный ответ → осиротевшая строка видна в list, самолечится).
    3. Вставляет строку последней.
    4. Возвращает токен один раз (в таблице токен-материал не хранится — JWT самодостаточен).
    • Чеканка без exp-клейма (критично): generateApiToken наследует глобальный expiresIn='90d' и его не подавить per-call. Поэтому api_key-токены чеканятся отдельным путём — локально сконструированный new JwtService({ secret }) (или сырой jsonwebtoken.sign) с ручным issuer: 'Docmost' для паритета клеймов и без expiresIn. expiresAt из строки в JWT не попадает никогда.
    • Дефолт срока — 1 год (DTO/UI), явный null (бессрочный) разрешён (осознанный выбор: homelab-агенты живут годами, форсить ротацию — операционная боль). expiresAt в прошлом — отвергать на уровне DTO.
  • validate(payload: JwtApiKeyPayload) → { user, workspace }единый валидатор для jwt.strategy И /mcp (R3):
    • checks: строка по apiKeyId есть; deleted_at IS NULL; (expires_at IS NULL OR > now()); payload.workspaceId === row.workspace_id; юзер загружен через userRepo.findById(sub, workspaceId, { includeIsAgent: true }) и не disabled/deleted.
    • возвращает { user, workspace } (workspace через workspaceRepo.findById) — этот shape требует AuthUser/AuthWorkspace декоратор (request.user.user) и MCP-identity; jwt.strategy отдаёт as-is.
    • семантика отказа (R4): определённый негативный факт (нет строки / deleted / expired / workspace mismatch / юзер disabled / kill-switch off) → UnauthorizedException; непредвиденная (инфра) ошибка ПРОБРАСЫВАЕТСЯ (→ 500/503), НЕ маскируется под «плохой ключ». Валидатор никогда не возвращает булев false (нечего негативно-кэшировать).
    • last_used_at: троттлированный best-effort UPDATE ВНУТРИ validate после всех проверок (обе поверхности бампают), обновлять только если старше ~1 ч; fire-and-forget, ошибки глотаются+логируются, никогда не валят запрос (зеркало trackActivity, jwt.strategy.ts:88). Разрешение forensics — 1 час (осознанная константа, R7).
    • Без кэша: validate = 2–3 PK-запроса (~1 мс), дешевле вытесняемого bcrypt на два порядка; кэш дал бы только ловушку JSON-сериализации Date в Redis + задержку ревокации. Ревокация — немедленная.

2. jwt.strategy: приём api_key напрямую

Заменить динамический EE-require на прямой вызов ApiKeyService.validate(payload). Ветка API_KEY дополнительно:

  • штампует req.raw.authType = 'api_key' И req.raw.apiKeyId (прайор-арт req.raw.sessionId/actor, jwt.strategy.ts:87,98) — нужно для гвардов ниже; ветка return'ит до общего блока, поэтому стампинг делается в самой ветке;
  • прогоняет resolveProvenance(user, payload) + req.raw.actor/aiChatId идентично ACCESS-ветке (jwt.strategy.ts:97-99) — иначе агентские REST-правки под api_key теряют non-spoofable маркер 'agent' (тихая деградация провенанса). validate для этого грузит юзера с {includeIsAgent:true}. Пересекается с #486 (коммит 11): если #486 смержен к моменту реализации — пункт схлопывается в тест.

3. /mcp: allowlist-роутер вместо ACCESS-пина

  • Новый примитив TokenService.verifyJwtOneOf(token, allowed: JwtType[]): верифицирует подпись один раз, проверяет payload.type ∈ allowed, возвращает payload; иначе — тот же generic-throw. (Не «verify-and-return-any-type» — тот переиспользуемый footgun; allowlist сохраняет свойство пиннинга.)
  • bindMcpBearerVerifier заменяет bindAccessJwtVerifier, его инвариант становится «единственное место, пинящее ALLOWLIST {ACCESS, API_KEY}». Роутер:
    • payload.type === ACCESSverifyBearerAccess без изменений (session-проверки). Чтобы не верифицировать дважды, роутер связывает deps.verifyJwt: async () => payload (closure над уже проверенным результатом verifyJwtOneOf) — «один раз» и «helper без изменений» совместимы.
    • payload.type === API_KEY → роутер сначала проверяет payload.workspaceId === expectedWorkspaceId, затем ApiKeyService.validate(payload).
  • Провенанс на /mcp: identity api_key-принципала мапится так же, MCP-транзит несёт Bearer в REST — там ветка jwt.strategy доштампует провенанс.

4. Эндпоинты выдачи (POST-RPC, конвенция дома)

POST /api/api-keys/create {name, expiresAt?}, POST /api/api-keys/list, POST /api/api-keys/revoke {id}. Все под JwtAuthGuard, @HttpCode(OK), @Inject(AUDIT_SERVICE) (образец comment.controller.ts:54).

  • create/list/revoke отвергают принципалов type='api_key' (гвард по req.raw.authType) — токен не может управлять токенами (GitHub-PAT-семантика: закрывает пост-ревокационное отмывание — утёкший ключ не начеканит замену). Тест: создать ключ → вызвать create этим ключом → 403.
  • list/revoke — admin-scope через CASL WorkspaceCaslSubject.API (Manage): админ видит все ключи воркспейса с атрибуцией creator, может отозвать любой; член — только свои (закрывает «утёкший ключ отсутствующего сотрудника»). create — только self.
  • Троттлинг createUserThrottlerGuard (per-user; дефолтный per-IP за Traefik схлопнулся бы в один бакет на всех при не выставленном TRUST_PROXY).
  • create/revoke эмитят AUDIT_SERVICE.log({event: API_KEY_CREATED/DELETED, ...}) И пишут структурную Logger.log-строку (actor id, apiKeyId, name, expiry, client IP) — реальный след живёт в container-логах (AUDIT_SERVICE — Noop в этом билде; emit оставлен spy-тестируемым для дня, когда синк появится). Долговечность следа = ретенции лог-пайплайна (зафиксированное допущение).

5. Закрытие отмывания через collab-token (fail-closed)

api_key стал полноправным REST-принципалом → может начеканить 24-ч COLLAB-токен, который AuthenticationExtension не сверяет с api_keys → ревокация не достаёт websocket-редактирование. Блокировать /collab-token для api_key нельзя (это ломает правки агентов — их основная функция). Решение fail-closed:

  • Позитивный дискриминатор в КАЖДЫЙ COLLAB-токен: JwtCollabPayload расширяется полем principal: 'session' | 'api_key'; для api_key-принципала (req.raw.authType) добавляется apiKeyId. getCollabToken/generateCollabToken (сигнатура (user, workspaceId)) расширяются, чтобы прокинуть его.
  • AuthenticationExtension.doAuthenticate: при apiKeyId в claim — на connect прогоняет ApiKeyService.validate (row-check); отозванный ключ → новых collab-подключений нет.
  • Дискриминатор ключуется на apiKeyId-присутствии, НЕ на actor:'agent': внутренний AI-агент чеканит collab-токен actor:'agent' без apiKeyId (session-backed) — он остаётся на no-check пути; внешний MCP-агент — isAgent-юзер с actor:'agent' плюс apiKeyId.
  • Grace + reject-claimless: после 24-ч grace-окна (макс. время жизни COLLAB-токена — к этому моменту всякий валидный токен начеканен пост-роллаутом и обязан нести дискриминатор) doAuthenticate отвергает COLLAB-токен без распознанного principal. «Claim absent» становится ошибкой, а не доверием (иначе любой баг, родивший claimless api_key-токен, невидим 24 ч). Pre-rollout claimless-токены безопасны: до фичи api_key не мог начеканить collab-токен вообще (все claimless — session/внутренний агент, не под api_key-ревокацией).
  • Остаточный риск (принят, заявлен): уже установленный websocket переживает ревокацию до следующего reconnect/re-auth — тот же класс, что человеческая сессия после смены пароля; ограничен churn'ом reconnect'ов hocuspocus, крайняя мера — рестарт инстанса.

6. Kill-switch API_KEYS_ENABLED (R8)

Env, дефолт on (правильная сторона: деплой без выставленной переменной не должен молча убить всех агентов). off → validateApiKey безусловно deny И эндпоинты выдачи 404.

  • Строгий парс @IsIn(['true','false']) (environment.validation.ts:123) — иначе =0/=off/=False молча значат «включено», и оператор, «дёрнувший рубильник» в инцидент, его не дёрнул. Любое иное значение — падение на boot, а не fail-open.
  • Boot-лог: API keys: ENABLED/DISABLED (API_KEYS_ENABLED=<value|unset>) — состояние после каждого деплоя проверяемо в логах.
  • Ранбук-строка: «kill-switch — временная мера; перед re-enable ревокни строки» (disable ≠ revoke: re-enable оживляет все неистёкшие токены).

Границы scope

  • Фаза 2 (UI-страница настроек) — отдельный тикет (см. «Связи»): список/создание/показ-один-раз/revoke, lazy-роут.
  • Не входит: права/скоупы у ключей (read-only и т.п.); admin-UI управления чужими ключами (эндпоинты есть, UI — позже); новые метрики (mcp_auth_rejections_total{reason} — помечен как будущее исключение, если понадобится); реальный аудит-синк (отдельная тема, AUDIT_SERVICE сейчас Noop).
  • Не трогаем: Basic-путь /mcp, лимитер, схему БД (миграций нет).

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

  1. Сценарий из фидбека: 20 параллельных MCP-вызовов с валидным api_key → 0 срабатываний лимитера, 0 bcrypt-вызовов (спай на verifyCredentials), латентность авторизации — суб-мс; в логах нет «Too many failed MCP login attempts».
  2. POST /api/api-keys/create валидным api_key-принципалом → 403 (токен не управляет токенами).
  3. /mcp и REST с api_key: валидный → работает; revokeнемедленно 401 на следующем запросе (без 5-с окна); истёкший (expires_at в прошлом) → 401; disabled-юзер → 401.
  4. Декодированный api_key-JWT НЕ содержит exp-клейма (тест — ловит живой 90d-баг).
  5. REST-правка is_agent-юзером под api_keycreatedSource/lastUpdatedSource = 'agent' (провенанс не деградирует).
  6. Отзыв ключа → новое collab-подключение этим ключом отклоняется на connect; COLLAB-токен без principal-дискриминатора после grace-окна отклоняется (тест: начеканить api_key-collab-токен, срезать apiKeyId → reject).
  7. Инфра-сбой (мок ошибки БД в validate) → 5xx, НЕ 401 (deny-on-decision / 5xx-on-infra).
  8. API_KEYS_ENABLED=false → api_key-токены deny, эндпоинты выдачи 404; =garbage → падение на boot; boot-лог печатает состояние.
  9. create/revokeAUDIT_SERVICE.log вызван с API_KEY_CREATED/DELETED (спай; Noop в билде) + структурная лог-строка с apiKeyId и IP.
  10. admin (CASL Manage на API) list → все ключи воркспейса с creator; член → только свои; revoke чужого членом → 403, админом → ok.

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

  • Каждый отказ авторизацииединый generic-401 на обеих поверхностях (сохраняет задокументированный анти-enumeration инвариант verifyBearerAccess; класс отказа НЕ в теле ответа). Скоуп униформизации — только Bearer-нога (оба типа токенов); специфичные сообщения Basic-пути (throttle/SSO/MFA-подсказки) не трогаем — они про метод аутентификации звонящего, не про enumeration. Униформизация — на поверхности ответа (mapAuthResultToResponse/финальный catch), не реворд исключений у источника (иначе ломается матчинг лимитера по CREDENTIALS_MISMATCH_MESSAGE).
  • Оператор: rate-limited структурный WARN per Bearer-rejection, ключ per (apiKeyId, class) (не глобальный — иначе один заклинивший агент с отозванным ключом навсегда съедает окно, а настоящая атака логируется в никуда; кардинальность ограничена: apiKeyId в лог-пути только после верификации подписи ≤ числу выданных ключей); unsigned-мусор — отдельная грубая строка, не тишина. WARN несёт apiKeyId + source IP. Плюс AUDIT_SERVICE.log(api_key.auth_rejected) на пост-ревокационную попытку (Noop сейчас). Агрегатно 401-шторм виден в http_request_duration_seconds{route="/mcp",status="401"}.
  • Синхронизированные часовые бомбы: годовой дефолт → все ключи умирают через год, первый сигнал — 3-часовой 401. Сдерживание: create-ответ и UI явно показывают вычисленную дату истечения («истекает 2027-07-11»); фаза-2 list подсвечивает «истекает в течение 30 дней». Заявленное ограничение: взгляд оператора на список — единственная система раннего предупреждения.
  • Rollback = revert deploy (ветка изолирована, api_key-токены сегодня и так отвергаются). Каверза: re-enable/roll-forward оживляет все неистёкшие токены — disable ≠ revoke.

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

  • Миграций нет (таблица api_keys существует). Zero schema-risk.
  • Изменение поведения для существующих: сегодня api_key-токен на /api/* кидает «Enterprise API Key module missing» → станет валидным принципалом. На деплое проверить, что api_keys пуста (EE-эра могла оставить строки, подписанные текущим APP_SECRET; near-zero для этого инстанса, но одна строка проверки бесплатна).
  • Ротация APP_SECRET инвалидирует все api_key-токены (как и все сессии) — задокументировать: как ответ на утечку это крайняя мера, row-revoke / kill-switch предпочтительнее. Несущая премиса безопасности (в rationale): JWT-с-row-check приемлем, потому что APP_SECRET уже чеканит неанкоренные ACCESS-токены (ветка optional-sessionId, jwt.strategy.ts:81) — api_key-путь строго более ограничен; если эту дыру когда-нибудь закроют — пересмотреть.
  • Basic-путь и все его сообщения — без изменений.

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

  • Клиентский токен-кэш vs серверный фикс → серверный (клиентский JWT-кэш + single-flight уже существуют в context.ts — исходная премиса была неверна; проактивный exp-рефреш кодера принят как бонус). Дизайн-очевидность после проверки кода.
  • Механизм type-routing → verifyJwtOneOf(allowlist), не verify-and-return-any-type (переиспользуемый footgun). Спор с dry-run критиком.
  • Срок хранения exp → exp-клейма нет никогда, срок только в строке (глобальный 90d-дефолт неподавляем per-call и молча убивал бы «бессрочные» ключи). Lifecycle+dry-run критики.
  • Machine-readable reason кодов → убраны полностью, единый 401 (сохранение анти-enumeration инварианта; агенту не нужно различать expired/revoked — реакция одна). Все три критика сошлись.
  • Кэш validate → убран (Date-serialization + задержка ревокации > экономии на 3 PK-lookup). Ops критик.
  • collab-отмывание: блок vs дискриминатор → fail-closed дискриминатор + grace-reject-claimless (блок ломает правки агентов). Lifecycle критик, 2 раунда.
  • Дефолт срока → 1 год, явный null разрешён (homelab-деплой владельца). Владелец.
  • Kill-switch дефолт → on + строгий парс (деплой без переменной не должен убивать агентов; но =0 не должен молча значить «on»). Ops критик.

Связи

  • Мета/зонтик: #497 (волна E). Провенанс-пункт пересекается с #486 (коммит 11) — если смержен к реализации, схлопывается в тест.
  • Фаза 2 (UI): отдельный тикет — см. следующий; blocked by эта ишью.
  • Родственный фидбек-пакет: #502 (markdown-режимы MCP), #503 (hardBreak).
## Проблема / Цель Агенты ходят в `/mcp` под Basic-auth (login/password в конфиге). Серверный Basic-путь ([mcp-auth.helpers.ts](apps/server/src/integrations/mcp/mcp-auth.helpers.ts)) на **каждый** запрос делает bcrypt-проверку пароля (~50–100 мс CPU, by design медленно) и резервирует слот в анти-брутфорс-лимитере. Следствие из агентского фидбека: 6–8 параллельных валидных чтений исчерпывают бюджет лимитера друг друга → «Too many failed MCP login attempts», скан ~300 страниц идёт только последовательно (один раз — 2-минутный таймаут). **Цель**: агенты аутентифицируются на `/mcp` **API-ключом** (`Authorization: Bearer <key>`) — HMAC-верификация (микросекунды) вместо bcrypt, и это не «попытка логина», лимитер не участвует. Параллелизм агентов перестаёт душиться. Basic-путь и лимитер — **не трогаем** (остаются для людей/простых сетапов). Эта ишью — **фаза 1 (сервер)**: выдача ключей (REST) + приём на /mcp и REST + закрытие путей отмывания. **Фаза 2 (UI-страница)** — отдельный тикет, ссылка в «Связях». Проектирование обеих фаз — здесь (полный жизненный цикл спроектирован до фазы 1, R1). ## Контекст и исследование Что уже есть (наследие апстрима Docmost, проверено на `develop`): | Есть | Файл | |---|---| | Таблица `api_keys` (id uuid pk `gen_uuid_v7()`, name, creator_id→users cascade, workspace_id→workspaces cascade, `expires_at`, `last_used_at`, created/updated, `deleted_at`) | миграция `20250912T101500-api-keys` — **новых миграций фича не вводит** | | Kysely-типы `ApiKeys`/`InsertableApiKey`/`UpdatableApiKey` | `db.d.ts`, `entity.types.ts` | | `JwtApiKeyPayload = {sub, workspaceId, apiKeyId, type:'api_key'}` | `jwt-payload.ts` | | Чеканка: `TokenService.generateApiToken({apiKeyId, user, workspaceId, expiresIn?})` | `token.service.ts:122-141` | | Аудит-константы `API_KEY_CREATED/DELETED`, `AuditResource.API_KEY` | `audit-events.ts` | | CASL: `WorkspaceCaslSubject.API`, админ держит `Manage` на нём | `workspace-ability.factory.ts:43`, `workspace-ability.type.ts:24` | Чего нет: эндпоинтов выдачи, приёма `api_key` на `/mcp`. Ключевые факты, найденные при adversarial-ревью (каждый влияет на дизайн): - **EE-модуль отсутствует**: [jwt.strategy.ts:104](apps/server/src/core/auth/strategies/jwt.strategy.ts#L104) `validateApiKey` динамически `require('./../../../ee/api-key/api-key.service')` → ветки `ee/` в форке нет → **сегодня api_key на любом `/api/*` кидает «Enterprise API Key module missing»**. То есть api_key-принципал сейчас не долетает ни до одного эндпоинта — базовый уровень «до». - **`/mcp`-Bearer жёстко пинит тип к `ACCESS`**: `bindAccessJwtVerifier` ([mcp-auth.helpers.ts:329](apps/server/src/integrations/mcp/mcp-auth.helpers.ts#L329)) — «единственное место, где /mcp-Bearer пинит тип». Type-routing без нового примитива невозможен: `TokenService.verifyJwt(token, type)` **бросает** на несовпадении типа. - **`generateApiToken` уже молча чеканит `exp = now+90d` для «бессрочных» ключей** (эмпирически проверено на установленных `@nestjs/jwt`/`jsonwebtoken`): глобальный `signOptions.expiresIn='90d'` из `token.module.ts` мержится в вызов даже при пустых per-call опциях, а `{expiresIn: undefined}` — **бросает**. Это живой баг, не гипотеза. - **`resolveProvenance` читает `user.isAgent`, а `userRepo.findById` его НЕ грузит без `{includeIsAgent:true}`** (`user.repo.ts:53,64`) — и ветка api_key в jwt.strategy `return`'ит **до** блока стампинга провенанса (`jwt.strategy.ts:46-48` перед `:91-99`). - **`AUDIT_SERVICE` в форке = `NoopAuditService`** (`audit.module.ts`) — аудит-события молча глотаются; реального синка нет (лид про «#486 ELK-воркер» — ложный, там elkjs-граф). - **`/auth/collab-token`** ([auth.controller.ts:204](apps/server/src/core/auth/auth.controller.ts#L204), под `JwtAuthGuard`) чеканит 24-часовой COLLAB-токен; его единственный потребитель `AuthenticationExtension.doAuthenticate` **не сверяется с `api_keys`**. MCP-клиент чеканит collab-токены для ВСЕХ правок контента (`packages/mcp/src/client/context.ts:41-46`). Смежное: зонтик стабилизации #497 (эта ишью — волна E), провенанс-фикс пересекается с #486 (коммит 11). ## Дизайн ### Модель состояний ключа (R1) | Состояние | Как входит | Как проверяется | Выход | |---|---|---|---| | `active` | create | строка есть, `deleted_at IS NULL`, (`expires_at IS NULL` OR `> now()`), юзер активен | → expired / revoked | | `expired` | время прошло `expires_at` | row-check (НЕ exp-клейм JWT) | только пересоздание | | `revoked` | `deleted_at` set (delete-эндпоинт) | row-check | терминальное | | `orphaned` | каскад удаления юзера/воркспейса | строки нет → deny | терминальное | **Владелец истины о сроке и отзыве — строка `api_keys`, не JWT.** Это единственный источник (R3). ### 1. Модуль `core/api-key` **`ApiKeyRepo`** (kysely, по образцу `user.repo.ts`): `insert`, `findById`, `findByCreator`, `findAllInWorkspace`, `softDelete`, `touchLastUsed`. Фильтрация `deleted_at IS NULL` — конвенция репо. **`ApiKeyService`**: - **`create(user, name, expiresAt?) → { token, key }`**: 1. **Генерирует `apiKeyId = uuid7()`** (`import { v7 as uuid7 } from 'uuid'` — прайор-арт `page.service.ts:26`) — id нужен в payload ДО строки. 2. **Чеканит JWT ПЕРВЫМ** (mint-then-insert: сбой чеканки → строки нет → инертно; потерянный ответ → осиротевшая строка видна в list, самолечится). 3. Вставляет строку последней. 4. Возвращает токен **один раз** (в таблице токен-материал не хранится — JWT самодостаточен). - **Чеканка без exp-клейма (критично)**: `generateApiToken` наследует глобальный `expiresIn='90d'` и его не подавить per-call. Поэтому api_key-токены чеканятся **отдельным путём** — локально сконструированный `new JwtService({ secret })` (или сырой `jsonwebtoken.sign`) с ручным `issuer: 'Docmost'` для паритета клеймов и **без** `expiresIn`. `expiresAt` из строки в JWT **не попадает никогда**. - **Дефолт срока — 1 год** (DTO/UI), явный `null` (бессрочный) разрешён (осознанный выбор: homelab-агенты живут годами, форсить ротацию — операционная боль). `expiresAt` в прошлом — отвергать на уровне DTO. - **`validate(payload: JwtApiKeyPayload) → { user, workspace }`** — **единый валидатор для jwt.strategy И /mcp** (R3): - checks: строка по `apiKeyId` есть; `deleted_at IS NULL`; (`expires_at IS NULL` OR `> now()`); `payload.workspaceId === row.workspace_id`; юзер загружен через `userRepo.findById(sub, workspaceId, { includeIsAgent: true })` и не disabled/deleted. - **возвращает `{ user, workspace }`** (workspace через `workspaceRepo.findById`) — этот shape требует `AuthUser`/`AuthWorkspace` декоратор (`request.user.user`) и MCP-identity; jwt.strategy отдаёт as-is. - **семантика отказа (R4)**: определённый негативный факт (нет строки / deleted / expired / workspace mismatch / юзер disabled / kill-switch off) → `UnauthorizedException`; **непредвиденная (инфра) ошибка ПРОБРАСЫВАЕТСЯ** (→ 500/503), НЕ маскируется под «плохой ключ». Валидатор никогда не возвращает булев `false` (нечего негативно-кэшировать). - **`last_used_at`**: троттлированный best-effort UPDATE ВНУТРИ `validate` после всех проверок (обе поверхности бампают), обновлять только если старше ~1 ч; **fire-and-forget, ошибки глотаются+логируются, никогда не валят запрос** (зеркало `trackActivity`, `jwt.strategy.ts:88`). Разрешение forensics — 1 час (осознанная константа, R7). - **Без кэша**: `validate` = 2–3 PK-запроса (~1 мс), дешевле вытесняемого bcrypt на два порядка; кэш дал бы только ловушку JSON-сериализации Date в Redis + задержку ревокации. Ревокация — немедленная. ### 2. jwt.strategy: приём api_key напрямую Заменить динамический EE-`require` на прямой вызов `ApiKeyService.validate(payload)`. Ветка API_KEY **дополнительно**: - **штампует `req.raw.authType = 'api_key'` И `req.raw.apiKeyId`** (прайор-арт `req.raw.sessionId`/`actor`, `jwt.strategy.ts:87,98`) — нужно для гвардов ниже; ветка `return`'ит до общего блока, поэтому стампинг делается в самой ветке; - **прогоняет `resolveProvenance(user, payload)` + `req.raw.actor/aiChatId`** идентично ACCESS-ветке (`jwt.strategy.ts:97-99`) — иначе агентские REST-правки под api_key теряют non-spoofable маркер `'agent'` (тихая деградация провенанса). `validate` для этого грузит юзера с `{includeIsAgent:true}`. Пересекается с #486 (коммит 11): если #486 смержен к моменту реализации — пункт схлопывается в тест. ### 3. `/mcp`: allowlist-роутер вместо ACCESS-пина - **Новый примитив `TokenService.verifyJwtOneOf(token, allowed: JwtType[])`**: верифицирует подпись **один раз**, проверяет `payload.type ∈ allowed`, возвращает payload; иначе — тот же generic-throw. (Не «verify-and-return-any-type» — тот переиспользуемый footgun; allowlist сохраняет свойство пиннинга.) - **`bindMcpBearerVerifier` заменяет `bindAccessJwtVerifier`**, его инвариант становится «единственное место, пинящее ALLOWLIST {ACCESS, API_KEY}». Роутер: - `payload.type === ACCESS` → **`verifyBearerAccess` без изменений** (session-проверки). Чтобы не верифицировать дважды, роутер связывает `deps.verifyJwt: async () => payload` (closure над уже проверенным результатом `verifyJwtOneOf`) — «один раз» и «helper без изменений» совместимы. - `payload.type === API_KEY` → роутер **сначала** проверяет `payload.workspaceId === expectedWorkspaceId`, **затем** `ApiKeyService.validate(payload)`. - **Провенанс на /mcp**: identity api_key-принципала мапится так же, MCP-транзит несёт Bearer в REST — там ветка jwt.strategy доштампует провенанс. ### 4. Эндпоинты выдачи (POST-RPC, конвенция дома) `POST /api/api-keys/create` `{name, expiresAt?}`, `POST /api/api-keys/list`, `POST /api/api-keys/revoke` `{id}`. Все под `JwtAuthGuard`, `@HttpCode(OK)`, `@Inject(AUDIT_SERVICE)` (образец `comment.controller.ts:54`). - **`create`/`list`/`revoke` отвергают принципалов `type='api_key'`** (гвард по `req.raw.authType`) — **токен не может управлять токенами** (GitHub-PAT-семантика: закрывает пост-ревокационное отмывание — утёкший ключ не начеканит замену). Тест: создать ключ → вызвать `create` этим ключом → 403. - **`list`/`revoke` — admin-scope через CASL `WorkspaceCaslSubject.API`** (`Manage`): админ видит **все** ключи воркспейса с атрибуцией `creator`, может отозвать любой; член — только свои (закрывает «утёкший ключ отсутствующего сотрудника»). `create` — только self. - **Троттлинг create** — `UserThrottlerGuard` (per-user; дефолтный per-IP за Traefik схлопнулся бы в один бакет на всех при не выставленном `TRUST_PROXY`). - `create`/`revoke` эмитят `AUDIT_SERVICE.log({event: API_KEY_CREATED/DELETED, ...})` **И** пишут структурную `Logger.log`-строку (actor id, apiKeyId, name, expiry, client IP) — **реальный след живёт в container-логах** (AUDIT_SERVICE — Noop в этом билде; emit оставлен spy-тестируемым для дня, когда синк появится). Долговечность следа = ретенции лог-пайплайна (зафиксированное допущение). ### 5. Закрытие отмывания через collab-token (fail-closed) api_key стал полноправным REST-принципалом → может начеканить 24-ч COLLAB-токен, который `AuthenticationExtension` не сверяет с `api_keys` → ревокация не достаёт websocket-редактирование. Блокировать `/collab-token` для api_key нельзя (это ломает правки агентов — их основная функция). Решение **fail-closed**: - **Позитивный дискриминатор в КАЖДЫЙ COLLAB-токен**: `JwtCollabPayload` расширяется полем `principal: 'session' | 'api_key'`; для api_key-принципала (`req.raw.authType`) добавляется `apiKeyId`. `getCollabToken`/`generateCollabToken` (сигнатура `(user, workspaceId)`) расширяются, чтобы прокинуть его. - **`AuthenticationExtension.doAuthenticate`**: при `apiKeyId` в claim — **на connect** прогоняет `ApiKeyService.validate` (row-check); отозванный ключ → новых collab-подключений нет. - **Дискриминатор ключуется на `apiKeyId`-присутствии, НЕ на `actor:'agent'`**: внутренний AI-агент чеканит collab-токен `actor:'agent'` **без** apiKeyId (session-backed) — он остаётся на no-check пути; внешний MCP-агент — `isAgent`-юзер с `actor:'agent'` **плюс** apiKeyId. - **Grace + reject-claimless**: после 24-ч grace-окна (макс. время жизни COLLAB-токена — к этому моменту всякий валидный токен начеканен пост-роллаутом и обязан нести дискриминатор) `doAuthenticate` **отвергает** COLLAB-токен без распознанного `principal`. «Claim absent» становится ошибкой, а не доверием (иначе любой баг, родивший claimless api_key-токен, невидим 24 ч). Pre-rollout claimless-токены безопасны: до фичи api_key не мог начеканить collab-токен вообще (все claimless — session/внутренний агент, не под api_key-ревокацией). - **Остаточный риск (принят, заявлен)**: уже установленный websocket переживает ревокацию до следующего reconnect/re-auth — тот же класс, что человеческая сессия после смены пароля; ограничен churn'ом reconnect'ов hocuspocus, крайняя мера — рестарт инстанса. ### 6. Kill-switch `API_KEYS_ENABLED` (R8) Env, дефолт **on** (правильная сторона: деплой без выставленной переменной не должен молча убить всех агентов). off → `validateApiKey` безусловно deny И эндпоинты выдачи 404. - **Строгий парс `@IsIn(['true','false'])`** (`environment.validation.ts:123`) — иначе `=0`/`=off`/`=False` молча значат «включено», и оператор, «дёрнувший рубильник» в инцидент, его не дёрнул. Любое иное значение — падение на boot, а не fail-open. - **Boot-лог**: `API keys: ENABLED/DISABLED (API_KEYS_ENABLED=<value|unset>)` — состояние после каждого деплоя проверяемо в логах. - Ранбук-строка: «kill-switch — временная мера; перед re-enable ревокни строки» (disable ≠ revoke: re-enable оживляет все неистёкшие токены). ## Границы scope - **Фаза 2 (UI-страница настроек)** — отдельный тикет (см. «Связи»): список/создание/показ-один-раз/revoke, lazy-роут. - **Не входит**: права/скоупы у ключей (read-only и т.п.); admin-UI управления чужими ключами (эндпоинты есть, UI — позже); новые метрики (`mcp_auth_rejections_total{reason}` — помечен как будущее исключение, если понадобится); реальный аудит-синк (отдельная тема, `AUDIT_SERVICE` сейчас Noop). - **Не трогаем**: Basic-путь `/mcp`, лимитер, схему БД (миграций нет). ## Критерии приёмки 1. **Сценарий из фидбека**: 20 параллельных MCP-вызовов с валидным api_key → 0 срабатываний лимитера, **0 bcrypt-вызовов** (спай на `verifyCredentials`), латентность авторизации — суб-мс; в логах нет «Too many failed MCP login attempts». 2. `POST /api/api-keys/create` валидным api_key-принципалом → **403** (токен не управляет токенами). 3. `/mcp` и REST с api_key: валидный → работает; `revoke` → **немедленно** 401 на следующем запросе (без 5-с окна); истёкший (`expires_at` в прошлом) → 401; disabled-юзер → 401. 4. **Декодированный api_key-JWT НЕ содержит `exp`-клейма** (тест — ловит живой 90d-баг). 5. **REST-правка `is_agent`-юзером под api_key** → `createdSource`/`lastUpdatedSource` = `'agent'` (провенанс не деградирует). 6. **Отзыв ключа → новое collab-подключение этим ключом отклоняется** на connect; COLLAB-токен без `principal`-дискриминатора после grace-окна отклоняется (тест: начеканить api_key-collab-токен, срезать apiKeyId → reject). 7. Инфра-сбой (мок ошибки БД в `validate`) → **5xx, НЕ 401** (deny-on-decision / 5xx-on-infra). 8. `API_KEYS_ENABLED=false` → api_key-токены deny, эндпоинты выдачи 404; `=garbage` → падение на boot; boot-лог печатает состояние. 9. `create`/`revoke` → `AUDIT_SERVICE.log` вызван с `API_KEY_CREATED/DELETED` (спай; Noop в билде) + структурная лог-строка с apiKeyId и IP. 10. admin (CASL `Manage` на `API`) `list` → все ключи воркспейса с `creator`; член → только свои; `revoke` чужого членом → 403, админом → ok. ## Отказы и наблюдаемость - **Каждый отказ авторизации** — **единый generic-401** на обеих поверхностях (сохраняет задокументированный анти-enumeration инвариант `verifyBearerAccess`; класс отказа НЕ в теле ответа). **Скоуп униформизации — только Bearer-нога** (оба типа токенов); специфичные сообщения Basic-пути (throttle/SSO/MFA-подсказки) **не трогаем** — они про метод аутентификации звонящего, не про enumeration. Униформизация — на поверхности ответа (`mapAuthResultToResponse`/финальный catch), **не** реворд исключений у источника (иначе ломается матчинг лимитера по `CREDENTIALS_MISMATCH_MESSAGE`). - **Оператор**: rate-limited структурный WARN per Bearer-rejection, ключ **per `(apiKeyId, class)`** (не глобальный — иначе один заклинивший агент с отозванным ключом навсегда съедает окно, а настоящая атака логируется в никуда; кардинальность ограничена: apiKeyId в лог-пути только после верификации подписи ≤ числу выданных ключей); unsigned-мусор — отдельная грубая строка, не тишина. WARN несёт `apiKeyId` + source IP. Плюс `AUDIT_SERVICE.log(api_key.auth_rejected)` на пост-ревокационную попытку (Noop сейчас). Агрегатно 401-шторм виден в `http_request_duration_seconds{route="/mcp",status="401"}`. - **Синхронизированные часовые бомбы**: годовой дефолт → все ключи умирают через год, первый сигнал — 3-часовой 401. Сдерживание: `create`-ответ и UI **явно показывают вычисленную дату истечения** («истекает 2027-07-11»); фаза-2 list подсвечивает «истекает в течение 30 дней». Заявленное ограничение: взгляд оператора на список — единственная система раннего предупреждения. - Rollback = revert deploy (ветка изолирована, api_key-токены сегодня и так отвергаются). Каверза: re-enable/roll-forward оживляет все неистёкшие токены — disable ≠ revoke. ## Совместимость и миграции - **Миграций нет** (таблица `api_keys` существует). Zero schema-risk. - **Изменение поведения для существующих**: сегодня api_key-токен на `/api/*` кидает «Enterprise API Key module missing» → станет валидным принципалом. На деплое **проверить, что `api_keys` пуста** (EE-эра могла оставить строки, подписанные текущим `APP_SECRET`; near-zero для этого инстанса, но одна строка проверки бесплатна). - **Ротация `APP_SECRET`** инвалидирует все api_key-токены (как и все сессии) — задокументировать: как ответ на утечку это крайняя мера, row-revoke / kill-switch предпочтительнее. Несущая премиса безопасности (в rationale): JWT-с-row-check приемлем, потому что `APP_SECRET` уже чеканит неанкоренные ACCESS-токены (ветка optional-`sessionId`, `jwt.strategy.ts:81`) — api_key-путь строго более ограничен; если эту дыру когда-нибудь закроют — пересмотреть. - Basic-путь и все его сообщения — без изменений. ## Решённые развилки - Клиентский токен-кэш vs серверный фикс → **серверный** (клиентский JWT-кэш + single-flight уже существуют в `context.ts` — исходная премиса была неверна; проактивный exp-рефреш кодера принят как бонус). Дизайн-очевидность после проверки кода. - Механизм type-routing → **`verifyJwtOneOf(allowlist)`**, не verify-and-return-any-type (переиспользуемый footgun). Спор с dry-run критиком. - Срок хранения exp → **exp-клейма нет никогда, срок только в строке** (глобальный 90d-дефолт неподавляем per-call и молча убивал бы «бессрочные» ключи). Lifecycle+dry-run критики. - Machine-readable reason кодов → **убраны полностью, единый 401** (сохранение анти-enumeration инварианта; агенту не нужно различать expired/revoked — реакция одна). Все три критика сошлись. - Кэш validate → **убран** (Date-serialization + задержка ревокации > экономии на 3 PK-lookup). Ops критик. - collab-отмывание: блок vs дискриминатор → **fail-closed дискриминатор + grace-reject-claimless** (блок ломает правки агентов). Lifecycle критик, 2 раунда. - Дефолт срока → **1 год, явный null разрешён** (homelab-деплой владельца). Владелец. - Kill-switch дефолт → **on** + строгий парс (деплой без переменной не должен убивать агентов; но `=0` не должен молча значить «on»). Ops критик. ## Связи - **Мета/зонтик**: #497 (волна E). Провенанс-пункт пересекается с #486 (коммит 11) — если смержен к реализации, схлопывается в тест. - **Фаза 2 (UI)**: отдельный тикет — см. следующий; blocked by эта ишью. - Родственный фидбек-пакет: #502 (markdown-режимы MCP), #503 (hardBreak).
agent_vscode changed title from [perf][mcp] Read-path агентов: bcrypt-логин на каждый вызов, лимитер душит параллельные валидные запросы, нет батч-чтения — скан 294 страниц занял минуты to [perf][mcp] Сессионный токен в MCP-клиенте: bcrypt-логин на каждый вызов + лимитер душит параллельные валидные запросы 2026-07-11 05:13:45 +03:00
agent_vscode changed title from [perf][mcp] Сессионный токен в MCP-клиенте: bcrypt-логин на каждый вызов + лимитер душит параллельные валидные запросы to [feat][auth][mcp] API-ключи для агентов: выдача (эндпоинты + UI) + приём api_key на /mcp — вместо bcrypt-Basic и лимитера на каждый запрос 2026-07-11 05:55:12 +03:00
agent_vscode changed title from [feat][auth][mcp] API-ключи для агентов: выдача (эндпоинты + UI) + приём api_key на /mcp — вместо bcrypt-Basic и лимитера на каждый запрос to [feat][auth][mcp] API-ключи для агентов — фаза 1 (сервер): выдача + приём api_key на /mcp вместо bcrypt-Basic 2026-07-11 06:22:14 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#501