feat: granular RBAC with resource/operation bindings, users, groups
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

- Replace admin role with granular roles: view, create, delete, edit, run
- Two binding types: resource bindings (role+resource+optional name) and
  operation bindings (role:run + action like backup, logs, impersonate)
- Name-scoped resource bindings for per-instance access control
- Remove role from project members (all permissions via RBAC)
- Add users, groups, RBAC CRUD endpoints and CLI commands
- describe user/group shows all RBAC access (direct + inherited)
- create rbac supports --subject, --binding, --operation flags
- Backup/restore handles users, groups, RBAC definitions
- mcplocal project-based MCP endpoint discovery
- Full test coverage for all new functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-23 11:05:19 +00:00
parent a6b5e24a8d
commit dcda93d179
67 changed files with 7256 additions and 498 deletions

View File

@@ -29,6 +29,29 @@ async function createUser(overrides: { email?: string; name?: string; role?: 'US
});
}
async function createGroup(overrides: { name?: string; description?: string } = {}) {
return prisma.group.create({
data: {
name: overrides.name ?? `group-${Date.now()}`,
description: overrides.description ?? 'Test group',
},
});
}
async function createProject(overrides: { name?: string; ownerId?: string } = {}) {
let ownerId = overrides.ownerId;
if (!ownerId) {
const user = await createUser();
ownerId = user.id;
}
return prisma.project.create({
data: {
name: overrides.name ?? `project-${Date.now()}`,
ownerId,
},
});
}
async function createServer(overrides: { name?: string; transport?: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP' } = {}) {
return prisma.mcpServer.create({
data: {
@@ -310,3 +333,236 @@ describe('AuditLog', () => {
expect(logs).toHaveLength(0);
});
});
// ── User SSO fields ──
describe('User SSO fields', () => {
it('stores provider and externalId', async () => {
const user = await prisma.user.create({
data: {
email: 'sso@example.com',
passwordHash: 'hash',
provider: 'oidc',
externalId: 'ext-123',
},
});
expect(user.provider).toBe('oidc');
expect(user.externalId).toBe('ext-123');
});
it('defaults provider and externalId to null', async () => {
const user = await createUser();
expect(user.provider).toBeNull();
expect(user.externalId).toBeNull();
});
});
// ── Group model ──
describe('Group', () => {
it('creates a group with defaults', async () => {
const group = await createGroup();
expect(group.id).toBeDefined();
expect(group.version).toBe(1);
});
it('enforces unique name', async () => {
await createGroup({ name: 'devs' });
await expect(createGroup({ name: 'devs' })).rejects.toThrow();
});
it('creates group members', async () => {
const group = await createGroup();
const user = await createUser();
const member = await prisma.groupMember.create({
data: { groupId: group.id, userId: user.id },
});
expect(member.groupId).toBe(group.id);
expect(member.userId).toBe(user.id);
});
it('enforces unique group-user pair', async () => {
const group = await createGroup();
const user = await createUser();
await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } });
await expect(
prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } }),
).rejects.toThrow();
});
it('cascades delete when group is deleted', async () => {
const group = await createGroup();
const user = await createUser();
await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } });
await prisma.group.delete({ where: { id: group.id } });
const members = await prisma.groupMember.findMany({ where: { groupId: group.id } });
expect(members).toHaveLength(0);
});
});
// ── RbacDefinition model ──
describe('RbacDefinition', () => {
it('creates with defaults', async () => {
const rbac = await prisma.rbacDefinition.create({
data: { name: 'test-rbac' },
});
expect(rbac.subjects).toEqual([]);
expect(rbac.roleBindings).toEqual([]);
expect(rbac.version).toBe(1);
});
it('enforces unique name', async () => {
await prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } });
await expect(prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } })).rejects.toThrow();
});
it('stores subjects as JSON', async () => {
const rbac = await prisma.rbacDefinition.create({
data: {
name: 'with-subjects',
subjects: [{ kind: 'User', name: 'alice@test.com' }, { kind: 'Group', name: 'devs' }],
},
});
const subjects = rbac.subjects as Array<{ kind: string; name: string }>;
expect(subjects).toHaveLength(2);
expect(subjects[0].kind).toBe('User');
});
it('stores roleBindings as JSON', async () => {
const rbac = await prisma.rbacDefinition.create({
data: {
name: 'with-bindings',
roleBindings: [{ role: 'editor', resource: 'servers' }],
},
});
const bindings = rbac.roleBindings as Array<{ role: string; resource: string }>;
expect(bindings).toHaveLength(1);
expect(bindings[0].role).toBe('editor');
});
it('updates subjects and roleBindings', async () => {
const rbac = await prisma.rbacDefinition.create({ data: { name: 'updatable-rbac' } });
const updated = await prisma.rbacDefinition.update({
where: { id: rbac.id },
data: {
subjects: [{ kind: 'User', name: 'bob@test.com' }],
roleBindings: [{ role: 'admin', resource: '*' }],
},
});
expect((updated.subjects as unknown[]).length).toBe(1);
expect((updated.roleBindings as unknown[]).length).toBe(1);
});
});
// ── ProjectServer model ──
describe('ProjectServer', () => {
it('links project to server', async () => {
const project = await createProject();
const server = await createServer();
const ps = await prisma.projectServer.create({
data: { projectId: project.id, serverId: server.id },
});
expect(ps.projectId).toBe(project.id);
expect(ps.serverId).toBe(server.id);
});
it('enforces unique project-server pair', async () => {
const project = await createProject();
const server = await createServer();
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
await expect(
prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }),
).rejects.toThrow();
});
it('cascades delete when project is deleted', async () => {
const project = await createProject();
const server = await createServer();
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
await prisma.project.delete({ where: { id: project.id } });
const links = await prisma.projectServer.findMany({ where: { projectId: project.id } });
expect(links).toHaveLength(0);
});
it('cascades delete when server is deleted', async () => {
const project = await createProject();
const server = await createServer();
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
await prisma.mcpServer.delete({ where: { id: server.id } });
const links = await prisma.projectServer.findMany({ where: { serverId: server.id } });
expect(links).toHaveLength(0);
});
});
// ── ProjectMember model ──
describe('ProjectMember', () => {
it('links project to user with role', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
const pm = await prisma.projectMember.create({
data: { projectId: project.id, userId: user.id, role: 'admin' },
});
expect(pm.role).toBe('admin');
});
it('defaults role to member', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
const pm = await prisma.projectMember.create({
data: { projectId: project.id, userId: user.id },
});
expect(pm.role).toBe('member');
});
it('enforces unique project-user pair', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } });
await expect(
prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } }),
).rejects.toThrow();
});
it('cascades delete when project is deleted', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } });
await prisma.project.delete({ where: { id: project.id } });
const members = await prisma.projectMember.findMany({ where: { projectId: project.id } });
expect(members).toHaveLength(0);
});
});
// ── Project new fields ──
describe('Project new fields', () => {
it('defaults proxyMode to direct', async () => {
const project = await createProject();
expect(project.proxyMode).toBe('direct');
});
it('stores proxyMode, llmProvider, llmModel', async () => {
const user = await createUser();
const project = await prisma.project.create({
data: {
name: 'filtered-project',
ownerId: user.id,
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
},
});
expect(project.proxyMode).toBe('filtered');
expect(project.llmProvider).toBe('gemini-cli');
expect(project.llmModel).toBe('gemini-2.0-flash');
});
it('defaults llmProvider and llmModel to null', async () => {
const project = await createProject();
expect(project.llmProvider).toBeNull();
expect(project.llmModel).toBeNull();
});
});