[feat][auth] API-ключи фаза 3A: снятие kill-switch API_KEYS_ENABLED + копируемый ключ (детерминированный re-mint + reveal под step-up) #557
Open
opened 2026-07-12 17:50:35 +03:00 by agent_vscode
·
0 comments
No Branch/Tag Specified
main
develop
feat/568-page-history-redesign
feat/561-comments-redesign
feat/557-apikey-phase3a
feat/530-search-semantic
feat/566-worktime-modal-redesign
feat/370-agent-save-version
recover/506-apikey-ui
feat/370-share-approved
fix/520-recovery-escalation
fix/554-literal-br
feat/529-search-lexical
feat/501-mcp-apikey-auth
feat/506-apikeys-ui
feat/395-work-time
feat/502-mcp-md-modes
feat/492-incremental-render
fix/549-hardbreak-md-canon
feat/542-resolve-undo
fix/505-drawio-strip-comments
fix/503-hardbreak-canon
docs/471-breadcrumb-permission
fix/534-mcp-spaceid
feat/481-version-coherence
fix/535-list-seam-coalesce
fix/525-insert-unloaded
fix/522-internal-links
fix/507-drawio-cyrillic
fix/541-reconnect-timeout
feat/493-converter
feat/370-page-versioning
fix/515-code-emphasis
fix/523-tree-dnd
fix/517-toasts
feat/495-tails
feat/494-mcp-registry-guards
feat/496-suggestions-audit
integ/497-wave1
feat/488-client-fsm
ci/476-publish-gates
feat/489-tool-resilience
feat/487-terminal-lifecycle
fix/486-iter1-prod-fires
refactor/347-client-md-paste
perf/479-getpage-cache
refactor/449-write-invariants
refactor/450-split-client
feat/443-get-page-context
feat/443-get-tree
feat/443-search
docs/415-lossy-descriptions
feat/413-markdown-default
refactor/412-camelcase-tool-names
feat/425-drawio-graph
fix/464-diffdocs-cpu-guard
refactor/411-update-page-markdown
feat/424-drawio-rules
fix/409-invalid-node-validation
refactor/448-generate-inventory
refactor/445-execute-mapping
refactor/446-derive-client-types
fix/447-registry-stamp-ci
fix/444-agent-loop-guards
fix/396-sendnow-detached-run
fix/452-immutable-cache-control
docs/footnote-authoring-comment-cleanup
feat/437-error-diagnostics
fix/442-cursor-pagination
feat/419-footnote-normalize
perf/399-comment-resolve-async
perf/435-collab-token-cache
perf/344-background-rerenders
perf/348-backend-lowhanging
feat/423-drawio-crud
perf/401-collab-hotpath
feat/430-live-reconnect
perf/400-collab-session
refactor/414-dedup-node-ops
feat/417-new-comments-signal
feat/408-createcomment-hints
feat/407-persist-tool-errors
feat/420-footnote-render
dummy-review/mcp-hang-fix
dummy-review/mcp-hang-base
feat/381-resumable-sse-pr1
feat/git-sync-2
docs/agents-workspace-build-order
feature/offline-sync
perf/342-code-splitting
perf/346-compression-cache
feat/196-multi-cursor
perf/343-typing-latency
docs/how-to-test
fix/ai-sdk-partial-output-oom
test/351-generative-converter
feat/371-roles-catalog
refactor/345-server-converter
refactor/294-spec-registry-cont
fix/363-migration-order
fix/e2e-callout-and-gate-build
fix/docker-re2-toolchain
feat/git-sync
fix/media-roundtrip-stability
fix/340-comment-panel-perf
fix/332-deferred-tools
fix/329-ephemeral-suggestions
fix/330-search-in-page
fix/328-resolved-anchor-spam
fix/331-intraline-diff
fix/324-coverage-gate
fix/325-mobile-390
feat/293-A-git-sync-package
feat/300-avatar-oklch
fix/321-banner-mobile
feat/300-avatar-colors
feat/315-comment-suggestions
feat/scroll-restore-stable-wait
feat/300-agent-avatar-stack
feat/300-avatar-polish
refactor/294-tool-spec-registry
feat/scroll-restore-ux
fix/responsive-tablet-sidebar
feature/ai-chat-page-change-observability
image-inline-center
fix/283-short-remap-title
fix/283-slash-layout
image-inline-row
feat/276-ai-chat-dock
fix/269-table-menu-refocus
docs/dev-stand-guide
feat/266-scroll-position
fix/260-collab-docname-slugid
test/244-phase2-tail
fix/262-reindex-progress-realtime
fix/258-changelog-compare-links
fix/244-dataloss-bugs
feat/246-spoiler
feat/221-image-captions
test/244-part-b
feat/251-intentional-clear
fix/embeddings-reindex-progress
refactor/193-tool-spec-registry
fix/255-ws-redis-adapter-leak
fix/252-e2e-open-handles
feat/229-catalog-yaml
feat/243-blob-sandbox
feat/228-inline-footnotes
fix/qa-ui-bugs-216-218
feature/agent-roles-catalog
fix/share-alias-rename
fix/ai-chat-empty-render
feat/191-chat-doc-binding
feat/201-temporary-notes
feat/198-interrupt-agent
feat/ai-chat-full-history
feat/199-ai-generate-title
feat/205-share-aliases
batch/issues-189-187-170
feat/170-mcp-test-button
feat/189-context-badge
feat/198-interrupt-agent-send-now
fix/issues-190-159
fix/ai-chat-new-chat-during-stream
fix/ai-chat-stream-perf
batch/issues-2026-06-25
feat/ai-chat-persistent-history
fix/ai-chat-copy-chat-wysiwyg
fix/ai-stream-reset-resilience
fix/ai-stream-undici-timeout
fix/footnote-review-1227-followup
fix/ai-chat-token-counter-realtime
docs/manual-qa-test-plan
v0.94.1
v0.94.0
v0.93.0
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
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#557
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 "%!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?
Фаза A мета-ишьи #556. Blocked by #501 (merged) и #506.
Проблема / Цель
API_KEYS_ENABLED— лишний kill-switch (строгий парс, boot-лог, 404-ветки). Ключи должны быть включены безусловно. Штатный «вырубить/mcp» — уже существующий workspace-тогглai.mcp; отдельный рубильник для api_key не нужен.revoke, без хранения токен-материала в БД.Контекст и исследование
isApiKeysEnabled()/getApiKeysEnabledRaw()в environment.service.ts:80-87; полеAPI_KEYS_ENABLED+ декораторы в environment.validation.ts (~строка 113);assertEnabled()на всех 3 эндпоинтах +onModuleInitboot-лог в api-key.controller.ts и api-key.service.ts; строки в.env.example. Других вызовов нет (подтверждено).apiKeyJwtService = new JwtService({ secret, signOptions:{ issuer:'Docmost' } })в token.service.ts:160-177 — специально в обход глобальногоexpiresIn, поэтому у токена нетexp. Полезная нагрузка — фиксированный литерал{ sub, apiKeyId, workspaceId, type }(token.service.ts:141-146), заголовок HS256 безkid. jsonwebtoken по умолчанию добавляетiat.ApiKeyService.validateуже грузит полную строкуrow(сname,expiresAt) но возвращает только{ user, workspace }(api-key.service.ts:127-163).rejectApiKeyPrincipal(req)(поreq.raw.authType==='api_key') уже применяется вcreate/list/revoke(api-key.controller.ts:59-66) — «токен не управляет токенами».AuthService.verifyUserCredentials(creds, workspaceId)(auth.service.ts:87-97) — точная не-side-effect проверка пароля. Первая задача кодера: проверить, есть ли в проекте уже готовый паттерн step-up/«подтвердить паролем» (например при смене email/удалении аккаунта) и переиспользовать его; если нет — использоватьverifyUserCredentials.DatabaseAuditService. Устаревший комментарий «AUDIT_SERVICE is a Noop in this build» в api-key.controller.ts:103-104 — поправить.Дизайн
1. Полное удаление
API_KEYS_ENABLEDВырезать без остатка: поле в
environment.validation.ts;isApiKeysEnabled()+getApiKeysEnabledRaw()вenvironment.service.ts;assertEnabled()и его вызовы во всех 3 эндпоинтах + инъекциюEnvironmentServiceв контроллер, если она больше нигде не нужна; kill-switch-ветку вApiKeyService.validate(if (!isApiKeysEnabled()) throw),onModuleInitboot-лог иimplements OnModuleInit+ инъекциюEnvironmentServiceв сервис, если она осталась только под это; строкиAPI_KEYS_ENABLEDв.env.example. Никаких остаточных проверок и boot-WARN: торчащий в окруженииAPI_KEYS_ENABLED=falseпросто игнорируется как неизвестная переменная (class-validator безforbidNonWhitelistedеё не тронет — проверить, что режим валидации именно такой, иначе оставить как есть в whitelisted-режиме).2. Детерминированный re-mint
В
apiKeyJwtServiceдобавитьnoTimestamp: true→signOptions: { issuer: 'Docmost', noTimestamp: true }. Результат: у api_key-токена нет ниexp, ниiat→ токен = чистая детерминированная функция(sub, apiKeyId, workspaceId, APP_SECRET)(HMAC + фиксированный порядок claim'ов → байт-в-байт стабилен).iat) остаются валидными:validateпроверяет подпись+тип+строку,iatне смотрит. Приrevealтакому ключу переминтится новый no-iat токен; старый (сiat) тоже продолжает работать до revoke. Оба указывают на одинapiKeyId— это ок (задокументировать в комментарии).3. Эндпоинт
POST /api-keys/reveal { id, password }под step-upJwtAuthGuard+rejectApiKeyPrincipal(req)(критично: без него утёкший ключ K1 вытащит бетреры всех соседних ключей того же владельца — key-laundering). Throttle как уcreate(UserThrottlerGuard,AUTH_THROTTLER).password; проверяется до любых действий (переиспользовать найденный step-up-паттерн илиverifyUserCredentials(...)). Неверный пароль →401. (Для SSO/MFA-пользователей без пароля — вне scope homelab; отметить как ограничение.)row.creatorId === user.id, даже для админа (в отличие от list/revoke — reveal даёт рабочий токен = импёрсонацию создателя). Чужой ключ → uniform404.apiKeyRepo.findById, который уже фильтруетdeletedAt IS NULL):findByIdвернёт undefined → uniform 404;row.expiresAt && row.expiresAt <= now):findByIdвозвращает истёкшую строку (фильтр только поdeletedAt), поэтому reveal обязан сам проверить срок → uniform 404 (не выдавать мёртвый токен; единый 404 сохраняет анти-enumeration);isUserDisabled(creator):generateApiTokenкинетForbiddenException(403) — перехватить и нормализовать в uniform 404, чтобы reveal не давал oracle «пользователь заблокирован vs ключ не существует».tokenService.generateApiToken({ apiKeyId: row.id, user: creator, workspaceId }), вернуть{ token }. Токен-материал в БД не пишется.AuditEvent.API_KEY_REVEALED(+AuditResource.API_KEY) в audit-events.ts — durable черезDatabaseAuditService. Плюс структурнаяLogger.log(actor id, apiKeyId, ip) без токен-материала.4. UI #506
Заменить модалку «показать один раз» на персистентную кнопку «скопировать» в строке каждого ключа: клик → (если требуется) промпт пароля step-up →
POST /api-keys/reveal→ скопировать токен в буфер + тост «скопировано». Токен живёт только в момент копирования, в state/localStorage/query-cache не оседает. Модалку одноразового показа при create можно упростить/убрать (create по-прежнему возвращает токен, но теперь он доступен и через reveal).Границы scope
/mcp-эксгумация — #PHASE_B. Персона — #PHASE_C.Критерии приёмки
grep -r 'API_KEYS_ENABLED' apps/server apps/client .env.example→ пусто; эндпоинтыapi-keys/*работают без всякой env-переменной; на инстансе сAPI_KEYS_ENABLED=falseв окружении ключи всё равно включены.createИ изreveal) не содержит ниexp, ниiat(юнит-тест декодит и проверяет отсутствие полей).revealодного ключа → байт-в-байт одинаковыйtoken(детерминизм).revealбез/с невернымpassword→401; с верным →200 {token}, и этим токеном проходит/apiзапрос.revealapi_key-принципалом (Bearer api_key) →403.revealчужого ключа (в т.ч. админом чужого) →404; revoked →404; expired →404(не выдаёт токен); disabled-создатель →404(не 403).revokeранее скопированный/re-minted токен на следующем запросе →401(немедленно, row-checkdeletedAt).revealпишетAPI_KEY_REVEALEDв audit (spy/DB) + структурную лог-строку без токена.Отказы и наблюдаемость
reveal— durable audit + throttle + step-up (тройная защита от угона копируемого бессрочного токена через компрометированную сессию).Совместимость и миграции
API_KEYS_ENABLEDудаляется целиком; на инстансе, где он стоял=false, ключи молча включатся — приемлемо (дефолт всегда был ON, homelab).Решённые развилки
noTimestamp), без хранения токена (владелец).Связи