feat(api-key): core-модуль выдачи и валидации api-ключей + kill-switch

Форк не несёт 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>
This commit is contained in:
2026-07-12 00:37:09 +03:00
parent d738780370
commit d3a049d176
15 changed files with 1148 additions and 67 deletions
@@ -0,0 +1,191 @@
import {
Injectable,
Logger,
OnModuleInit,
UnauthorizedException,
} from '@nestjs/common';
import { v7 as uuid7 } from 'uuid';
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { TokenService } from '../auth/services/token.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { JwtApiKeyPayload } from '../auth/dto/jwt-payload';
import { ApiKey, User, Workspace } from '@docmost/db/types/entity.types';
import { isUserDisabled } from '../../common/helpers';
// Default lifetime for a new key when the caller does not specify one: 1 year.
// The owner runs a homelab where agents live for years; forcing rotation is
// operational pain, so an explicit `null` (unlimited) is also allowed.
const DEFAULT_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
// last_used_at is a best-effort forensics stamp, not an access record; we only
// refresh it when it is older than this to avoid a write on every request. The
// 1h resolution is a deliberate constant (forensics granularity, not accounting).
const LAST_USED_THROTTLE_MS = 60 * 60 * 1000;
/**
* Core API-key lifecycle service. Owns minting (create), the single validator
* shared by BOTH the REST jwt.strategy path and the /mcp Bearer path (validate),
* and revocation (revoke). The `api_keys` ROW is the sole source of truth for a
* key's lifetime and revocation — never the JWT, which carries no `exp` claim.
*/
@Injectable()
export class ApiKeyService implements OnModuleInit {
private readonly logger = new Logger(ApiKeyService.name);
constructor(
private readonly apiKeyRepo: ApiKeyRepo,
private readonly userRepo: UserRepo,
private readonly workspaceRepo: WorkspaceRepo,
private readonly tokenService: TokenService,
private readonly environmentService: EnvironmentService,
) {}
onModuleInit() {
// Boot log so the kill-switch state after each deploy is verifiable in logs.
const enabled = this.environmentService.isApiKeysEnabled();
const raw = this.environmentService.getApiKeysEnabledRaw();
this.logger.log(
`API keys: ${enabled ? 'ENABLED' : 'DISABLED'} (API_KEYS_ENABLED=${
raw ?? 'unset'
})`,
);
}
/**
* Mint a new key for `user`. mint-then-insert ordering (R1):
* 1. generate the id first — it must be in the JWT payload before the row.
* 2. mint the JWT (no `exp` claim). A mint failure aborts before any row is
* written (inert), so a half-created key cannot exist.
* 3. insert the row last. A lost response leaves an orphaned row that is
* visible in `list` and self-heals (the user revokes it).
* The token is returned ONCE and never stored — the JWT is self-contained, so
* no token material lives in the table.
*
* `expiresAt`: `undefined` -> default 1 year; `null` -> unlimited (explicit);
* a Date -> that instant (a past date is rejected at the DTO layer).
*/
async create(
user: User,
name: string,
expiresAt?: Date | null,
): Promise<{ token: string; key: ApiKey }> {
const resolvedExpiresAt =
expiresAt === undefined
? new Date(Date.now() + DEFAULT_LIFETIME_MS)
: expiresAt;
const apiKeyId = uuid7();
const token = await this.tokenService.generateApiToken({
apiKeyId,
user,
workspaceId: user.workspaceId,
});
const key = await this.apiKeyRepo.insert({
id: apiKeyId,
name,
creatorId: user.id,
workspaceId: user.workspaceId,
expiresAt: resolvedExpiresAt,
});
return { token, key };
}
/**
* The single validator for an api-key principal, shared by jwt.strategy and the
* /mcp Bearer router. Returns `{ user, workspace }` (the same shape the access
* path returns) so the AuthUser/AuthWorkspace decorators and MCP identity work
* unchanged.
*
* Failure semantics (R4, anti-enumeration): a DEFINITE negative fact — feature
* disabled, missing/revoked/expired row, workspace mismatch, disabled user —
* throws a bare `UnauthorizedException` (a single generic 401 for every case;
* an agent cannot distinguish expired from revoked, and its reaction is
* identical). An UNEXPECTED (infra) error is NOT caught here: it propagates so
* the surface returns 5xx, never a masked 401 (deny-on-decision / 5xx-on-infra).
* There is NO validate cache: it is 2–3 PK lookups (~1ms), two orders of
* magnitude cheaper than the bcrypt it replaces; a cache would only add a
* Date-serialization trap and a revocation lag. Revocation is immediate.
*/
async validate(
payload: JwtApiKeyPayload,
): Promise<{ user: User; workspace: Workspace }> {
// Kill-switch OFF: deny unconditionally (same generic 401). Note the
// endpoints additionally 404 at the controller; here we deny the token.
if (!this.environmentService.isApiKeysEnabled()) {
throw new UnauthorizedException();
}
if (!payload?.apiKeyId || !payload?.sub || !payload?.workspaceId) {
throw new UnauthorizedException();
}
const row = await this.apiKeyRepo.findById(
payload.apiKeyId,
payload.workspaceId,
);
// Absent row = revoked (soft-deleted, invisible to findById), orphaned
// (creator/workspace cascade-deleted), or never existed. All terminal deny.
if (!row) {
throw new UnauthorizedException();
}
// Expiry is read from the ROW, never an `exp` JWT claim.
if (row.expiresAt && row.expiresAt.getTime() <= Date.now()) {
throw new UnauthorizedException();
}
const user = await this.userRepo.findById(
payload.sub,
payload.workspaceId,
{ includeIsAgent: true },
);
if (!user || isUserDisabled(user)) {
throw new UnauthorizedException();
}
// The key acts only as its creator (defence in depth against a token whose
// signed `sub` ever drifted from the row's owner).
if (row.creatorId !== user.id) {
throw new UnauthorizedException();
}
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
if (!workspace) {
throw new UnauthorizedException();
}
// Best-effort, throttled, fire-and-forget forensics stamp AFTER all checks.
this.touchLastUsed(row);
return { user, workspace };
}
/**
* Revoke (soft-delete) a key. Authorization/ownership is decided by the caller
* (the controller, via CASL); this only performs the terminal write. Idempotent:
* a second revoke is a no-op (the row is already invisible).
*/
async revoke(id: string, workspaceId: string): Promise<void> {
await this.apiKeyRepo.softDelete(id, workspaceId);
}
// Throttled best-effort last_used_at bump: skip if it was touched within the
// window; otherwise fire-and-forget so a stamp write never fails or slows the
// request (mirrors SessionActivityService.trackActivity).
private touchLastUsed(row: ApiKey): void {
const last = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0;
if (Date.now() - last < LAST_USED_THROTTLE_MS) return;
void this.apiKeyRepo.touchLastUsed(row.id).catch((err) => {
this.logger.warn(
`Failed to update api_key last_used_at for ${row.id}: ${
(err as Error)?.message ?? err
}`,
);
});
}
}