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>
194 lines
6.7 KiB
TypeScript
194 lines
6.7 KiB
TypeScript
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();
|
|
});
|