feat: granular RBAC with resource/operation bindings, users, groups
- 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:
@@ -15,13 +15,17 @@ model User {
|
||||
name String?
|
||||
passwordHash String
|
||||
role Role @default(USER)
|
||||
provider String?
|
||||
externalId String?
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sessions Session[]
|
||||
auditLogs AuditLog[]
|
||||
projects Project[]
|
||||
sessions Session[]
|
||||
auditLogs AuditLog[]
|
||||
ownedProjects Project[]
|
||||
projectMemberships ProjectMember[]
|
||||
groupMemberships GroupMember[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
@@ -71,6 +75,7 @@ model McpServer {
|
||||
templateVersion String?
|
||||
|
||||
instances McpInstance[]
|
||||
projects ProjectServer[]
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
@@ -117,23 +122,95 @@ model Secret {
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
// ── Groups ──
|
||||
|
||||
model Group {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String @default("")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
members GroupMember[]
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
model GroupMember {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([groupId, userId])
|
||||
@@index([groupId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// ── RBAC Definitions ──
|
||||
|
||||
model RbacDefinition {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
subjects Json @default("[]")
|
||||
roleBindings Json @default("[]")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
// ── Projects ──
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String @default("")
|
||||
proxyMode String @default("direct")
|
||||
llmProvider String?
|
||||
llmModel String?
|
||||
ownerId String
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
servers ProjectServer[]
|
||||
members ProjectMember[]
|
||||
|
||||
@@index([name])
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model ProjectServer {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
serverId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([projectId, serverId])
|
||||
}
|
||||
|
||||
model ProjectMember {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
userId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([projectId, userId])
|
||||
}
|
||||
|
||||
// ── MCP Instances (running containers) ──
|
||||
|
||||
model McpInstance {
|
||||
|
||||
@@ -49,10 +49,15 @@ export async function clearAllTables(client: PrismaClient): Promise<void> {
|
||||
// Delete in order respecting foreign keys
|
||||
await client.auditLog.deleteMany();
|
||||
await client.mcpInstance.deleteMany();
|
||||
await client.projectServer.deleteMany();
|
||||
await client.projectMember.deleteMany();
|
||||
await client.secret.deleteMany();
|
||||
await client.session.deleteMany();
|
||||
await client.project.deleteMany();
|
||||
await client.mcpServer.deleteMany();
|
||||
await client.mcpTemplate.deleteMany();
|
||||
await client.groupMember.deleteMany();
|
||||
await client.group.deleteMany();
|
||||
await client.rbacDefinition.deleteMany();
|
||||
await client.user.deleteMany();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user