From 783cf15179ef907460ddd7817cbad0d53399d8e8 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 23 Feb 2026 17:50:01 +0000 Subject: [PATCH] feat: remove ProjectMember, add expose RBAC role, attach/detach-server commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/cli/src/commands/apply.ts | 5 +- src/cli/src/commands/create.ts | 10 +- src/cli/src/commands/describe.ts | 11 -- src/cli/src/commands/get.ts | 2 - src/cli/src/commands/project-ops.ts | 47 +++++++ src/cli/src/index.ts | 12 +- src/cli/tests/commands/apply.test.ts | 6 +- src/cli/tests/commands/get.test.ts | 3 - src/cli/tests/commands/project.test.ts | 30 +---- .../migration.sql | 8 ++ src/db/prisma/schema.prisma | 14 --- src/mcpd/src/main.ts | 14 ++- .../src/repositories/project.repository.ts | 24 ++-- src/mcpd/src/routes/projects.ts | 15 +++ .../src/services/backup/backup-service.ts | 2 - .../src/services/backup/restore-service.ts | 22 +--- src/mcpd/src/services/project.service.ts | 40 +++--- src/mcpd/src/services/rbac.service.ts | 5 +- src/mcpd/src/validation/project.schema.ts | 2 - .../src/validation/rbac-definition.schema.ts | 2 +- src/mcpd/tests/backup.test.ts | 22 +--- src/mcpd/tests/project-service.test.ts | 118 ++++++++---------- src/mcpd/tests/rbac.test.ts | 88 +++++++++++++ 23 files changed, 283 insertions(+), 219 deletions(-) create mode 100644 src/cli/src/commands/project-ops.ts create mode 100644 src/db/prisma/migrations/20260223120000_remove_project_members/migration.sql diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 56c1c6e..6d2aa34 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -87,7 +87,7 @@ const RESOURCE_ALIASES: Record = { const RbacRoleBindingSchema = z.union([ z.object({ - role: z.enum(['edit', 'view', 'create', 'delete', 'run']), + role: z.enum(['edit', 'view', 'create', 'delete', 'run', 'expose']), resource: z.string().min(1).transform((r) => RESOURCE_ALIASES[r] ?? r), name: z.string().min(1).optional(), }), @@ -110,7 +110,6 @@ const ProjectSpecSchema = z.object({ llmProvider: z.string().optional(), llmModel: z.string().optional(), servers: z.array(z.string()).default([]), - members: z.array(z.string().email()).default([]), }); const ApplyConfigSchema = z.object({ @@ -246,7 +245,7 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args } } - // Apply projects (send full spec including servers/members) + // Apply projects (send full spec including servers) for (const project of config.projects) { try { const existing = await findByName(client, 'projects', project.name); diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index fbec5fd..3a69f4d 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -196,10 +196,9 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .argument('', 'Project name') .option('-d, --description ', 'Project description', '') .option('--proxy-mode ', 'Proxy mode (direct, filtered)') - .option('--llm-provider ', 'LLM provider name') - .option('--llm-model ', 'LLM model name') + .option('--proxy-mode-llm-provider ', 'LLM provider name (for filtered proxy mode)') + .option('--proxy-mode-llm-model ', 'LLM model name (for filtered proxy mode)') .option('--server ', 'Server name (repeat for multiple)', collect, []) - .option('--member ', 'Member email (repeat for multiple)', collect, []) .option('--force', 'Update if already exists') .action(async (name: string, opts) => { const body: Record = { @@ -207,10 +206,9 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { description: opts.description, proxyMode: opts.proxyMode ?? 'direct', }; - if (opts.llmProvider) body.llmProvider = opts.llmProvider; - if (opts.llmModel) body.llmModel = opts.llmModel; + if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider; + if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel; if (opts.server.length > 0) body.servers = opts.server; - if (opts.member.length > 0) body.members = opts.member; try { const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body); diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index 8f891ca..7c8026a 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -162,17 +162,6 @@ function formatProjectDetail(project: Record): string { } } - // Members section (no role — all permissions are in RBAC) - const members = project.members as Array<{ user: { email: string } }> | undefined; - if (members && members.length > 0) { - lines.push(''); - lines.push('Members:'); - lines.push(' EMAIL'); - for (const m of members) { - lines.push(` ${m.user.email}`); - } - } - lines.push(''); lines.push('Metadata:'); lines.push(` ${pad('ID:', 12)}${project.id}`); diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 766a9c5..4fe33ff 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -24,7 +24,6 @@ interface ProjectRow { proxyMode: string; ownerId: string; servers?: Array<{ server: { name: string } }>; - members?: Array<{ user: { email: string }; role: string }>; } interface SecretRow { @@ -85,7 +84,6 @@ const projectColumns: Column[] = [ { header: 'NAME', key: 'name' }, { header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 }, { header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 }, - { header: 'MEMBERS', key: (r) => r.members ? String(r.members.length) : '0', width: 8 }, { header: 'DESCRIPTION', key: 'description', width: 30 }, { header: 'ID', key: 'id' }, ]; diff --git a/src/cli/src/commands/project-ops.ts b/src/cli/src/commands/project-ops.ts new file mode 100644 index 0000000..dde5fd2 --- /dev/null +++ b/src/cli/src/commands/project-ops.ts @@ -0,0 +1,47 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; +import { resolveNameOrId } from './shared.js'; + +export interface ProjectOpsDeps { + client: ApiClient; + log: (...args: string[]) => void; + getProject: () => string | undefined; +} + +function requireProject(deps: ProjectOpsDeps): string { + const project = deps.getProject(); + if (!project) { + deps.log('Error: --project is required for this command.'); + process.exitCode = 1; + throw new Error('--project required'); + } + return project; +} + +export function createAttachServerCommand(deps: ProjectOpsDeps): Command { + const { client, log } = deps; + + return new Command('attach-server') + .description('Attach a server to a project (requires --project)') + .argument('', 'Server name to attach') + .action(async (serverName: string) => { + const projectName = requireProject(deps); + const projectId = await resolveNameOrId(client, 'projects', projectName); + await client.post(`/api/v1/projects/${projectId}/servers`, { server: serverName }); + log(`server '${serverName}' attached to project '${projectName}'`); + }); +} + +export function createDetachServerCommand(deps: ProjectOpsDeps): Command { + const { client, log } = deps; + + return new Command('detach-server') + .description('Detach a server from a project (requires --project)') + .argument('', 'Server name to detach') + .action(async (serverName: string) => { + const projectName = requireProject(deps); + const projectId = await resolveNameOrId(client, 'projects', projectName); + await client.delete(`/api/v1/projects/${projectId}/servers/${serverName}`); + log(`server '${serverName}' detached from project '${projectName}'`); + }); +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index b9947b0..89ad9ad 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -12,6 +12,7 @@ import { createCreateCommand } from './commands/create.js'; import { createEditCommand } from './commands/edit.js'; import { createBackupCommand, createRestoreCommand } from './commands/backup.js'; import { createLoginCommand, createLogoutCommand } from './commands/auth.js'; +import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js'; import { ApiClient, ApiError } from './api-client.js'; import { loadConfig } from './config/index.js'; import { loadCredentials } from './auth/index.js'; @@ -24,7 +25,8 @@ export function createProgram(): Command { .version(APP_VERSION, '-v, --version') .enablePositionalOptions() .option('--daemon-url ', 'mcplocal daemon URL') - .option('--direct', 'bypass mcplocal and connect directly to mcpd'); + .option('--direct', 'bypass mcplocal and connect directly to mcpd') + .option('--project ', 'Target project for project commands'); program.addCommand(createStatusCommand()); program.addCommand(createLoginCommand()); @@ -126,6 +128,14 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + const projectOpsDeps = { + client, + log: (...args: string[]) => console.log(...args), + getProject: () => program.opts().project as string | undefined, + }; + program.addCommand(createAttachServerCommand(projectOpsDeps)); + program.addCommand(createDetachServerCommand(projectOpsDeps)); + return program; } diff --git a/src/cli/tests/commands/apply.test.ts b/src/cli/tests/commands/apply.test.ts index 14f4360..b12ae87 100644 --- a/src/cli/tests/commands/apply.test.ts +++ b/src/cli/tests/commands/apply.test.ts @@ -326,7 +326,7 @@ rbacBindings: rmSync(tmpDir, { recursive: true, force: true }); }); - it('applies projects with servers and members', async () => { + it('applies projects with servers', async () => { const configPath = join(tmpDir, 'config.yaml'); writeFileSync(configPath, ` projects: @@ -338,9 +338,6 @@ projects: servers: - my-grafana - my-ha - members: - - alice@test.com - - bob@test.com `); const cmd = createApplyCommand({ client, log }); @@ -352,7 +349,6 @@ projects: llmProvider: 'gemini-cli', llmModel: 'gemini-2.0-flash', servers: ['my-grafana', 'my-ha'], - members: ['alice@test.com', 'bob@test.com'], })); expect(output.join('\n')).toContain('Created project: smart-home'); diff --git a/src/cli/tests/commands/get.test.ts b/src/cli/tests/commands/get.test.ts index 83734e0..1ef2c74 100644 --- a/src/cli/tests/commands/get.test.ts +++ b/src/cli/tests/commands/get.test.ts @@ -181,7 +181,6 @@ describe('get command', () => { proxyMode: 'filtered', ownerId: 'usr-1', servers: [{ server: { name: 'grafana' } }], - members: [{ user: { email: 'a@b.com' }, role: 'admin' }, { user: { email: 'c@d.com' }, role: 'member' }], }]); const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'projects']); @@ -189,11 +188,9 @@ describe('get command', () => { const text = deps.output.join('\n'); expect(text).toContain('MODE'); expect(text).toContain('SERVERS'); - expect(text).toContain('MEMBERS'); expect(text).toContain('smart-home'); expect(text).toContain('filtered'); expect(text).toContain('1'); - expect(text).toContain('2'); }); it('displays mixed resource and operation bindings', async () => { diff --git a/src/cli/tests/commands/project.test.ts b/src/cli/tests/commands/project.test.ts index c15c242..aeef1db 100644 --- a/src/cli/tests/commands/project.test.ts +++ b/src/cli/tests/commands/project.test.ts @@ -30,8 +30,8 @@ describe('project with new fields', () => { 'project', 'smart-home', '-d', 'Smart home project', '--proxy-mode', 'filtered', - '--llm-provider', 'gemini-cli', - '--llm-model', 'gemini-2.0-flash', + '--proxy-mode-llm-provider', 'gemini-cli', + '--proxy-mode-llm-model', 'gemini-2.0-flash', '--server', 'my-grafana', '--server', 'my-ha', ], { from: 'user' }); @@ -46,20 +46,6 @@ describe('project with new fields', () => { })); }); - it('creates project with members', async () => { - const cmd = createCreateCommand({ client, log }); - await cmd.parseAsync([ - 'project', 'team-project', - '--member', 'alice@test.com', - '--member', 'bob@test.com', - ], { from: 'user' }); - - expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ - name: 'team-project', - members: ['alice@test.com', 'bob@test.com'], - })); - }); - it('defaults proxy mode to direct', async () => { const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['project', 'basic'], { from: 'user' }); @@ -71,7 +57,7 @@ describe('project with new fields', () => { }); describe('get projects shows new columns', () => { - it('shows MODE, SERVERS, MEMBERS columns', async () => { + it('shows MODE and SERVERS columns', async () => { const deps = { output: [] as string[], fetchResource: vi.fn(async () => [{ @@ -81,7 +67,6 @@ describe('project with new fields', () => { proxyMode: 'filtered', ownerId: 'user-1', servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }], - members: [{ user: { email: 'alice@test.com' } }], }]), log: (...args: string[]) => deps.output.push(args.join(' ')), }; @@ -91,13 +76,12 @@ describe('project with new fields', () => { const text = deps.output.join('\n'); expect(text).toContain('MODE'); expect(text).toContain('SERVERS'); - expect(text).toContain('MEMBERS'); expect(text).toContain('smart-home'); }); }); describe('describe project shows full detail', () => { - it('shows servers and members', async () => { + it('shows servers and proxy config', async () => { const deps = { output: [] as string[], client: mockClient(), @@ -113,10 +97,6 @@ describe('project with new fields', () => { { server: { name: 'my-grafana' } }, { server: { name: 'my-ha' } }, ], - members: [ - { user: { email: 'alice@test.com' } }, - { user: { email: 'bob@test.com' } }, - ], createdAt: '2025-01-01', updatedAt: '2025-01-01', })), @@ -131,8 +111,6 @@ describe('project with new fields', () => { expect(text).toContain('gemini-cli'); expect(text).toContain('my-grafana'); expect(text).toContain('my-ha'); - expect(text).toContain('alice@test.com'); - expect(text).toContain('bob@test.com'); }); }); }); diff --git a/src/db/prisma/migrations/20260223120000_remove_project_members/migration.sql b/src/db/prisma/migrations/20260223120000_remove_project_members/migration.sql new file mode 100644 index 0000000..0e07037 --- /dev/null +++ b/src/db/prisma/migrations/20260223120000_remove_project_members/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "ProjectMember" DROP CONSTRAINT IF EXISTS "ProjectMember_projectId_fkey"; + +-- DropForeignKey +ALTER TABLE "ProjectMember" DROP CONSTRAINT IF EXISTS "ProjectMember_userId_fkey"; + +-- DropTable +DROP TABLE IF EXISTS "ProjectMember"; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 92f2ca8..13243ae 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -24,7 +24,6 @@ model User { sessions Session[] auditLogs AuditLog[] ownedProjects Project[] - projectMemberships ProjectMember[] groupMemberships GroupMember[] @@index([email]) @@ -181,7 +180,6 @@ model Project { owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) servers ProjectServer[] - members ProjectMember[] @@index([name]) @@index([ownerId]) @@ -199,18 +197,6 @@ model ProjectServer { @@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 { diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 076dd4b..31e5af4 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -93,6 +93,18 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { const resource = resourceMap[segment]; if (resource === undefined) return { kind: 'skip' }; + // Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission + const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/); + if (mcpConfigMatch?.[1]) { + return { kind: 'resource', resource: 'projects', action: 'expose', resourceName: mcpConfigMatch[1] }; + } + + // Special case: /api/v1/projects/:id/servers — attach/detach requires 'edit' + const projectServersMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/servers/); + if (projectServersMatch?.[1] && method !== 'GET') { + return { kind: 'resource', resource: 'projects', action: 'edit', resourceName: projectServersMatch[1] }; + } + // Map HTTP method to action let action: RbacAction; switch (method) { @@ -223,7 +235,7 @@ async function main(): Promise { const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo); serverService.setInstanceService(instanceService); const secretService = new SecretService(secretRepo); - const projectService = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo); + const projectService = new ProjectService(projectRepo, serverRepo, secretRepo); const auditLogService = new AuditLogService(auditLogRepo); const metricsCollector = new MetricsCollector(); const healthAggregator = new HealthAggregator(metricsCollector, orchestrator); diff --git a/src/mcpd/src/repositories/project.repository.ts b/src/mcpd/src/repositories/project.repository.ts index 0e718e6..ffee081 100644 --- a/src/mcpd/src/repositories/project.repository.ts +++ b/src/mcpd/src/repositories/project.repository.ts @@ -2,12 +2,10 @@ import type { PrismaClient, Project } from '@prisma/client'; export interface ProjectWithRelations extends Project { servers: Array<{ id: string; server: { id: string; name: string } }>; - members: Array<{ id: string; user: { id: string; email: string; name: string | null } }>; } const PROJECT_INCLUDE = { servers: { include: { server: { select: { id: true, name: true } } } }, - members: { include: { user: { select: { id: true, email: true, name: true } } } }, } as const; export interface IProjectRepository { @@ -18,7 +16,8 @@ export interface IProjectRepository { update(id: string, data: Record): Promise; delete(id: string): Promise; setServers(projectId: string, serverIds: string[]): Promise; - setMembers(projectId: string, userIds: string[]): Promise; + addServer(projectId: string, serverId: string): Promise; + removeServer(projectId: string, serverId: string): Promise; } export class ProjectRepository implements IProjectRepository { @@ -76,14 +75,17 @@ export class ProjectRepository implements IProjectRepository { }); } - async setMembers(projectId: string, userIds: string[]): Promise { - await this.prisma.$transaction(async (tx) => { - await tx.projectMember.deleteMany({ where: { projectId } }); - if (userIds.length > 0) { - await tx.projectMember.createMany({ - data: userIds.map((userId) => ({ projectId, userId })), - }); - } + async addServer(projectId: string, serverId: string): Promise { + await this.prisma.projectServer.upsert({ + where: { projectId_serverId: { projectId, serverId } }, + create: { projectId, serverId }, + update: {}, + }); + } + + async removeServer(projectId: string, serverId: string): Promise { + await this.prisma.projectServer.deleteMany({ + where: { projectId, serverId }, }); } } diff --git a/src/mcpd/src/routes/projects.ts b/src/mcpd/src/routes/projects.ts index 416f735..36619ad 100644 --- a/src/mcpd/src/routes/projects.ts +++ b/src/mcpd/src/routes/projects.ts @@ -34,6 +34,21 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ return service.generateMcpConfig(request.params.id); }); + // Attach a server to a project + app.post<{ Params: { id: string }; Body: { server: string } }>('/api/v1/projects/:id/servers', async (request) => { + const body = request.body as { server?: string }; + if (!body.server) { + throw Object.assign(new Error('Missing "server" in request body'), { statusCode: 400 }); + } + return service.addServer(request.params.id, body.server); + }); + + // Detach a server from a project + app.delete<{ Params: { id: string; serverName: string } }>('/api/v1/projects/:id/servers/:serverName', async (request, reply) => { + await service.removeServer(request.params.id, request.params.serverName); + reply.code(204); + }); + // List servers in a project (for mcplocal discovery) app.get<{ Params: { id: string } }>('/api/v1/projects/:id/servers', async (request) => { const project = await service.resolveAndGet(request.params.id); diff --git a/src/mcpd/src/services/backup/backup-service.ts b/src/mcpd/src/services/backup/backup-service.ts index 47f8123..c2e80f1 100644 --- a/src/mcpd/src/services/backup/backup-service.ts +++ b/src/mcpd/src/services/backup/backup-service.ts @@ -43,7 +43,6 @@ export interface BackupProject { llmProvider?: string | null; llmModel?: string | null; serverNames?: string[]; - members?: string[]; } export interface BackupUser { @@ -120,7 +119,6 @@ export class BackupService { llmProvider: proj.llmProvider, llmModel: proj.llmModel, serverNames: proj.servers.map((ps) => ps.server.name), - members: proj.members.map((pm) => pm.user.email), })); } diff --git a/src/mcpd/src/services/backup/restore-service.ts b/src/mcpd/src/services/backup/restore-service.ts index 6c817b4..002d514 100644 --- a/src/mcpd/src/services/backup/restore-service.ts +++ b/src/mcpd/src/services/backup/restore-service.ts @@ -260,15 +260,11 @@ export class RestoreService { if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel; await this.projectRepo.update(existing.id, updateData); - // Re-link servers and members + // Re-link servers if (project.serverNames && project.serverNames.length > 0) { const serverIds = await this.resolveServerNames(project.serverNames); await this.projectRepo.setServers(existing.id, serverIds); } - if (project.members && project.members.length > 0 && this.userRepo) { - const memberData = await this.resolveProjectMembers(project.members); - await this.projectRepo.setMembers(existing.id, memberData); - } result.projectsCreated++; continue; @@ -289,11 +285,6 @@ export class RestoreService { const serverIds = await this.resolveServerNames(project.serverNames); await this.projectRepo.setServers(created.id, serverIds); } - // Link members - if (project.members && project.members.length > 0 && this.userRepo) { - const memberData = await this.resolveProjectMembers(project.members); - await this.projectRepo.setMembers(created.id, memberData); - } result.projectsCreated++; } catch (err) { @@ -359,15 +350,4 @@ export class RestoreService { return ids; } - /** Resolve project member emails to user IDs. */ - private async resolveProjectMembers( - members: string[], - ): Promise { - const resolved: string[] = []; - for (const email of members) { - const user = await this.userRepo!.findByEmail(email); - if (user) resolved.push(user.id); - } - return resolved; - } } diff --git a/src/mcpd/src/services/project.service.ts b/src/mcpd/src/services/project.service.ts index 5274176..8f0ec12 100644 --- a/src/mcpd/src/services/project.service.ts +++ b/src/mcpd/src/services/project.service.ts @@ -1,7 +1,6 @@ import type { McpServer } from '@prisma/client'; import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js'; import type { IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.js'; -import type { IUserRepository } from '../repositories/user.repository.js'; import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js'; import { NotFoundError, ConflictError } from './mcp-server.service.js'; import { resolveServerEnv } from './env-resolver.js'; @@ -13,7 +12,6 @@ export class ProjectService { private readonly projectRepo: IProjectRepository, private readonly serverRepo: IMcpServerRepository, private readonly secretRepo: ISecretRepository, - private readonly userRepo: IUserRepository, ) {} async list(ownerId?: string): Promise { @@ -52,9 +50,6 @@ export class ProjectService { // Resolve server names to IDs const serverIds = await this.resolveServerNames(data.servers); - // Resolve member emails to user IDs - const resolvedMembers = await this.resolveMemberEmails(data.members); - const project = await this.projectRepo.create({ name: data.name, description: data.description, @@ -64,13 +59,10 @@ export class ProjectService { ...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}), }); - // Link servers and members + // Link servers if (serverIds.length > 0) { await this.projectRepo.setServers(project.id, serverIds); } - if (resolvedMembers.length > 0) { - await this.projectRepo.setMembers(project.id, resolvedMembers); - } // Re-fetch to include relations return this.getById(project.id); @@ -98,12 +90,6 @@ export class ProjectService { await this.projectRepo.setServers(project.id, serverIds); } - // Update members if provided - if (data.members !== undefined) { - const resolvedMembers = await this.resolveMemberEmails(data.members); - await this.projectRepo.setMembers(project.id, resolvedMembers); - } - // Re-fetch to include updated relations return this.getById(project.id); } @@ -141,6 +127,22 @@ export class ProjectService { return generateMcpConfig(serverEntries); } + async addServer(idOrName: string, serverName: string): Promise { + const project = await this.resolveAndGet(idOrName); + const server = await this.serverRepo.findByName(serverName); + if (server === null) throw new NotFoundError(`Server not found: ${serverName}`); + await this.projectRepo.addServer(project.id, server.id); + return this.getById(project.id); + } + + async removeServer(idOrName: string, serverName: string): Promise { + const project = await this.resolveAndGet(idOrName); + const server = await this.serverRepo.findByName(serverName); + if (server === null) throw new NotFoundError(`Server not found: ${serverName}`); + await this.projectRepo.removeServer(project.id, server.id); + return this.getById(project.id); + } + private async resolveServerNames(names: string[]): Promise { return Promise.all(names.map(async (name) => { const server = await this.serverRepo.findByName(name); @@ -148,12 +150,4 @@ export class ProjectService { return server.id; })); } - - private async resolveMemberEmails(emails: string[]): Promise { - return Promise.all(emails.map(async (email) => { - const user = await this.userRepo.findByEmail(email); - if (user === null) throw new NotFoundError(`User not found: ${email}`); - return user.id; - })); - } } diff --git a/src/mcpd/src/services/rbac.service.ts b/src/mcpd/src/services/rbac.service.ts index ee5adc0..ce5663f 100644 --- a/src/mcpd/src/services/rbac.service.ts +++ b/src/mcpd/src/services/rbac.service.ts @@ -8,7 +8,7 @@ import { type RbacRoleBinding, } from '../validation/rbac-definition.schema.js'; -export type RbacAction = 'view' | 'create' | 'delete' | 'edit' | 'run'; +export type RbacAction = 'view' | 'create' | 'delete' | 'edit' | 'run' | 'expose'; export interface ResourcePermission { role: string; @@ -30,11 +30,12 @@ export interface AllowedScope { /** Maps roles to the set of actions they grant. */ const ROLE_ACTIONS: Record = { - edit: ['view', 'create', 'delete', 'edit'], + edit: ['view', 'create', 'delete', 'edit', 'expose'], view: ['view'], create: ['create'], delete: ['delete'], run: ['run'], + expose: ['expose', 'view'], }; export class RbacService { diff --git a/src/mcpd/src/validation/project.schema.ts b/src/mcpd/src/validation/project.schema.ts index 0d62ecb..dc2b4de 100644 --- a/src/mcpd/src/validation/project.schema.ts +++ b/src/mcpd/src/validation/project.schema.ts @@ -7,7 +7,6 @@ export const CreateProjectSchema = z.object({ llmProvider: z.string().max(100).optional(), llmModel: z.string().max(100).optional(), servers: z.array(z.string().min(1)).default([]), - members: z.array(z.string().email()).default([]), }).refine( (d) => d.proxyMode !== 'filtered' || d.llmProvider, { message: 'llmProvider is required when proxyMode is "filtered"' }, @@ -19,7 +18,6 @@ export const UpdateProjectSchema = z.object({ llmProvider: z.string().max(100).nullable().optional(), llmModel: z.string().max(100).nullable().optional(), servers: z.array(z.string().min(1)).optional(), - members: z.array(z.string().email()).optional(), }); export type CreateProjectInput = z.infer; diff --git a/src/mcpd/src/validation/rbac-definition.schema.ts b/src/mcpd/src/validation/rbac-definition.schema.ts index 9806ebc..554fdba 100644 --- a/src/mcpd/src/validation/rbac-definition.schema.ts +++ b/src/mcpd/src/validation/rbac-definition.schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run'] as const; +export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const; export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac'] as const; /** Singular→plural map for resource names. */ diff --git a/src/mcpd/tests/backup.test.ts b/src/mcpd/tests/backup.test.ts index b2cdc79..d5b8b3d 100644 --- a/src/mcpd/tests/backup.test.ts +++ b/src/mcpd/tests/backup.test.ts @@ -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).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).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).mockImplementation(async () => { callOrder.push('server'); return { id: 'srv' }; }); (userRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('user'); return { id: 'usr' }; }); (groupRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('group'); return { id: 'grp' }; }); - (projectRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('project'); return { id: 'proj', servers: [], members: [] }; }); + (projectRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('project'); return { id: 'proj', servers: [] }; }); (rbacRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('rbac'); return { id: 'rbac' }; }); await restoreService.restore(fullBundle); diff --git a/src/mcpd/tests/project-service.test.ts b/src/mcpd/tests/project-service.test.ts index 6d88c16..a6322f0 100644 --- a/src/mcpd/tests/project-service.test.ts +++ b/src/mcpd/tests/project-service.test.ts @@ -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 { @@ -19,7 +18,6 @@ function makeProject(overrides: Partial = {}): 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 })), 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; let serverRepo: ReturnType; let secretRepo: ReturnType; - let userRepo: ReturnType; 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' }); diff --git a/src/mcpd/tests/rbac.test.ts b/src/mcpd/tests/rbac.test.ts index f375b2b..62acd10 100644 --- a/src/mcpd/tests/rbac.test.ts +++ b/src/mcpd/tests/rbac.test.ts @@ -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); + }); + }); }); -- 2.49.1