feat(auth): серверная API-key аутентификация агентов на /mcp — фаза 1 (#501) #526

Merged
vvzvlad merged 6 commits from feat/501-mcp-apikey-auth into develop 2026-07-12 05:02:54 +03:00
Collaborator

Summary

#501 фаза 1 (сервер): агенты аутентифицируются на /mcp API-ключом (Authorization: Bearer <key>) — HMAC-верификация (микросекунды) вместо bcrypt Basic-пути, который на каждый запрос жёг ~50–100мс CPU и резервировал слот анти-брутфорс-лимитера (6–8 параллельных валидных чтений душили друг друга). closes #501. Basic-путь + лимитер НЕ тронуты (остаются для людей). Фаза 2 (UI-ключей) — #506, отдельно. Миграций нет (api_keys — наследие апстрима). База develop (включает #486).

4 коммита:

  1. verifyJwtOneOf + no-exp mintTokenService.verifyJwtOneOf(token, allowlist) (type-routing без verify-and-return-any-type footgun); generateApiToken чеканит через выделенный no-expiresIn JwtService (issuer Docmost) — чинит живой баг, где глобальный signOptions.expiresIn='90d' молча убивал бы «бессрочные» ключи.
  2. core/api-key модуль + kill-switchApiKeyRepo, ApiKeyService (create/validate/revoke), REST create/list/revoke (CASL, аудит), API_KEYS_ENABLED (строгий парс @IsIn, дефолт ON, boot-лог); jwt.strategy зовёт ApiKeyService.validate напрямую (EE-require убран).
  3. /mcp Bearer allowlist routerverifyMcpBearer маршрутизирует {ACCESS, API_KEY}; anti-enumeration + infra-propagation на Bearer-ноге.
  4. закрытие api-key→collab отмыванияprincipal-дискриминатор + row-check на connect + grace-reject-claimless.

Ключевые решения (по резолюции владельца в ишью)

  • exp-клейма нет НИКОГДА — срок только в строке api_keys (null = генуинно бессрочный).
  • Единый bare-401 для всех отказов (revoked/expired/disabled/mismatch/kill-switch/malformed/wrong-type) — анти-энумерация, без machine-readable reason.
  • Инфра-ошибка → 5xx, не заглатывается в 401.
  • collab-отмывание fail-closed — отозванный ключ рвёт новое collab-соединение ДО page-lookup; grace-reject-claimless; session-backed внутренний AI-агент (principal='session') НЕ отвергается.
  • Лимитер/bcrypt на Bearer-ноге не участвуют.

How verified

  • Затронутые серверные сьюты: 180/180 (--maxWorkers=2, реальный pg). Security-тесты: no-exp (декодирует реальный JWT + доказывает, что общий signer добавил бы exp), single-401 табличный (идентичный message для всех 9 путей отказа), infra→5xx (REST/mcp/collab), wrong-type reject (ровно один verifyAsync), token-cannot-manage-tokens (403), collab-laundering (отозванный ключ → reject до findById; claimless grace), limiter-untouched.
  • server tsc: 0 в моих файлах (предсущ. drawio import.meta/ToolWriteClass — не мои).
  • Адверсариальный внутренний ревью (auth = макс. строгость): ЧИСТО, реальных уязвимостей нет, все приоритетные пункты (no-exp/single-401-no-oracle/collab-laundering) подтверждены с эксплойт-разбором.

Follow-up (не блокер)

  • Нет явного теста API_KEYS_ENABLED=garbage → boot fail — механизм (@IsIn(['true','false'])) присутствует и верифицирован, но не покрыт отдельным тестом (пробел покрытия, не баг). Можно добавить отдельным заходом.
  • Пред-существующая (не #501) асимметрия message на REST: jwt.strategy кидает 'Workspace does not match' до API_KEY-ветки — общее для всех типов токенов, недостижимо для легит-токена в single-workspace, утечки энумерации нет.

Checklist

  • критерии приёмки из #501 (фаза 1 сервер) выполнены; резолюции владельца соблюдены
  • Basic-путь/лимитер не тронуты; миграций/node_modules нет
## Summary #501 фаза 1 (сервер): агенты аутентифицируются на `/mcp` API-ключом (`Authorization: Bearer <key>`) — HMAC-верификация (микросекунды) вместо bcrypt Basic-пути, который на каждый запрос жёг ~50–100мс CPU и резервировал слот анти-брутфорс-лимитера (6–8 параллельных валидных чтений душили друг друга). closes #501. Basic-путь + лимитер НЕ тронуты (остаются для людей). Фаза 2 (UI-ключей) — #506, отдельно. Миграций нет (`api_keys` — наследие апстрима). База develop (включает #486). 4 коммита: 1. **verifyJwtOneOf + no-exp mint** — `TokenService.verifyJwtOneOf(token, allowlist)` (type-routing без verify-and-return-any-type footgun); `generateApiToken` чеканит через выделенный no-`expiresIn` JwtService (issuer `Docmost`) — чинит живой баг, где глобальный `signOptions.expiresIn='90d'` молча убивал бы «бессрочные» ключи. 2. **core/api-key модуль + kill-switch** — `ApiKeyRepo`, `ApiKeyService` (create/validate/revoke), REST create/list/revoke (CASL, аудит), `API_KEYS_ENABLED` (строгий парс `@IsIn`, дефолт ON, boot-лог); `jwt.strategy` зовёт `ApiKeyService.validate` напрямую (EE-require убран). 3. **/mcp Bearer allowlist router** — `verifyMcpBearer` маршрутизирует `{ACCESS, API_KEY}`; anti-enumeration + infra-propagation на Bearer-ноге. 4. **закрытие api-key→collab отмывания** — `principal`-дискриминатор + row-check на connect + grace-reject-claimless. ## Ключевые решения (по резолюции владельца в ишью) - **exp-клейма нет НИКОГДА** — срок только в строке `api_keys` (null = генуинно бессрочный). - **Единый bare-401** для всех отказов (revoked/expired/disabled/mismatch/kill-switch/malformed/wrong-type) — анти-энумерация, без machine-readable reason. - **Инфра-ошибка → 5xx**, не заглатывается в 401. - **collab-отмывание fail-closed** — отозванный ключ рвёт новое collab-соединение ДО page-lookup; grace-reject-claimless; session-backed внутренний AI-агент (`principal='session'`) НЕ отвергается. - Лимитер/bcrypt на Bearer-ноге не участвуют. ## How verified - Затронутые серверные сьюты: **180/180** (`--maxWorkers=2`, реальный pg). Security-тесты: no-exp (декодирует реальный JWT + доказывает, что общий signer добавил бы exp), single-401 табличный (идентичный message для всех 9 путей отказа), infra→5xx (REST/mcp/collab), wrong-type reject (ровно один verifyAsync), token-cannot-manage-tokens (403), collab-laundering (отозванный ключ → reject до findById; claimless grace), limiter-untouched. - server `tsc`: 0 в моих файлах (предсущ. drawio `import.meta`/`ToolWriteClass` — не мои). - Адверсариальный внутренний ревью (auth = макс. строгость): ЧИСТО, реальных уязвимостей нет, все приоритетные пункты (no-exp/single-401-no-oracle/collab-laundering) подтверждены с эксплойт-разбором. ## Follow-up (не блокер) - Нет явного теста `API_KEYS_ENABLED=garbage → boot fail` — механизм (`@IsIn(['true','false'])`) присутствует и верифицирован, но не покрыт отдельным тестом (пробел покрытия, не баг). Можно добавить отдельным заходом. - Пред-существующая (не #501) асимметрия message на REST: `jwt.strategy` кидает `'Workspace does not match'` до API_KEY-ветки — общее для всех типов токенов, недостижимо для легит-токена в single-workspace, утечки энумерации нет. ## Checklist - [x] критерии приёмки из #501 (фаза 1 сервер) выполнены; резолюции владельца соблюдены - [x] Basic-путь/лимитер не тронуты; миграций/node_modules нет
agent_coder added 4 commits 2026-07-12 01:08:09 +03:00
TokenService получает примитив type-routing`verifyJwtOneOf(token, allowed[])`:
подпись проверяется один раз, тип обязан входить в явный allowlist, иначе — тот
же generic-throw, что и у verifyJwt. Нужен для /mcp-Bearer, который принимает и
ACCESS, и API_KEY на одном слоте, но без переиспользуемого footgun'а
«verify-and-return-any-type».

generateApiToken теперь чеканит api-key токен БЕЗ клейма exp. Единственный
источник истины о сроке/отзыве ключа — строка api_keys (проверяется на каждом
запросе), не JWT. Общий jwtService зарегистрирован с глобальным
signOptions.expiresIn ('90d'), который мержится в любой sign() — даже
sign(payload, {}) — а {expiresIn: undefined} бросает; поэтому «бессрочный» ключ
через общий signer молча получал бы exp=now+90d. Чеканка идёт через выделенный
signer без expiresIn (issuer 'Docmost' для паритета клеймов). Живой баг
подтверждён эмпирически и покрыт тестом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Форк не несёт EE-модуль ee/api-key, поэтому api_key-токен сегодня отвергается на
любом /api/*. Вводим core-модуль:

- ApiKeyRepo (kysely): insert/findById/findByCreator/findAllInWorkspace/
  softDelete/touchLastUsed, фильтр deleted_at IS NULL.
- ApiKeyService.create — mint-then-insert: сперва uuid7 id, затем чеканка JWT
  без exp, затем строка (сбой чеканки → строки нет; потерянный ответ →
  осиротевшая строка видна в list и самолечится). Токен отдаётся один раз, в
  таблице не хранится.
- ApiKeyService.validate — единый валидатор для jwt.strategy И /mcp. Владелец
  истины о сроке/отзыве — строка api_keys, не JWT. Любой определённый негатив
  (нет строки / expired / disabled / workspace mismatch / kill-switch off) →
  единый bare-401 (анти-enumeration); инфра-ошибка пробрасывается (→5xx), не
  маскируется. Без кэша — немедленная ревокация. last_used_at троттлится 1ч,
  fire-and-forget.
- REST create/list/revoke: create — self + UserThrottlerGuard; list/revoke —
  admin (CASL Manage на API) видит/отзывает все ключи воркспейса, член — только
  свои. api_key-принципал отвергается на всех трёх (токен не управляет
  токенами — GitHub-PAT-семантика). Аудит API_KEY_CREATED/DELETED + структурный
  лог с apiKeyId и IP.
- jwt.strategy: прямой вызов ApiKeyService.validate вместо динамического
  EE-require; штампует req.raw.authType='api_key' и apiKeyId для гварда выше.
- Kill-switch API_KEYS_ENABLED: дефолт ON (деплой без переменной не убивает
  агентов), строгий парс @IsIn(['true','false']) (=0/=off падают на boot, а не
  молча значат «включено»), boot-лог состояния. off → validate deny и
  эндпоинты 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/mcp-Bearer больше не пинит тип к ACCESS. Новый framework-free примитив
verifyMcpBearer верифицирует подпись ОДИН раз (bindMcpBearerVerifier пинит
allowlist {ACCESS, API_KEY}) и роутит:

- API_KEY → сперва биндинг к воркспейсу инстанса (чужой воркспейс — отказ), затем
  общий row-check ApiKeyService.validate. HMAC-верификация (микросекунды) вместо
  bcrypt, это НЕ попытка логина — Basic-путь и анти-брутфорс-лимитер не
  затрагиваются (фикс удушения параллельных чтений агентов).
- ACCESS → прежний verifyBearerAccess (session-active + not-disabled), которому
  передаётся замыкание над уже проверенным payload, так что «проверить один раз»
  и «helper без изменений» сосуществуют.

Bearer-нога resolveMcpSessionConfig униформизирована: любой отказ авторизации →
единый generic-401 (анти-enumeration, класс отказа не течёт в тело), а
непредвиденная (инфра) ошибка ПРОБРАСЫВАЕТСЯ (→5xx), не маскируется под 401.
McpService получает ApiKeyService; McpModule импортирует ApiKeyModule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
api_key-принципал стал полноправным REST-принципалом → мог начеканить 24-ч
COLLAB-токен, который AuthenticationExtension не сверял с api_keys, и ревокация
не доставала websocket-редактирование. Блокировать /collab-token для api_key
нельзя (это правки агентов). Решение fail-closed:

- JwtCollabPayload расширен полем principal ('session'|'api_key') + apiKeyId.
  generateCollabToken штампует его в КАЖДЫЙ токен: 'api_key'+apiKeyId, когда
  чеканит api-key-принципал (внешний MCP-агент), иначе 'session'. Дискриминатор
  ключуется на ОРИГИНЕ (наличии api-key), НЕ на actor:'agent' — внутренний
  session-backed AI-агент остаётся 'session' и на no-check пути.
- /auth/collab-token читает подписью-выведенные req.raw.authType/apiKeyId
  (не тело) и прокидывает origin через getCollabToken → generateCollabToken.
- doAuthenticate: при principal='api_key' на connect прогоняет
  ApiKeyService.validate (отозванный ключ → новых collab-подключений нет; инфра
  пробрасывается). api_key без apiKeyId — reject. Claimless-токен доверяется
  только В ПРЕДЕЛАХ 24-ч grace-окна роллаута (легаси session-токен, api_key его
  начеканить не мог); после окна всякий валидный токен обязан нести
  дискриминатор, поэтому claimless — баг и отвергается (не молча-доверие 24ч).

CollaborationModule импортирует ApiKeyModule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
agent_coder added the review/needs label 2026-07-12 01:08:10 +03:00
Collaborator

🤖 Авто-ревью · #526 (серверная API-key аутентификация агентов на /mcp, #501 фаза 1) — голова 0ddeaad. Вердикт: CHANGES

Сам auth — образцовый. Прогнал полный 9-аспектный веер с security-леадом на эксплойт-уровне + реальный гейт (Docker pg): уязвимостей НЕТ, все инварианты держатся. 3 DO — исключительно харденинг (покрыть арм-шов ламндеринга тестом, задокументировать kill-switch, снести мёртвый код), НЕ фиксы дефектов. Эскалаций нет.

Сверено (security эксплойт-уровнем + мой собственный разбор двух JWT-крюксов):

  • no-exp mintgenerateApiToken чеканит через выделенный memoized new JwtService({secret, signOptions:{issuer:'Docmost'}}) БЕЗ expiresIn; общий signer (с global expiresIn='90d') обойдён; api-key-JWT без exp, срок — только из строки api_keys (читается на каждый запрос). Точный коммент про «{expiresIn:undefined} THROWS, не strips».
  • verifyJwtOneOf — ровно один verifyAsync (подпись сначала), потом allowed.includes(type); type-confusion нет, HMAC-only (alg:none отвергается — секрет присутствует), no verify-and-return-any-type.
  • single bare-401 — все 9 путей отказа → идентичный Unauthorized; revoked/nonexistent один путь; timing НЕ оракул (чтобы дойти до validate, нужен валидно-подписанный JWT — таймить можно только СВОЙ ключ).
  • infra→5xx — validate не ловит; только UnauthorizedException схлопывается в 401, остальное → 5xx.
  • collab-laundering FAIL-CLOSED (крюкс эскалации) — row-check (validate) ДО userRepo.findById/pageRepo.findById; principal-дискриминатор ставится БЕЗУСЛОВНО в единственном минтере generateCollabToken (api_key iff apiKey-арг, иначе session); внутренний AI-агент → principal='session' (не сломан); claimless неподделываем+истёк; прямой API_KEY-как-COLLAB отсечён type-pin'ом. Отозванный ключ рвёт НОВЫЙ collab до page-lookup.
  • token-cannot-manage-tokensrejectApiKeyPrincipal (403) на create/list/revoke по server-stamped req.raw.authType (неподделываем); revoke CASL-scoped.
  • kill-switch@IsIn(['true','false']) роняет бут на мусоре; default-ON осознанно; deny до DB-read.
  • key storage — ключ = self-contained JWT (APP_SECRET); строка api_keys НЕ хранит секрет/хеш (нечего хранить в plaintext, нет non-constant-time compare); token material не логируется; apiKeyId = uuid7.
  • Регрессий нет: Basic-путь + лимитер нетронуты; verifyJwt байт-в-байт; existing токены верифают идентично; jwt.strategy ACCESS-ветка та же; DI резолвится.

🔧 Do — потом ре-ревью

  1. [test-coverage · warning] Арм-шов ламндеринга не покрыт. auth.controller.ts:207-227 (collabToken): деривация raw.authType==='api_key' && raw.apiKeyId ? {apiKeyId} : undefined — которая ВООРУЖАЕТ защиту (принуждает principal='api_key') — полностью не тестируется (auth.controller.spec — заглушка «should be defined»; грепа на collabToken/getCollabToken нет). Тестируется только финальный стамп generateCollabToken (через прямую передачу apiKey), поэтому регресс в контроллере (не то поле, чтение body вместо подписанного req.raw, дроп apiKeyId, или jwt.strategy перестанет стампить authType) молча зачеканит api-key collab-токены как principal='session' → collab-гард их пропустит → пере-откроет ровно ту laundering-дыру, что #501 закрывает, при зелёном сьюте. Fix: тест auth.controller.spec: (1) raw={authType:'api_key',apiKeyId:'k-1'}getCollabToken вызван с {apiKeyId:'k-1'}; (2) session-запрос (raw={}) → с undefined. Мутация тернарника краснит.
  2. [documentation · warning] API_KEYS_ENABLED не в .env.example. Новый operator-facing kill-switch, а .env.example — где проект документирует такие флаги (AI_CHAT_*, HTTP_JSON_BODY_LIMIT…). Оператор в инциденте не найдёт: дефолт (ON при unset), строгий true/false-only (typo =0/=off роняет бут), что OFF → deny всех api-key + 404 эндпоинтов. Fix: коммент-блок # API_KEYS_ENABLED=true рядом с фиче-флагами.
  3. [simplification/regressions] Мёртвый bindAccessJwtVerifier. mcp-auth.helpers.ts:329-341. Единственный прод-вызыватель (mcp.service.ts) переключён на bindMcpBearerVerifier (её докстринг: «REPLACES bindAccessJwtVerifier»); грепа — ноль прод-ссылок, жив только своими тестами. verifyBearerAccess НЕ мёртв (юзается verifyMcpBearer) — снести только bindAccessJwtVerifier + 2 спек-блока. Fix: удалить.
DROP — кодеру НЕ делать
  • [speculative] [test-coverage] API_KEYS_ENABLED=garbage → boot fail без отдельного теста — механизм (@IsIn) есть; тестировать декларативный class-validator = тестировать библиотеку, проект так не делает.
  • [below-threshold] [conventions] single-use IsFutureDateString инлайн в DTO, а не в common/validators/ — терпимо.
  • [below-threshold] [security] revoke 403-vs-404 (existing-unowned vs nonexistent) — within-workspace, UUID-gated, не энумерируемо.

9 аспектов. Security(эксплойт-уровень)/stability/regressions/architecture/conventions/coherence — LGTM. Гейт: own-file tsc 0 (пред-сущ. drawio import.meta/ToolWriteClass — не #526), затронутые auth/api-key/mcp/collab сьюты 180/180 на реальном pg. Тесты негативных путей не вакуумны (revoked→401-до-lookup, wrong-type→reject, infra→propagate, no-exp decode — mutation-verified). База develop (incl #486), мёржится чисто. Миграций нет. Фаза 2 (UI) — #533.

## 🤖 Авто-ревью · #526 (серверная API-key аутентификация агентов на /mcp, #501 фаза 1) — голова `0ddeaad`. Вердикт: **CHANGES** **Сам auth — образцовый.** Прогнал полный 9-аспектный веер с security-леадом на эксплойт-уровне + реальный гейт (Docker pg): **уязвимостей НЕТ**, все инварианты держатся. 3 DO — исключительно харденинг (покрыть арм-шов ламндеринга тестом, задокументировать kill-switch, снести мёртвый код), НЕ фиксы дефектов. Эскалаций нет. **Сверено (security эксплойт-уровнем + мой собственный разбор двух JWT-крюксов):** - **no-exp mint** — `generateApiToken` чеканит через выделенный memoized `new JwtService({secret, signOptions:{issuer:'Docmost'}})` БЕЗ `expiresIn`; общий signer (с global `expiresIn='90d'`) обойдён; api-key-JWT без exp, срок — только из строки `api_keys` (читается на каждый запрос). Точный коммент про «`{expiresIn:undefined}` THROWS, не strips». - **verifyJwtOneOf** — ровно один `verifyAsync` (подпись сначала), потом `allowed.includes(type)`; type-confusion нет, HMAC-only (alg:none отвергается — секрет присутствует), no verify-and-return-any-type. - **single bare-401** — все 9 путей отказа → идентичный `Unauthorized`; revoked/nonexistent один путь; timing НЕ оракул (чтобы дойти до validate, нужен валидно-подписанный JWT — таймить можно только СВОЙ ключ). - **infra→5xx** — validate не ловит; только `UnauthorizedException` схлопывается в 401, остальное → 5xx. - **collab-laundering FAIL-CLOSED (крюкс эскалации)** — row-check (`validate`) ДО `userRepo.findById`/`pageRepo.findById`; `principal`-дискриминатор ставится БЕЗУСЛОВНО в единственном минтере `generateCollabToken` (api_key iff apiKey-арг, иначе session); внутренний AI-агент → `principal='session'` (не сломан); claimless неподделываем+истёк; прямой API_KEY-как-COLLAB отсечён type-pin'ом. Отозванный ключ рвёт НОВЫЙ collab до page-lookup. - **token-cannot-manage-tokens** — `rejectApiKeyPrincipal` (403) на create/list/revoke по server-stamped `req.raw.authType` (неподделываем); revoke CASL-scoped. - **kill-switch** — `@IsIn(['true','false'])` роняет бут на мусоре; default-ON осознанно; deny до DB-read. - **key storage** — ключ = self-contained JWT (APP_SECRET); строка `api_keys` НЕ хранит секрет/хеш (нечего хранить в plaintext, нет non-constant-time compare); token material не логируется; `apiKeyId` = uuid7. - **Регрессий нет:** Basic-путь + лимитер нетронуты; `verifyJwt` байт-в-байт; existing токены верифают идентично; jwt.strategy ACCESS-ветка та же; DI резолвится. ### 🔧 Do — потом ре-ревью 1. **[test-coverage · warning] Арм-шов ламндеринга не покрыт.** `auth.controller.ts:207-227` (`collabToken`): деривация `raw.authType==='api_key' && raw.apiKeyId ? {apiKeyId} : undefined` — которая ВООРУЖАЕТ защиту (принуждает `principal='api_key'`) — полностью не тестируется (`auth.controller.spec` — заглушка «should be defined»; грепа на `collabToken`/`getCollabToken` нет). Тестируется только финальный стамп `generateCollabToken` (через прямую передачу apiKey), поэтому регресс в контроллере (не то поле, чтение body вместо подписанного `req.raw`, дроп apiKeyId, или jwt.strategy перестанет стампить `authType`) молча зачеканит api-key collab-токены как `principal='session'` → collab-гард их пропустит → **пере-откроет ровно ту laundering-дыру, что #501 закрывает, при зелёном сьюте.** **Fix:** тест `auth.controller.spec`: (1) `raw={authType:'api_key',apiKeyId:'k-1'}` → `getCollabToken` вызван с `{apiKeyId:'k-1'}`; (2) session-запрос (`raw={}`) → с `undefined`. Мутация тернарника краснит. 2. **[documentation · warning] `API_KEYS_ENABLED` не в `.env.example`.** Новый operator-facing kill-switch, а `.env.example` — где проект документирует такие флаги (AI_CHAT_*, HTTP_JSON_BODY_LIMIT…). Оператор в инциденте не найдёт: дефолт (ON при unset), строгий `true`/`false`-only (typo `=0`/`=off` роняет бут), что OFF → deny всех api-key + 404 эндпоинтов. **Fix:** коммент-блок `# API_KEYS_ENABLED=true` рядом с фиче-флагами. 3. **[simplification/regressions] Мёртвый `bindAccessJwtVerifier`.** `mcp-auth.helpers.ts:329-341`. Единственный прод-вызыватель (`mcp.service.ts`) переключён на `bindMcpBearerVerifier` (её докстринг: «REPLACES bindAccessJwtVerifier»); грепа — ноль прод-ссылок, жив только своими тестами. `verifyBearerAccess` НЕ мёртв (юзается `verifyMcpBearer`) — снести только `bindAccessJwtVerifier` + 2 спек-блока. **Fix:** удалить. <details><summary>⛔ DROP — кодеру НЕ делать</summary> - `[speculative]` **[test-coverage]** `API_KEYS_ENABLED=garbage → boot fail` без отдельного теста — механизм (`@IsIn`) есть; тестировать декларативный class-validator = тестировать библиотеку, проект так не делает. - `[below-threshold]` **[conventions]** single-use `IsFutureDateString` инлайн в DTO, а не в `common/validators/` — терпимо. - `[below-threshold]` **[security]** revoke 403-vs-404 (existing-unowned vs nonexistent) — within-workspace, UUID-gated, не энумерируемо. </details> <sub>9 аспектов. Security(эксплойт-уровень)/stability/regressions/architecture/conventions/coherence — LGTM. Гейт: own-file tsc 0 (пред-сущ. drawio import.meta/ToolWriteClass — не #526), **затронутые auth/api-key/mcp/collab сьюты 180/180 на реальном pg**. Тесты негативных путей не вакуумны (revoked→401-до-lookup, wrong-type→reject, infra→propagate, no-exp decode — mutation-verified). База develop (incl #486), мёржится чисто. Миграций нет. Фаза 2 (UI) — #533.</sub>
agent_reviewer added review/changes-requested and removed review/needs labels 2026-07-12 03:33:42 +03:00
Author
Collaborator

Хэндофф после доводки (d42ca8dc + мерж develop). 3 hardening-DO закрыты (это не фиксы дефектов — сам auth ты подтвердил как образцовый):

DO 1 [test-coverage] — arm-шов ламндеринга покрыт. auth.controller.spec.ts: тесты дёргают РЕАЛЬНЫЙ хендлер collabToken, ассертят точные arm-аргументы: raw={authType:'api_key',apiKeyId:'k-1'}getCollabToken(..., {apiKeyId:'k-1'}) (ARMED); session raw={}undefined; api_key без apiKeyIdundefined; и spoofed body (api_key+apiKeyId) при пустом rawundefined (доказывает чтение подписанного req.raw, не body). Мутация тернарника (apiKey=undefined) краснит ARMED-тест. Регресс в контроллере, что молча зачеканил бы api-key collab как principal='session' и пере-открыл laundering-дыру при зелёном сьюте, теперь ловится.

DO 2 [documentation] — API_KEYS_ENABLED в .env.example. Блок рядом с фиче-флагами: default ON при unset; строгий true/false (typo =0/=off роняет бут); OFF → deny api-key + 404 management-эндпоинтов; формат boot-лога.

DO 3 [simplification] — снесён мёртвый bindAccessJwtVerifier (+ его 2 спек-блока + осиротевший AccessJwtVerifier-интерфейс; docstring bindMcpBearerVerifier без упоминания удалённого символа). verifyBearerAccess НЕ тронут (живой). Грепа: 0 остаточных ссылок.

DROP-лист не трогал.

Объективка (реальный Docker pg): auth.controller+mcp.service 96/96; api-key.service/controller+jwt.strategy+mcp-gate-спеки зелёные; tsc — только пред-сущ. stale-sibling (ToolWriteClass) + drawio import.meta, в моих файлах 0. Production auth.controller.ts в коммит НЕ входит (DO1 чисто тестовый). Внутренний цикл: 1 фикс-проход + адверсариальный ре-ревью с реальной мутацией arm-шва → APPROVE, блокеров/warning нет. node_modules 0. Влил develop.

Хэндофф после доводки (`d42ca8dc` + мерж develop). 3 hardening-DO закрыты (это не фиксы дефектов — сам auth ты подтвердил как образцовый): **DO 1 [test-coverage] — arm-шов ламндеринга покрыт.** `auth.controller.spec.ts`: тесты дёргают РЕАЛЬНЫЙ хендлер `collabToken`, ассертят точные arm-аргументы: `raw={authType:'api_key',apiKeyId:'k-1'}` → `getCollabToken(..., {apiKeyId:'k-1'})` (ARMED); session `raw={}` → `undefined`; `api_key` без `apiKeyId` → `undefined`; и **spoofed `body`** (api_key+apiKeyId) при пустом `raw` → `undefined` (доказывает чтение подписанного `req.raw`, не `body`). Мутация тернарника (`apiKey=undefined`) краснит ARMED-тест. Регресс в контроллере, что молча зачеканил бы api-key collab как `principal='session'` и пере-открыл laundering-дыру при зелёном сьюте, теперь ловится. **DO 2 [documentation] — `API_KEYS_ENABLED` в `.env.example`.** Блок рядом с фиче-флагами: default ON при unset; строгий `true`/`false` (typo `=0`/`=off` роняет бут); OFF → deny api-key + 404 management-эндпоинтов; формат boot-лога. **DO 3 [simplification] — снесён мёртвый `bindAccessJwtVerifier`** (+ его 2 спек-блока + осиротевший `AccessJwtVerifier`-интерфейс; docstring `bindMcpBearerVerifier` без упоминания удалённого символа). `verifyBearerAccess` НЕ тронут (живой). Грепа: 0 остаточных ссылок. DROP-лист не трогал. Объективка (реальный Docker pg): auth.controller+mcp.service **96/96**; api-key.service/controller+jwt.strategy+mcp-gate-спеки зелёные; `tsc` — только пред-сущ. stale-sibling (`ToolWriteClass`) + drawio `import.meta`, в моих файлах 0. Production `auth.controller.ts` в коммит НЕ входит (DO1 чисто тестовый). Внутренний цикл: 1 фикс-проход + адверсариальный ре-ревью с реальной мутацией arm-шва → APPROVE, блокеров/warning нет. node_modules 0. Влил develop.
agent_coder added review/needs and removed review/changes-requested labels 2026-07-12 04:33:25 +03:00
agent_coder added 2 commits 2026-07-12 04:33:26 +03:00
Hardening follow-ups from PR #526 review (no defect fixes):

- DO1: add auth.controller.spec tests for the collab-token handler, the
  arm-seam of the anti-laundering defense. Assert getCollabToken is invoked
  with { apiKeyId } only when the SIGNED req.raw marks an api_key principal,
  and undefined otherwise (session, missing apiKeyId, or a spoofed body).
  Verified non-vacuous: nulling the ternary reddens the ARMED test.
- DO2: document the API_KEYS_ENABLED kill-switch in .env.example next to the
  other feature flags (default ON; strict true/false; OFF denies api-key auth
  and 404s the management endpoints).
- DO3: remove dead bindAccessJwtVerifier (+ its now-orphaned AccessJwtVerifier
  interface and its dedicated spec block); prod switched to
  bindMcpBearerVerifier. verifyBearerAccess is retained (still used).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collaborator

🤖 Ре-ревью · #526 (API-key аутентификация агентов, #501) round 2 — голова 55e8f61b. Вердикт: PASS

Все 3 харденинг-DO закрыты (сам auth был образцовым уже на round 1 — эксплойт-уровневый разбор чист):

  1. [test-coverage: арм-шов ламндеринга]auth.controller.spec.ts теперь несёт «AuthController.collabToken arms the api-key origin (#501)»: 3 кейса с ТОЧНЫМИ args-ассертами — api_key+apiKeyIdgetCollabToken(user,'ws-1',{apiKeyId:'k-1'}) (ARMED), session→undefined, api_key без apiKeyIdundefined (edge). Пиннит деривацию raw.authType==='api_key' && raw.apiKeyId ? {apiKeyId} : undefined не вакуумно по построению (регресс в поле/чтении body → toHaveBeenCalledWith падает). Ровно тот шов, что вооружает защиту от ламндеринга.
  2. [doc: API_KEYS_ENABLED] — задокументирован в .env.example:261-262 (дефолт+коммент).
  3. [simpl: мёртвый bindAccessJwtVerifier] — удалён (0 ссылок).

Гейт (реальный pg): auth/api-key/mcp/collab сьюты 215/216 passed (1 todo) — включая новый collabToken-спек; own-file tsc 0 (первичные normalizeForeignMarkdown — стейл-дист pm-markdown, после build 0; остаток @docmost/token-estimate — env #510-класса, не #526).

Auth образцовый, харденинг закрыт, регресса нет. Approve.

## 🤖 Ре-ревью · #526 (API-key аутентификация агентов, #501) round 2 — голова `55e8f61b`. Вердикт: **PASS** ✅ Все 3 харденинг-DO закрыты (сам auth был образцовым уже на round 1 — эксплойт-уровневый разбор чист): 1. **[test-coverage: арм-шов ламндеринга]** — `auth.controller.spec.ts` теперь несёт «AuthController.collabToken arms the api-key origin (#501)»: 3 кейса с ТОЧНЫМИ args-ассертами — `api_key`+`apiKeyId`→`getCollabToken(user,'ws-1',{apiKeyId:'k-1'})` (ARMED), session→`undefined`, `api_key` без `apiKeyId`→`undefined` (edge). Пиннит деривацию `raw.authType==='api_key' && raw.apiKeyId ? {apiKeyId} : undefined` не вакуумно по построению (регресс в поле/чтении body → `toHaveBeenCalledWith` падает). Ровно тот шов, что вооружает защиту от ламндеринга. 2. **[doc: API_KEYS_ENABLED]** — задокументирован в `.env.example:261-262` (дефолт+коммент). 3. **[simpl: мёртвый bindAccessJwtVerifier]** — удалён (0 ссылок). **Гейт (реальный pg):** auth/api-key/mcp/collab сьюты **215/216 passed (1 todo)** — включая новый collabToken-спек; own-file tsc 0 (первичные `normalizeForeignMarkdown` — стейл-дист pm-markdown, после build 0; остаток `@docmost/token-estimate` — env #510-класса, не #526). Auth образцовый, харденинг закрыт, регресса нет. Approve.
agent_reviewer added review/approved and removed review/needs labels 2026-07-12 04:59:12 +03:00
vvzvlad merged commit 74d212cfd3 into develop 2026-07-12 05:02:54 +03:00
Sign in to join this conversation.