feat: remove ProjectMember, add expose RBAC role, attach/detach-server commands
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

- Remove ProjectMember model entirely (RBAC manages project access)
- Add 'expose' RBAC role for /mcp-config endpoint access (edit implies expose)
- Rename CLI flags: --llm-provider → --proxy-mode-llm-provider, --llm-model → --proxy-mode-llm-model
- Add attach-server / detach-server CLI commands (mcpctl --project NAME attach-server SERVER)
- Add POST/DELETE /api/v1/projects/:id/servers endpoints for server attach/detach
- Remove members from backup/restore, apply, get, describe
- Prisma migration to drop ProjectMember table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-23 17:50:01 +00:00
parent 5844d6c73f
commit 783cf15179
23 changed files with 283 additions and 219 deletions

View File

@@ -921,4 +921,92 @@ describe('RbacService', () => {
expect(await svc.canAccess('user-1', 'edit', 'servers')).toBe(false);
});
});
describe('expose role', () => {
it('grants expose access with expose role binding', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'expose', resource: 'projects' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canAccess('user-1', 'expose', 'projects')).toBe(true);
});
it('grants expose access with edit role binding (edit includes expose)', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'edit', resource: 'projects' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canAccess('user-1', 'expose', 'projects')).toBe(true);
});
it('denies expose access with view role binding', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'view', resource: 'projects' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canAccess('user-1', 'expose', 'projects')).toBe(false);
});
it('expose role also grants view access', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'expose', resource: 'projects' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canAccess('user-1', 'view', 'projects')).toBe(true);
});
it('expose role with name-scoped binding', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'expose', resource: 'projects', name: 'my-project' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
expect(await service.canAccess('user-1', 'expose', 'projects', 'my-project')).toBe(true);
expect(await service.canAccess('user-1', 'expose', 'projects', 'other-project')).toBe(false);
});
it('getAllowedScope with expose role grants view scope', async () => {
const repo = mockRepo([
makeDef({
roleBindings: [{ role: 'expose', resource: 'projects' }],
}),
]);
const prisma = mockPrisma({
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
groupMember: { findMany: vi.fn(async () => []) },
});
const service = new RbacService(repo, prisma);
const scope = await service.getAllowedScope('user-1', 'view', 'projects');
expect(scope.wildcard).toBe(true);
});
});
});