feat(mcpd): McpToken schema + CRUD routes + introspection
Adds a new McpToken Prisma model (project-scoped, SHA-256 hashed at rest, optional expiry, revocable) plus backing repository, service, and REST routes. Tokens are a first-class RBAC subject: new 'McpToken' kind is added to the subject enum and the service auto-creates an RbacDefinition with subject McpToken:<sha> when bindings are provided. Creator-permission ceiling: the service rejects any requested binding the creator cannot already satisfy themselves (re-uses rbacService.canAccess / canRunOperation). rbacMode=clone snapshots the creator's full permissions into the token. Routes: POST /api/v1/mcptokens create (returns raw token once) GET /api/v1/mcptokens list (filter by project) GET /api/v1/mcptokens/:id describe (no secret in response) POST /api/v1/mcptokens/:id/revoke soft-delete + remove RbacDef DELETE /api/v1/mcptokens/:id hard-delete GET /api/v1/mcptokens/introspect validate raw bearer (used by mcplocal) Extends AuditEvent with optional tokenName/tokenSha fields (indexed) so token-driven activity can be filtered later. Adds token helpers in @mcpctl/shared: TOKEN_PREFIX='mcpctl_pat_', generateToken, hashToken, isMcpToken, timingSafeEqualHex. Follow-up PRs add the auth-hook dispatch on the prefix, the CLI verbs, and the HTTP-mode mcplocal that calls /introspect. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
246
src/mcpd/tests/mcp-token-service.test.ts
Normal file
246
src/mcpd/tests/mcp-token-service.test.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { McpTokenService, PermissionCeilingError } from '../src/services/mcp-token.service.js';
|
||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||
import type { IMcpTokenRepository, McpTokenWithRelations } from '../src/repositories/interfaces.js';
|
||||
import type { IProjectRepository } from '../src/repositories/project.repository.js';
|
||||
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
|
||||
import type { RbacService } from '../src/services/rbac.service.js';
|
||||
import { hashToken, isMcpToken, TOKEN_PREFIX } from '@mcpctl/shared';
|
||||
|
||||
const PROJECT = { id: 'cproj1', name: 'myproj' };
|
||||
|
||||
function makeRow(overrides: Partial<McpTokenWithRelations> = {}): McpTokenWithRelations {
|
||||
return {
|
||||
id: 'ctok1',
|
||||
name: 'mytok',
|
||||
projectId: PROJECT.id,
|
||||
tokenHash: 'deadbeef',
|
||||
tokenPrefix: 'mcpctl_pat_abcd',
|
||||
ownerId: 'cuser1',
|
||||
description: '',
|
||||
createdAt: new Date(),
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
project: PROJECT,
|
||||
owner: { id: 'cuser1', email: 'alice@example.com' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockTokenRepo(): IMcpTokenRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByHash: vi.fn(async () => null),
|
||||
findByNameAndProject: vi.fn(async () => null),
|
||||
create: vi.fn(async (input) => makeRow({
|
||||
name: input.name,
|
||||
projectId: input.projectId,
|
||||
tokenHash: input.tokenHash,
|
||||
tokenPrefix: input.tokenPrefix,
|
||||
ownerId: input.ownerId,
|
||||
description: input.description ?? '',
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
})),
|
||||
revoke: vi.fn(async (id) => makeRow({ id, revokedAt: new Date() })),
|
||||
touchLastUsed: vi.fn(async () => {}),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockProjectRepo(): IProjectRepository {
|
||||
return {
|
||||
findById: vi.fn(async (id) => (id === PROJECT.id ? PROJECT : null)),
|
||||
findByName: vi.fn(async (name) => (name === PROJECT.name ? PROJECT : null)),
|
||||
// minimal stubs for the rest — not exercised in these tests
|
||||
findAll: vi.fn(async () => []),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
attachServer: vi.fn(),
|
||||
detachServer: vi.fn(),
|
||||
listServers: vi.fn(async () => []),
|
||||
} as unknown as IProjectRepository;
|
||||
}
|
||||
|
||||
function mockRbacRepo(): IRbacDefinitionRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({ id: 'rbac-1', name: 'x', subjects: [], roleBindings: [], version: 1, createdAt: new Date(), updatedAt: new Date() })),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockRbacService(overrides: Partial<RbacService> = {}): RbacService {
|
||||
return {
|
||||
canAccess: vi.fn(async () => true),
|
||||
canRunOperation: vi.fn(async () => true),
|
||||
getAllowedScope: vi.fn(async () => ({ wildcard: true, names: new Set() })),
|
||||
getPermissions: vi.fn(async () => []),
|
||||
...overrides,
|
||||
} as unknown as RbacService;
|
||||
}
|
||||
|
||||
describe('McpTokenService.create', () => {
|
||||
let tokenRepo: ReturnType<typeof mockTokenRepo>;
|
||||
let projectRepo: IProjectRepository;
|
||||
let rbacRepo: ReturnType<typeof mockRbacRepo>;
|
||||
let rbacService: RbacService;
|
||||
let service: McpTokenService;
|
||||
|
||||
beforeEach(() => {
|
||||
tokenRepo = mockTokenRepo();
|
||||
projectRepo = mockProjectRepo();
|
||||
rbacRepo = mockRbacRepo();
|
||||
rbacService = mockRbacService();
|
||||
service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);
|
||||
});
|
||||
|
||||
it('creates a token with no bindings (rbacMode=empty, default)', async () => {
|
||||
const result = await service.create('cuser1', {
|
||||
name: 'mytok',
|
||||
projectId: PROJECT.id,
|
||||
});
|
||||
expect(result.raw).toMatch(new RegExp(`^${TOKEN_PREFIX}`));
|
||||
expect(isMcpToken(result.raw)).toBe(true);
|
||||
expect(tokenRepo.create).toHaveBeenCalledTimes(1);
|
||||
// Hash must be persisted, never raw
|
||||
const args = vi.mocked(tokenRepo.create).mock.calls[0]![0];
|
||||
expect(args.tokenHash).toBe(hashToken(result.raw));
|
||||
expect(args.tokenPrefix).toBe(result.raw.slice(0, 16));
|
||||
// No RBAC definition should be created when there are no bindings
|
||||
expect(rbacRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates an RbacDefinition with subject McpToken:<sha> when bindings are given', async () => {
|
||||
const result = await service.create('cuser1', {
|
||||
name: 'mytok',
|
||||
projectId: PROJECT.id,
|
||||
bindings: [{ role: 'view', resource: 'servers' }],
|
||||
});
|
||||
expect(rbacRepo.create).toHaveBeenCalledTimes(1);
|
||||
const defArgs = vi.mocked(rbacRepo.create).mock.calls[0]![0];
|
||||
const subjects = defArgs.subjects as Array<{ kind: string; name: string }>;
|
||||
expect(subjects).toEqual([{ kind: 'McpToken', name: hashToken(result.raw) }]);
|
||||
expect(defArgs.roleBindings).toEqual([{ role: 'view', resource: 'servers' }]);
|
||||
});
|
||||
|
||||
it('rejects bindings the creator does not have (ceiling violation)', async () => {
|
||||
rbacService = mockRbacService({
|
||||
canAccess: vi.fn(async () => false),
|
||||
} as Partial<RbacService>);
|
||||
service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);
|
||||
|
||||
await expect(
|
||||
service.create('cuser1', {
|
||||
name: 'mytok',
|
||||
projectId: PROJECT.id,
|
||||
bindings: [{ role: 'edit', resource: 'servers' }],
|
||||
}),
|
||||
).rejects.toThrow(PermissionCeilingError);
|
||||
expect(tokenRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clones the creator\'s permissions when rbacMode=clone', async () => {
|
||||
rbacService = mockRbacService({
|
||||
getPermissions: vi.fn(async () => [
|
||||
{ role: 'view', resource: 'servers' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
]),
|
||||
} as Partial<RbacService>);
|
||||
service = new McpTokenService(tokenRepo, projectRepo, rbacRepo, rbacService);
|
||||
|
||||
await service.create('cuser1', {
|
||||
name: 'mytok',
|
||||
projectId: PROJECT.id,
|
||||
rbacMode: 'clone',
|
||||
});
|
||||
expect(rbacRepo.create).toHaveBeenCalledTimes(1);
|
||||
const defArgs = vi.mocked(rbacRepo.create).mock.calls[0]![0];
|
||||
expect(defArgs.roleBindings).toEqual([
|
||||
{ role: 'view', resource: 'servers' },
|
||||
{ role: 'run', action: 'logs' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws NotFoundError if project does not exist', async () => {
|
||||
await expect(
|
||||
service.create('cuser1', { name: 'mytok', projectId: 'nope' }),
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('throws ConflictError if active token with same name in same project exists', async () => {
|
||||
vi.mocked(tokenRepo.findByNameAndProject).mockResolvedValueOnce(makeRow());
|
||||
await expect(
|
||||
service.create('cuser1', { name: 'mytok', projectId: PROJECT.id }),
|
||||
).rejects.toThrow(ConflictError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpTokenService.introspectRaw', () => {
|
||||
let tokenRepo: ReturnType<typeof mockTokenRepo>;
|
||||
let service: McpTokenService;
|
||||
|
||||
beforeEach(() => {
|
||||
tokenRepo = mockTokenRepo();
|
||||
service = new McpTokenService(tokenRepo, mockProjectRepo(), mockRbacRepo(), mockRbacService());
|
||||
});
|
||||
|
||||
it('returns ok=false for unknown tokens', async () => {
|
||||
const result = await service.introspectRaw(`${TOKEN_PREFIX}unknown`);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.tokenName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns ok=true and principal info for active tokens, and updates lastUsedAt', async () => {
|
||||
const raw = `${TOKEN_PREFIX}aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`;
|
||||
const hash = hashToken(raw);
|
||||
vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hash }));
|
||||
const result = await service.introspectRaw(raw);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.projectName).toBe(PROJECT.name);
|
||||
expect(result.tokenName).toBe('mytok');
|
||||
expect(tokenRepo.touchLastUsed).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects revoked tokens', async () => {
|
||||
const raw = `${TOKEN_PREFIX}bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb`;
|
||||
vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hashToken(raw), revokedAt: new Date() }));
|
||||
const result = await service.introspectRaw(raw);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.revoked).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects expired tokens', async () => {
|
||||
const raw = `${TOKEN_PREFIX}cccccccccccccccccccccccccccccccc`;
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
vi.mocked(tokenRepo.findByHash).mockResolvedValueOnce(makeRow({ tokenHash: hashToken(raw), expiresAt: past }));
|
||||
const result = await service.introspectRaw(raw);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.expired).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('McpTokenService.revoke', () => {
|
||||
it('marks revokedAt and removes the auto-created RbacDefinition', async () => {
|
||||
const tokenRepo = mockTokenRepo();
|
||||
const rbacRepo = mockRbacRepo();
|
||||
const service = new McpTokenService(tokenRepo, mockProjectRepo(), rbacRepo, mockRbacService());
|
||||
|
||||
const row = makeRow();
|
||||
vi.mocked(tokenRepo.findById).mockResolvedValue(row);
|
||||
vi.mocked(rbacRepo.findByName).mockResolvedValue({
|
||||
id: 'rbac-ctok1', name: 'mcptoken-ctok1', subjects: [], roleBindings: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.revoke('ctok1');
|
||||
|
||||
expect(tokenRepo.revoke).toHaveBeenCalledWith('ctok1');
|
||||
expect(rbacRepo.findByName).toHaveBeenCalledWith('mcptoken-ctok1');
|
||||
expect(rbacRepo.delete).toHaveBeenCalledWith('rbac-ctok1');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user