diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 9e68b22..ea1fcbd 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -39,6 +39,7 @@ import { GroupService, } from './services/index.js'; import type { RbacAction } from './services/index.js'; +import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js'; import { createAuthMiddleware } from './middleware/auth.js'; import { registerMcpServerRoutes, @@ -119,6 +120,47 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { return check; } +/** + * Migrate legacy 'admin' role bindings → granular roles. + * Old format: { role: 'admin', resource: '*' } + * New format: { role: 'edit', resource: '*' }, { role: 'run', resource: '*' }, + * plus operation bindings for impersonate, logs, backup, restore, audit-purge + */ +async function migrateAdminRole(rbacRepo: InstanceType): Promise { + const definitions = await rbacRepo.findAll(); + for (const def of definitions) { + const bindings = def.roleBindings as Array>; + const hasAdminRole = bindings.some((b) => b['role'] === 'admin'); + if (!hasAdminRole) continue; + + // Replace admin bindings with granular equivalents + const newBindings: Array> = []; + for (const b of bindings) { + if (b['role'] === 'admin') { + const resource = b['resource'] as string; + newBindings.push({ role: 'edit', resource }); + newBindings.push({ role: 'run', resource }); + } else { + newBindings.push(b as Record); + } + } + // Add operation bindings (idempotent — only for wildcard admin) + const hasWildcard = bindings.some((b) => b['role'] === 'admin' && b['resource'] === '*'); + if (hasWildcard) { + const ops = ['impersonate', 'logs', 'backup', 'restore', 'audit-purge']; + for (const op of ops) { + if (!newBindings.some((b) => b['action'] === op)) { + newBindings.push({ role: 'run', action: op }); + } + } + } + + await rbacRepo.update(def.id, { roleBindings: newBindings as UpdateRbacDefinitionInput['roleBindings'] }); + // eslint-disable-next-line no-console + console.log(`mcpd: migrated RBAC '${def.name}' from admin → granular roles`); + } +} + async function main(): Promise { const config = loadConfigFromEnv(); @@ -161,6 +203,9 @@ async function main(): Promise { const userRepo = new UserRepository(prisma); const groupRepo = new GroupRepository(prisma); + // Migrate legacy 'admin' role → granular roles + await migrateAdminRole(rbacDefinitionRepo); + // Orchestrator const orchestrator = new DockerContainerManager(); diff --git a/src/mcpd/tests/rbac.test.ts b/src/mcpd/tests/rbac.test.ts index a93cfcc..f56c68b 100644 --- a/src/mcpd/tests/rbac.test.ts +++ b/src/mcpd/tests/rbac.test.ts @@ -680,4 +680,52 @@ describe('RbacService', () => { expect(perms).toEqual([]); }); }); + + describe('unknown/legacy roles are denied', () => { + let service: RbacService; + + beforeEach(() => { + const repo = mockRepo([ + makeDef({ + roleBindings: [{ role: 'admin', resource: '*' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + service = new RbacService(repo, prisma); + }); + + it('denies view when only legacy admin role exists', async () => { + expect(await service.canAccess('user-1', 'view', 'servers')).toBe(false); + }); + + it('denies create when only legacy admin role exists', async () => { + expect(await service.canAccess('user-1', 'create', 'servers')).toBe(false); + }); + + it('denies edit when only legacy admin role exists', async () => { + expect(await service.canAccess('user-1', 'edit', 'servers')).toBe(false); + }); + + it('denies delete when only legacy admin role exists', async () => { + expect(await service.canAccess('user-1', 'delete', 'servers')).toBe(false); + }); + + it('denies any made-up role', async () => { + const repo = mockRepo([ + makeDef({ + roleBindings: [{ role: 'superuser', resource: 'servers' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + const svc = new RbacService(repo, prisma); + expect(await svc.canAccess('user-1', 'view', 'servers')).toBe(false); + expect(await svc.canAccess('user-1', 'edit', 'servers')).toBe(false); + }); + }); });