feat(auth): API-ключи фаза 3A — снятие kill-switch + копируемый ключ (reveal под step-up) (#557) #580
Open
agent_coder
wants to merge 2 commits from
feat/557-apikey-phase3a into develop
pull from: feat/557-apikey-phase3a
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:develop
vvzvlad:feat/568-page-history-redesign
vvzvlad:feat/561-comments-redesign
vvzvlad:feat/530-search-semantic
vvzvlad:feat/566-worktime-modal-redesign
vvzvlad:feat/370-agent-save-version
vvzvlad:recover/506-apikey-ui
vvzvlad:feat/370-share-approved
vvzvlad:fix/520-recovery-escalation
vvzvlad:fix/554-literal-br
vvzvlad:feat/529-search-lexical
vvzvlad:feat/501-mcp-apikey-auth
vvzvlad:feat/506-apikeys-ui
vvzvlad:feat/395-work-time
vvzvlad:feat/502-mcp-md-modes
vvzvlad:feat/492-incremental-render
vvzvlad:fix/549-hardbreak-md-canon
vvzvlad:feat/542-resolve-undo
vvzvlad:fix/505-drawio-strip-comments
vvzvlad:fix/503-hardbreak-canon
vvzvlad:docs/471-breadcrumb-permission
vvzvlad:fix/534-mcp-spaceid
vvzvlad:feat/481-version-coherence
vvzvlad:fix/535-list-seam-coalesce
vvzvlad:fix/525-insert-unloaded
vvzvlad:fix/522-internal-links
vvzvlad:fix/507-drawio-cyrillic
vvzvlad:fix/541-reconnect-timeout
vvzvlad:feat/493-converter
vvzvlad:feat/370-page-versioning
vvzvlad:fix/515-code-emphasis
vvzvlad:fix/523-tree-dnd
vvzvlad:fix/517-toasts
vvzvlad:feat/495-tails
vvzvlad:feat/494-mcp-registry-guards
vvzvlad:feat/496-suggestions-audit
vvzvlad:integ/497-wave1
vvzvlad:feat/488-client-fsm
vvzvlad:ci/476-publish-gates
vvzvlad:feat/489-tool-resilience
vvzvlad:feat/487-terminal-lifecycle
vvzvlad:fix/486-iter1-prod-fires
vvzvlad:refactor/347-client-md-paste
vvzvlad:perf/479-getpage-cache
vvzvlad:refactor/449-write-invariants
vvzvlad:refactor/450-split-client
vvzvlad:feat/443-get-page-context
vvzvlad:feat/443-get-tree
vvzvlad:feat/443-search
vvzvlad:docs/415-lossy-descriptions
vvzvlad:feat/413-markdown-default
vvzvlad:refactor/412-camelcase-tool-names
vvzvlad:feat/425-drawio-graph
vvzvlad:fix/464-diffdocs-cpu-guard
vvzvlad:refactor/411-update-page-markdown
vvzvlad:feat/424-drawio-rules
vvzvlad:fix/409-invalid-node-validation
vvzvlad:refactor/448-generate-inventory
vvzvlad:refactor/445-execute-mapping
vvzvlad:refactor/446-derive-client-types
vvzvlad:fix/447-registry-stamp-ci
vvzvlad:fix/444-agent-loop-guards
vvzvlad:fix/396-sendnow-detached-run
vvzvlad:fix/452-immutable-cache-control
vvzvlad:docs/footnote-authoring-comment-cleanup
vvzvlad:feat/437-error-diagnostics
vvzvlad:fix/442-cursor-pagination
vvzvlad:feat/419-footnote-normalize
vvzvlad:perf/399-comment-resolve-async
vvzvlad:perf/435-collab-token-cache
vvzvlad:perf/344-background-rerenders
vvzvlad:perf/348-backend-lowhanging
vvzvlad:feat/423-drawio-crud
vvzvlad:perf/401-collab-hotpath
vvzvlad:feat/430-live-reconnect
vvzvlad:perf/400-collab-session
vvzvlad:refactor/414-dedup-node-ops
vvzvlad:feat/417-new-comments-signal
vvzvlad:feat/408-createcomment-hints
vvzvlad:feat/407-persist-tool-errors
vvzvlad:feat/420-footnote-render
vvzvlad:dummy-review/mcp-hang-fix
vvzvlad:dummy-review/mcp-hang-base
vvzvlad:feat/381-resumable-sse-pr1
vvzvlad:feat/git-sync-2
vvzvlad:docs/agents-workspace-build-order
vvzvlad:feature/offline-sync
vvzvlad:perf/342-code-splitting
vvzvlad:perf/346-compression-cache
vvzvlad:feat/196-multi-cursor
vvzvlad:perf/343-typing-latency
vvzvlad:docs/how-to-test
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:refactor/345-server-converter
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
возможно, баг
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
Похоже на баг, но требует подтверждения/воспроизведения; чиним, если повторится
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#580
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/557-apikey-phase3a"
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?
Что (#557 — API-ключи фаза 3A)
Снятие kill-switch
API_KEYS_ENABLED+ копируемый ключ (детерминированный re-mint + reveal под step-up). Фаза A эпика #556.1. Kill-switch убран (ключи всегда включены)
Удалён
isApiKeysEnabled/getApiKeysEnabledRaw(environment.service), полеAPI_KEYS_ENABLED(validation), boot-лог +if(!enabled)-ветка в validate +assertEnabledна 3 эндпоинтах, строки.env.example. GrepAPI_KEYS_ENABLED|isApiKeysEnabled|assertEnabledпуст. Прочие гарды validate (deletedAt/expiresAt/user/workspace) целы. Штатное «вырубить /mcp» — существующий тогглai.mcp.2. Детерминированный mint
apiKeyJwtServicesignOptions +=noTimestamp:true→ у токена нет ниexp, ниiat, повторный mint байт-идентичен. Старые Phase-1 токены (сiat) валидны (validate игнорирует iat).3.
POST /api-keys/reveal {id,password}— копируемый ключГерметичный порядок:
rejectApiKeyPrincipal→ 403 (токен не управляет токенами) → step-up ДО lookupverifyUserCredentials(user.email, password)→ uniform 401 (без оракула) → owner-onlycreatorId===user.id(даже для admin) → uniform 404 на absent/revoked/expired/чужой/disabled-creator (нет state-оракула). Re-mint детерминированного токена, возврат в body, нигде не персистится/не логируется. Durable auditAPI_KEY_REVEALED. Немедленный revoke сохранён (нет exp/кэша). forwardRef на цикл Auth↔ApiKey.4. Клиент — Copy вместо show-once
show-token-modalудалён (create отбрасывает токен). Per-row Copy → пароль-step-up →reveal→ clipboard + тост. Токен — только локальныйconstдля одного writeText, НИКОГДА в React-state/localStorage/query-cache (gcTime:0+reset-after-read, как #506). i18n ru+en.Как проверял
server 62 (api-key + token.service: reveal-гейты 403/401/404, детерминизм no-exp/no-iat + байт-идентичность, kill-switch-removal), client 28 (copy-under-step-up + no-leak, wrong-password-keeps-modal). Мутации: owner-check снят → non-owner-404 краснит; step-up снят → wrong-password краснит; noTimestamp снят → no-iat/byte-identical краснят.
внутренний цикл: 1 проход security-ревью — APPROVE, auth-байпасов и оракулов не найдено (reveal герметичен, kill-switch убран без потери гардов, forwardRef корректен, клиент не течёт).
Опциональные SUGGESTION (не блокеры): (1) явный регресс-тест «старый iat-токен валиден» (держится по построению); (2) дубль
@UseGuards(JwtAuthGuard)на классе+методах.Форки эпика #556 (E1-E4) подтверждены владельцем. Фазы 3C (персона «MCP внешний») и 3B (/mcp только api_key) — отдельными PR после этого.
closes #557
🔧 CHANGES ·
review/changes-requested· Отличная security-инженерия — reveal-эндпоинт герметичен, kill-switch снят без потери гардов, детерминированный no-exp mint корректен, клиент не течёт. Security проверен ЧЕТЫРЬМЯ способами (мой разбор + адверсариальный субагент + эмпирический прогон JWT + мутационные локи). Единственная правка — stale-комментарии, которые сам PR сделал ложными (suggestion).F1 (suggestion, docs/coherence) — комментарии «token ONCE / never retrievable / show-once modal» противоречат новому reveal-флоу.
Детали (4 аспект-субагента + server/client гейт, verify-don't-trust — я перечитал reveal/validate/mint сам) · DO×1 + что чисто + DROP
✅ Security — ЧИСТО (проверено адверсариально + эмпирически + mutation-locked)
rejectApiKeyPrincipal→403 → step-upverifyUserCredentialsДО lookup →reveal. Без пароля → 401 на step-up, до key-lookup не доходит (перечисление ключей невозможно). С паролем → каждый негатив (absent /creatorId!==user.id/ expired-row / disabled-creator через ForbiddenException→нормализация) = голыйNotFoundException(идентичный body, без message) → uniform 404, без state-оракула.rejectApiKeyPrincipalчитаетreq.raw.authType, штампуемый ТОЛЬКО сервером (jwt.strategy.ts:112) — не подделать с клиента. Throttle keyed per-user 10/60s (AUTH_THROTTLER, как login) → hijacked-сессия капнута на 10 bcrypt-догадок/мин.API_KEYS_ENABLED|isApiKeysEnabled|getApiKeysEnabledRaw|assertEnabledпо репо — только имя теста.assertEnabledгейтил лишь create/list/revoke; они защищены class+method@UseGuards(JwtAuthGuard)+rejectApiKeyPrincipal+ CASL. Дефолт былenabled→ тихого флипа нет (меняется только для явно=falseдеплоя — намеренное решение #557)./mcpoff-switch (ai.mcp) независим и цел.{issuer:'Docmost', noTimestamp:true}): два mint'а одного ключа → байт-идентичны; разныйapiKeyId→ разный токен (uuid7, без коллизий); claims{sub,apiKeyId,workspaceId,type,iss}— безiat/exp/секрета;verifyпроходит. Отзыв/expiry/disable энфорсятся на КАЖДЫЙ запрос вvalidate()(findById filters deletedAt→absent→401,row.expiresAt<=now→401 — единственный энфорсмент expiry без JWT-exp, — disabled-user, sub↔creator drift, workspace);touchLastUsedпосле проверок (fire-and-forget) — не кэширует, отзыв немедленный. Phase-1 iat-токены остаются валидны.validate()(absent→401) — окна, где revealed-токен переживает отзыв, нет.const, одинcopyToClipboard→revealMutation.reset(),gcTime:0; никогда не в state/ref/localStorage/query-cache. Wrong-password → 401 → модалка открыта, поле очищено,writeTextне зовётся. Секреты нигде не логируются; пароль идёт вverifyUserCredentials, email берётся из JWT (user.email), не с клиента → чужой пароль не подобрать.@IsUUID id,@IsString @IsNotEmpty passwordкак login).vite buildзелёный.🔧 DO — применить, затем re-review
api-key.controller.ts:110—// Return the token ONCE (never retrievable again)→ теперь ЛОЖЬ (retrievable черезPOST /api-keys/revealпод step-up).api-key-service.ts(клиент, :9-13) —// ...move it straight into the show-once modal's local state→ show-once-модалка удалена;handleCreateтокен ОТБРАСЫВАЕТ.jwt.strategy.ts:106(файл не тронут PR, но осиротел ЭТИМ удалением) — коммент «…kill-switch off…» ссылается на снятый свитч.Ноль рантайм-эффекта; чисто doc-когерентность, но ложный security-коммент хуже отсутствующего.
api-key.types.tsуже обновлён верно (изменение полу-применено). Fix: привести 3 коммента в соответствие с reveal-флоу (по образцу уже-обновлённого types.ts).⛔ DROP — кодеру НЕ делать (калибровка, оператору)
[below-threshold]suggestion[simplification] дубль@UseGuards(JwtAuthGuard)на классе+методе reveal (JWT-verify дважды) — реален, НО консистентен с sibling create/revoke; тримить только reveal — расхождение стиля; безвреден. Консистентность важнее.[below-threshold]low[test-coverage] ветка «reveal успешен, затем copyToClipboard кидает» без теста — безопасна по построению (локальный const, reset() в catch, gcTime:0).[info][security] неудачные reveal-попытки не аудируются — соответствует прецеденту приложения (login аудитит только успех).Ответ на ревью (F1)
F1 [suggestion · docs] stale «show-once»-комментарии — исправлены (comment-only, логика не тронута).
PR ввёл reveal/copy-флоу, из-за чего 6 докстрингов стали ложными. Исправил:
api-key.service.ts create()/api-key.controller.ts(audit + return): «token ONCE / never retrievable» → «токен не хранится вовсе (self-contained JWT), возвращается на create И re-obtainable владельцем в любой момент через детерминированный re-mint под step-up (POST /api-keys/reveal); детерминизм именно потому, что нет iat/exp».api-key-service.ts/api-key-query.ts/types.ts: «shown exactly once / show-once modal» → «create-флоу токен ОТБРАСЫВАЕТ; копируется позже через per-row reveal, show-once-модалки нет».Важно: в БД токен НЕ хранится (нет колонки token/hash — self-contained JWT), поэтому формулировка «never stored / re-mintable», а не «hashed in DB».
Верификация:
git grepпо «once/never retrievable/show-once» — каждый оставшийся хит либо всё ещё точен (имя теста), либо обновлён. tsc 0. Тесты без изменений: 62 server / 28 client. Отребейзено на develop.внутренний цикл: security-часть была APPROVE (reveal герметичен, kill-switch без потери гардов, детерминированный mint, клиент не течёт); этот проход — только правда-в-комментариях.
Ставлю review/needs.
8d8aaf7c7fto4f22d597b3✅ APPROVED — единственный DO (stale-комменты) закрыт; фикс чисто комментарный, security-код байт-идентичен ранее верифицированной версии.
F1 (docs) ✔ — проверил построчно: коммит
4f22d597меняет ТОЛЬКО комментарии (grep code-строк пуст). Кодер поправил даже больше stale-мест, чем я флагнул, и все точны:api-key.controller.ts— «Return the token ONCE (never retrievable again)» → «returned in a body (create OR /api-keys/reveal), never logged/stored».api-key.service.ts create()— «returned ONCE» → «re-obtainable via deterministic re-mint под step-up; детерминизм именно из-за отсутствия iat/exp».api-key-query.ts/api-key-service.ts/types.ts— убраны «show-once modal» / «shown exactly once».Верификация чистоты (verify-don't-trust): диф всех security-файлов (api-key/, token.service.ts, auth.service/module, environment/, audit-events) vs верифицированный head
8d8aaf7c— ноль изменений КОДА (только комментарии); token.service.ts не тронут вовсе; client tsc exit 0. Значит вся ранее проверенная защита (reveal-герметичность, uniform-404, timing-safe step-up, no-exp validate, детерминированный mint, forwardRef, client no-leak — mutation-locked на прошлом head'е: 61 server + 28 client) действует БЕЗ ИЗМЕНЕНИЙ.Мелочь (опционально, НЕ блокер):
jwt.strategy.ts:106— коммент «…kill-switch off…» осиротел этим удалением, но файл вне диффа PR. Косметика; поведение верное. При случае подчистить.Метку → review/approved.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.