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:
@@ -87,7 +87,7 @@ const RESOURCE_ALIASES: Record<string, string> = {
|
|||||||
|
|
||||||
const RbacRoleBindingSchema = z.union([
|
const RbacRoleBindingSchema = z.union([
|
||||||
z.object({
|
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),
|
resource: z.string().min(1).transform((r) => RESOURCE_ALIASES[r] ?? r),
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
}),
|
}),
|
||||||
@@ -110,7 +110,6 @@ const ProjectSpecSchema = z.object({
|
|||||||
llmProvider: z.string().optional(),
|
llmProvider: z.string().optional(),
|
||||||
llmModel: z.string().optional(),
|
llmModel: z.string().optional(),
|
||||||
servers: z.array(z.string()).default([]),
|
servers: z.array(z.string()).default([]),
|
||||||
members: z.array(z.string().email()).default([]),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ApplyConfigSchema = z.object({
|
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) {
|
for (const project of config.projects) {
|
||||||
try {
|
try {
|
||||||
const existing = await findByName(client, 'projects', project.name);
|
const existing = await findByName(client, 'projects', project.name);
|
||||||
|
|||||||
@@ -196,10 +196,9 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
.argument('<name>', 'Project name')
|
.argument('<name>', 'Project name')
|
||||||
.option('-d, --description <text>', 'Project description', '')
|
.option('-d, --description <text>', 'Project description', '')
|
||||||
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
|
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
|
||||||
.option('--llm-provider <name>', 'LLM provider name')
|
.option('--proxy-mode-llm-provider <name>', 'LLM provider name (for filtered proxy mode)')
|
||||||
.option('--llm-model <name>', 'LLM model name')
|
.option('--proxy-mode-llm-model <name>', 'LLM model name (for filtered proxy mode)')
|
||||||
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
||||||
.option('--member <email>', 'Member email (repeat for multiple)', collect, [])
|
|
||||||
.option('--force', 'Update if already exists')
|
.option('--force', 'Update if already exists')
|
||||||
.action(async (name: string, opts) => {
|
.action(async (name: string, opts) => {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
@@ -207,10 +206,9 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
description: opts.description,
|
description: opts.description,
|
||||||
proxyMode: opts.proxyMode ?? 'direct',
|
proxyMode: opts.proxyMode ?? 'direct',
|
||||||
};
|
};
|
||||||
if (opts.llmProvider) body.llmProvider = opts.llmProvider;
|
if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider;
|
||||||
if (opts.llmModel) body.llmModel = opts.llmModel;
|
if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel;
|
||||||
if (opts.server.length > 0) body.servers = opts.server;
|
if (opts.server.length > 0) body.servers = opts.server;
|
||||||
if (opts.member.length > 0) body.members = opts.member;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body);
|
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body);
|
||||||
|
|||||||
@@ -162,17 +162,6 @@ function formatProjectDetail(project: Record<string, unknown>): 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('');
|
||||||
lines.push('Metadata:');
|
lines.push('Metadata:');
|
||||||
lines.push(` ${pad('ID:', 12)}${project.id}`);
|
lines.push(` ${pad('ID:', 12)}${project.id}`);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ interface ProjectRow {
|
|||||||
proxyMode: string;
|
proxyMode: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
servers?: Array<{ server: { name: string } }>;
|
servers?: Array<{ server: { name: string } }>;
|
||||||
members?: Array<{ user: { email: string }; role: string }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SecretRow {
|
interface SecretRow {
|
||||||
@@ -85,7 +84,6 @@ const projectColumns: Column<ProjectRow>[] = [
|
|||||||
{ header: 'NAME', key: 'name' },
|
{ header: 'NAME', key: 'name' },
|
||||||
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
|
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
|
||||||
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
|
{ 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: 'DESCRIPTION', key: 'description', width: 30 },
|
||||||
{ header: 'ID', key: 'id' },
|
{ header: 'ID', key: 'id' },
|
||||||
];
|
];
|
||||||
|
|||||||
47
src/cli/src/commands/project-ops.ts
Normal file
47
src/cli/src/commands/project-ops.ts
Normal file
@@ -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 <name> 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>', '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>', '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}'`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { createCreateCommand } from './commands/create.js';
|
|||||||
import { createEditCommand } from './commands/edit.js';
|
import { createEditCommand } from './commands/edit.js';
|
||||||
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
||||||
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
||||||
|
import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js';
|
||||||
import { ApiClient, ApiError } from './api-client.js';
|
import { ApiClient, ApiError } from './api-client.js';
|
||||||
import { loadConfig } from './config/index.js';
|
import { loadConfig } from './config/index.js';
|
||||||
import { loadCredentials } from './auth/index.js';
|
import { loadCredentials } from './auth/index.js';
|
||||||
@@ -24,7 +25,8 @@ export function createProgram(): Command {
|
|||||||
.version(APP_VERSION, '-v, --version')
|
.version(APP_VERSION, '-v, --version')
|
||||||
.enablePositionalOptions()
|
.enablePositionalOptions()
|
||||||
.option('--daemon-url <url>', 'mcplocal daemon URL')
|
.option('--daemon-url <url>', 'mcplocal daemon URL')
|
||||||
.option('--direct', 'bypass mcplocal and connect directly to mcpd');
|
.option('--direct', 'bypass mcplocal and connect directly to mcpd')
|
||||||
|
.option('--project <name>', 'Target project for project commands');
|
||||||
|
|
||||||
program.addCommand(createStatusCommand());
|
program.addCommand(createStatusCommand());
|
||||||
program.addCommand(createLoginCommand());
|
program.addCommand(createLoginCommand());
|
||||||
@@ -126,6 +128,14 @@ export function createProgram(): Command {
|
|||||||
log: (...args) => console.log(...args),
|
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;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ rbacBindings:
|
|||||||
rmSync(tmpDir, { recursive: true, force: true });
|
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');
|
const configPath = join(tmpDir, 'config.yaml');
|
||||||
writeFileSync(configPath, `
|
writeFileSync(configPath, `
|
||||||
projects:
|
projects:
|
||||||
@@ -338,9 +338,6 @@ projects:
|
|||||||
servers:
|
servers:
|
||||||
- my-grafana
|
- my-grafana
|
||||||
- my-ha
|
- my-ha
|
||||||
members:
|
|
||||||
- alice@test.com
|
|
||||||
- bob@test.com
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cmd = createApplyCommand({ client, log });
|
const cmd = createApplyCommand({ client, log });
|
||||||
@@ -352,7 +349,6 @@ projects:
|
|||||||
llmProvider: 'gemini-cli',
|
llmProvider: 'gemini-cli',
|
||||||
llmModel: 'gemini-2.0-flash',
|
llmModel: 'gemini-2.0-flash',
|
||||||
servers: ['my-grafana', 'my-ha'],
|
servers: ['my-grafana', 'my-ha'],
|
||||||
members: ['alice@test.com', 'bob@test.com'],
|
|
||||||
}));
|
}));
|
||||||
expect(output.join('\n')).toContain('Created project: smart-home');
|
expect(output.join('\n')).toContain('Created project: smart-home');
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ describe('get command', () => {
|
|||||||
proxyMode: 'filtered',
|
proxyMode: 'filtered',
|
||||||
ownerId: 'usr-1',
|
ownerId: 'usr-1',
|
||||||
servers: [{ server: { name: 'grafana' } }],
|
servers: [{ server: { name: 'grafana' } }],
|
||||||
members: [{ user: { email: 'a@b.com' }, role: 'admin' }, { user: { email: 'c@d.com' }, role: 'member' }],
|
|
||||||
}]);
|
}]);
|
||||||
const cmd = createGetCommand(deps);
|
const cmd = createGetCommand(deps);
|
||||||
await cmd.parseAsync(['node', 'test', 'projects']);
|
await cmd.parseAsync(['node', 'test', 'projects']);
|
||||||
@@ -189,11 +188,9 @@ describe('get command', () => {
|
|||||||
const text = deps.output.join('\n');
|
const text = deps.output.join('\n');
|
||||||
expect(text).toContain('MODE');
|
expect(text).toContain('MODE');
|
||||||
expect(text).toContain('SERVERS');
|
expect(text).toContain('SERVERS');
|
||||||
expect(text).toContain('MEMBERS');
|
|
||||||
expect(text).toContain('smart-home');
|
expect(text).toContain('smart-home');
|
||||||
expect(text).toContain('filtered');
|
expect(text).toContain('filtered');
|
||||||
expect(text).toContain('1');
|
expect(text).toContain('1');
|
||||||
expect(text).toContain('2');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays mixed resource and operation bindings', async () => {
|
it('displays mixed resource and operation bindings', async () => {
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ describe('project with new fields', () => {
|
|||||||
'project', 'smart-home',
|
'project', 'smart-home',
|
||||||
'-d', 'Smart home project',
|
'-d', 'Smart home project',
|
||||||
'--proxy-mode', 'filtered',
|
'--proxy-mode', 'filtered',
|
||||||
'--llm-provider', 'gemini-cli',
|
'--proxy-mode-llm-provider', 'gemini-cli',
|
||||||
'--llm-model', 'gemini-2.0-flash',
|
'--proxy-mode-llm-model', 'gemini-2.0-flash',
|
||||||
'--server', 'my-grafana',
|
'--server', 'my-grafana',
|
||||||
'--server', 'my-ha',
|
'--server', 'my-ha',
|
||||||
], { from: 'user' });
|
], { 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 () => {
|
it('defaults proxy mode to direct', async () => {
|
||||||
const cmd = createCreateCommand({ client, log });
|
const cmd = createCreateCommand({ client, log });
|
||||||
await cmd.parseAsync(['project', 'basic'], { from: 'user' });
|
await cmd.parseAsync(['project', 'basic'], { from: 'user' });
|
||||||
@@ -71,7 +57,7 @@ describe('project with new fields', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('get projects shows new columns', () => {
|
describe('get projects shows new columns', () => {
|
||||||
it('shows MODE, SERVERS, MEMBERS columns', async () => {
|
it('shows MODE and SERVERS columns', async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
output: [] as string[],
|
output: [] as string[],
|
||||||
fetchResource: vi.fn(async () => [{
|
fetchResource: vi.fn(async () => [{
|
||||||
@@ -81,7 +67,6 @@ describe('project with new fields', () => {
|
|||||||
proxyMode: 'filtered',
|
proxyMode: 'filtered',
|
||||||
ownerId: 'user-1',
|
ownerId: 'user-1',
|
||||||
servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }],
|
servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }],
|
||||||
members: [{ user: { email: 'alice@test.com' } }],
|
|
||||||
}]),
|
}]),
|
||||||
log: (...args: string[]) => deps.output.push(args.join(' ')),
|
log: (...args: string[]) => deps.output.push(args.join(' ')),
|
||||||
};
|
};
|
||||||
@@ -91,13 +76,12 @@ describe('project with new fields', () => {
|
|||||||
const text = deps.output.join('\n');
|
const text = deps.output.join('\n');
|
||||||
expect(text).toContain('MODE');
|
expect(text).toContain('MODE');
|
||||||
expect(text).toContain('SERVERS');
|
expect(text).toContain('SERVERS');
|
||||||
expect(text).toContain('MEMBERS');
|
|
||||||
expect(text).toContain('smart-home');
|
expect(text).toContain('smart-home');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('describe project shows full detail', () => {
|
describe('describe project shows full detail', () => {
|
||||||
it('shows servers and members', async () => {
|
it('shows servers and proxy config', async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
output: [] as string[],
|
output: [] as string[],
|
||||||
client: mockClient(),
|
client: mockClient(),
|
||||||
@@ -113,10 +97,6 @@ describe('project with new fields', () => {
|
|||||||
{ server: { name: 'my-grafana' } },
|
{ server: { name: 'my-grafana' } },
|
||||||
{ server: { name: 'my-ha' } },
|
{ server: { name: 'my-ha' } },
|
||||||
],
|
],
|
||||||
members: [
|
|
||||||
{ user: { email: 'alice@test.com' } },
|
|
||||||
{ user: { email: 'bob@test.com' } },
|
|
||||||
],
|
|
||||||
createdAt: '2025-01-01',
|
createdAt: '2025-01-01',
|
||||||
updatedAt: '2025-01-01',
|
updatedAt: '2025-01-01',
|
||||||
})),
|
})),
|
||||||
@@ -131,8 +111,6 @@ describe('project with new fields', () => {
|
|||||||
expect(text).toContain('gemini-cli');
|
expect(text).toContain('gemini-cli');
|
||||||
expect(text).toContain('my-grafana');
|
expect(text).toContain('my-grafana');
|
||||||
expect(text).toContain('my-ha');
|
expect(text).toContain('my-ha');
|
||||||
expect(text).toContain('alice@test.com');
|
|
||||||
expect(text).toContain('bob@test.com');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -24,7 +24,6 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
ownedProjects Project[]
|
ownedProjects Project[]
|
||||||
projectMemberships ProjectMember[]
|
|
||||||
groupMemberships GroupMember[]
|
groupMemberships GroupMember[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@ -181,7 +180,6 @@ model Project {
|
|||||||
|
|
||||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
servers ProjectServer[]
|
servers ProjectServer[]
|
||||||
members ProjectMember[]
|
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@ -199,18 +197,6 @@ model ProjectServer {
|
|||||||
@@unique([projectId, serverId])
|
@@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) ──
|
// ── MCP Instances (running containers) ──
|
||||||
|
|
||||||
model McpInstance {
|
model McpInstance {
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
const resource = resourceMap[segment];
|
const resource = resourceMap[segment];
|
||||||
if (resource === undefined) return { kind: 'skip' };
|
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
|
// Map HTTP method to action
|
||||||
let action: RbacAction;
|
let action: RbacAction;
|
||||||
switch (method) {
|
switch (method) {
|
||||||
@@ -223,7 +235,7 @@ async function main(): Promise<void> {
|
|||||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
||||||
serverService.setInstanceService(instanceService);
|
serverService.setInstanceService(instanceService);
|
||||||
const secretService = new SecretService(secretRepo);
|
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 auditLogService = new AuditLogService(auditLogRepo);
|
||||||
const metricsCollector = new MetricsCollector();
|
const metricsCollector = new MetricsCollector();
|
||||||
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
|
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ import type { PrismaClient, Project } from '@prisma/client';
|
|||||||
|
|
||||||
export interface ProjectWithRelations extends Project {
|
export interface ProjectWithRelations extends Project {
|
||||||
servers: Array<{ id: string; server: { id: string; name: string } }>;
|
servers: Array<{ id: string; server: { id: string; name: string } }>;
|
||||||
members: Array<{ id: string; user: { id: string; email: string; name: string | null } }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROJECT_INCLUDE = {
|
const PROJECT_INCLUDE = {
|
||||||
servers: { include: { server: { select: { id: true, name: true } } } },
|
servers: { include: { server: { select: { id: true, name: true } } } },
|
||||||
members: { include: { user: { select: { id: true, email: true, name: true } } } },
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export interface IProjectRepository {
|
export interface IProjectRepository {
|
||||||
@@ -18,7 +16,8 @@ export interface IProjectRepository {
|
|||||||
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
|
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
|
||||||
delete(id: string): Promise<void>;
|
delete(id: string): Promise<void>;
|
||||||
setServers(projectId: string, serverIds: string[]): Promise<void>;
|
setServers(projectId: string, serverIds: string[]): Promise<void>;
|
||||||
setMembers(projectId: string, userIds: string[]): Promise<void>;
|
addServer(projectId: string, serverId: string): Promise<void>;
|
||||||
|
removeServer(projectId: string, serverId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectRepository implements IProjectRepository {
|
export class ProjectRepository implements IProjectRepository {
|
||||||
@@ -76,14 +75,17 @@ export class ProjectRepository implements IProjectRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMembers(projectId: string, userIds: string[]): Promise<void> {
|
async addServer(projectId: string, serverId: string): Promise<void> {
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.projectServer.upsert({
|
||||||
await tx.projectMember.deleteMany({ where: { projectId } });
|
where: { projectId_serverId: { projectId, serverId } },
|
||||||
if (userIds.length > 0) {
|
create: { projectId, serverId },
|
||||||
await tx.projectMember.createMany({
|
update: {},
|
||||||
data: userIds.map((userId) => ({ projectId, userId })),
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
async removeServer(projectId: string, serverId: string): Promise<void> {
|
||||||
|
await this.prisma.projectServer.deleteMany({
|
||||||
|
where: { projectId, serverId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,21 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
|
|||||||
return service.generateMcpConfig(request.params.id);
|
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)
|
// List servers in a project (for mcplocal discovery)
|
||||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/servers', async (request) => {
|
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/servers', async (request) => {
|
||||||
const project = await service.resolveAndGet(request.params.id);
|
const project = await service.resolveAndGet(request.params.id);
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export interface BackupProject {
|
|||||||
llmProvider?: string | null;
|
llmProvider?: string | null;
|
||||||
llmModel?: string | null;
|
llmModel?: string | null;
|
||||||
serverNames?: string[];
|
serverNames?: string[];
|
||||||
members?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupUser {
|
export interface BackupUser {
|
||||||
@@ -120,7 +119,6 @@ export class BackupService {
|
|||||||
llmProvider: proj.llmProvider,
|
llmProvider: proj.llmProvider,
|
||||||
llmModel: proj.llmModel,
|
llmModel: proj.llmModel,
|
||||||
serverNames: proj.servers.map((ps) => ps.server.name),
|
serverNames: proj.servers.map((ps) => ps.server.name),
|
||||||
members: proj.members.map((pm) => pm.user.email),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -260,15 +260,11 @@ export class RestoreService {
|
|||||||
if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel;
|
if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel;
|
||||||
await this.projectRepo.update(existing.id, updateData);
|
await this.projectRepo.update(existing.id, updateData);
|
||||||
|
|
||||||
// Re-link servers and members
|
// Re-link servers
|
||||||
if (project.serverNames && project.serverNames.length > 0) {
|
if (project.serverNames && project.serverNames.length > 0) {
|
||||||
const serverIds = await this.resolveServerNames(project.serverNames);
|
const serverIds = await this.resolveServerNames(project.serverNames);
|
||||||
await this.projectRepo.setServers(existing.id, serverIds);
|
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++;
|
result.projectsCreated++;
|
||||||
continue;
|
continue;
|
||||||
@@ -289,11 +285,6 @@ export class RestoreService {
|
|||||||
const serverIds = await this.resolveServerNames(project.serverNames);
|
const serverIds = await this.resolveServerNames(project.serverNames);
|
||||||
await this.projectRepo.setServers(created.id, serverIds);
|
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++;
|
result.projectsCreated++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -359,15 +350,4 @@ export class RestoreService {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve project member emails to user IDs. */
|
|
||||||
private async resolveProjectMembers(
|
|
||||||
members: string[],
|
|
||||||
): Promise<string[]> {
|
|
||||||
const resolved: string[] = [];
|
|
||||||
for (const email of members) {
|
|
||||||
const user = await this.userRepo!.findByEmail(email);
|
|
||||||
if (user) resolved.push(user.id);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { McpServer } from '@prisma/client';
|
import type { McpServer } from '@prisma/client';
|
||||||
import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
|
import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
|
||||||
import type { IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.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 { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
|
||||||
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||||
import { resolveServerEnv } from './env-resolver.js';
|
import { resolveServerEnv } from './env-resolver.js';
|
||||||
@@ -13,7 +12,6 @@ export class ProjectService {
|
|||||||
private readonly projectRepo: IProjectRepository,
|
private readonly projectRepo: IProjectRepository,
|
||||||
private readonly serverRepo: IMcpServerRepository,
|
private readonly serverRepo: IMcpServerRepository,
|
||||||
private readonly secretRepo: ISecretRepository,
|
private readonly secretRepo: ISecretRepository,
|
||||||
private readonly userRepo: IUserRepository,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(ownerId?: string): Promise<ProjectWithRelations[]> {
|
async list(ownerId?: string): Promise<ProjectWithRelations[]> {
|
||||||
@@ -52,9 +50,6 @@ export class ProjectService {
|
|||||||
// Resolve server names to IDs
|
// Resolve server names to IDs
|
||||||
const serverIds = await this.resolveServerNames(data.servers);
|
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({
|
const project = await this.projectRepo.create({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
@@ -64,13 +59,10 @@ export class ProjectService {
|
|||||||
...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}),
|
...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Link servers and members
|
// Link servers
|
||||||
if (serverIds.length > 0) {
|
if (serverIds.length > 0) {
|
||||||
await this.projectRepo.setServers(project.id, serverIds);
|
await this.projectRepo.setServers(project.id, serverIds);
|
||||||
}
|
}
|
||||||
if (resolvedMembers.length > 0) {
|
|
||||||
await this.projectRepo.setMembers(project.id, resolvedMembers);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-fetch to include relations
|
// Re-fetch to include relations
|
||||||
return this.getById(project.id);
|
return this.getById(project.id);
|
||||||
@@ -98,12 +90,6 @@ export class ProjectService {
|
|||||||
await this.projectRepo.setServers(project.id, serverIds);
|
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
|
// Re-fetch to include updated relations
|
||||||
return this.getById(project.id);
|
return this.getById(project.id);
|
||||||
}
|
}
|
||||||
@@ -141,6 +127,22 @@ export class ProjectService {
|
|||||||
return generateMcpConfig(serverEntries);
|
return generateMcpConfig(serverEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addServer(idOrName: string, serverName: string): Promise<ProjectWithRelations> {
|
||||||
|
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<ProjectWithRelations> {
|
||||||
|
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<string[]> {
|
private async resolveServerNames(names: string[]): Promise<string[]> {
|
||||||
return Promise.all(names.map(async (name) => {
|
return Promise.all(names.map(async (name) => {
|
||||||
const server = await this.serverRepo.findByName(name);
|
const server = await this.serverRepo.findByName(name);
|
||||||
@@ -148,12 +150,4 @@ export class ProjectService {
|
|||||||
return server.id;
|
return server.id;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveMemberEmails(emails: string[]): Promise<string[]> {
|
|
||||||
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;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type RbacRoleBinding,
|
type RbacRoleBinding,
|
||||||
} from '../validation/rbac-definition.schema.js';
|
} 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 {
|
export interface ResourcePermission {
|
||||||
role: string;
|
role: string;
|
||||||
@@ -30,11 +30,12 @@ export interface AllowedScope {
|
|||||||
|
|
||||||
/** Maps roles to the set of actions they grant. */
|
/** Maps roles to the set of actions they grant. */
|
||||||
const ROLE_ACTIONS: Record<string, readonly RbacAction[]> = {
|
const ROLE_ACTIONS: Record<string, readonly RbacAction[]> = {
|
||||||
edit: ['view', 'create', 'delete', 'edit'],
|
edit: ['view', 'create', 'delete', 'edit', 'expose'],
|
||||||
view: ['view'],
|
view: ['view'],
|
||||||
create: ['create'],
|
create: ['create'],
|
||||||
delete: ['delete'],
|
delete: ['delete'],
|
||||||
run: ['run'],
|
run: ['run'],
|
||||||
|
expose: ['expose', 'view'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RbacService {
|
export class RbacService {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export const CreateProjectSchema = z.object({
|
|||||||
llmProvider: z.string().max(100).optional(),
|
llmProvider: z.string().max(100).optional(),
|
||||||
llmModel: z.string().max(100).optional(),
|
llmModel: z.string().max(100).optional(),
|
||||||
servers: z.array(z.string().min(1)).default([]),
|
servers: z.array(z.string().min(1)).default([]),
|
||||||
members: z.array(z.string().email()).default([]),
|
|
||||||
}).refine(
|
}).refine(
|
||||||
(d) => d.proxyMode !== 'filtered' || d.llmProvider,
|
(d) => d.proxyMode !== 'filtered' || d.llmProvider,
|
||||||
{ message: 'llmProvider is required when proxyMode is "filtered"' },
|
{ message: 'llmProvider is required when proxyMode is "filtered"' },
|
||||||
@@ -19,7 +18,6 @@ export const UpdateProjectSchema = z.object({
|
|||||||
llmProvider: z.string().max(100).nullable().optional(),
|
llmProvider: z.string().max(100).nullable().optional(),
|
||||||
llmModel: z.string().max(100).nullable().optional(),
|
llmModel: z.string().max(100).nullable().optional(),
|
||||||
servers: z.array(z.string().min(1)).optional(),
|
servers: z.array(z.string().min(1)).optional(),
|
||||||
members: z.array(z.string().email()).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
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;
|
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac'] as const;
|
||||||
|
|
||||||
/** Singular→plural map for resource names. */
|
/** Singular→plural map for resource names. */
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const mockProjects = [
|
|||||||
id: 'proj1', name: 'my-project', description: 'Test project', proxyMode: 'direct', llmProvider: null, llmModel: null,
|
id: 'proj1', name: 'my-project', description: 'Test project', proxyMode: 'direct', llmProvider: null, llmModel: null,
|
||||||
ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
servers: [{ id: 'ps1', server: { id: 's1', name: 'github' } }],
|
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]),
|
findAll: vi.fn(async () => [...mockProjects]),
|
||||||
findById: vi.fn(async (id: string) => mockProjects.find((p) => p.id === id) ?? null),
|
findById: vi.fn(async (id: string) => mockProjects.find((p) => p.id === id) ?? null),
|
||||||
findByName: vi.fn(async () => 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 })),
|
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
|
||||||
delete: vi.fn(async () => {}),
|
delete: vi.fn(async () => {}),
|
||||||
setServers: 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' }]);
|
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 bundle = await backupService.createBackup();
|
||||||
const proj = bundle.projects[0]!;
|
const proj = bundle.projects[0]!;
|
||||||
expect(proj.proxyMode).toBe('direct');
|
expect(proj.proxyMode).toBe('direct');
|
||||||
expect(proj.serverNames).toEqual(['github']);
|
expect(proj.serverNames).toEqual(['github']);
|
||||||
expect(proj.members).toEqual(['alice@test.com']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters resources', async () => {
|
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)
|
// Simulate servers exist (restored in prior step)
|
||||||
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||||
// After server restore, we can find them
|
// After server restore, we can find them
|
||||||
@@ -419,14 +418,6 @@ describe('RestoreService', () => {
|
|||||||
return null;
|
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);
|
const result = await restoreService.restore(fullBundle);
|
||||||
|
|
||||||
expect(result.projectsCreated).toBe(1);
|
expect(result.projectsCreated).toBe(1);
|
||||||
@@ -437,7 +428,6 @@ describe('RestoreService', () => {
|
|||||||
llmModel: 'gpt-4',
|
llmModel: 'gpt-4',
|
||||||
}));
|
}));
|
||||||
expect(projectRepo.setServers).toHaveBeenCalled();
|
expect(projectRepo.setServers).toHaveBeenCalled();
|
||||||
expect(projectRepo.setMembers).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('restores old bundle without users/groups/rbac', async () => {
|
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' }; });
|
(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' }; });
|
(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' }; });
|
(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' }; });
|
(rbacRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('rbac'); return { id: 'rbac' }; });
|
||||||
|
|
||||||
await restoreService.restore(fullBundle);
|
await restoreService.restore(fullBundle);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { ProjectService } from '../src/services/project.service.js';
|
|||||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||||
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
||||||
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.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';
|
import type { McpServer } from '@prisma/client';
|
||||||
|
|
||||||
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
||||||
@@ -19,7 +18,6 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
servers: [],
|
servers: [],
|
||||||
members: [],
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -64,7 +62,8 @@ function mockProjectRepo(): IProjectRepository {
|
|||||||
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
||||||
delete: vi.fn(async () => {}),
|
delete: vi.fn(async () => {}),
|
||||||
setServers: 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', () => {
|
describe('ProjectService', () => {
|
||||||
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
||||||
let serverRepo: ReturnType<typeof mockServerRepo>;
|
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||||||
let secretRepo: ReturnType<typeof mockSecretRepo>;
|
let secretRepo: ReturnType<typeof mockSecretRepo>;
|
||||||
let userRepo: ReturnType<typeof mockUserRepo>;
|
|
||||||
let service: ProjectService;
|
let service: ProjectService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
projectRepo = mockProjectRepo();
|
projectRepo = mockProjectRepo();
|
||||||
serverRepo = mockServerRepo();
|
serverRepo = mockServerRepo();
|
||||||
secretRepo = mockSecretRepo();
|
secretRepo = mockSecretRepo();
|
||||||
userRepo = mockUserRepo();
|
service = new ProjectService(projectRepo, serverRepo, secretRepo);
|
||||||
service = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
@@ -164,32 +147,6 @@ describe('ProjectService', () => {
|
|||||||
expect(result.servers).toHaveLength(2);
|
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 () => {
|
it('creates project with proxyMode and llmProvider', async () => {
|
||||||
const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' });
|
const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' });
|
||||||
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
||||||
@@ -219,16 +176,6 @@ describe('ProjectService', () => {
|
|||||||
).rejects.toThrow(NotFoundError);
|
).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', () => {
|
describe('getById', () => {
|
||||||
@@ -277,19 +224,6 @@ describe('ProjectService', () => {
|
|||||||
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
|
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 () => {
|
it('updates proxyMode', async () => {
|
||||||
const existing = makeProject({ id: 'proj-1' });
|
const existing = makeProject({ id: 'proj-1' });
|
||||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
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', () => {
|
describe('generateMcpConfig', () => {
|
||||||
it('generates direct mode config with STDIO servers', async () => {
|
it('generates direct mode config with STDIO servers', async () => {
|
||||||
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });
|
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });
|
||||||
|
|||||||
@@ -921,4 +921,92 @@ describe('RbacService', () => {
|
|||||||
expect(await svc.canAccess('user-1', 'edit', 'servers')).toBe(false);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user