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:
@@ -0,0 +1,193 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { ApiKeyController } from './api-key.controller';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../casl/interfaces/workspace-ability.type';
|
||||
|
||||
/**
|
||||
* Authorization contract for the /api-keys management surface.
|
||||
*
|
||||
* - A token cannot manage tokens (an api_key PRINCIPAL is 403 on every method)
|
||||
* — GitHub-PAT semantics closing post-revocation laundering.
|
||||
* - admin (CASL Manage on API) sees/revokes all workspace keys; a member only
|
||||
* their own.
|
||||
* - kill-switch OFF -> the surface 404s (looks like the feature does not exist).
|
||||
*/
|
||||
|
||||
function makeController(over: any = {}) {
|
||||
const apiKeyService = {
|
||||
create: jest.fn().mockResolvedValue({
|
||||
token: 'tok',
|
||||
key: { id: 'k1', name: 'n', expiresAt: null, createdAt: new Date() },
|
||||
}),
|
||||
revoke: jest.fn().mockResolvedValue(undefined),
|
||||
...(over.apiKeyService ?? {}),
|
||||
};
|
||||
const apiKeyRepo = {
|
||||
findAllInWorkspace: jest.fn().mockResolvedValue([]),
|
||||
findByCreator: jest.fn().mockResolvedValue([]),
|
||||
findById: jest.fn(),
|
||||
...(over.apiKeyRepo ?? {}),
|
||||
};
|
||||
const workspaceAbility = {
|
||||
createForUser: jest.fn().mockReturnValue({
|
||||
can: (_a: any, _s: any) => over.canManage ?? false,
|
||||
}),
|
||||
...(over.workspaceAbility ?? {}),
|
||||
};
|
||||
const environmentService = {
|
||||
isApiKeysEnabled: jest.fn().mockReturnValue(over.enabled ?? true),
|
||||
...(over.environmentService ?? {}),
|
||||
};
|
||||
const auditService = { log: jest.fn() };
|
||||
const controller = new ApiKeyController(
|
||||
apiKeyService as any,
|
||||
apiKeyRepo as any,
|
||||
workspaceAbility as any,
|
||||
environmentService as any,
|
||||
auditService as any,
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
apiKeyService,
|
||||
apiKeyRepo,
|
||||
workspaceAbility,
|
||||
environmentService,
|
||||
auditService,
|
||||
};
|
||||
}
|
||||
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
const workspace = { id: 'ws-1' } as any;
|
||||
const reqAccess = () => ({ raw: {}, ip: '1.2.3.4', socket: {} }) as any;
|
||||
const reqApiKey = () =>
|
||||
({ raw: { authType: 'api_key', apiKeyId: 'k-self' }, ip: '1.2.3.4', socket: {} }) as any;
|
||||
|
||||
describe('ApiKeyController — a token cannot manage tokens', () => {
|
||||
it('403 on create for an api_key principal', async () => {
|
||||
const { controller, apiKeyService } = makeController();
|
||||
await expect(
|
||||
controller.create({ name: 'x' } as any, user, reqApiKey()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(apiKeyService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('403 on list for an api_key principal', async () => {
|
||||
const { controller } = makeController();
|
||||
await expect(
|
||||
controller.list(user, workspace, reqApiKey()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('403 on revoke for an api_key principal', async () => {
|
||||
const { controller } = makeController();
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqApiKey()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — kill-switch OFF → 404', () => {
|
||||
it('create/list/revoke all 404 when disabled', async () => {
|
||||
const { controller } = makeController({ enabled: false });
|
||||
await expect(
|
||||
controller.create({ name: 'x' } as any, user, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(
|
||||
controller.list(user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — list scoping', () => {
|
||||
it('admin (Manage on API) lists ALL workspace keys', async () => {
|
||||
const { controller, apiKeyRepo } = makeController({ canManage: true });
|
||||
await controller.list(user, workspace, reqAccess());
|
||||
expect(apiKeyRepo.findAllInWorkspace).toHaveBeenCalledWith('ws-1');
|
||||
expect(apiKeyRepo.findByCreator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('member lists ONLY their own keys', async () => {
|
||||
const { controller, apiKeyRepo } = makeController({ canManage: false });
|
||||
await controller.list(user, workspace, reqAccess());
|
||||
expect(apiKeyRepo.findByCreator).toHaveBeenCalledWith('u-1', 'ws-1');
|
||||
expect(apiKeyRepo.findAllInWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — revoke scoping', () => {
|
||||
it("member cannot revoke another user's key (403)", async () => {
|
||||
const { controller, apiKeyRepo, apiKeyService } = makeController({
|
||||
canManage: false,
|
||||
});
|
||||
apiKeyRepo.findById.mockResolvedValue({
|
||||
id: 'k1',
|
||||
creatorId: 'someone-else',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(apiKeyService.revoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('member CAN revoke their own key', async () => {
|
||||
const { controller, apiKeyRepo, apiKeyService } = makeController({
|
||||
canManage: false,
|
||||
});
|
||||
apiKeyRepo.findById.mockResolvedValue({
|
||||
id: 'k1',
|
||||
creatorId: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
|
||||
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
|
||||
});
|
||||
|
||||
it("admin CAN revoke another user's key", async () => {
|
||||
const { controller, apiKeyRepo, apiKeyService } = makeController({
|
||||
canManage: true,
|
||||
});
|
||||
apiKeyRepo.findById.mockResolvedValue({
|
||||
id: 'k1',
|
||||
creatorId: 'someone-else',
|
||||
workspaceId: 'ws-1',
|
||||
});
|
||||
await controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess());
|
||||
expect(apiKeyService.revoke).toHaveBeenCalledWith('k1', 'ws-1');
|
||||
});
|
||||
|
||||
it('404 when the key does not exist', async () => {
|
||||
const { controller, apiKeyRepo } = makeController({ canManage: true });
|
||||
apiKeyRepo.findById.mockResolvedValue(undefined);
|
||||
await expect(
|
||||
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyController — create emits audit + returns token once', () => {
|
||||
it('logs API_KEY_CREATED and returns the token + computed expiry', async () => {
|
||||
const { controller, auditService } = makeController();
|
||||
const res = await controller.create(
|
||||
{ name: 'ci' } as any,
|
||||
user,
|
||||
reqAccess(),
|
||||
);
|
||||
expect(res.token).toBe('tok');
|
||||
expect(res.apiKey).toMatchObject({ id: 'k1', name: 'n' });
|
||||
expect(auditService.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ event: 'api_key.created' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Sanity: the CASL subject used is the workspace API subject.
|
||||
it('uses WorkspaceCaslSubject.API with the Manage action', () => {
|
||||
expect(WorkspaceCaslSubject.API).toBe('api_key');
|
||||
expect(WorkspaceCaslAction.Manage).toBeDefined();
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import { RevokeApiKeyDto } from './dto/revoke-api-key.dto';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
||||
import {
|
||||
WorkspaceCaslAction,
|
||||
WorkspaceCaslSubject,
|
||||
} from '../casl/interfaces/workspace-ability.type';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import {
|
||||
AUDIT_SERVICE,
|
||||
IAuditService,
|
||||
} from '../../integrations/audit/audit.service';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||
import { AUTH_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('api-keys')
|
||||
export class ApiKeyController {
|
||||
private readonly logger = new Logger(ApiKeyController.name);
|
||||
|
||||
constructor(
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
private readonly apiKeyRepo: ApiKeyRepo,
|
||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
) {}
|
||||
|
||||
// Kill-switch OFF: the issuance surface disappears entirely (404), not a 403 —
|
||||
// it must look like the feature does not exist.
|
||||
private assertEnabled(): void {
|
||||
if (!this.environmentService.isApiKeysEnabled()) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
}
|
||||
|
||||
// A token cannot manage tokens (GitHub-PAT semantics): an api-key principal is
|
||||
// refused on the management surface, so a leaked key cannot mint a replacement
|
||||
// or revoke the keys that would lock it out (closes post-revocation laundering).
|
||||
private rejectApiKeyPrincipal(req: FastifyRequest): void {
|
||||
if ((req.raw as { authType?: string }).authType === 'api_key') {
|
||||
throw new ForbiddenException('API keys cannot manage API keys');
|
||||
}
|
||||
}
|
||||
|
||||
private clientIp(req: FastifyRequest): string {
|
||||
return req.ip ?? req.socket?.remoteAddress ?? 'unknown';
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@Throttle({ [AUTH_THROTTLER]: { limit: 10, ttl: 60_000 } })
|
||||
@Post('create')
|
||||
async create(
|
||||
@Body() dto: CreateApiKeyDto,
|
||||
@AuthUser() user: User,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
// undefined -> service default (1 year); null -> unlimited; string -> Date.
|
||||
const expiresAt =
|
||||
dto.expiresAt === undefined
|
||||
? undefined
|
||||
: dto.expiresAt === null
|
||||
? null
|
||||
: new Date(dto.expiresAt);
|
||||
|
||||
const { token, key } = await this.apiKeyService.create(
|
||||
user,
|
||||
dto.name,
|
||||
expiresAt,
|
||||
);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.API_KEY_CREATED,
|
||||
resourceType: AuditResource.API_KEY,
|
||||
resourceId: key.id,
|
||||
});
|
||||
// The durable trail lives in container logs (AUDIT_SERVICE is a Noop in this
|
||||
// build). No token material — the JWT is only ever returned in the response.
|
||||
this.logger.log(
|
||||
`API key created: id=${key.id} name=${JSON.stringify(
|
||||
key.name,
|
||||
)} actor=${user.id} expiresAt=${
|
||||
key.expiresAt ? new Date(key.expiresAt).toISOString() : 'never'
|
||||
} ip=${this.clientIp(req)}`,
|
||||
);
|
||||
|
||||
// Return the token ONCE (never retrievable again) and the computed expiry so
|
||||
// the caller/UI can surface "expires <date>" (the year-default time-bomb
|
||||
// early-warning).
|
||||
return {
|
||||
token,
|
||||
apiKey: {
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
expiresAt: key.expiresAt,
|
||||
createdAt: key.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('list')
|
||||
async list(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
// Admin (CASL Manage on API): every key in the workspace, attributed to its
|
||||
// creator (closes "a leaked key of a departed employee is invisible"). A
|
||||
// member sees only their own.
|
||||
const keys = ability.can(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.API,
|
||||
)
|
||||
? await this.apiKeyRepo.findAllInWorkspace(workspace.id)
|
||||
: await this.apiKeyRepo.findByCreator(user.id, workspace.id);
|
||||
|
||||
return keys.map((k) => ({
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
expiresAt: k.expiresAt,
|
||||
lastUsedAt: k.lastUsedAt,
|
||||
createdAt: k.createdAt,
|
||||
creator: k.creator,
|
||||
}));
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('revoke')
|
||||
async revoke(
|
||||
@Body() dto: RevokeApiKeyDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
this.assertEnabled();
|
||||
this.rejectApiKeyPrincipal(req);
|
||||
|
||||
const key = await this.apiKeyRepo.findById(dto.id, workspace.id);
|
||||
// Uniform 404 for a missing/already-revoked key regardless of who asks — no
|
||||
// existence oracle for keys the caller may not own.
|
||||
if (!key) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||
const canManageAll = ability.can(
|
||||
WorkspaceCaslAction.Manage,
|
||||
WorkspaceCaslSubject.API,
|
||||
);
|
||||
// Admin may revoke any key in the workspace; a member only their own.
|
||||
if (!canManageAll && key.creatorId !== user.id) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.apiKeyService.revoke(key.id, workspace.id);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.API_KEY_DELETED,
|
||||
resourceType: AuditResource.API_KEY,
|
||||
resourceId: key.id,
|
||||
});
|
||||
this.logger.log(
|
||||
`API key revoked: id=${key.id} name=${JSON.stringify(
|
||||
key.name,
|
||||
)} actor=${user.id} ip=${this.clientIp(req)}`,
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { ApiKeyController } from './api-key.controller';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
|
||||
// Core (non-EE) API-key feature: issuance REST endpoints + the shared validator
|
||||
// consumed by jwt.strategy (REST) and McpService (the /mcp Bearer router).
|
||||
// DatabaseModule (global) provides ApiKeyRepo/UserRepo/WorkspaceRepo; CaslModule
|
||||
// (global) provides WorkspaceAbilityFactory; TokenModule provides TokenService
|
||||
// (the no-exp api-key signer). ApiKeyService is exported so AuthModule (for
|
||||
// jwt.strategy) and McpModule (for the /mcp router) can inject it directly,
|
||||
// replacing the absent EE `ee/api-key` dynamic require.
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
controllers: [ApiKeyController],
|
||||
providers: [ApiKeyService],
|
||||
exports: [ApiKeyService],
|
||||
})
|
||||
export class ApiKeyModule {}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { ApiKeyService } from './api-key.service';
|
||||
import { JwtType } from '../auth/dto/jwt-payload';
|
||||
|
||||
/**
|
||||
* Security contract for ApiKeyService.validate — the single validator shared by
|
||||
* jwt.strategy (REST) and the /mcp Bearer router.
|
||||
*
|
||||
* Invariants under test:
|
||||
* - Anti-enumeration: every DEFINITE deny (missing/revoked/expired row, disabled
|
||||
* user, workspace mismatch, kill-switch off) throws the SAME bare
|
||||
* UnauthorizedException — an agent cannot distinguish expired from revoked.
|
||||
* - deny-on-decision / 5xx-on-infra: an UNEXPECTED (infra) error PROPAGATES as
|
||||
* itself (→ 5xx), never masked as a 401.
|
||||
* - Expiry is read from the ROW, never a JWT exp claim.
|
||||
* - No validate cache (each call re-reads the row → immediate revocation).
|
||||
*/
|
||||
|
||||
const APP_SECRET = 'secret';
|
||||
|
||||
function makeDeps(over: Partial<Record<string, any>> = {}) {
|
||||
const apiKeyRepo = {
|
||||
findById: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
touchLastUsed: jest.fn().mockResolvedValue(undefined),
|
||||
...(over.apiKeyRepo ?? {}),
|
||||
};
|
||||
const userRepo = {
|
||||
findById: jest.fn(),
|
||||
...(over.userRepo ?? {}),
|
||||
};
|
||||
const workspaceRepo = {
|
||||
findById: jest.fn().mockResolvedValue({ id: 'ws-1' }),
|
||||
...(over.workspaceRepo ?? {}),
|
||||
};
|
||||
const tokenService = {
|
||||
generateApiToken: jest.fn().mockResolvedValue('minted.jwt.token'),
|
||||
...(over.tokenService ?? {}),
|
||||
};
|
||||
const environmentService = {
|
||||
isApiKeysEnabled: jest.fn().mockReturnValue(true),
|
||||
getApiKeysEnabledRaw: jest.fn().mockReturnValue(undefined),
|
||||
getAppSecret: jest.fn().mockReturnValue(APP_SECRET),
|
||||
...(over.environmentService ?? {}),
|
||||
};
|
||||
const service = new (ApiKeyService as unknown as new (...a: any[]) => ApiKeyService)(
|
||||
apiKeyRepo,
|
||||
userRepo,
|
||||
workspaceRepo,
|
||||
tokenService,
|
||||
environmentService,
|
||||
);
|
||||
return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService, environmentService };
|
||||
}
|
||||
|
||||
const payload = (over: Record<string, any> = {}) => ({
|
||||
sub: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
apiKeyId: 'key-1',
|
||||
type: JwtType.API_KEY,
|
||||
...over,
|
||||
});
|
||||
|
||||
const activeRow = (over: Record<string, any> = {}) => ({
|
||||
id: 'key-1',
|
||||
name: 'k',
|
||||
creatorId: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
deletedAt: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const activeUser = (over: Record<string, any> = {}) => ({
|
||||
id: 'u-1',
|
||||
workspaceId: 'ws-1',
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
isAgent: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('ApiKeyService.validate', () => {
|
||||
it('returns { user, workspace } for a valid active key', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
|
||||
const res = await service.validate(payload() as any);
|
||||
|
||||
expect(res.user).toMatchObject({ id: 'u-1' });
|
||||
expect(res.workspace).toMatchObject({ id: 'ws-1' });
|
||||
// Loads isAgent so downstream provenance does not silently degrade.
|
||||
expect(userRepo.findById).toHaveBeenCalledWith('u-1', 'ws-1', {
|
||||
includeIsAgent: true,
|
||||
});
|
||||
});
|
||||
|
||||
// --- Anti-enumeration: every deny is the SAME bare 401 -------------------
|
||||
const denyCases: Array<[string, (d: ReturnType<typeof makeDeps>) => void]> = [
|
||||
[
|
||||
'missing/orphaned row',
|
||||
(d) => d.apiKeyRepo.findById.mockResolvedValue(undefined),
|
||||
],
|
||||
[
|
||||
'revoked (soft-deleted → findById returns undefined)',
|
||||
(d) => d.apiKeyRepo.findById.mockResolvedValue(undefined),
|
||||
],
|
||||
[
|
||||
'expired (expires_at in the past)',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(
|
||||
activeRow({ expiresAt: new Date(Date.now() - 1000) }),
|
||||
);
|
||||
d.userRepo.findById.mockResolvedValue(activeUser());
|
||||
},
|
||||
],
|
||||
[
|
||||
'disabled user',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
d.userRepo.findById.mockResolvedValue(
|
||||
activeUser({ deactivatedAt: new Date() }),
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
'user not found',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
d.userRepo.findById.mockResolvedValue(undefined);
|
||||
},
|
||||
],
|
||||
[
|
||||
'creator/sub mismatch',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow({ creatorId: 'other' }));
|
||||
d.userRepo.findById.mockResolvedValue(activeUser());
|
||||
},
|
||||
],
|
||||
[
|
||||
'workspace row missing',
|
||||
(d) => {
|
||||
d.apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
d.userRepo.findById.mockResolvedValue(activeUser());
|
||||
d.workspaceRepo.findById.mockResolvedValue(undefined);
|
||||
},
|
||||
],
|
||||
[
|
||||
'kill-switch OFF',
|
||||
(d) => d.environmentService.isApiKeysEnabled.mockReturnValue(false),
|
||||
],
|
||||
[
|
||||
'malformed payload (missing apiKeyId)',
|
||||
() => undefined,
|
||||
],
|
||||
];
|
||||
|
||||
it.each(denyCases)(
|
||||
'throws a bare UnauthorizedException with NO message for: %s',
|
||||
async (label, arrange) => {
|
||||
const deps = makeDeps();
|
||||
arrange(deps);
|
||||
const p =
|
||||
label === 'malformed payload (missing apiKeyId)'
|
||||
? (payload({ apiKeyId: undefined }) as any)
|
||||
: (payload() as any);
|
||||
|
||||
const err = await deps.service.validate(p).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(UnauthorizedException);
|
||||
// Anti-enumeration: identical, class-less message for every failure mode.
|
||||
expect(err.message).toBe('Unauthorized');
|
||||
},
|
||||
);
|
||||
|
||||
it('kill-switch OFF denies BEFORE any DB read', async () => {
|
||||
const { service, apiKeyRepo, environmentService } = makeDeps();
|
||||
environmentService.isApiKeysEnabled.mockReturnValue(false);
|
||||
|
||||
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(apiKeyRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- Infra error propagates (5xx), NOT masked as 401 ---------------------
|
||||
it('PROPAGATES an infra (DB) error instead of masking it as 401', async () => {
|
||||
const { service, apiKeyRepo } = makeDeps();
|
||||
const boom = new Error('connection terminated');
|
||||
apiKeyRepo.findById.mockRejectedValue(boom);
|
||||
|
||||
await expect(service.validate(payload() as any)).rejects.toBe(boom);
|
||||
});
|
||||
|
||||
// --- No cache: revocation is immediate on the next call ------------------
|
||||
it('re-reads the row on every call (no validate cache → immediate revocation)', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow());
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
await service.validate(payload() as any);
|
||||
|
||||
// Now the key is revoked (row invisible) — the very next call denies.
|
||||
apiKeyRepo.findById.mockResolvedValue(undefined);
|
||||
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(apiKeyRepo.findById).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// --- last_used_at is throttled + fire-and-forget -------------------------
|
||||
it('touches last_used_at when stale and skips when fresh', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
|
||||
// Stale (never used) -> touch.
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null }));
|
||||
await service.validate(payload() as any);
|
||||
expect(apiKeyRepo.touchLastUsed).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Fresh (used 1 minute ago) -> skip.
|
||||
apiKeyRepo.touchLastUsed.mockClear();
|
||||
apiKeyRepo.findById.mockResolvedValue(
|
||||
activeRow({ lastUsedAt: new Date(Date.now() - 60_000) }),
|
||||
);
|
||||
await service.validate(payload() as any);
|
||||
expect(apiKeyRepo.touchLastUsed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a failing last_used_at touch never fails the request', async () => {
|
||||
const { service, apiKeyRepo, userRepo } = makeDeps();
|
||||
apiKeyRepo.findById.mockResolvedValue(activeRow({ lastUsedAt: null }));
|
||||
userRepo.findById.mockResolvedValue(activeUser());
|
||||
apiKeyRepo.touchLastUsed.mockRejectedValue(new Error('write failed'));
|
||||
|
||||
await expect(service.validate(payload() as any)).resolves.toMatchObject({
|
||||
user: { id: 'u-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeyService.create (mint-then-insert, no exp)', () => {
|
||||
it('mints the JWT BEFORE inserting the row and returns the token once', async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
const order: string[] = [];
|
||||
tokenService.generateApiToken.mockImplementation(async () => {
|
||||
order.push('mint');
|
||||
return 'tok';
|
||||
});
|
||||
apiKeyRepo.insert.mockImplementation(async (row: any) => {
|
||||
order.push('insert');
|
||||
return { ...row, createdAt: new Date() };
|
||||
});
|
||||
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
const res = await service.create(user, 'my key');
|
||||
|
||||
expect(order).toEqual(['mint', 'insert']);
|
||||
expect(res.token).toBe('tok');
|
||||
// The id is generated before minting and reused for the row.
|
||||
const mintArg = tokenService.generateApiToken.mock.calls[0][0];
|
||||
const insertArg = apiKeyRepo.insert.mock.calls[0][0];
|
||||
expect(mintArg.apiKeyId).toBe(insertArg.id);
|
||||
});
|
||||
|
||||
it('applies the 1-year default when expiresAt is undefined', async () => {
|
||||
const { service, apiKeyRepo } = makeDeps();
|
||||
apiKeyRepo.insert.mockImplementation(async (row: any) => row);
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
|
||||
await service.create(user, 'k');
|
||||
|
||||
const insertArg = apiKeyRepo.insert.mock.calls[0][0];
|
||||
const days =
|
||||
(insertArg.expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000);
|
||||
expect(days).toBeGreaterThan(364);
|
||||
expect(days).toBeLessThan(367);
|
||||
});
|
||||
|
||||
it('honors an explicit null (unlimited)', async () => {
|
||||
const { service, apiKeyRepo } = makeDeps();
|
||||
apiKeyRepo.insert.mockImplementation(async (row: any) => row);
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
|
||||
await service.create(user, 'k', null);
|
||||
|
||||
expect(apiKeyRepo.insert.mock.calls[0][0].expiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT insert a row when minting fails (mint-then-insert → inert)', async () => {
|
||||
const { service, apiKeyRepo, tokenService } = makeDeps();
|
||||
tokenService.generateApiToken.mockRejectedValue(new Error('mint failed'));
|
||||
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
|
||||
|
||||
await expect(service.create(user, 'k')).rejects.toThrow('mint failed');
|
||||
expect(apiKeyRepo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* `expiresAt` must be strictly in the future. Skips validation for `null`
|
||||
* (explicit "unlimited") and `undefined` (server applies the 1-year default),
|
||||
* so only an actually-supplied date is range-checked. Rejecting a PAST date at
|
||||
* the DTO layer means a caller cannot mint an already-dead key.
|
||||
*/
|
||||
function IsFutureDateString(options?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'isFutureDateString',
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: {
|
||||
message: 'expiresAt must be a date in the future',
|
||||
...options,
|
||||
},
|
||||
validator: {
|
||||
validate(value: unknown) {
|
||||
if (value === null || value === undefined) return true;
|
||||
if (typeof value !== 'string') return false;
|
||||
const t = Date.parse(value);
|
||||
return !Number.isNaN(t) && t > Date.now();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class CreateApiKeyDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
// undefined -> default 1 year (applied server-side); null -> unlimited
|
||||
// (explicit); an ISO date string -> that instant, which must be in the future.
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@IsFutureDateString()
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class RevokeApiKeyDto {
|
||||
@IsUUID()
|
||||
id: string;
|
||||
}
|
||||
@@ -5,9 +5,13 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||
import { SignupService } from './services/signup.service';
|
||||
import { TokenModule } from './token.module';
|
||||
import { ApiKeyModule } from '../api-key/api-key.module';
|
||||
|
||||
@Module({
|
||||
imports: [TokenModule, WorkspaceModule],
|
||||
// ApiKeyModule supplies ApiKeyService, injected into JwtStrategy so an
|
||||
// api_key Bearer/cookie token is validated directly (replacing the absent EE
|
||||
// `ee/api-key` dynamic require).
|
||||
imports: [TokenModule, WorkspaceModule, ApiKeyModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, SignupService, JwtStrategy],
|
||||
exports: [SignupService, AuthService],
|
||||
|
||||
@@ -22,7 +22,8 @@ describe('JwtStrategy — provenance derivation', () => {
|
||||
const userSessionRepo: any = { findActiveById: jest.fn() };
|
||||
const sessionActivityService: any = { trackActivity: jest.fn() };
|
||||
const environmentService: any = { getAppSecret: () => 'test-secret' };
|
||||
const moduleRef: any = {};
|
||||
// ACCESS-path tests never touch the api-key seam; a bare stub suffices.
|
||||
const apiKeyService: any = { validate: jest.fn() };
|
||||
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
@@ -30,7 +31,7 @@ describe('JwtStrategy — provenance derivation', () => {
|
||||
userSessionRepo,
|
||||
sessionActivityService,
|
||||
environmentService,
|
||||
moduleRef,
|
||||
apiKeyService,
|
||||
);
|
||||
return { strategy, userRepo };
|
||||
}
|
||||
@@ -122,25 +123,29 @@ describe('JwtStrategy — provenance derivation', () => {
|
||||
});
|
||||
|
||||
/**
|
||||
* Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486).
|
||||
* Provenance derivation on the API-KEY path (jwt.strategy.validateApiKey, #486
|
||||
* + #501).
|
||||
*
|
||||
* The access-token path stamped provenance; the API-key path returned early
|
||||
* WITHOUT it, so an is_agent API key's REST writes recorded no 'agent' marker.
|
||||
* The API-key payload carries no signed claim, so provenance is resolved from the
|
||||
* SERVER-SIDE user returned by ApiKeyService.validateApiKey: isAgent -> 'agent',
|
||||
* SERVER-SIDE user returned by ApiKeyService.validate: isAgent -> 'agent',
|
||||
* otherwise 'user'; aiChatId is always null (an API key has no ai_chats row).
|
||||
*
|
||||
* The enterprise ApiKeyService is not bundled in the OSS build, so the strategy
|
||||
* loads it through an overridable `resolveApiKeyService` seam that we stub here.
|
||||
* #501 wires the CORE ApiKeyService (the EE `ee/api-key` module is absent in the
|
||||
* fork) directly into the strategy — no dynamic require. The strategy also stamps
|
||||
* `req.raw.authType='api_key'` + `req.raw.apiKeyId` for the "a token cannot manage
|
||||
* tokens" guard on the /api-keys surface.
|
||||
*/
|
||||
describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
function makeApiKeyStrategy(validateApiKeyImpl: (p: any) => Promise<any>) {
|
||||
describe('JwtStrategy — API-key provenance derivation (#486/#501)', () => {
|
||||
function makeApiKeyStrategy(validateImpl: (p: any) => Promise<any>) {
|
||||
const userRepo: any = { findById: jest.fn() };
|
||||
const workspaceRepo: any = { findById: jest.fn() };
|
||||
const userSessionRepo: any = { findActiveById: jest.fn() };
|
||||
const sessionActivityService: any = { trackActivity: jest.fn() };
|
||||
const environmentService: any = { getAppSecret: () => 'test-secret' };
|
||||
const moduleRef: any = {};
|
||||
const validate = jest.fn(validateImpl);
|
||||
const apiKeyService: any = { validate };
|
||||
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
@@ -148,14 +153,9 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
userSessionRepo,
|
||||
sessionActivityService,
|
||||
environmentService,
|
||||
moduleRef,
|
||||
apiKeyService,
|
||||
);
|
||||
// Stub the EE ApiKeyService seam (the real module is not in the OSS build).
|
||||
const validateApiKey = jest.fn(validateApiKeyImpl);
|
||||
jest
|
||||
.spyOn(strategy as any, 'resolveApiKeyService')
|
||||
.mockReturnValue({ validateApiKey });
|
||||
return { strategy, validateApiKey };
|
||||
return { strategy, validate };
|
||||
}
|
||||
|
||||
const makeReq = () => ({ raw: {} as Record<string, any> });
|
||||
@@ -166,22 +166,23 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
type: JwtType.API_KEY,
|
||||
});
|
||||
|
||||
it("stamps actor='agent' for an is_agent API key (from the validated user)", async () => {
|
||||
it("stamps actor='agent' + authType/apiKeyId for an is_agent API key", async () => {
|
||||
const validated = {
|
||||
user: { id: 'svc-1', isAgent: true },
|
||||
workspace: { id: 'ws-1' },
|
||||
};
|
||||
const { strategy, validateApiKey } = makeApiKeyStrategy(
|
||||
async () => validated,
|
||||
);
|
||||
const { strategy, validate } = makeApiKeyStrategy(async () => validated);
|
||||
const req = makeReq();
|
||||
|
||||
const result = await strategy.validate(req, apiKeyPayload() as any);
|
||||
|
||||
expect(validateApiKey).toHaveBeenCalledTimes(1);
|
||||
expect(validate).toHaveBeenCalledTimes(1);
|
||||
expect(req.raw.actor).toBe('agent');
|
||||
// API keys carry no internal ai_chats row -> null.
|
||||
expect(req.raw.aiChatId).toBeNull();
|
||||
// Principal-kind markers for the management-surface guard.
|
||||
expect(req.raw.authType).toBe('api_key');
|
||||
expect(req.raw.apiKeyId).toBe('key-1');
|
||||
// The validated auth object is returned unchanged (req.user shape preserved).
|
||||
expect(result).toBe(validated);
|
||||
});
|
||||
@@ -197,25 +198,19 @@ describe('JwtStrategy — API-key provenance derivation (#486)', () => {
|
||||
|
||||
expect(req.raw.actor).toBe('user');
|
||||
expect(req.raw.aiChatId).toBeNull();
|
||||
expect(req.raw.authType).toBe('api_key');
|
||||
});
|
||||
|
||||
it('throws Unauthorized (and stamps nothing) when the EE module is missing', async () => {
|
||||
const userRepo: any = { findById: jest.fn() };
|
||||
const strategy = new JwtStrategy(
|
||||
userRepo,
|
||||
{ findById: jest.fn() } as any,
|
||||
{ findActiveById: jest.fn() } as any,
|
||||
{ trackActivity: jest.fn() } as any,
|
||||
{ getAppSecret: () => 'test-secret' } as any,
|
||||
{} as any,
|
||||
);
|
||||
// EE not bundled: the seam returns null.
|
||||
jest.spyOn(strategy as any, 'resolveApiKeyService').mockReturnValue(null);
|
||||
it('propagates a validate() rejection and stamps nothing', async () => {
|
||||
const { strategy } = makeApiKeyStrategy(async () => {
|
||||
throw new UnauthorizedException();
|
||||
});
|
||||
const req = makeReq();
|
||||
|
||||
await expect(
|
||||
strategy.validate(req, apiKeyPayload() as any),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
expect(req.raw.actor).toBeUndefined();
|
||||
expect(req.raw.authType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
@@ -9,20 +9,18 @@ import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { SessionActivityService } from '../../session/session-activity.service';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { resolveProvenance } from '../../../common/decorators/auth-provenance.decorator';
|
||||
import { ApiKeyService } from '../../api-key/api-key.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
private logger = new Logger('JwtStrategy');
|
||||
|
||||
constructor(
|
||||
private userRepo: UserRepo,
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
private userSessionRepo: UserSessionRepo,
|
||||
private sessionActivityService: SessionActivityService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: (req: FastifyRequest) => {
|
||||
@@ -102,12 +100,17 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
}
|
||||
|
||||
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
|
||||
const apiKeyService = this.resolveApiKeyService();
|
||||
if (!apiKeyService) {
|
||||
throw new UnauthorizedException('Enterprise API Key module missing');
|
||||
}
|
||||
// The fork ships the core `ApiKeyService` (the EE `ee/api-key` module is
|
||||
// absent). `validate` throws a bare UnauthorizedException on any definite
|
||||
// deny (missing/revoked/expired row, disabled user, kill-switch off) and
|
||||
// propagates infra errors (→ 5xx) rather than masking them as a 401.
|
||||
const result = await this.apiKeyService.validate(payload);
|
||||
|
||||
const result = await apiKeyService.validateApiKey(payload);
|
||||
// Stamp the principal kind + key id so the /api-keys management surface can
|
||||
// enforce "a token cannot manage tokens". Done in this branch because it
|
||||
// returns before the shared ACCESS-path stamping below.
|
||||
req.raw.authType = 'api_key';
|
||||
req.raw.apiKeyId = payload.apiKeyId;
|
||||
|
||||
// Stamp the agent-edit provenance for the API-KEY path too (#486). Unlike the
|
||||
// access-token path above, it CANNOT be resolved before this point: the
|
||||
@@ -119,32 +122,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
// SERVER-SIDE user (never a client field), so an 'agent' badge is unspoofable
|
||||
// — mirroring the access-token path. Passing `null` for the claim means the
|
||||
// actor is decided solely by user.isAgent.
|
||||
const provenance = resolveProvenance((result as any)?.user, null);
|
||||
const provenance = resolveProvenance(result.user, null);
|
||||
req.raw.actor = provenance.actor;
|
||||
req.raw.aiChatId = provenance.aiChatId;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the enterprise ApiKeyService, or `null` when the EE module is not
|
||||
* bundled in this build (community build). Extracted as an overridable seam so
|
||||
* the API-key provenance stamping can be unit-tested without the EE package
|
||||
* present (docmost is OSS + a separate EE bundle; `require` of the EE path
|
||||
* throws here). Any load/resolve error is treated as "module missing".
|
||||
*/
|
||||
protected resolveApiKeyService(): {
|
||||
validateApiKey: (payload: JwtApiKeyPayload) => Promise<unknown>;
|
||||
} | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const ApiKeyModule = require('./../../../ee/api-key/api-key.service');
|
||||
return this.moduleRef.get(ApiKeyModule.ApiKeyService, { strict: false });
|
||||
} catch (err) {
|
||||
this.logger.debug(
|
||||
'API Key module requested but enterprise module not bundled in this build',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ApiKeyModule } from './api-key/api-key.module';
|
||||
import { WorkspaceModule } from './workspace/workspace.module';
|
||||
import { PageModule } from './page/page.module';
|
||||
import { AttachmentModule } from './attachment/attachment.module';
|
||||
@@ -29,6 +30,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
imports: [
|
||||
UserModule,
|
||||
AuthModule,
|
||||
ApiKeyModule,
|
||||
WorkspaceModule,
|
||||
PageModule,
|
||||
AttachmentModule,
|
||||
|
||||
@@ -37,6 +37,7 @@ import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider
|
||||
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
|
||||
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo';
|
||||
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
|
||||
import { PageListener } from '@docmost/db/listeners/page.listener';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import * as postgres from 'postgres';
|
||||
@@ -129,6 +130,7 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||
AiMcpServerRepo,
|
||||
AiAgentRoleRepo,
|
||||
PageEmbeddingRepo,
|
||||
ApiKeyRepo,
|
||||
PageListener,
|
||||
],
|
||||
exports: [
|
||||
@@ -164,6 +166,7 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
|
||||
AiMcpServerRepo,
|
||||
AiAgentRoleRepo,
|
||||
PageEmbeddingRepo,
|
||||
ApiKeyRepo,
|
||||
],
|
||||
})
|
||||
export class DatabaseModule implements OnApplicationBootstrap {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { DB, Users } from '@docmost/db/types/db';
|
||||
import { dbOrTx } from '@docmost/db/utils';
|
||||
import { ApiKey, InsertableApiKey } from '@docmost/db/types/entity.types';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { ExpressionBuilder } from 'kysely';
|
||||
|
||||
// Public-facing shape: the api_keys row plus a compact creator attribution used
|
||||
// by the admin list (the workspace-wide view attributes each key to its author).
|
||||
export type ApiKeyWithCreator = ApiKey & {
|
||||
creator: Pick<Users, 'id' | 'name' | 'email' | 'avatarUrl'> | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
// Compact creator attribution embedded in the admin list rows. A nested
|
||||
// subquery (jsonObjectFrom) keeps it one round-trip; the token material is
|
||||
// never stored, so nothing sensitive is joined in.
|
||||
private withCreator(eb: ExpressionBuilder<DB, 'apiKeys'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('users')
|
||||
.select(['users.id', 'users.name', 'users.email', 'users.avatarUrl'])
|
||||
.whereRef('users.id', '=', 'apiKeys.creatorId'),
|
||||
).as('creator');
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ApiKey | undefined> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.selectFrom('apiKeys')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
// Convention (soft-delete): a revoked key is invisible to reads.
|
||||
.where('deletedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findByCreator(
|
||||
creatorId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ApiKeyWithCreator[]> {
|
||||
return this.db
|
||||
.selectFrom('apiKeys')
|
||||
.selectAll('apiKeys')
|
||||
.select((eb) => this.withCreator(eb))
|
||||
.where('creatorId', '=', creatorId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute() as unknown as Promise<ApiKeyWithCreator[]>;
|
||||
}
|
||||
|
||||
async findAllInWorkspace(
|
||||
workspaceId: string,
|
||||
): Promise<ApiKeyWithCreator[]> {
|
||||
return this.db
|
||||
.selectFrom('apiKeys')
|
||||
.selectAll('apiKeys')
|
||||
.select((eb) => this.withCreator(eb))
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute() as unknown as Promise<ApiKeyWithCreator[]>;
|
||||
}
|
||||
|
||||
async insert(
|
||||
insertable: InsertableApiKey,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<ApiKey> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('apiKeys')
|
||||
.values(insertable)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
// Soft-delete = revoke. Terminal: the row stays for forensics but is invisible
|
||||
// to every read above, so validate() denies it immediately (no grace window).
|
||||
async softDelete(id: string, workspaceId: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('apiKeys')
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
// Throttled best-effort forensics stamp. Not on the critical path: callers
|
||||
// fire-and-forget and swallow errors, so it never fails a request.
|
||||
async touchLastUsed(id: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('apiKeys')
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.where('deletedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,23 @@ export class EnvironmentService {
|
||||
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
|
||||
}
|
||||
|
||||
// Kill-switch for the agent API-key feature. Default ON (unset -> enabled): a
|
||||
// deploy that never sets the variable must NOT silently kill every agent. The
|
||||
// parse is STRICT — the value is validated by environment.validation to be
|
||||
// exactly 'true' or 'false' (or absent), so a typo like `=0`/`=off`/`=False`
|
||||
// fails at boot rather than being read as "enabled" (which would leave an
|
||||
// operator who yanked the switch during an incident with it still on). When
|
||||
// OFF: validate() denies every api-key token and the issuance endpoints 404.
|
||||
isApiKeysEnabled(): boolean {
|
||||
return this.configService.get<string>('API_KEYS_ENABLED', 'true') !== 'false';
|
||||
}
|
||||
|
||||
// Raw value (or undefined) for the boot log, so the state after each deploy is
|
||||
// verifiable in container logs: `API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`.
|
||||
getApiKeysEnabledRaw(): string | undefined {
|
||||
return this.configService.get<string>('API_KEYS_ENABLED');
|
||||
}
|
||||
|
||||
getCookieExpiresIn(): Date {
|
||||
const expiresInStr = this.getJwtTokenExpiresIn();
|
||||
let msUntilExpiry: number;
|
||||
|
||||
@@ -103,6 +103,15 @@ export class EnvironmentVariables {
|
||||
@IsString()
|
||||
TYPESENSE_LOCALE: string;
|
||||
|
||||
// Agent API-key kill-switch. Optional (absent -> default ON). STRICT: only the
|
||||
// literals 'true'/'false' are accepted, so `=0`/`=off`/`=False` fail at boot
|
||||
// instead of being silently read as "enabled" — the switch must actually flip
|
||||
// when an operator flips it. See EnvironmentService.isApiKeysEnabled.
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
@IsString()
|
||||
API_KEYS_ENABLED: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((obj) => obj.AI_DRIVER)
|
||||
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
|
||||
|
||||
Reference in New Issue
Block a user