feat: remove ProjectMember, add expose RBAC role, attach/detach-server commands

- 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 1f628d39d2
commit 329315ec71
23 changed files with 283 additions and 219 deletions

View File

@@ -37,7 +37,6 @@ const mockProjects = [
id: 'proj1', name: 'my-project', description: 'Test project', proxyMode: 'direct', llmProvider: null, llmModel: null,
ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(),
servers: [{ id: 'ps1', server: { id: 's1', name: 'github' } }],
members: [{ id: 'pm1', user: { id: 'u1', email: 'alice@test.com', name: 'Alice' } }],
},
];
@@ -91,11 +90,12 @@ function mockProjectRepo(): IProjectRepository {
findAll: vi.fn(async () => [...mockProjects]),
findById: vi.fn(async (id: string) => mockProjects.find((p) => p.id === id) ?? null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, servers: [], members: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, servers: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
delete: vi.fn(async () => {}),
setServers: vi.fn(async () => {}),
setMembers: vi.fn(async () => {}),
addServer: vi.fn(async () => {}),
removeServer: vi.fn(async () => {}),
};
}
@@ -214,12 +214,11 @@ describe('BackupService', () => {
expect(bundle.rbacBindings![0]!.subjects).toEqual([{ kind: 'User', name: 'alice@test.com' }]);
});
it('includes enriched projects with server names and members', async () => {
it('includes enriched projects with server names', async () => {
const bundle = await backupService.createBackup();
const proj = bundle.projects[0]!;
expect(proj.proxyMode).toBe('direct');
expect(proj.serverNames).toEqual(['github']);
expect(proj.members).toEqual(['alice@test.com']);
});
it('filters resources', async () => {
@@ -406,7 +405,7 @@ describe('RestoreService', () => {
}));
});
it('restores enriched projects with server and member linking', async () => {
it('restores enriched projects with server linking', async () => {
// Simulate servers exist (restored in prior step)
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
// After server restore, we can find them
@@ -419,14 +418,6 @@ describe('RestoreService', () => {
return null;
});
// Simulate users exist for member resolution
let userCallCount = 0;
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockImplementation(async (email: string) => {
userCallCount++;
if (userCallCount > 2 && email === 'alice@test.com') return { id: 'restored-u1', email };
return null;
});
const result = await restoreService.restore(fullBundle);
expect(result.projectsCreated).toBe(1);
@@ -437,7 +428,6 @@ describe('RestoreService', () => {
llmModel: 'gpt-4',
}));
expect(projectRepo.setServers).toHaveBeenCalled();
expect(projectRepo.setMembers).toHaveBeenCalled();
});
it('restores old bundle without users/groups/rbac', async () => {
@@ -551,7 +541,7 @@ describe('RestoreService', () => {
(serverRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('server'); return { id: 'srv' }; });
(userRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('user'); return { id: 'usr' }; });
(groupRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('group'); return { id: 'grp' }; });
(projectRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('project'); return { id: 'proj', servers: [], members: [] }; });
(projectRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('project'); return { id: 'proj', servers: [] }; });
(rbacRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('rbac'); return { id: 'rbac' }; });
await restoreService.restore(fullBundle);

View File

@@ -3,7 +3,6 @@ import { ProjectService } from '../src/services/project.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
import type { IUserRepository } from '../src/repositories/user.repository.js';
import type { McpServer } from '@prisma/client';
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
@@ -19,7 +18,6 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
createdAt: new Date(),
updatedAt: new Date(),
servers: [],
members: [],
...overrides,
};
}
@@ -64,7 +62,8 @@ function mockProjectRepo(): IProjectRepository {
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
delete: vi.fn(async () => {}),
setServers: vi.fn(async () => {}),
setMembers: vi.fn(async () => {}),
addServer: vi.fn(async () => {}),
removeServer: vi.fn(async () => {}),
};
}
@@ -90,33 +89,17 @@ function mockSecretRepo(): ISecretRepository {
};
}
function mockUserRepo(): IUserRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByEmail: vi.fn(async () => null),
create: vi.fn(async () => ({
id: 'u-1', email: 'test@example.com', name: null, role: 'user',
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
count: vi.fn(async () => 0),
};
}
describe('ProjectService', () => {
let projectRepo: ReturnType<typeof mockProjectRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let secretRepo: ReturnType<typeof mockSecretRepo>;
let userRepo: ReturnType<typeof mockUserRepo>;
let service: ProjectService;
beforeEach(() => {
projectRepo = mockProjectRepo();
serverRepo = mockServerRepo();
secretRepo = mockSecretRepo();
userRepo = mockUserRepo();
service = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo);
service = new ProjectService(projectRepo, serverRepo, secretRepo);
});
describe('create', () => {
@@ -164,32 +147,6 @@ describe('ProjectService', () => {
expect(result.servers).toHaveLength(2);
});
it('creates project with members (resolves emails)', async () => {
vi.mocked(userRepo.findByEmail).mockImplementation(async (email) => {
if (email === 'alice@test.com') {
return { id: 'u-alice', email: 'alice@test.com', name: 'Alice', role: 'user', provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() };
}
return null;
});
const created = makeProject({ id: 'proj-new' });
vi.mocked(projectRepo.create).mockResolvedValue(created);
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({
id: 'proj-new',
members: [
{ id: 'pm-1', user: { id: 'u-alice', email: 'alice@test.com', name: 'Alice' } },
],
}));
const result = await service.create({
name: 'my-project',
members: ['alice@test.com'],
}, 'user-1');
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-new', ['u-alice']);
expect(result.members).toHaveLength(1);
});
it('creates project with proxyMode and llmProvider', async () => {
const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' });
vi.mocked(projectRepo.create).mockResolvedValue(created);
@@ -219,16 +176,6 @@ describe('ProjectService', () => {
).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when member email resolution fails', async () => {
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
await expect(
service.create({
name: 'my-project',
members: ['nobody@test.com'],
}, 'user-1'),
).rejects.toThrow(NotFoundError);
});
});
describe('getById', () => {
@@ -277,19 +224,6 @@ describe('ProjectService', () => {
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
});
it('updates members (full replacement)', async () => {
const existing = makeProject({ id: 'proj-1' });
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
vi.mocked(userRepo.findByEmail).mockResolvedValue({
id: 'u-bob', email: 'bob@test.com', name: 'Bob', role: 'user',
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.update('proj-1', { members: ['bob@test.com'] });
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-1', ['u-bob']);
});
it('updates proxyMode', async () => {
const existing = makeProject({ id: 'proj-1' });
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
@@ -314,6 +248,52 @@ describe('ProjectService', () => {
});
});
describe('addServer', () => {
it('attaches a server by name', async () => {
const project = makeProject({ id: 'proj-1' });
const srv = makeServer({ id: 'srv-1', name: 'my-ha' });
vi.mocked(projectRepo.findById).mockResolvedValue(project);
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
await service.addServer('proj-1', 'my-ha');
expect(projectRepo.addServer).toHaveBeenCalledWith('proj-1', 'srv-1');
});
it('throws NotFoundError when project not found', async () => {
await expect(service.addServer('missing', 'my-ha')).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when server not found', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' }));
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
await expect(service.addServer('proj-1', 'nonexistent')).rejects.toThrow(NotFoundError);
});
});
describe('removeServer', () => {
it('detaches a server by name', async () => {
const project = makeProject({ id: 'proj-1' });
const srv = makeServer({ id: 'srv-1', name: 'my-ha' });
vi.mocked(projectRepo.findById).mockResolvedValue(project);
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
await service.removeServer('proj-1', 'my-ha');
expect(projectRepo.removeServer).toHaveBeenCalledWith('proj-1', 'srv-1');
});
it('throws NotFoundError when project not found', async () => {
await expect(service.removeServer('missing', 'my-ha')).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError when server not found', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' }));
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
await expect(service.removeServer('proj-1', 'nonexistent')).rejects.toThrow(NotFoundError);
});
});
describe('generateMcpConfig', () => {
it('generates direct mode config with STDIO servers', async () => {
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });

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