Compare commits
4 Commits
feat/proje
...
fix/migrat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec1dfe7438 | ||
| 50b4112398 | |||
|
|
bb17a892d6 | ||
| a8117091a1 |
@@ -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,
|
||||||
@@ -114,7 +115,50 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/);
|
const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/);
|
||||||
const resourceName = nameMatch?.[1];
|
const resourceName = nameMatch?.[1];
|
||||||
|
|
||||||
return { kind: 'resource', resource, action, resourceName };
|
const check: PermissionCheck = { kind: 'resource', resource, action };
|
||||||
|
if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName;
|
||||||
|
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> {
|
||||||
@@ -159,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();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { IProjectRepository } from '../../repositories/project.repository.j
|
|||||||
import type { IUserRepository } from '../../repositories/user.repository.js';
|
import type { IUserRepository } from '../../repositories/user.repository.js';
|
||||||
import type { IGroupRepository } from '../../repositories/group.repository.js';
|
import type { IGroupRepository } from '../../repositories/group.repository.js';
|
||||||
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js';
|
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js';
|
||||||
|
import type { RbacRoleBinding } from '../../validation/rbac-definition.schema.js';
|
||||||
import { decrypt } from './crypto.js';
|
import { decrypt } from './crypto.js';
|
||||||
import type { BackupBundle } from './backup-service.js';
|
import type { BackupBundle } from './backup-service.js';
|
||||||
|
|
||||||
@@ -317,7 +318,7 @@ export class RestoreService {
|
|||||||
// overwrite
|
// overwrite
|
||||||
await this.rbacRepo.update(existing.id, {
|
await this.rbacRepo.update(existing.id, {
|
||||||
subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>,
|
subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>,
|
||||||
roleBindings: rbac.roleBindings as Array<{ role: string; resource: string } | { role: 'run'; action: string }>,
|
roleBindings: rbac.roleBindings as RbacRoleBinding[],
|
||||||
});
|
});
|
||||||
result.rbacCreated++;
|
result.rbacCreated++;
|
||||||
continue;
|
continue;
|
||||||
@@ -326,7 +327,7 @@ export class RestoreService {
|
|||||||
await this.rbacRepo.create({
|
await this.rbacRepo.create({
|
||||||
name: rbac.name,
|
name: rbac.name,
|
||||||
subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>,
|
subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>,
|
||||||
roleBindings: rbac.roleBindings as Array<{ role: string; resource: string } | { role: 'run'; action: string }>,
|
roleBindings: rbac.roleBindings as RbacRoleBinding[],
|
||||||
});
|
});
|
||||||
result.rbacCreated++;
|
result.rbacCreated++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js';
|
export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js';
|
||||||
export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js';
|
export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js';
|
||||||
export { CreateProjectSchema, UpdateProjectSchema } from './project.schema.js';
|
export { CreateProjectSchema, UpdateProjectSchema } from './project.schema.js';
|
||||||
export type { CreateProjectInput, UpdateProjectInput, ProjectMemberInput } from './project.schema.js';
|
export type { CreateProjectInput, UpdateProjectInput } from './project.schema.js';
|
||||||
export { CreateRbacDefinitionSchema, UpdateRbacDefinitionSchema, RbacSubjectSchema, RbacRoleBindingSchema, RBAC_ROLES, RBAC_RESOURCES } from './rbac-definition.schema.js';
|
export { CreateRbacDefinitionSchema, UpdateRbacDefinitionSchema, RbacSubjectSchema, RbacRoleBindingSchema, RBAC_ROLES, RBAC_RESOURCES } from './rbac-definition.schema.js';
|
||||||
export type { CreateRbacDefinitionInput, UpdateRbacDefinitionInput, RbacSubject, RbacRoleBinding } from './rbac-definition.schema.js';
|
export type { CreateRbacDefinitionInput, UpdateRbacDefinitionInput, RbacSubject, RbacRoleBinding } from './rbac-definition.schema.js';
|
||||||
|
|||||||
@@ -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