Compare commits

..

4 Commits

Author SHA1 Message Date
Michal
ec1dfe7438 fix: migrate legacy admin role to granular roles at startup
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- 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>
2026-02-23 11:31:15 +00:00
50b4112398 Merge pull request 'fix: resolve tsc --build type errors' (#20) from fix/build-type-errors into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 11:08:08 +00:00
Michal
bb17a892d6 fix: resolve tsc --build type errors (exactOptionalPropertyTypes)
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Fix resourceName assignment in mapUrlToPermission for strictness
- Use RbacRoleBinding type in restore-service instead of loose cast
- Remove stale ProjectMemberInput export from validation index

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:07:46 +00:00
a8117091a1 Merge pull request 'feat: granular RBAC with resource/operation bindings, users, groups' (#19) from feat/projects-rbac-users-groups into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 11:05:51 +00:00
4 changed files with 100 additions and 4 deletions

View File

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

View File

@@ -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) {

View File

@@ -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';

View File

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