[feat][auth][mcp] API-ключи для агентов — фаза 1 (сервер): выдача + приём api_key на /mcp вместо bcrypt-Basic #501
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Проблема / Цель
Агенты ходят в
/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-минутный таймаут).Цель: агенты аутентифицируются на
/mcpAPI-ключом (Authorization: Bearer <key>) — HMAC-верификация (микросекунды) вместо bcrypt, и это не «попытка логина», лимитер не участвует. Параллелизм агентов перестаёт душиться. Basic-путь и лимитер — не трогаем (остаются для людей/простых сетапов).Эта ишью — фаза 1 (сервер): выдача ключей (REST) + приём на /mcp и REST + закрытие путей отмывания. Фаза 2 (UI-страница) — отдельный тикет, ссылка в «Связях». Проектирование обеих фаз — здесь (полный жизненный цикл спроектирован до фазы 1, R1).
Контекст и исследование
Что уже есть (наследие апстрима Docmost, проверено на
develop):api_keys(id uuid pkgen_uuid_v7(), name, creator_id→users cascade, workspace_id→workspaces cascade,expires_at,last_used_at, created/updated,deleted_at)20250912T101500-api-keys— новых миграций фича не вводитApiKeys/InsertableApiKey/UpdatableApiKeydb.d.ts,entity.types.tsJwtApiKeyPayload = {sub, workspaceId, apiKeyId, type:'api_key'}jwt-payload.tsTokenService.generateApiToken({apiKeyId, user, workspaceId, expiresIn?})token.service.ts:122-141API_KEY_CREATED/DELETED,AuditResource.API_KEYaudit-events.tsWorkspaceCaslSubject.API, админ держитManageна нёмworkspace-ability.factory.ts:43,workspace-ability.type.ts:24Чего нет: эндпоинтов выдачи, приёма
api_keyна/mcp. Ключевые факты, найденные при adversarial-ревью (каждый влияет на дизайн):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.strategyreturn'ит до блока стампинга провенанса (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)
activedeleted_at IS NULL, (expires_at IS NULLOR> now()), юзер активенexpiredexpires_atrevokeddeleted_atset (delete-эндпоинт)orphanedВладелец истины о сроке и отзыве — строка
api_keys, не JWT. Это единственный источник (R3).1. Модуль
core/api-keyApiKeyRepo(kysely, по образцуuser.repo.ts):insert,findById,findByCreator,findAllInWorkspace,softDelete,touchLastUsed. Фильтрацияdeleted_at IS NULL— конвенция репо.ApiKeyService:create(user, name, expiresAt?) → { token, key }:apiKeyId = uuid7()(import { v7 as uuid7 } from 'uuid'— прайор-артpage.service.ts:26) — id нужен в payload ДО строки.generateApiTokenнаследует глобальныйexpiresIn='90d'и его не подавить per-call. Поэтому api_key-токены чеканятся отдельным путём — локально сконструированныйnew JwtService({ secret })(или сыройjsonwebtoken.sign) с ручнымissuer: 'Docmost'для паритета клеймов и безexpiresIn.expiresAtиз строки в JWT не попадает никогда.null(бессрочный) разрешён (осознанный выбор: homelab-агенты живут годами, форсить ротацию — операционная боль).expiresAtв прошлом — отвергать на уровне DTO.validate(payload: JwtApiKeyPayload) → { user, workspace }— единый валидатор для jwt.strategy И /mcp (R3):apiKeyIdесть;deleted_at IS NULL; (expires_at IS NULLOR> 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.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).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 через CASLWorkspaceCaslSubject.API(Manage): админ видит все ключи воркспейса с атрибуциейcreator, может отозвать любой; член — только свои (закрывает «утёкший ключ отсутствующего сотрудника»).create— только self.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: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.doAuthenticateотвергает COLLAB-токен без распознанногоprincipal. «Claim absent» становится ошибкой, а не доверием (иначе любой баг, родивший claimless api_key-токен, невидим 24 ч). Pre-rollout claimless-токены безопасны: до фичи api_key не мог начеканить collab-токен вообще (все claimless — session/внутренний агент, не под api_key-ревокацией).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.API keys: ENABLED/DISABLED (API_KEYS_ENABLED=<value|unset>)— состояние после каждого деплоя проверяемо в логах.Границы scope
mcp_auth_rejections_total{reason}— помечен как будущее исключение, если понадобится); реальный аудит-синк (отдельная тема,AUDIT_SERVICEсейчас Noop)./mcp, лимитер, схему БД (миграций нет).Критерии приёмки
verifyCredentials), латентность авторизации — суб-мс; в логах нет «Too many failed MCP login attempts».POST /api/api-keys/createвалидным api_key-принципалом → 403 (токен не управляет токенами)./mcpи REST с api_key: валидный → работает;revoke→ немедленно 401 на следующем запросе (без 5-с окна); истёкший (expires_atв прошлом) → 401; disabled-юзер → 401.exp-клейма (тест — ловит живой 90d-баг).is_agent-юзером под api_key →createdSource/lastUpdatedSource='agent'(провенанс не деградирует).principal-дискриминатора после grace-окна отклоняется (тест: начеканить api_key-collab-токен, срезать apiKeyId → reject).validate) → 5xx, НЕ 401 (deny-on-decision / 5xx-on-infra).API_KEYS_ENABLED=false→ api_key-токены deny, эндпоинты выдачи 404;=garbage→ падение на boot; boot-лог печатает состояние.create/revoke→AUDIT_SERVICE.logвызван сAPI_KEY_CREATED/DELETED(спай; Noop в билде) + структурная лог-строка с apiKeyId и IP.ManageнаAPI)list→ все ключи воркспейса сcreator; член → только свои;revokeчужого членом → 403, админом → ok.Отказы и наблюдаемость
verifyBearerAccess; класс отказа НЕ в теле ответа). Скоуп униформизации — только Bearer-нога (оба типа токенов); специфичные сообщения Basic-пути (throttle/SSO/MFA-подсказки) не трогаем — они про метод аутентификации звонящего, не про enumeration. Униформизация — на поверхности ответа (mapAuthResultToResponse/финальный catch), не реворд исключений у источника (иначе ломается матчинг лимитера поCREDENTIALS_MISMATCH_MESSAGE).(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"}.create-ответ и UI явно показывают вычисленную дату истечения («истекает 2027-07-11»); фаза-2 list подсвечивает «истекает в течение 30 дней». Заявленное ограничение: взгляд оператора на список — единственная система раннего предупреждения.Совместимость и миграции
api_keysсуществует). Zero schema-risk./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-путь строго более ограничен; если эту дыру когда-нибудь закроют — пересмотреть.Решённые развилки
context.ts— исходная премиса была неверна; проактивный exp-рефреш кодера принят как бонус). Дизайн-очевидность после проверки кода.verifyJwtOneOf(allowlist), не verify-and-return-any-type (переиспользуемый footgun). Спор с dry-run критиком.=0не должен молча значить «on»). Ops критик.Связи
[perf][mcp] Read-path агентов: bcrypt-логин на каждый вызов, лимитер душит параллельные валидные запросы, нет батч-чтения — скан 294 страниц занял минутыto [perf][mcp] Сессионный токен в MCP-клиенте: bcrypt-логин на каждый вызов + лимитер душит параллельные валидные запросы[perf][mcp] Сессионный токен в MCP-клиенте: bcrypt-логин на каждый вызов + лимитер душит параллельные валидные запросыto [feat][auth][mcp] API-ключи для агентов: выдача (эндпоинты + UI) + приём api_key на /mcp — вместо bcrypt-Basic и лимитера на каждый запрос[feat][auth][mcp] API-ключи для агентов: выдача (эндпоинты + UI) + приём api_key на /mcp — вместо bcrypt-Basic и лимитера на каждый запросto [feat][auth][mcp] API-ключи для агентов — фаза 1 (сервер): выдача + приём api_key на /mcp вместо bcrypt-Basic