[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
Collaborator

Фаза A мета-ишьи #556. Blocked by #501 (merged) и #506.

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

  1. API_KEYS_ENABLED — лишний kill-switch (строгий парс, boot-лог, 404-ветки). Ключи должны быть включены безусловно. Штатный «вырубить /mcp» — уже существующий workspace-тоггл ai.mcp; отдельный рубильник для api_key не нужен.
  2. Токен показывается один раз (#506) — неудобно. Нужна возможность скопировать существующий ключ в любой момент, сохранив немедленный revoke, без хранения токен-материала в БД.

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

  • kill-switch завязан ровно на 4 файла: isApiKeysEnabled()/getApiKeysEnabledRaw() в environment.service.ts:80-87; поле API_KEYS_ENABLED + декораторы в environment.validation.ts (~строка 113); assertEnabled() на всех 3 эндпоинтах + onModuleInit boot-лог в api-key.controller.ts и api-key.service.ts; строки в .env.example. Других вызовов нет (подтверждено).
  • api_key-токен мятится выделенным 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 уже грузит полную строку rowname, 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) — «токен не управляет токенами».
  • Step-up: в ядре есть AuthService.verifyUserCredentials(creds, workspaceId) (auth.service.ts:87-97) — точная не-side-effect проверка пароля. Первая задача кодера: проверить, есть ли в проекте уже готовый паттерн step-up/«подтвердить паролем» (например при смене email/удалении аккаунта) и переиспользовать его; если нет — использовать verifyUserCredentials.
  • Аудит реальный (не Noop): с #496 привязан 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), onModuleInit boot-лог и implements OnModuleInit + инъекцию EnvironmentService в сервис, если она осталась только под это; строки API_KEYS_ENABLED в .env.example. Никаких остаточных проверок и boot-WARN: торчащий в окружении API_KEYS_ENABLED=false просто игнорируется как неизвестная переменная (class-validator без forbidNonWhitelisted её не тронет — проверить, что режим валидации именно такой, иначе оставить как есть в whitelisted-режиме).

2. Детерминированный re-mint

В apiKeyJwtService добавить noTimestamp: truesignOptions: { issuer: 'Docmost', noTimestamp: true }. Результат: у api_key-токена нет ни exp, ни iat → токен = чистая детерминированная функция (sub, apiKeyId, workspaceId, APP_SECRET) (HMAC + фиксированный порядок claim'ов → байт-в-байт стабилен).

  • Старые ключи (фаза 1, с iat) остаются валидными: validate проверяет подпись+тип+строку, iat не смотрит. При reveal такому ключу переминтится новый no-iat токен; старый (с iat) тоже продолжает работать до revoke. Оба указывают на один apiKeyId — это ок (задокументировать в комментарии).

3. Эндпоинт POST /api-keys/reveal { id, password } под step-up

  • Guard: JwtAuthGuard + rejectApiKeyPrincipal(req) (критично: без него утёкший ключ K1 вытащит бет­реры всех соседних ключей того же владельца — key-laundering). Throttle как у create (UserThrottlerGuard, AUTH_THROTTLER).
  • Step-up: тело несёт password; проверяется до любых действий (переиспользовать найденный step-up-паттерн или verifyUserCredentials(...)). Неверный пароль → 401. (Для SSO/MFA-пользователей без пароля — вне scope homelab; отметить как ограничение.)
  • Только владелец: row.creatorId === user.id, даже для админа (в отличие от list/revoke — reveal даёт рабочий токен = импёрсонацию создателя). Чужой ключ → uniform 404.
  • Состояния (row грузится через apiKeyRepo.findById, который уже фильтрует deletedAt IS NULL):
    • revoked/несуществующий → findById вернёт undefined → uniform 404;
    • expired (row.expiresAt && row.expiresAt <= now): findById возвращает истёкшую строку (фильтр только по deletedAt), поэтому reveal обязан сам проверить срок → uniform 404 (не выдавать мёртвый токен; единый 404 сохраняет анти-enumeration);
    • isUserDisabled(creator): generateApiToken кинет ForbiddenException (403) — перехватить и нормализовать в uniform 404, чтобы reveal не давал oracle «пользователь заблокирован vs ключ не существует».
  • Re-mint: 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

  • Только ядро api_key + правка UI #506. /mcp-эксгумация — #PHASE_B. Персона — #PHASE_C.
  • Не входит: SSO/MFA-вариант step-up (homelab на пароле); rename/продление ключа.

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

  1. grep -r 'API_KEYS_ENABLED' apps/server apps/client .env.example → пусто; эндпоинты api-keys/* работают без всякой env-переменной; на инстансе с API_KEYS_ENABLED=false в окружении ключи всё равно включены.
  2. Декодированный api_key-JWT (из create И из reveal) не содержит ни exp, ни iat (юнит-тест декодит и проверяет отсутствие полей).
  3. Два последовательных reveal одного ключа → байт-в-байт одинаковый token (детерминизм).
  4. reveal без/с неверным password401; с верным → 200 {token}, и этим токеном проходит /api запрос.
  5. reveal api_key-принципалом (Bearer api_key) → 403.
  6. reveal чужого ключа (в т.ч. админом чужого) → 404; revoked → 404; expired → 404 (не выдаёт токен); disabled-создатель → 404 (не 403).
  7. После revoke ранее скопированный/re-minted токен на следующем запросе → 401 (немедленно, row-check deletedAt).
  8. reveal пишет API_KEY_REVEALED в audit (spy/DB) + структурную лог-строку без токена.
  9. UI: кнопка «скопировать» на строке ключа копирует рабочий токен; токена нет в localStorage/query-cache после копирования (DevTools).

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

  • Все негативы reveal — uniform 404 (кроме step-up 401 и principal-403), чтобы не было oracle существования/состояния ключа.
  • reveal — durable audit + throttle + step-up (тройная защита от угона копируемого бессрочного токена через компрометированную сессию).

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

  • Миграций БД нет. API_KEYS_ENABLED удаляется целиком; на инстансе, где он стоял =false, ключи молча включатся — приемлемо (дефолт всегда был ON, homelab).
  • Изменение поведения: токен теперь извлекаем повторно (через reveal под step-up) — осознанная смена модели «показать один раз».

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

  • reveal-механизм → детерминированный re-mint (noTimestamp), без хранения токена (владелец).
  • reveal-безопасность → step-up паролем (адверсариальный ревью + владелец).
  • reveal-scope → только свои ключи, даже админ (закрывает импёрсонацию; владелец).
  • expired/disabled/чужой/revoked → единый 404 (анти-enumeration; адверсариальный ревью).

Связи

  • Мета: #556. Blocked by #501 (merged), #506 (UI — эту фазу правит: заменяет «показ один раз» на «копировать»).
Фаза A мета-ишьи #556. **Blocked by #501 (merged) и #506.** ## Проблема / Цель 1. `API_KEYS_ENABLED` — лишний kill-switch (строгий парс, boot-лог, 404-ветки). Ключи должны быть включены безусловно. Штатный «вырубить `/mcp`» — уже существующий workspace-тоггл `ai.mcp`; отдельный рубильник для api_key не нужен. 2. Токен показывается один раз (#506) — неудобно. Нужна возможность **скопировать существующий ключ** в любой момент, сохранив немедленный `revoke`, без хранения токен-материала в БД. ## Контекст и исследование - kill-switch завязан ровно на 4 файла: `isApiKeysEnabled()`/`getApiKeysEnabledRaw()` в [environment.service.ts:80-87](apps/server/src/integrations/environment/environment.service.ts#L80-L87); поле `API_KEYS_ENABLED` + декораторы в [environment.validation.ts](apps/server/src/integrations/environment/environment.validation.ts) (~строка 113); `assertEnabled()` на всех 3 эндпоинтах + `onModuleInit` boot-лог в [api-key.controller.ts](apps/server/src/core/api-key/api-key.controller.ts) и [api-key.service.ts](apps/server/src/core/api-key/api-key.service.ts); строки в `.env.example`. Других вызовов нет (подтверждено). - api_key-токен мятится **выделенным** `apiKeyJwtService = new JwtService({ secret, signOptions:{ issuer:'Docmost' } })` в [token.service.ts:160-177](apps/server/src/core/auth/services/token.service.ts#L160-L177) — специально в обход глобального `expiresIn`, поэтому у токена нет `exp`. Полезная нагрузка — фиксированный литерал `{ sub, apiKeyId, workspaceId, type }` ([token.service.ts:141-146](apps/server/src/core/auth/services/token.service.ts#L141-L146)), заголовок HS256 без `kid`. jsonwebtoken по умолчанию добавляет `iat`. - `ApiKeyService.validate` уже грузит полную строку `row` (с `name`, `expiresAt`) но возвращает только `{ user, workspace }` ([api-key.service.ts:127-163](apps/server/src/core/api-key/api-key.service.ts#L127-L163)). - `rejectApiKeyPrincipal(req)` (по `req.raw.authType==='api_key'`) уже применяется в `create`/`list`/`revoke` ([api-key.controller.ts:59-66](apps/server/src/core/api-key/api-key.controller.ts#L59-L66)) — «токен не управляет токенами». - Step-up: в ядре есть `AuthService.verifyUserCredentials(creds, workspaceId)` ([auth.service.ts:87-97](apps/server/src/core/auth/services/auth.service.ts#L87-L97)) — точная не-side-effect проверка пароля. **Первая задача кодера**: проверить, есть ли в проекте уже готовый паттерн step-up/«подтвердить паролем» (например при смене email/удалении аккаунта) и переиспользовать его; если нет — использовать `verifyUserCredentials`. - Аудит **реальный** (не Noop): с #496 привязан `DatabaseAuditService`. Устаревший комментарий «AUDIT_SERVICE is a Noop in this build» в [api-key.controller.ts:103-104](apps/server/src/core/api-key/api-key.controller.ts#L103-L104) — **поправить**. ## Дизайн ### 1. Полное удаление `API_KEYS_ENABLED` Вырезать без остатка: поле в `environment.validation.ts`; `isApiKeysEnabled()`+`getApiKeysEnabledRaw()` в `environment.service.ts`; `assertEnabled()` и его вызовы во всех 3 эндпоинтах + инъекцию `EnvironmentService` в контроллер, если она больше нигде не нужна; kill-switch-ветку в `ApiKeyService.validate` (`if (!isApiKeysEnabled()) throw`), `onModuleInit` boot-лог и `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'ов → байт-в-байт стабилен). - **Старые ключи (фаза 1, с `iat`) остаются валидными**: `validate` проверяет подпись+тип+строку, `iat` не смотрит. При `reveal` такому ключу переминтится новый no-iat токен; старый (с `iat`) тоже продолжает работать до revoke. Оба указывают на один `apiKeyId` — это ок (задокументировать в комментарии). ### 3. Эндпоинт `POST /api-keys/reveal { id, password }` под step-up - Guard: `JwtAuthGuard` + **`rejectApiKeyPrincipal(req)`** (критично: без него утёкший ключ K1 вытащит бет­реры всех соседних ключей того же владельца — key-laundering). Throttle как у `create` (`UserThrottlerGuard`, `AUTH_THROTTLER`). - **Step-up**: тело несёт `password`; проверяется до любых действий (переиспользовать найденный step-up-паттерн или `verifyUserCredentials(...)`). Неверный пароль → `401`. (Для SSO/MFA-пользователей без пароля — вне scope homelab; отметить как ограничение.) - **Только владелец**: `row.creatorId === user.id`, **даже для админа** (в отличие от list/revoke — reveal даёт рабочий токен = импёрсонацию создателя). Чужой ключ → uniform `404`. - **Состояния** (row грузится через `apiKeyRepo.findById`, который уже фильтрует `deletedAt IS NULL`): - revoked/несуществующий → `findById` вернёт undefined → **uniform 404**; - **expired** (`row.expiresAt && row.expiresAt <= now`): `findById` возвращает истёкшую строку (фильтр только по `deletedAt`), поэтому reveal **обязан сам** проверить срок → **uniform 404** (не выдавать мёртвый токен; единый 404 сохраняет анти-enumeration); - `isUserDisabled(creator)`: `generateApiToken` кинет `ForbiddenException` (403) — **перехватить и нормализовать в uniform 404**, чтобы reveal не давал oracle «пользователь заблокирован vs ключ не существует». - Re-mint: `tokenService.generateApiToken({ apiKeyId: row.id, user: creator, workspaceId })`, вернуть `{ token }`. Токен-материал в БД не пишется. - Аудит: новое событие `AuditEvent.API_KEY_REVEALED` (+ `AuditResource.API_KEY`) в [audit-events.ts](apps/server/src/common/events/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 - Только ядро api_key + правка UI #506. `/mcp`-эксгумация — #PHASE_B. Персона — #PHASE_C. - Не входит: SSO/MFA-вариант step-up (homelab на пароле); rename/продление ключа. ## Критерии приёмки 1. `grep -r 'API_KEYS_ENABLED' apps/server apps/client .env.example` → пусто; эндпоинты `api-keys/*` работают без всякой env-переменной; на инстансе с `API_KEYS_ENABLED=false` в окружении ключи всё равно включены. 2. Декодированный api_key-JWT (из `create` И из `reveal`) не содержит ни `exp`, ни `iat` (юнит-тест декодит и проверяет отсутствие полей). 3. Два последовательных `reveal` одного ключа → **байт-в-байт одинаковый** `token` (детерминизм). 4. `reveal` без/с неверным `password` → `401`; с верным → `200 {token}`, и этим токеном проходит `/api` запрос. 5. `reveal` api_key-принципалом (Bearer api_key) → `403`. 6. `reveal` чужого ключа (в т.ч. админом чужого) → `404`; revoked → `404`; expired → `404` (не выдаёт токен); disabled-создатель → `404` (не 403). 7. После `revoke` ранее скопированный/re-minted токен на следующем запросе → `401` (немедленно, row-check `deletedAt`). 8. `reveal` пишет `API_KEY_REVEALED` в audit (spy/DB) + структурную лог-строку без токена. 9. UI: кнопка «скопировать» на строке ключа копирует рабочий токен; токена нет в localStorage/query-cache после копирования (DevTools). ## Отказы и наблюдаемость - Все негативы reveal — **uniform 404** (кроме step-up 401 и principal-403), чтобы не было oracle существования/состояния ключа. - `reveal` — durable audit + throttle + step-up (тройная защита от угона копируемого бессрочного токена через компрометированную сессию). ## Совместимость и миграции - Миграций БД нет. `API_KEYS_ENABLED` удаляется целиком; на инстансе, где он стоял `=false`, ключи молча включатся — приемлемо (дефолт всегда был ON, homelab). - Изменение поведения: токен теперь извлекаем повторно (через reveal под step-up) — осознанная смена модели «показать один раз». ## Решённые развилки - reveal-механизм → детерминированный re-mint (`noTimestamp`), без хранения токена (владелец). - reveal-безопасность → step-up паролем (адверсариальный ревью + владелец). - reveal-scope → только свои ключи, даже админ (закрывает импёрсонацию; владелец). - expired/disabled/чужой/revoked → единый 404 (анти-enumeration; адверсариальный ревью). ## Связи - Мета: #556. Blocked by #501 (merged), #506 (UI — эту фазу правит: заменяет «показ один раз» на «копировать»).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#557