fix: migrate legacy admin role at startup #21

Merged
michal merged 1 commits from fix/migrate-legacy-admin-role into main 2026-02-23 11:31:32 +00:00
2 changed files with 93 additions and 0 deletions

View File

@@ -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<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> {
const config = loadConfigFromEnv();
@@ -161,6 +203,9 @@ async function main(): Promise<void> {
const userRepo = new UserRepository(prisma);
const groupRepo = new GroupRepository(prisma);
// Migrate legacy 'admin' role → granular roles
await migrateAdminRole(rbacDefinitionRepo);
// Orchestrator
const orchestrator = new DockerContainerManager();

View File

@@ -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);
});
});
});