/** * Integration tests reproducing RBAC name-scoped access bugs. * * Bug 1: `mcpctl get servers` shows ALL servers despite user only having * view:servers+name:my-home-assistant * Bug 2: `mcpctl get server my-home-assistant -o yaml` returns 403 because * CLI resolves name→CUID, and RBAC compares CUID against binding name * * These tests spin up a full Fastify app with auth + RBAC hooks + server routes, * exactly like main.ts, to catch regressions at the HTTP level. */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js'; import { McpServerService } from '../src/services/mcp-server.service.js'; import { InstanceService } from '../src/services/instance.service.js'; import { RbacService } from '../src/services/rbac.service.js'; import { errorHandler } from '../src/middleware/error-handler.js'; import type { IMcpServerRepository, IMcpInstanceRepository } from '../src/repositories/interfaces.js'; import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js'; import type { McpOrchestrator } from '../src/services/orchestrator.js'; import type { McpServer, RbacDefinition, PrismaClient } from '@prisma/client'; // ── Test data ── const SERVERS: McpServer[] = [ { id: 'clxyz000000001', name: 'my-home-assistant', description: 'HA server', transport: 'STDIO', packageName: null, dockerImage: null, repositoryUrl: null, externalUrl: null, command: null, containerPort: null, replicas: 1, env: [], healthCheck: null, version: 1, createdAt: new Date(), updatedAt: new Date() }, { id: 'clxyz000000002', name: 'slack-server', description: 'Slack MCP', transport: 'STDIO', packageName: null, dockerImage: null, repositoryUrl: null, externalUrl: null, command: null, containerPort: null, replicas: 1, env: [], healthCheck: null, version: 1, createdAt: new Date(), updatedAt: new Date() }, { id: 'clxyz000000003', name: 'github-server', description: 'GitHub MCP', transport: 'STDIO', packageName: null, dockerImage: null, repositoryUrl: null, externalUrl: null, command: null, containerPort: null, replicas: 1, env: [], healthCheck: null, version: 1, createdAt: new Date(), updatedAt: new Date() }, ]; // User tokens → userId mapping const SESSIONS: Record = { 'scoped-token': { userId: 'user-scoped' }, 'admin-token': { userId: 'user-admin' }, 'multi-scoped-token': { userId: 'user-multi' }, 'secrets-only-token': { userId: 'user-secrets' }, 'edit-scoped-token': { userId: 'user-edit-scoped' }, }; // User email mapping const USERS: Record = { 'user-scoped': { email: 'scoped@example.com' }, 'user-admin': { email: 'admin@example.com' }, 'user-multi': { email: 'multi@example.com' }, 'user-secrets': { email: 'secrets@example.com' }, 'user-edit-scoped': { email: 'editscoped@example.com' }, }; // RBAC definitions const RBAC_DEFS: RbacDefinition[] = [ { id: 'rbac-scoped', name: 'scoped-view', version: 1, createdAt: new Date(), updatedAt: new Date(), subjects: [{ kind: 'User', name: 'scoped@example.com' }], roleBindings: [{ role: 'view', resource: 'servers', name: 'my-home-assistant' }], }, { id: 'rbac-admin', name: 'admin-all', version: 1, createdAt: new Date(), updatedAt: new Date(), subjects: [{ kind: 'User', name: 'admin@example.com' }], roleBindings: [{ role: 'edit', resource: '*' }], }, { id: 'rbac-multi', name: 'multi-scoped', version: 1, createdAt: new Date(), updatedAt: new Date(), subjects: [{ kind: 'User', name: 'multi@example.com' }], roleBindings: [ { role: 'view', resource: 'servers', name: 'my-home-assistant' }, { role: 'view', resource: 'servers', name: 'slack-server' }, ], }, { id: 'rbac-secrets', name: 'secrets-only', version: 1, createdAt: new Date(), updatedAt: new Date(), subjects: [{ kind: 'User', name: 'secrets@example.com' }], roleBindings: [{ role: 'view', resource: 'secrets' }], }, { id: 'rbac-edit-scoped', name: 'edit-scoped', version: 1, createdAt: new Date(), updatedAt: new Date(), subjects: [{ kind: 'User', name: 'editscoped@example.com' }], roleBindings: [{ role: 'edit', resource: 'servers', name: 'my-home-assistant' }], }, ]; // ── Mock factories ── function mockServerRepo(): IMcpServerRepository { return { findAll: vi.fn(async () => [...SERVERS]), findById: vi.fn(async (id: string) => SERVERS.find((s) => s.id === id) ?? null), findByName: vi.fn(async (name: string) => SERVERS.find((s) => s.name === name) ?? null), create: vi.fn(async () => SERVERS[0]!), update: vi.fn(async () => SERVERS[0]!), delete: vi.fn(async () => {}), }; } function mockRbacRepo(): IRbacDefinitionRepository { return { findAll: vi.fn(async () => [...RBAC_DEFS]), findById: vi.fn(async () => null), findByName: vi.fn(async () => null), create: vi.fn(async () => RBAC_DEFS[0]!), update: vi.fn(async () => RBAC_DEFS[0]!), delete: vi.fn(async () => {}), }; } function mockPrisma(): PrismaClient { return { user: { findUnique: vi.fn(async ({ where }: { where: { id: string } }) => { const u = USERS[where.id]; return u ? { email: u.email } : null; }), }, groupMember: { findMany: vi.fn(async () => []), }, } as unknown as PrismaClient; } function stubInstanceRepo(): IMcpInstanceRepository { return { findAll: vi.fn(async () => []), findById: vi.fn(async () => null), findByContainerId: vi.fn(async () => null), create: vi.fn(async (data) => ({ id: 'inst-stub', serverId: data.serverId, containerId: null, status: data.status ?? 'STOPPED', port: null, metadata: {}, healthStatus: null, lastHealthCheck: null, events: [], version: 1, createdAt: new Date(), updatedAt: new Date(), }) as never), updateStatus: vi.fn(async () => ({}) as never), delete: vi.fn(async () => {}), }; } function stubOrchestrator(): McpOrchestrator { return { ping: vi.fn(async () => true), pullImage: vi.fn(async () => {}), createContainer: vi.fn(async () => ({ containerId: 'ctr', name: 'stub', state: 'running' as const, port: 3000, createdAt: new Date() })), stopContainer: vi.fn(async () => {}), removeContainer: vi.fn(async () => {}), inspectContainer: vi.fn(async () => ({ containerId: 'ctr', name: 'stub', state: 'running' as const, createdAt: new Date() })), getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })), }; } // ── App setup (replicates main.ts hooks) ── import { normalizeResource } from '../src/validation/rbac-definition.schema.js'; import type { RbacAction } from '../src/services/rbac.service.js'; type PermissionCheck = | { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string } | { kind: 'operation'; operation: string } | { kind: 'skip' }; function mapUrlToPermission(method: string, url: string): PermissionCheck { const match = url.match(/^\/api\/v1\/([a-z-]+)/); if (!match) return { kind: 'skip' }; const segment = match[1] as string; if (segment === 'backup') return { kind: 'operation', operation: 'backup' }; if (segment === 'restore') return { kind: 'operation', operation: 'restore' }; if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' }; const resourceMap: Record = { servers: 'servers', instances: 'instances', secrets: 'secrets', projects: 'projects', templates: 'templates', users: 'users', groups: 'groups', rbac: 'rbac', 'audit-logs': 'rbac', mcp: 'servers', }; const resource = resourceMap[segment]; if (resource === undefined) return { kind: 'skip' }; let action: RbacAction; switch (method) { case 'GET': case 'HEAD': action = 'view'; break; case 'POST': action = 'create'; break; case 'DELETE': action = 'delete'; break; default: action = 'edit'; break; } const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/); const resourceName = nameMatch?.[1]; const check: PermissionCheck = { kind: 'resource', resource, action }; if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName; return check; } let app: FastifyInstance; afterEach(async () => { if (app) await app.close(); }); async function createTestApp() { const serverRepo = mockServerRepo(); const rbacRepo = mockRbacRepo(); const prisma = mockPrisma(); const rbacService = new RbacService(rbacRepo, prisma); const CUID_RE = /^c[^\s-]{8,}$/i; const nameResolvers: Record }> = { servers: serverRepo, }; app = Fastify({ logger: false }); app.setErrorHandler(errorHandler); // Auth hook (mock) app.addHook('preHandler', async (request, reply) => { const url = request.url; if (url.startsWith('/api/v1/auth/') || url === '/healthz') return; if (!url.startsWith('/api/v1/')) return; const header = request.headers.authorization; if (!header?.startsWith('Bearer ')) { reply.code(401).send({ error: 'Unauthorized' }); return; } const token = header.slice(7); const session = SESSIONS[token]; if (!session) { reply.code(401).send({ error: 'Invalid token' }); return; } request.userId = session.userId; }); // RBAC hook (replicates main.ts) app.addHook('preHandler', async (request, reply) => { if (reply.sent) return; const url = request.url; if (url.startsWith('/api/v1/auth/') || url === '/healthz') return; if (!url.startsWith('/api/v1/')) return; if (request.userId === undefined) return; const check = mapUrlToPermission(request.method, url); if (check.kind === 'skip') return; let allowed: boolean; if (check.kind === 'operation') { allowed = await rbacService.canRunOperation(request.userId, check.operation); } else { // CUID→name resolution if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) { const resolver = nameResolvers[check.resource]; if (resolver) { const entity = await resolver.findById(check.resourceName); if (entity) check.resourceName = entity.name; } } allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName); // Compute scope for list filtering if (allowed && check.resourceName === undefined) { request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource); } } if (!allowed) { reply.code(403).send({ error: 'Forbidden' }); } }); // Routes const serverService = new McpServerService(serverRepo); const instanceService = new InstanceService(stubInstanceRepo(), serverRepo, stubOrchestrator()); serverService.setInstanceService(instanceService); registerMcpServerRoutes(app, serverService, instanceService); // preSerialization hook (list filtering) app.addHook('preSerialization', async (request, _reply, payload) => { if (!request.rbacScope || request.rbacScope.wildcard) return payload; if (!Array.isArray(payload)) return payload; return (payload as Array>).filter((item) => { const name = item['name']; return typeof name === 'string' && request.rbacScope!.names.has(name); }); }); await app.ready(); return app; } // ── Tests ── describe('RBAC name-scoped integration (reproduces mcpctl bugs)', () => { beforeEach(async () => { await createTestApp(); }); describe('Bug 1: mcpctl get servers (list filtering)', () => { it('name-scoped user sees ONLY their permitted server', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/servers', headers: { authorization: 'Bearer scoped-token' }, }); expect(res.statusCode).toBe(200); const servers = res.json>(); expect(servers).toHaveLength(1); expect(servers[0]!.name).toBe('my-home-assistant'); }); it('wildcard user sees ALL servers', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/servers', headers: { authorization: 'Bearer admin-token' }, }); expect(res.statusCode).toBe(200); const servers = res.json>(); expect(servers).toHaveLength(3); }); it('user with multiple name-scoped bindings sees only those servers', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/servers', headers: { authorization: 'Bearer multi-scoped-token' }, }); expect(res.statusCode).toBe(200); const servers = res.json>(); expect(servers).toHaveLength(2); const names = servers.map((s) => s.name); expect(names).toContain('my-home-assistant'); expect(names).toContain('slack-server'); expect(names).not.toContain('github-server'); }); it('user with no server permissions gets 403', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/servers', headers: { authorization: 'Bearer secrets-only-token' }, }); expect(res.statusCode).toBe(403); }); }); describe('Bug 2: mcpctl get server NAME (CUID resolution)', () => { it('allows access when URL contains CUID matching a name-scoped binding', async () => { // CLI resolves my-home-assistant → clxyz000000001 const res = await app.inject({ method: 'GET', url: '/api/v1/servers/clxyz000000001', headers: { authorization: 'Bearer scoped-token' }, }); expect(res.statusCode).toBe(200); expect(res.json<{ name: string }>().name).toBe('my-home-assistant'); }); it('denies access when CUID resolves to server NOT in binding', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/servers/clxyz000000002', headers: { authorization: 'Bearer scoped-token' }, }); expect(res.statusCode).toBe(403); }); it('passes RBAC when URL has human-readable name (route 404 is expected)', async () => { // Human name in URL: RBAC passes (matches binding directly), // but the route only does findById, so it 404s. // CLI always resolves name→CUID first, so this doesn't happen in practice. // The important thing: it does NOT return 403. const res = await app.inject({ method: 'GET', url: '/api/v1/servers/my-home-assistant', headers: { authorization: 'Bearer scoped-token' }, }); expect(res.statusCode).toBe(404); // Not 403! }); it('handles nonexistent CUID gracefully (403)', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/servers/cnonexistent12345678', headers: { authorization: 'Bearer scoped-token' }, }); expect(res.statusCode).toBe(403); }); it('wildcard user can access any server by CUID', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/servers/clxyz000000002', headers: { authorization: 'Bearer admin-token' }, }); expect(res.statusCode).toBe(200); expect(res.json<{ name: string }>().name).toBe('slack-server'); }); }); describe('name-scoped write operations', () => { it('name-scoped edit user can DELETE their named server by CUID', async () => { const res = await app.inject({ method: 'DELETE', url: '/api/v1/servers/clxyz000000001', headers: { authorization: 'Bearer edit-scoped-token' }, }); expect(res.statusCode).toBe(204); }); it('name-scoped edit user CANNOT delete other servers', async () => { const res = await app.inject({ method: 'DELETE', url: '/api/v1/servers/clxyz000000002', headers: { authorization: 'Bearer edit-scoped-token' }, }); expect(res.statusCode).toBe(403); }); it('name-scoped view user CANNOT delete their named server', async () => { const res = await app.inject({ method: 'DELETE', url: '/api/v1/servers/clxyz000000001', headers: { authorization: 'Bearer scoped-token' }, }); expect(res.statusCode).toBe(403); }); }); describe('preSerialization edge cases', () => { it('single-object responses pass through unmodified', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/servers/clxyz000000001', headers: { authorization: 'Bearer scoped-token' }, }); expect(res.statusCode).toBe(200); expect(res.json<{ name: string }>().name).toBe('my-home-assistant'); }); it('unauthenticated requests get 401', async () => { const res = await app.inject({ method: 'GET', url: '/api/v1/servers', }); expect(res.statusCode).toBe(401); }); }); });