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>
300 lines
10 KiB
TypeScript
300 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|