d3a049d176
Форк не несёт 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>
192 lines
7.3 KiB
TypeScript
192 lines
7.3 KiB
TypeScript
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
|
|
}`,
|
|
);
|
|
});
|
|
}
|
|
}
|