fix: migrate legacy admin role to granular roles at startup
- Add migrateAdminRole() that runs on mcpd boot
- Converts { role: 'admin', resource: X } → edit + run bindings
- Adds operation bindings for wildcard admin (impersonate, logs, etc.)
- Add tests verifying unknown/legacy roles are denied by canAccess
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
|||||||
GroupService,
|
GroupService,
|
||||||
} from './services/index.js';
|
} from './services/index.js';
|
||||||
import type { RbacAction } 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 { createAuthMiddleware } from './middleware/auth.js';
|
||||||
import {
|
import {
|
||||||
registerMcpServerRoutes,
|
registerMcpServerRoutes,
|
||||||
@@ -119,6 +120,47 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
return check;
|
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<typeof RbacDefinitionRepository>): Promise<void> {
|
||||||
|
const definitions = await rbacRepo.findAll();
|
||||||
|
for (const def of definitions) {
|
||||||
|
const bindings = def.roleBindings as Array<Record<string, unknown>>;
|
||||||
|
const hasAdminRole = bindings.some((b) => b['role'] === 'admin');
|
||||||
|
if (!hasAdminRole) continue;
|
||||||
|
|
||||||
|
// Replace admin bindings with granular equivalents
|
||||||
|
const newBindings: Array<Record<string, string>> = [];
|
||||||
|
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<string, string>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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<void> {
|
async function main(): Promise<void> {
|
||||||
const config = loadConfigFromEnv();
|
const config = loadConfigFromEnv();
|
||||||
|
|
||||||
@@ -161,6 +203,9 @@ async function main(): Promise<void> {
|
|||||||
const userRepo = new UserRepository(prisma);
|
const userRepo = new UserRepository(prisma);
|
||||||
const groupRepo = new GroupRepository(prisma);
|
const groupRepo = new GroupRepository(prisma);
|
||||||
|
|
||||||
|
// Migrate legacy 'admin' role → granular roles
|
||||||
|
await migrateAdminRole(rbacDefinitionRepo);
|
||||||
|
|
||||||
// Orchestrator
|
// Orchestrator
|
||||||
const orchestrator = new DockerContainerManager();
|
const orchestrator = new DockerContainerManager();
|
||||||
|
|
||||||
|
|||||||
@@ -680,4 +680,52 @@ describe('RbacService', () => {
|
|||||||
expect(perms).toEqual([]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user