From dcda93d17918bbe30c59c827cf22d2022d94e973 Mon Sep 17 00:00:00 2001 From: Michal Date: Mon, 23 Feb 2026 11:05:19 +0000 Subject: [PATCH] feat: granular RBAC with resource/operation bindings, users, groups - Replace admin role with granular roles: view, create, delete, edit, run - Two binding types: resource bindings (role+resource+optional name) and operation bindings (role:run + action like backup, logs, impersonate) - Name-scoped resource bindings for per-instance access control - Remove role from project members (all permissions via RBAC) - Add users, groups, RBAC CRUD endpoints and CLI commands - describe user/group shows all RBAC access (direct + inherited) - create rbac supports --subject, --binding, --operation flags - Backup/restore handles users, groups, RBAC definitions - mcplocal project-based MCP endpoint discovery - Full test coverage for all new functionality Co-Authored-By: Claude Opus 4.6 --- src/cli/src/commands/apply.ts | 171 ++++- src/cli/src/commands/auth.ts | 113 ++- src/cli/src/commands/claude.ts | 155 ---- src/cli/src/commands/config.ts | 128 +++- src/cli/src/commands/create.ts | 146 +++- src/cli/src/commands/describe.ts | 282 +++++++- src/cli/src/commands/edit.ts | 2 +- src/cli/src/commands/get.ts | 62 +- src/cli/src/commands/project.ts | 15 - src/cli/src/commands/shared.ts | 25 +- src/cli/src/index.ts | 47 +- src/cli/tests/commands/apply.test.ts | 347 +++++++++ src/cli/tests/commands/auth.test.ts | 72 ++ src/cli/tests/commands/claude.test.ts | 239 +++--- src/cli/tests/commands/create.test.ts | 252 ++++++- src/cli/tests/commands/describe.test.ts | 406 +++++++++++ src/cli/tests/commands/get.test.ts | 169 +++++ src/cli/tests/commands/project.test.ts | 125 +++- src/cli/tests/e2e/cli-commands.test.ts | 39 +- src/db/prisma/schema.prisma | 85 ++- src/db/tests/helpers.ts | 5 + src/db/tests/models.test.ts | 256 +++++++ src/mcpd/src/main.ts | 133 +++- src/mcpd/src/middleware/rbac.ts | 36 + src/mcpd/src/repositories/group.repository.ts | 93 +++ src/mcpd/src/repositories/index.ts | 8 +- .../src/repositories/project.repository.ts | 86 ++- .../rbac-definition.repository.ts | 48 ++ src/mcpd/src/repositories/user.repository.ts | 76 ++ src/mcpd/src/routes/auth.ts | 72 ++ src/mcpd/src/routes/backup.ts | 4 +- src/mcpd/src/routes/groups.ts | 35 + src/mcpd/src/routes/index.ts | 3 + src/mcpd/src/routes/projects.ts | 19 +- src/mcpd/src/routes/rbac-definitions.ts | 30 + src/mcpd/src/routes/users.ts | 31 + src/mcpd/src/services/auth.service.ts | 28 + .../src/services/backup/backup-service.ts | 76 +- .../src/services/backup/restore-service.ts | 241 +++++- src/mcpd/src/services/group.service.ts | 89 +++ src/mcpd/src/services/index.ts | 5 + src/mcpd/src/services/mcp-config-generator.ts | 30 +- src/mcpd/src/services/project.service.ts | 133 +++- .../src/services/rbac-definition.service.ts | 54 ++ src/mcpd/src/services/rbac.service.ts | 130 ++++ src/mcpd/src/services/user.service.ts | 60 ++ src/mcpd/src/validation/group.schema.ts | 15 + src/mcpd/src/validation/index.ts | 4 +- src/mcpd/src/validation/project.schema.ts | 15 +- .../src/validation/rbac-definition.schema.ts | 71 ++ src/mcpd/src/validation/user.schema.ts | 15 + src/mcpd/tests/auth-bootstrap.test.ts | 424 +++++++++++ src/mcpd/tests/backup.test.ts | 330 ++++++++- src/mcpd/tests/group-service.test.ts | 250 +++++++ src/mcpd/tests/mcp-config-generator.test.ts | 42 +- src/mcpd/tests/project-service.test.ts | 365 +++++++++- .../tests/rbac-definition-service.test.ts | 229 ++++++ src/mcpd/tests/rbac.test.ts | 683 ++++++++++++++++++ src/mcpd/tests/user-service.test.ts | 208 ++++++ src/mcplocal/src/discovery.ts | 34 + src/mcplocal/src/http/index.ts | 1 + src/mcplocal/src/http/mcpd-client.ts | 6 +- src/mcplocal/src/http/project-mcp-endpoint.ts | 131 ++++ src/mcplocal/src/http/routes/proxy.ts | 7 +- src/mcplocal/src/http/server.ts | 4 + src/mcplocal/tests/project-discovery.test.ts | 87 +++ .../tests/project-mcp-endpoint.test.ts | 172 +++++ 67 files changed, 7256 insertions(+), 498 deletions(-) delete mode 100644 src/cli/src/commands/claude.ts delete mode 100644 src/cli/src/commands/project.ts create mode 100644 src/mcpd/src/middleware/rbac.ts create mode 100644 src/mcpd/src/repositories/group.repository.ts create mode 100644 src/mcpd/src/repositories/rbac-definition.repository.ts create mode 100644 src/mcpd/src/repositories/user.repository.ts create mode 100644 src/mcpd/src/routes/groups.ts create mode 100644 src/mcpd/src/routes/rbac-definitions.ts create mode 100644 src/mcpd/src/routes/users.ts create mode 100644 src/mcpd/src/services/group.service.ts create mode 100644 src/mcpd/src/services/rbac-definition.service.ts create mode 100644 src/mcpd/src/services/rbac.service.ts create mode 100644 src/mcpd/src/services/user.service.ts create mode 100644 src/mcpd/src/validation/group.schema.ts create mode 100644 src/mcpd/src/validation/rbac-definition.schema.ts create mode 100644 src/mcpd/src/validation/user.schema.ts create mode 100644 src/mcpd/tests/auth-bootstrap.test.ts create mode 100644 src/mcpd/tests/group-service.test.ts create mode 100644 src/mcpd/tests/rbac-definition-service.test.ts create mode 100644 src/mcpd/tests/rbac.test.ts create mode 100644 src/mcpd/tests/user-service.test.ts create mode 100644 src/mcplocal/src/http/project-mcp-endpoint.ts create mode 100644 src/mcplocal/tests/project-discovery.test.ts create mode 100644 src/mcplocal/tests/project-mcp-endpoint.test.ts diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index cb1bd40..56c1c6e 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -63,17 +63,70 @@ const TemplateSpecSchema = z.object({ healthCheck: HealthCheckSchema.optional(), }); +const UserSpecSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + name: z.string().optional(), +}); + +const GroupSpecSchema = z.object({ + name: z.string().min(1), + description: z.string().default(''), + members: z.array(z.string().email()).default([]), +}); + +const RbacSubjectSchema = z.object({ + kind: z.enum(['User', 'Group']), + name: z.string().min(1), +}); + +const RESOURCE_ALIASES: Record = { + server: 'servers', instance: 'instances', secret: 'secrets', + project: 'projects', template: 'templates', user: 'users', group: 'groups', +}; + +const RbacRoleBindingSchema = z.union([ + z.object({ + role: z.enum(['edit', 'view', 'create', 'delete', 'run']), + resource: z.string().min(1).transform((r) => RESOURCE_ALIASES[r] ?? r), + name: z.string().min(1).optional(), + }), + z.object({ + role: z.literal('run'), + action: z.string().min(1), + }), +]); + +const RbacBindingSpecSchema = z.object({ + name: z.string().min(1), + subjects: z.array(RbacSubjectSchema).default([]), + roleBindings: z.array(RbacRoleBindingSchema).default([]), +}); + const ProjectSpecSchema = z.object({ name: z.string().min(1), description: z.string().default(''), + proxyMode: z.enum(['direct', 'filtered']).default('direct'), + 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({ - servers: z.array(ServerSpecSchema).default([]), secrets: z.array(SecretSpecSchema).default([]), + servers: z.array(ServerSpecSchema).default([]), + users: z.array(UserSpecSchema).default([]), + groups: z.array(GroupSpecSchema).default([]), projects: z.array(ProjectSpecSchema).default([]), templates: z.array(TemplateSpecSchema).default([]), -}); + rbacBindings: z.array(RbacBindingSpecSchema).default([]), + rbac: z.array(RbacBindingSpecSchema).default([]), +}).transform((data) => ({ + ...data, + // Merge rbac into rbacBindings so both keys work + rbacBindings: [...data.rbacBindings, ...data.rbac], +})); export type ApplyConfig = z.infer; @@ -87,17 +140,25 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command { return new Command('apply') .description('Apply declarative configuration from a YAML or JSON file') - .argument('', 'Path to config file (.yaml, .yml, or .json)') + .argument('[file]', 'Path to config file (.yaml, .yml, or .json)') + .option('-f, --file ', 'Path to config file (alternative to positional arg)') .option('--dry-run', 'Validate and show changes without applying') - .action(async (file: string, opts: { dryRun?: boolean }) => { + .action(async (fileArg: string | undefined, opts: { file?: string; dryRun?: boolean }) => { + const file = fileArg ?? opts.file; + if (!file) { + throw new Error('File path required. Usage: mcpctl apply or mcpctl apply -f '); + } const config = loadConfigFile(file); if (opts.dryRun) { log('Dry run - would apply:'); - if (config.servers.length > 0) log(` ${config.servers.length} server(s)`); if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`); + if (config.servers.length > 0) log(` ${config.servers.length} server(s)`); + if (config.users.length > 0) log(` ${config.users.length} user(s)`); + if (config.groups.length > 0) log(` ${config.groups.length} group(s)`); if (config.projects.length > 0) log(` ${config.projects.length} project(s)`); if (config.templates.length > 0) log(` ${config.templates.length} template(s)`); + if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`); return; } @@ -119,21 +180,7 @@ function loadConfigFile(path: string): ApplyConfig { } async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise { - // Apply servers first - for (const server of config.servers) { - try { - const existing = await findByName(client, 'servers', server.name); - if (existing) { - await client.put(`/api/v1/servers/${(existing as { id: string }).id}`, server); - log(`Updated server: ${server.name}`); - } else { - await client.post('/api/v1/servers', server); - log(`Created server: ${server.name}`); - } - } catch (err) { - log(`Error applying server '${server.name}': ${err instanceof Error ? err.message : err}`); - } - } + // Apply order: secrets, servers, users, groups, projects, templates, rbacBindings // Apply secrets for (const secret of config.secrets) { @@ -151,20 +198,63 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args } } - // Apply projects + // Apply servers + for (const server of config.servers) { + try { + const existing = await findByName(client, 'servers', server.name); + if (existing) { + await client.put(`/api/v1/servers/${(existing as { id: string }).id}`, server); + log(`Updated server: ${server.name}`); + } else { + await client.post('/api/v1/servers', server); + log(`Created server: ${server.name}`); + } + } catch (err) { + log(`Error applying server '${server.name}': ${err instanceof Error ? err.message : err}`); + } + } + + // Apply users (matched by email) + for (const user of config.users) { + try { + const existing = await findByField(client, 'users', 'email', user.email); + if (existing) { + await client.put(`/api/v1/users/${(existing as { id: string }).id}`, user); + log(`Updated user: ${user.email}`); + } else { + await client.post('/api/v1/users', user); + log(`Created user: ${user.email}`); + } + } catch (err) { + log(`Error applying user '${user.email}': ${err instanceof Error ? err.message : err}`); + } + } + + // Apply groups + for (const group of config.groups) { + try { + const existing = await findByName(client, 'groups', group.name); + if (existing) { + await client.put(`/api/v1/groups/${(existing as { id: string }).id}`, group); + log(`Updated group: ${group.name}`); + } else { + await client.post('/api/v1/groups', group); + log(`Created group: ${group.name}`); + } + } catch (err) { + log(`Error applying group '${group.name}': ${err instanceof Error ? err.message : err}`); + } + } + + // Apply projects (send full spec including servers/members) for (const project of config.projects) { try { const existing = await findByName(client, 'projects', project.name); if (existing) { - await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, { - description: project.description, - }); + await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, project); log(`Updated project: ${project.name}`); } else { - await client.post('/api/v1/projects', { - name: project.name, - description: project.description, - }); + await client.post('/api/v1/projects', project); log(`Created project: ${project.name}`); } } catch (err) { @@ -187,6 +277,22 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args log(`Error applying template '${template.name}': ${err instanceof Error ? err.message : err}`); } } + + // Apply RBAC bindings + for (const rbacBinding of config.rbacBindings) { + try { + const existing = await findByName(client, 'rbac', rbacBinding.name); + if (existing) { + await client.put(`/api/v1/rbac/${(existing as { id: string }).id}`, rbacBinding); + log(`Updated rbacBinding: ${rbacBinding.name}`); + } else { + await client.post('/api/v1/rbac', rbacBinding); + log(`Created rbacBinding: ${rbacBinding.name}`); + } + } catch (err) { + log(`Error applying rbacBinding '${rbacBinding.name}': ${err instanceof Error ? err.message : err}`); + } + } } async function findByName(client: ApiClient, resource: string, name: string): Promise { @@ -198,5 +304,14 @@ async function findByName(client: ApiClient, resource: string, name: string): Pr } } +async function findByField(client: ApiClient, resource: string, field: T, value: string): Promise { + try { + const items = await client.get>>(`/api/v1/${resource}`); + return items.find((item) => item[field] === value) ?? null; + } catch { + return null; + } +} + // Export for testing export { loadConfigFile, applyConfig }; diff --git a/src/cli/src/commands/auth.ts b/src/cli/src/commands/auth.ts index d063f7a..46813a3 100644 --- a/src/cli/src/commands/auth.ts +++ b/src/cli/src/commands/auth.ts @@ -10,6 +10,10 @@ export interface PromptDeps { password(message: string): Promise; } +export interface StatusResponse { + hasUsers: boolean; +} + export interface AuthCommandDeps { configDeps: Partial; credentialsDeps: Partial; @@ -17,6 +21,8 @@ export interface AuthCommandDeps { log: (...args: string[]) => void; loginRequest: (mcpdUrl: string, email: string, password: string) => Promise; logoutRequest: (mcpdUrl: string, token: string) => Promise; + statusRequest: (mcpdUrl: string) => Promise; + bootstrapRequest: (mcpdUrl: string, email: string, password: string, name?: string) => Promise; } interface LoginResponse { @@ -80,6 +86,70 @@ function defaultLogoutRequest(mcpdUrl: string, token: string): Promise { }); } +function defaultStatusRequest(mcpdUrl: string): Promise { + return new Promise((resolve, reject) => { + const url = new URL('/api/v1/auth/status', mcpdUrl); + const opts: http.RequestOptions = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'GET', + timeout: 10000, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(opts, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf-8'); + if ((res.statusCode ?? 0) >= 400) { + reject(new Error(`Status check failed (${res.statusCode}): ${raw}`)); + return; + } + resolve(JSON.parse(raw) as StatusResponse); + }); + }); + req.on('error', (err) => reject(new Error(`Cannot reach mcpd: ${err.message}`))); + req.on('timeout', () => { req.destroy(); reject(new Error('Status request timed out')); }); + req.end(); + }); +} + +function defaultBootstrapRequest(mcpdUrl: string, email: string, password: string, name?: string): Promise { + return new Promise((resolve, reject) => { + const url = new URL('/api/v1/auth/bootstrap', mcpdUrl); + const payload: Record = { email, password }; + if (name) { + payload['name'] = name; + } + const body = JSON.stringify(payload); + const opts: http.RequestOptions = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method: 'POST', + timeout: 10000, + headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, + }; + const req = http.request(opts, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf-8'); + if ((res.statusCode ?? 0) >= 400) { + reject(new Error(`Bootstrap failed (${res.statusCode}): ${raw}`)); + return; + } + resolve(JSON.parse(raw) as LoginResponse); + }); + }); + req.on('error', (err) => reject(new Error(`Cannot reach mcpd: ${err.message}`))); + req.on('timeout', () => { req.destroy(); reject(new Error('Bootstrap request timed out')); }); + req.write(body); + req.end(); + }); +} + async function defaultInput(message: string): Promise { const { default: inquirer } = await import('inquirer'); const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]); @@ -99,10 +169,12 @@ const defaultDeps: AuthCommandDeps = { log: (...args) => console.log(...args), loginRequest: defaultLoginRequest, logoutRequest: defaultLogoutRequest, + statusRequest: defaultStatusRequest, + bootstrapRequest: defaultBootstrapRequest, }; export function createLoginCommand(deps?: Partial): Command { - const { configDeps, credentialsDeps, prompt, log, loginRequest } = { ...defaultDeps, ...deps }; + const { configDeps, credentialsDeps, prompt, log, loginRequest, statusRequest, bootstrapRequest } = { ...defaultDeps, ...deps }; return new Command('login') .description('Authenticate with mcpd') @@ -111,17 +183,36 @@ export function createLoginCommand(deps?: Partial): Command { const config = loadConfig(configDeps); const mcpdUrl = opts.mcpdUrl ?? config.mcpdUrl; - const email = await prompt.input('Email:'); - const password = await prompt.password('Password:'); - try { - const result = await loginRequest(mcpdUrl, email, password); - saveCredentials({ - token: result.token, - mcpdUrl, - user: result.user.email, - }, credentialsDeps); - log(`Logged in as ${result.user.email}`); + const status = await statusRequest(mcpdUrl); + + if (!status.hasUsers) { + log('No users configured. Creating first admin account.'); + const email = await prompt.input('Email:'); + const password = await prompt.password('Password:'); + const name = await prompt.input('Name (optional):'); + + const result = name + ? await bootstrapRequest(mcpdUrl, email, password, name) + : await bootstrapRequest(mcpdUrl, email, password); + saveCredentials({ + token: result.token, + mcpdUrl, + user: result.user.email, + }, credentialsDeps); + log(`Logged in as ${result.user.email} (admin)`); + } else { + const email = await prompt.input('Email:'); + const password = await prompt.password('Password:'); + + const result = await loginRequest(mcpdUrl, email, password); + saveCredentials({ + token: result.token, + mcpdUrl, + user: result.user.email, + }, credentialsDeps); + log(`Logged in as ${result.user.email}`); + } } catch (err) { log(`Login failed: ${(err as Error).message}`); process.exitCode = 1; diff --git a/src/cli/src/commands/claude.ts b/src/cli/src/commands/claude.ts deleted file mode 100644 index 6756037..0000000 --- a/src/cli/src/commands/claude.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Command } from 'commander'; -import { writeFileSync, readFileSync, existsSync } from 'node:fs'; -import { resolve } from 'node:path'; -import type { ApiClient } from '../api-client.js'; - -interface McpConfig { - mcpServers: Record }>; -} - -export interface ClaudeCommandDeps { - client: ApiClient; - log: (...args: unknown[]) => void; -} - -export function createClaudeCommand(deps: ClaudeCommandDeps): Command { - const { client, log } = deps; - - const cmd = new Command('claude') - .description('Manage Claude MCP configuration (.mcp.json)'); - - cmd - .command('generate ') - .description('Generate .mcp.json from a project configuration') - .option('-o, --output ', 'Output file path', '.mcp.json') - .option('--merge', 'Merge with existing .mcp.json instead of overwriting') - .option('--stdout', 'Print to stdout instead of writing a file') - .action(async (projectId: string, opts: { output: string; merge?: boolean; stdout?: boolean }) => { - const config = await client.get(`/api/v1/projects/${projectId}/mcp-config`); - - if (opts.stdout) { - log(JSON.stringify(config, null, 2)); - return; - } - - const outputPath = resolve(opts.output); - let finalConfig = config; - - if (opts.merge && existsSync(outputPath)) { - try { - const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig; - finalConfig = { - mcpServers: { - ...existing.mcpServers, - ...config.mcpServers, - }, - }; - } catch { - // If existing file is invalid, just overwrite - } - } - - writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); - const serverCount = Object.keys(finalConfig.mcpServers).length; - log(`Wrote ${outputPath} (${serverCount} server(s))`); - }); - - cmd - .command('show') - .description('Show current .mcp.json configuration') - .option('-p, --path ', 'Path to .mcp.json', '.mcp.json') - .action((opts: { path: string }) => { - const filePath = resolve(opts.path); - if (!existsSync(filePath)) { - log(`No .mcp.json found at ${filePath}`); - return; - } - const content = readFileSync(filePath, 'utf-8'); - try { - const config = JSON.parse(content) as McpConfig; - const servers = Object.entries(config.mcpServers ?? {}); - if (servers.length === 0) { - log('No MCP servers configured.'); - return; - } - log(`MCP servers in ${filePath}:\n`); - for (const [name, server] of servers) { - log(` ${name}`); - log(` command: ${server.command} ${server.args.join(' ')}`); - if (server.env) { - const envKeys = Object.keys(server.env); - log(` env: ${envKeys.join(', ')}`); - } - } - } catch { - log(`Invalid JSON in ${filePath}`); - } - }); - - cmd - .command('add ') - .description('Add an MCP server entry to .mcp.json') - .requiredOption('-c, --command ', 'Command to run') - .option('-a, --args ', 'Command arguments') - .option('-e, --env ', 'Environment variables') - .option('-p, --path ', 'Path to .mcp.json', '.mcp.json') - .action((name: string, opts: { command: string; args?: string[]; env?: string[]; path: string }) => { - const filePath = resolve(opts.path); - let config: McpConfig = { mcpServers: {} }; - - if (existsSync(filePath)) { - try { - config = JSON.parse(readFileSync(filePath, 'utf-8')) as McpConfig; - } catch { - // Start fresh - } - } - - const entry: { command: string; args: string[]; env?: Record } = { - command: opts.command, - args: opts.args ?? [], - }; - - if (opts.env && opts.env.length > 0) { - const env: Record = {}; - for (const pair of opts.env) { - const eqIdx = pair.indexOf('='); - if (eqIdx > 0) { - env[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1); - } - } - entry.env = env; - } - - config.mcpServers[name] = entry; - writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n'); - log(`Added '${name}' to ${filePath}`); - }); - - cmd - .command('remove ') - .description('Remove an MCP server entry from .mcp.json') - .option('-p, --path ', 'Path to .mcp.json', '.mcp.json') - .action((name: string, opts: { path: string }) => { - const filePath = resolve(opts.path); - if (!existsSync(filePath)) { - log(`No .mcp.json found at ${filePath}`); - return; - } - - try { - const config = JSON.parse(readFileSync(filePath, 'utf-8')) as McpConfig; - if (!(name in config.mcpServers)) { - log(`Server '${name}' not found in ${filePath}`); - return; - } - delete config.mcpServers[name]; - writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n'); - log(`Removed '${name}' from ${filePath}`); - } catch { - log(`Invalid JSON in ${filePath}`); - } - }); - - return cmd; -} diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 25fc461..2bf8368 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -1,19 +1,35 @@ import { Command } from 'commander'; +import { writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { homedir } from 'node:os'; import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js'; import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js'; import { formatJson, formatYaml } from '../formatters/index.js'; +import { saveCredentials, loadCredentials } from '../auth/index.js'; +import type { CredentialsDeps, StoredCredentials } from '../auth/index.js'; +import type { ApiClient } from '../api-client.js'; + +interface McpConfig { + mcpServers: Record }>; +} export interface ConfigCommandDeps { configDeps: Partial; log: (...args: string[]) => void; } +export interface ConfigApiDeps { + client: ApiClient; + credentialsDeps: Partial; + log: (...args: string[]) => void; +} + const defaultDeps: ConfigCommandDeps = { configDeps: {}, log: (...args) => console.log(...args), }; -export function createConfigCommand(deps?: Partial): Command { +export function createConfigCommand(deps?: Partial, apiDeps?: ConfigApiDeps): Command { const { configDeps, log } = { ...defaultDeps, ...deps }; const config = new Command('config').description('Manage mcpctl configuration'); @@ -68,5 +84,115 @@ export function createConfigCommand(deps?: Partial): Command log('Configuration reset to defaults'); }); + if (apiDeps) { + const { client, credentialsDeps, log: apiLog } = apiDeps; + + config + .command('claude-generate') + .description('Generate .mcp.json from a project configuration') + .requiredOption('--project ', 'Project name') + .option('-o, --output ', 'Output file path', '.mcp.json') + .option('--merge', 'Merge with existing .mcp.json instead of overwriting') + .option('--stdout', 'Print to stdout instead of writing a file') + .action(async (opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => { + const mcpConfig = await client.get(`/api/v1/projects/${opts.project}/mcp-config`); + + if (opts.stdout) { + apiLog(JSON.stringify(mcpConfig, null, 2)); + return; + } + + const outputPath = resolve(opts.output); + let finalConfig = mcpConfig; + + if (opts.merge && existsSync(outputPath)) { + try { + const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig; + finalConfig = { + mcpServers: { + ...existing.mcpServers, + ...mcpConfig.mcpServers, + }, + }; + } catch { + // If existing file is invalid, just overwrite + } + } + + writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); + const serverCount = Object.keys(finalConfig.mcpServers).length; + apiLog(`Wrote ${outputPath} (${serverCount} server(s))`); + }); + + config + .command('impersonate') + .description('Impersonate another user or return to original identity') + .argument('[email]', 'Email of user to impersonate') + .option('--quit', 'Stop impersonating and return to original identity') + .action(async (email: string | undefined, opts: { quit?: boolean }) => { + const configDir = credentialsDeps?.configDir ?? join(homedir(), '.mcpctl'); + const backupPath = join(configDir, 'credentials-backup'); + + if (opts.quit) { + if (!existsSync(backupPath)) { + apiLog('No impersonation session to quit'); + process.exitCode = 1; + return; + } + + const backupRaw = readFileSync(backupPath, 'utf-8'); + const backup = JSON.parse(backupRaw) as StoredCredentials; + saveCredentials(backup, credentialsDeps); + + // Remove backup file + const { unlinkSync } = await import('node:fs'); + unlinkSync(backupPath); + + apiLog(`Returned to ${backup.user}`); + return; + } + + if (!email) { + apiLog('Email is required when not using --quit'); + process.exitCode = 1; + return; + } + + // Save current credentials as backup + const currentCreds = loadCredentials(credentialsDeps); + if (!currentCreds) { + apiLog('Not logged in. Run "mcpctl login" first.'); + process.exitCode = 1; + return; + } + + writeFileSync(backupPath, JSON.stringify(currentCreds, null, 2) + '\n', 'utf-8'); + + try { + const result = await client.post<{ token: string; user: { email: string } }>( + '/api/v1/auth/impersonate', + { email }, + ); + + saveCredentials({ + token: result.token, + mcpdUrl: currentCreds.mcpdUrl, + user: result.user.email, + }, credentialsDeps); + + apiLog(`Impersonating ${result.user.email}. Use 'mcpctl config impersonate --quit' to return.`); + } catch (err) { + // Restore backup on failure + const backup = JSON.parse(readFileSync(backupPath, 'utf-8')) as StoredCredentials; + saveCredentials(backup, credentialsDeps); + const { unlinkSync } = await import('node:fs'); + unlinkSync(backupPath); + + apiLog(`Impersonate failed: ${(err as Error).message}`); + process.exitCode = 1; + } + }); + } + return config; } diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index d705abb..fbec5fd 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -55,7 +55,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { const { client, log } = deps; const cmd = new Command('create') - .description('Create a resource (server, project)'); + .description('Create a resource (server, secret, project, user, group, rbac)'); // --- create server --- cmd.command('server') @@ -195,19 +195,32 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .description('Create a project') .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('--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 = { + name, + description: opts.description, + proxyMode: opts.proxyMode ?? 'direct', + }; + if (opts.llmProvider) body.llmProvider = opts.llmProvider; + if (opts.llmModel) body.llmModel = opts.llmModel; + 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', { - name, - description: opts.description, - }); + const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body); log(`project '${project.name}' created (id: ${project.id})`); } catch (err) { if (err instanceof ApiError && err.status === 409 && opts.force) { const existing = (await client.get>('/api/v1/projects')).find((p) => p.name === name); if (!existing) throw err; - await client.put(`/api/v1/projects/${existing.id}`, { description: opts.description }); + const { name: _n, ...updateBody } = body; + await client.put(`/api/v1/projects/${existing.id}`, updateBody); log(`project '${name}' updated (id: ${existing.id})`); } else { throw err; @@ -215,5 +228,126 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { } }); + // --- create user --- + cmd.command('user') + .description('Create a user') + .argument('', 'User email address') + .option('--password ', 'User password') + .option('--name ', 'User display name') + .option('--force', 'Update if already exists') + .action(async (email: string, opts) => { + if (!opts.password) { + throw new Error('--password is required'); + } + const body: Record = { + email, + password: opts.password, + }; + if (opts.name) body.name = opts.name; + + try { + const user = await client.post<{ id: string; email: string }>('/api/v1/users', body); + log(`user '${user.email}' created (id: ${user.id})`); + } catch (err) { + if (err instanceof ApiError && err.status === 409 && opts.force) { + const existing = (await client.get>('/api/v1/users')).find((u) => u.email === email); + if (!existing) throw err; + const { email: _e, ...updateBody } = body; + await client.put(`/api/v1/users/${existing.id}`, updateBody); + log(`user '${email}' updated (id: ${existing.id})`); + } else { + throw err; + } + } + }); + + // --- create group --- + cmd.command('group') + .description('Create a group') + .argument('', 'Group name') + .option('--description ', 'Group description') + .option('--member ', 'Member email (repeat for multiple)', collect, []) + .option('--force', 'Update if already exists') + .action(async (name: string, opts) => { + const body: Record = { + name, + members: opts.member, + }; + if (opts.description) body.description = opts.description; + + try { + const group = await client.post<{ id: string; name: string }>('/api/v1/groups', body); + log(`group '${group.name}' created (id: ${group.id})`); + } catch (err) { + if (err instanceof ApiError && err.status === 409 && opts.force) { + const existing = (await client.get>('/api/v1/groups')).find((g) => g.name === name); + if (!existing) throw err; + const { name: _n, ...updateBody } = body; + await client.put(`/api/v1/groups/${existing.id}`, updateBody); + log(`group '${name}' updated (id: ${existing.id})`); + } else { + throw err; + } + } + }); + + // --- create rbac --- + cmd.command('rbac') + .description('Create an RBAC binding definition') + .argument('', 'RBAC binding name') + .option('--subject ', 'Subject as Kind:name (repeat for multiple)', collect, []) + .option('--binding ', 'Role binding as role:resource (e.g. edit:servers, run:projects)', collect, []) + .option('--operation ', 'Operation binding (e.g. logs, backup)', collect, []) + .option('--force', 'Update if already exists') + .action(async (name: string, opts) => { + const subjects = (opts.subject as string[]).map((entry: string) => { + const colonIdx = entry.indexOf(':'); + if (colonIdx === -1) { + throw new Error(`Invalid subject format '${entry}'. Expected Kind:name (e.g. User:alice@example.com)`); + } + return { kind: entry.slice(0, colonIdx), name: entry.slice(colonIdx + 1) }; + }); + + const roleBindings: Array> = []; + + // Resource bindings from --binding flag (role:resource or role:resource:name) + for (const entry of opts.binding as string[]) { + const parts = entry.split(':'); + if (parts.length === 2) { + roleBindings.push({ role: parts[0]!, resource: parts[1]! }); + } else if (parts.length === 3) { + roleBindings.push({ role: parts[0]!, resource: parts[1]!, name: parts[2]! }); + } else { + throw new Error(`Invalid binding format '${entry}'. Expected role:resource or role:resource:name (e.g. edit:servers, view:servers:my-ha)`); + } + } + + // Operation bindings from --operation flag + for (const action of opts.operation as string[]) { + roleBindings.push({ role: 'run', action }); + } + + const body: Record = { + name, + subjects, + roleBindings, + }; + + try { + const rbac = await client.post<{ id: string; name: string }>('/api/v1/rbac', body); + log(`rbac '${rbac.name}' created (id: ${rbac.id})`); + } catch (err) { + if (err instanceof ApiError && err.status === 409 && opts.force) { + const existing = (await client.get>('/api/v1/rbac')).find((r) => r.name === name); + if (!existing) throw err; + const { name: _n, ...updateBody } = body; + await client.put(`/api/v1/rbac/${existing.id}`, updateBody); + log(`rbac '${name}' updated (id: ${existing.id})`); + } else { + throw err; + } + } + }); + return cmd; } diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index 5f2221a..8f891ca 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -138,11 +138,45 @@ function formatProjectDetail(project: Record): string { lines.push(`=== Project: ${project.name} ===`); lines.push(`${pad('Name:')}${project.name}`); if (project.description) lines.push(`${pad('Description:')}${project.description}`); - if (project.ownerId) lines.push(`${pad('Owner:')}${project.ownerId}`); + + // Proxy config section + const proxyMode = project.proxyMode as string | undefined; + const llmProvider = project.llmProvider as string | undefined; + const llmModel = project.llmModel as string | undefined; + if (proxyMode || llmProvider || llmModel) { + lines.push(''); + lines.push('Proxy Config:'); + lines.push(` ${pad('Mode:', 18)}${proxyMode ?? 'direct'}`); + if (llmProvider) lines.push(` ${pad('LLM Provider:', 18)}${llmProvider}`); + if (llmModel) lines.push(` ${pad('LLM Model:', 18)}${llmModel}`); + } + + // Servers section + const servers = project.servers as Array<{ server: { name: string } }> | undefined; + if (servers && servers.length > 0) { + lines.push(''); + lines.push('Servers:'); + lines.push(' NAME'); + for (const s of servers) { + lines.push(` ${s.server.name}`); + } + } + + // 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}`); + if (project.ownerId) lines.push(` ${pad('Owner:', 12)}${project.ownerId}`); if (project.createdAt) lines.push(` ${pad('Created:', 12)}${project.createdAt}`); if (project.updatedAt) lines.push(` ${pad('Updated:', 12)}${project.updatedAt}`); @@ -240,6 +274,231 @@ function formatTemplateDetail(template: Record): string { return lines.join('\n'); } +interface RbacBinding { role: string; resource?: string; action?: string; name?: string } +interface RbacDef { name: string; subjects: Array<{ kind: string; name: string }>; roleBindings: RbacBinding[] } +interface PermissionSet { source: string; bindings: RbacBinding[] } + +function formatPermissionSections(sections: PermissionSet[]): string[] { + const lines: string[] = []; + for (const section of sections) { + const bindings = section.bindings; + if (bindings.length === 0) continue; + + const resourceBindings = bindings.filter((b) => 'resource' in b && b.resource !== undefined); + const operationBindings = bindings.filter((b) => 'action' in b && b.action !== undefined); + + if (resourceBindings.length > 0) { + lines.push(''); + lines.push(`${section.source} — Resources:`); + const roleW = Math.max(6, ...resourceBindings.map((b) => b.role.length)) + 2; + const resW = Math.max(10, ...resourceBindings.map((b) => (b.resource ?? '').length)) + 2; + const hasName = resourceBindings.some((b) => b.name); + if (hasName) { + lines.push(` ${'ROLE'.padEnd(roleW)}${'RESOURCE'.padEnd(resW)}NAME`); + } else { + lines.push(` ${'ROLE'.padEnd(roleW)}RESOURCE`); + } + for (const b of resourceBindings) { + if (hasName) { + lines.push(` ${b.role.padEnd(roleW)}${(b.resource ?? '').padEnd(resW)}${b.name ?? '*'}`); + } else { + lines.push(` ${b.role.padEnd(roleW)}${b.resource}`); + } + } + } + + if (operationBindings.length > 0) { + lines.push(''); + lines.push(`${section.source} — Operations:`); + lines.push(` ${'ACTION'.padEnd(20)}ROLE`); + for (const b of operationBindings) { + lines.push(` ${(b.action ?? '').padEnd(20)}${b.role}`); + } + } + } + return lines; +} + +function collectBindingsForSubject( + rbacDefs: RbacDef[], + kind: string, + name: string, +): { rbacName: string; bindings: RbacBinding[] }[] { + const results: { rbacName: string; bindings: RbacBinding[] }[] = []; + for (const def of rbacDefs) { + const matched = def.subjects.some((s) => s.kind === kind && s.name === name); + if (matched) { + results.push({ rbacName: def.name, bindings: def.roleBindings }); + } + } + return results; +} + +function formatUserDetail( + user: Record, + rbacDefs?: RbacDef[], + userGroups?: string[], +): string { + const lines: string[] = []; + lines.push(`=== User: ${user.email} ===`); + lines.push(`${pad('Email:')}${user.email}`); + lines.push(`${pad('Name:')}${(user.name as string | null) ?? '-'}`); + lines.push(`${pad('Provider:')}${(user.provider as string | null) ?? 'local'}`); + + if (userGroups && userGroups.length > 0) { + lines.push(`${pad('Groups:')}${userGroups.join(', ')}`); + } + + if (rbacDefs) { + const email = user.email as string; + + // Direct permissions (User:email subjects) + const directMatches = collectBindingsForSubject(rbacDefs, 'User', email); + const directBindings = directMatches.flatMap((m) => m.bindings); + const directSources = directMatches.map((m) => m.rbacName).join(', '); + + // Inherited permissions (Group:name subjects) + const inheritedSections: PermissionSet[] = []; + if (userGroups) { + for (const groupName of userGroups) { + const groupMatches = collectBindingsForSubject(rbacDefs, 'Group', groupName); + const groupBindings = groupMatches.flatMap((m) => m.bindings); + if (groupBindings.length > 0) { + inheritedSections.push({ source: `Inherited (${groupName})`, bindings: groupBindings }); + } + } + } + + const sections: PermissionSet[] = []; + if (directBindings.length > 0) { + sections.push({ source: `Direct (${directSources})`, bindings: directBindings }); + } + sections.push(...inheritedSections); + + if (sections.length > 0) { + lines.push(''); + lines.push('Access:'); + lines.push(...formatPermissionSections(sections)); + } else { + lines.push(''); + lines.push('Access: (none)'); + } + } + + lines.push(''); + lines.push('Metadata:'); + lines.push(` ${pad('ID:', 12)}${user.id}`); + if (user.createdAt) lines.push(` ${pad('Created:', 12)}${user.createdAt}`); + if (user.updatedAt) lines.push(` ${pad('Updated:', 12)}${user.updatedAt}`); + + return lines.join('\n'); +} + +function formatGroupDetail(group: Record, rbacDefs?: RbacDef[]): string { + const lines: string[] = []; + lines.push(`=== Group: ${group.name} ===`); + lines.push(`${pad('Name:')}${group.name}`); + if (group.description) lines.push(`${pad('Description:')}${group.description}`); + + const members = group.members as Array<{ user: { email: string }; createdAt?: string }> | undefined; + if (members && members.length > 0) { + lines.push(''); + lines.push('Members:'); + const emailW = Math.max(6, ...members.map((m) => m.user.email.length)) + 2; + lines.push(` ${'EMAIL'.padEnd(emailW)}ADDED`); + for (const m of members) { + const added = (m.createdAt as string | undefined) ?? '-'; + lines.push(` ${m.user.email.padEnd(emailW)}${added}`); + } + } + + if (rbacDefs) { + const groupName = group.name as string; + const matches = collectBindingsForSubject(rbacDefs, 'Group', groupName); + const allBindings = matches.flatMap((m) => m.bindings); + const sources = matches.map((m) => m.rbacName).join(', '); + + if (allBindings.length > 0) { + const sections: PermissionSet[] = [{ source: `Granted (${sources})`, bindings: allBindings }]; + lines.push(''); + lines.push('Access:'); + lines.push(...formatPermissionSections(sections)); + } else { + lines.push(''); + lines.push('Access: (none)'); + } + } + + lines.push(''); + lines.push('Metadata:'); + lines.push(` ${pad('ID:', 12)}${group.id}`); + if (group.createdAt) lines.push(` ${pad('Created:', 12)}${group.createdAt}`); + if (group.updatedAt) lines.push(` ${pad('Updated:', 12)}${group.updatedAt}`); + + return lines.join('\n'); +} + +function formatRbacDetail(rbac: Record): string { + const lines: string[] = []; + lines.push(`=== RBAC: ${rbac.name} ===`); + lines.push(`${pad('Name:')}${rbac.name}`); + + const subjects = rbac.subjects as Array<{ kind: string; name: string }> | undefined; + if (subjects && subjects.length > 0) { + lines.push(''); + lines.push('Subjects:'); + const kindW = Math.max(6, ...subjects.map((s) => s.kind.length)) + 2; + lines.push(` ${'KIND'.padEnd(kindW)}NAME`); + for (const s of subjects) { + lines.push(` ${s.kind.padEnd(kindW)}${s.name}`); + } + } + + const roleBindings = rbac.roleBindings as Array<{ role: string; resource?: string; action?: string; name?: string }> | undefined; + if (roleBindings && roleBindings.length > 0) { + // Separate resource bindings from operation bindings + const resourceBindings = roleBindings.filter((b) => 'resource' in b && b.resource !== undefined); + const operationBindings = roleBindings.filter((b) => 'action' in b && b.action !== undefined); + + if (resourceBindings.length > 0) { + lines.push(''); + lines.push('Resource Bindings:'); + const roleW = Math.max(6, ...resourceBindings.map((b) => b.role.length)) + 2; + const resW = Math.max(10, ...resourceBindings.map((b) => (b.resource ?? '').length)) + 2; + const hasName = resourceBindings.some((b) => b.name); + if (hasName) { + lines.push(` ${'ROLE'.padEnd(roleW)}${'RESOURCE'.padEnd(resW)}NAME`); + } else { + lines.push(` ${'ROLE'.padEnd(roleW)}RESOURCE`); + } + for (const b of resourceBindings) { + if (hasName) { + lines.push(` ${b.role.padEnd(roleW)}${(b.resource ?? '').padEnd(resW)}${b.name ?? '*'}`); + } else { + lines.push(` ${b.role.padEnd(roleW)}${b.resource}`); + } + } + } + + if (operationBindings.length > 0) { + lines.push(''); + lines.push('Operations:'); + lines.push(` ${'ACTION'.padEnd(20)}ROLE`); + for (const b of operationBindings) { + lines.push(` ${(b.action ?? '').padEnd(20)}${b.role}`); + } + } + } + + lines.push(''); + lines.push('Metadata:'); + lines.push(` ${pad('ID:', 12)}${rbac.id}`); + if (rbac.createdAt) lines.push(` ${pad('Created:', 12)}${rbac.createdAt}`); + if (rbac.updatedAt) lines.push(` ${pad('Updated:', 12)}${rbac.updatedAt}`); + + return lines.join('\n'); +} + function formatGenericDetail(obj: Record): string { const lines: string[] = []; for (const [key, value] of Object.entries(obj)) { @@ -341,6 +600,27 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { case 'projects': deps.log(formatProjectDetail(item)); break; + case 'users': { + // Fetch RBAC definitions and groups to show permissions + const [rbacDefsForUser, allGroupsForUser] = await Promise.all([ + deps.client.get('/api/v1/rbac').catch(() => [] as RbacDef[]), + deps.client.get }>>('/api/v1/groups').catch(() => []), + ]); + const userEmail = item.email as string; + const userGroupNames = allGroupsForUser + .filter((g) => g.members?.some((m) => m.user.email === userEmail)) + .map((g) => g.name); + deps.log(formatUserDetail(item, rbacDefsForUser, userGroupNames)); + break; + } + case 'groups': { + const rbacDefsForGroup = await deps.client.get('/api/v1/rbac').catch(() => [] as RbacDef[]); + deps.log(formatGroupDetail(item, rbacDefsForGroup)); + break; + } + case 'rbac': + deps.log(formatRbacDetail(item)); + break; default: deps.log(formatGenericDetail(item)); } diff --git a/src/cli/src/commands/edit.ts b/src/cli/src/commands/edit.ts index ae8134c..ba9d2fe 100644 --- a/src/cli/src/commands/edit.ts +++ b/src/cli/src/commands/edit.ts @@ -47,7 +47,7 @@ export function createEditCommand(deps: EditCommandDeps): Command { return; } - const validResources = ['servers', 'secrets', 'projects']; + const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac']; if (!validResources.includes(resource)) { log(`Error: unknown resource type '${resourceArg}'`); process.exitCode = 1; diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index ccefd42..766a9c5 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -21,7 +21,10 @@ interface ProjectRow { id: string; name: string; description: string; + proxyMode: string; ownerId: string; + servers?: Array<{ server: { name: string } }>; + members?: Array<{ user: { email: string }; role: string }>; } interface SecretRow { @@ -57,10 +60,61 @@ const serverColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +interface UserRow { + id: string; + email: string; + name: string | null; + provider: string | null; +} + +interface GroupRow { + id: string; + name: string; + description: string; + members?: Array<{ user: { email: string } }>; +} + +interface RbacRow { + id: string; + name: string; + subjects: Array<{ kind: string; name: string }>; + roleBindings: Array<{ role: string; resource?: string; action?: string; name?: string }>; +} + 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' }, +]; + +const userColumns: Column[] = [ + { header: 'EMAIL', key: 'email' }, + { header: 'NAME', key: (r) => r.name ?? '-' }, + { header: 'PROVIDER', key: (r) => r.provider ?? 'local', width: 10 }, + { header: 'ID', key: 'id' }, +]; + +const groupColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'MEMBERS', key: (r) => r.members ? String(r.members.length) : '0', width: 8 }, { header: 'DESCRIPTION', key: 'description', width: 40 }, - { header: 'OWNER', key: 'ownerId' }, + { header: 'ID', key: 'id' }, +]; + +const rbacColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'SUBJECTS', key: (r) => r.subjects.map((s) => `${s.kind}:${s.name}`).join(', '), width: 30 }, + { header: 'BINDINGS', key: (r) => r.roleBindings.map((b) => { + if ('action' in b && b.action !== undefined) return `run>${b.action}`; + if ('resource' in b && b.resource !== undefined) { + const base = `${b.role}:${b.resource}`; + return b.name ? `${base}:${b.name}` : base; + } + return b.role; + }).join(', '), width: 40 }, { header: 'ID', key: 'id' }, ]; @@ -99,6 +153,12 @@ function getColumnsForResource(resource: string): Column return templateColumns as unknown as Column>[]; case 'instances': return instanceColumns as unknown as Column>[]; + case 'users': + return userColumns as unknown as Column>[]; + case 'groups': + return groupColumns as unknown as Column>[]; + case 'rbac': + return rbacColumns as unknown as Column>[]; default: return [ { header: 'ID', key: 'id' as keyof Record }, diff --git a/src/cli/src/commands/project.ts b/src/cli/src/commands/project.ts deleted file mode 100644 index 2696ba4..0000000 --- a/src/cli/src/commands/project.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Command } from 'commander'; -import type { ApiClient } from '../api-client.js'; - -export interface ProjectCommandDeps { - client: ApiClient; - log: (...args: unknown[]) => void; -} - -export function createProjectCommand(_deps: ProjectCommandDeps): Command { - const cmd = new Command('project') - .alias('proj') - .description('Project-specific actions (create with "create project", list with "get projects")'); - - return cmd; -} diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index baa75a0..f06ce89 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -11,6 +11,11 @@ export const RESOURCE_ALIASES: Record = { sec: 'secrets', template: 'templates', tpl: 'templates', + user: 'users', + group: 'groups', + rbac: 'rbac', + 'rbac-definition': 'rbac', + 'rbac-binding': 'rbac', }; export function resolveResource(name: string): string { @@ -28,9 +33,23 @@ export async function resolveNameOrId( if (/^c[a-z0-9]{24}/.test(nameOrId)) { return nameOrId; } - const items = await client.get>(`/api/v1/${resource}`); - const match = items.find((item) => item.name === nameOrId); - if (match) return match.id; + // Users resolve by email, not name + if (resource === 'users') { + const items = await client.get>(`/api/v1/${resource}`); + const match = items.find((item) => item.email === nameOrId); + if (match) return match.id; + throw new Error(`user '${nameOrId}' not found`); + } + const items = await client.get>>(`/api/v1/${resource}`); + const match = items.find((item) => { + // Instances use server.name, other resources use name directly + if (resource === 'instances') { + const server = item.server as { name?: string } | undefined; + return server?.name === nameOrId; + } + return item.name === nameOrId; + }); + if (match) return match.id as string; throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found`); } diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 48fae67..b9947b0 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -10,8 +10,6 @@ import { createLogsCommand } from './commands/logs.js'; import { createApplyCommand } from './commands/apply.js'; import { createCreateCommand } from './commands/create.js'; import { createEditCommand } from './commands/edit.js'; -import { createClaudeCommand } from './commands/claude.js'; -import { createProjectCommand } from './commands/project.js'; import { createBackupCommand, createRestoreCommand } from './commands/backup.js'; import { createLoginCommand, createLogoutCommand } from './commands/auth.js'; import { ApiClient, ApiError } from './api-client.js'; @@ -28,7 +26,6 @@ export function createProgram(): Command { .option('--daemon-url ', 'mcplocal daemon URL') .option('--direct', 'bypass mcplocal and connect directly to mcpd'); - program.addCommand(createConfigCommand()); program.addCommand(createStatusCommand()); program.addCommand(createLoginCommand()); program.addCommand(createLogoutCommand()); @@ -48,6 +45,12 @@ export function createProgram(): Command { const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined }); + program.addCommand(createConfigCommand(undefined, { + client, + credentialsDeps: {}, + log: (...args) => console.log(...args), + })); + const fetchResource = async (resource: string, nameOrId?: string): Promise => { if (nameOrId) { // Glob pattern — use query param filtering @@ -113,16 +116,6 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); - program.addCommand(createClaudeCommand({ - client, - log: (...args) => console.log(...args), - })); - - program.addCommand(createProjectCommand({ - client, - log: (...args) => console.log(...args), - })); - program.addCommand(createBackupCommand({ client, log: (...args) => console.log(...args), @@ -145,14 +138,28 @@ const isDirectRun = if (isDirectRun) { createProgram().parseAsync(process.argv).catch((err: unknown) => { if (err instanceof ApiError) { - let msg: string; - try { - const parsed = JSON.parse(err.body) as { error?: string; message?: string }; - msg = parsed.error ?? parsed.message ?? err.body; - } catch { - msg = err.body; + if (err.status === 401) { + console.error("Error: you need to log in. Run 'mcpctl login' to authenticate."); + } else if (err.status === 403) { + console.error('Error: permission denied. You do not have access to this resource.'); + } else { + let msg: string; + try { + const parsed = JSON.parse(err.body) as { error?: string; message?: string; details?: unknown }; + msg = parsed.error ?? parsed.message ?? err.body; + if (parsed.details && Array.isArray(parsed.details)) { + const issues = parsed.details as Array<{ message?: string; path?: string[] }>; + const detail = issues.map((i) => { + const path = i.path?.join('.') ?? ''; + return path ? `${path}: ${i.message}` : (i.message ?? ''); + }).filter(Boolean).join('; '); + if (detail) msg += `: ${detail}`; + } + } catch { + msg = err.body; + } + console.error(`Error: ${msg}`); } - console.error(`Error: ${msg}`); } else if (err instanceof Error) { console.error(`Error: ${err.message}`); } else { diff --git a/src/cli/tests/commands/apply.test.ts b/src/cli/tests/commands/apply.test.ts index 6c73e9e..14f4360 100644 --- a/src/cli/tests/commands/apply.test.ts +++ b/src/cli/tests/commands/apply.test.ts @@ -159,4 +159,351 @@ projects: rmSync(tmpDir, { recursive: true, force: true }); }); + + it('applies users (no role field)', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +users: + - email: alice@test.com + password: password123 + name: Alice +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record; + expect(callBody).toEqual(expect.objectContaining({ + email: 'alice@test.com', + password: 'password123', + name: 'Alice', + })); + expect(callBody).not.toHaveProperty('role'); + expect(output.join('\n')).toContain('Created user: alice@test.com'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('updates existing users matched by email', async () => { + vi.mocked(client.get).mockImplementation(async (url: string) => { + if (url === '/api/v1/users') return [{ id: 'usr-1', email: 'alice@test.com' }]; + return []; + }); + + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +users: + - email: alice@test.com + password: newpassword + name: Alice Updated +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', expect.objectContaining({ + email: 'alice@test.com', + name: 'Alice Updated', + })); + expect(output.join('\n')).toContain('Updated user: alice@test.com'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('applies groups', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +groups: + - name: dev-team + description: Development team + members: + - alice@test.com + - bob@test.com +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/groups', expect.objectContaining({ + name: 'dev-team', + description: 'Development team', + members: ['alice@test.com', 'bob@test.com'], + })); + expect(output.join('\n')).toContain('Created group: dev-team'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('updates existing groups', async () => { + vi.mocked(client.get).mockImplementation(async (url: string) => { + if (url === '/api/v1/groups') return [{ id: 'grp-1', name: 'dev-team' }]; + return []; + }); + + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +groups: + - name: dev-team + description: Updated devs + members: + - new@test.com +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', expect.objectContaining({ + name: 'dev-team', + description: 'Updated devs', + })); + expect(output.join('\n')).toContain('Updated group: dev-team'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('applies rbacBindings', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +rbac: + - name: developers + subjects: + - kind: User + name: alice@test.com + - kind: Group + name: dev-team + roleBindings: + - role: edit + resource: servers + - role: view + resource: instances +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({ + name: 'developers', + subjects: [ + { kind: 'User', name: 'alice@test.com' }, + { kind: 'Group', name: 'dev-team' }, + ], + roleBindings: [ + { role: 'edit', resource: 'servers' }, + { role: 'view', resource: 'instances' }, + ], + })); + expect(output.join('\n')).toContain('Created rbacBinding: developers'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('updates existing rbacBindings', async () => { + vi.mocked(client.get).mockImplementation(async (url: string) => { + if (url === '/api/v1/rbac') return [{ id: 'rbac-1', name: 'developers' }]; + return []; + }); + + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +rbacBindings: + - name: developers + subjects: + - kind: User + name: new@test.com + roleBindings: + - role: edit + resource: "*" +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', expect.objectContaining({ + name: 'developers', + })); + expect(output.join('\n')).toContain('Updated rbacBinding: developers'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('applies projects with servers and members', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +projects: + - name: smart-home + description: Home automation + proxyMode: filtered + llmProvider: gemini-cli + llmModel: gemini-2.0-flash + servers: + - my-grafana + - my-ha + members: + - alice@test.com + - bob@test.com +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ + name: 'smart-home', + proxyMode: 'filtered', + 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'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('dry-run shows all new resource types', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +secrets: + - name: creds + data: + TOKEN: abc +users: + - email: alice@test.com + password: password123 +groups: + - name: dev-team + members: [] +projects: + - name: my-proj + description: A project +rbacBindings: + - name: admins + subjects: + - kind: User + name: admin@test.com + roleBindings: + - role: edit + resource: "*" +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' }); + + expect(client.post).not.toHaveBeenCalled(); + const text = output.join('\n'); + expect(text).toContain('Dry run'); + expect(text).toContain('1 secret(s)'); + expect(text).toContain('1 user(s)'); + expect(text).toContain('1 group(s)'); + expect(text).toContain('1 project(s)'); + expect(text).toContain('1 rbacBinding(s)'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('applies resources in correct order', async () => { + const callOrder: string[] = []; + vi.mocked(client.post).mockImplementation(async (url: string) => { + callOrder.push(url); + return { id: 'new-id', name: 'test' }; + }); + + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +rbacBindings: + - name: admins + subjects: + - kind: User + name: admin@test.com + roleBindings: + - role: edit + resource: "*" +users: + - email: admin@test.com + password: password123 +secrets: + - name: creds + data: + KEY: val +groups: + - name: dev-team +servers: + - name: my-server + transport: STDIO +projects: + - name: my-proj +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + // Apply order: secrets → servers → users → groups → projects → templates → rbacBindings + expect(callOrder[0]).toBe('/api/v1/secrets'); + expect(callOrder[1]).toBe('/api/v1/servers'); + expect(callOrder[2]).toBe('/api/v1/users'); + expect(callOrder[3]).toBe('/api/v1/groups'); + expect(callOrder[4]).toBe('/api/v1/projects'); + expect(callOrder[5]).toBe('/api/v1/rbac'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('applies rbac with operation bindings', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +rbac: + - name: ops-team + subjects: + - kind: Group + name: ops + roleBindings: + - role: edit + resource: servers + - role: run + action: backup + - role: run + action: logs +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({ + name: 'ops-team', + roleBindings: [ + { role: 'edit', resource: 'servers' }, + { role: 'run', action: 'backup' }, + { role: 'run', action: 'logs' }, + ], + })); + expect(output.join('\n')).toContain('Created rbacBinding: ops-team'); + + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('applies rbac with name-scoped resource binding', async () => { + const configPath = join(tmpDir, 'config.yaml'); + writeFileSync(configPath, ` +rbac: + - name: ha-viewer + subjects: + - kind: User + name: alice@test.com + roleBindings: + - role: view + resource: servers + name: my-ha +`); + + const cmd = createApplyCommand({ client, log }); + await cmd.parseAsync([configPath], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({ + name: 'ha-viewer', + roleBindings: [ + { role: 'view', resource: 'servers', name: 'my-ha' }, + ], + })); + + rmSync(tmpDir, { recursive: true, force: true }); + }); }); diff --git a/src/cli/tests/commands/auth.test.ts b/src/cli/tests/commands/auth.test.ts index f6770f1..2193254 100644 --- a/src/cli/tests/commands/auth.test.ts +++ b/src/cli/tests/commands/auth.test.ts @@ -37,6 +37,8 @@ describe('login command', () => { user: { email }, }), logoutRequest: async () => {}, + statusRequest: async () => ({ hasUsers: true }), + bootstrapRequest: async () => ({ token: '', user: { email: '' } }), }); await cmd.parseAsync([], { from: 'user' }); expect(output[0]).toContain('Logged in as alice@test.com'); @@ -58,6 +60,8 @@ describe('login command', () => { log, loginRequest: async () => { throw new Error('Invalid credentials'); }, logoutRequest: async () => {}, + statusRequest: async () => ({ hasUsers: true }), + bootstrapRequest: async () => ({ token: '', user: { email: '' } }), }); await cmd.parseAsync([], { from: 'user' }); expect(output[0]).toContain('Login failed'); @@ -83,6 +87,8 @@ describe('login command', () => { return { token: 'tok', user: { email } }; }, logoutRequest: async () => {}, + statusRequest: async () => ({ hasUsers: true }), + bootstrapRequest: async () => ({ token: '', user: { email: '' } }), }); await cmd.parseAsync([], { from: 'user' }); expect(capturedUrl).toBe('http://custom:3100'); @@ -103,12 +109,74 @@ describe('login command', () => { return { token: 'tok', user: { email } }; }, logoutRequest: async () => {}, + statusRequest: async () => ({ hasUsers: true }), + bootstrapRequest: async () => ({ token: '', user: { email: '' } }), }); await cmd.parseAsync(['--mcpd-url', 'http://override:3100'], { from: 'user' }); expect(capturedUrl).toBe('http://override:3100'); }); }); +describe('login bootstrap flow', () => { + it('bootstraps first admin when no users exist', async () => { + let bootstrapCalled = false; + const cmd = createLoginCommand({ + configDeps: { configDir: tempDir }, + credentialsDeps: { configDir: tempDir }, + prompt: { + input: async (msg) => { + if (msg.includes('Name')) return 'Admin User'; + return 'admin@test.com'; + }, + password: async () => 'admin-pass', + }, + log, + loginRequest: async () => ({ token: '', user: { email: '' } }), + logoutRequest: async () => {}, + statusRequest: async () => ({ hasUsers: false }), + bootstrapRequest: async (_url, email, _password) => { + bootstrapCalled = true; + return { token: 'admin-token', user: { email } }; + }, + }); + await cmd.parseAsync([], { from: 'user' }); + + expect(bootstrapCalled).toBe(true); + expect(output.join('\n')).toContain('No users configured'); + expect(output.join('\n')).toContain('admin@test.com'); + expect(output.join('\n')).toContain('admin'); + + const creds = loadCredentials({ configDir: tempDir }); + expect(creds).not.toBeNull(); + expect(creds!.token).toBe('admin-token'); + expect(creds!.user).toBe('admin@test.com'); + }); + + it('falls back to normal login when users exist', async () => { + let loginCalled = false; + const cmd = createLoginCommand({ + configDeps: { configDir: tempDir }, + credentialsDeps: { configDir: tempDir }, + prompt: { + input: async () => 'alice@test.com', + password: async () => 'secret', + }, + log, + loginRequest: async (_url, email) => { + loginCalled = true; + return { token: 'session-tok', user: { email } }; + }, + logoutRequest: async () => {}, + statusRequest: async () => ({ hasUsers: true }), + bootstrapRequest: async () => { throw new Error('Should not be called'); }, + }); + await cmd.parseAsync([], { from: 'user' }); + + expect(loginCalled).toBe(true); + expect(output.join('\n')).not.toContain('No users configured'); + }); +}); + describe('logout command', () => { it('removes credentials on logout', async () => { saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice' }, { configDir: tempDir }); @@ -120,6 +188,8 @@ describe('logout command', () => { log, loginRequest: async () => ({ token: '', user: { email: '' } }), logoutRequest: async () => { logoutCalled = true; }, + statusRequest: async () => ({ hasUsers: true }), + bootstrapRequest: async () => ({ token: '', user: { email: '' } }), }); await cmd.parseAsync([], { from: 'user' }); expect(output[0]).toContain('Logged out successfully'); @@ -137,6 +207,8 @@ describe('logout command', () => { log, loginRequest: async () => ({ token: '', user: { email: '' } }), logoutRequest: async () => {}, + statusRequest: async () => ({ hasUsers: true }), + bootstrapRequest: async () => ({ token: '', user: { email: '' } }), }); await cmd.parseAsync([], { from: 'user' }); expect(output[0]).toContain('Not logged in'); diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index 59b5a59..ac7c126 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createClaudeCommand } from '../../src/commands/claude.js'; +import { createConfigCommand } from '../../src/commands/config.js'; import type { ApiClient } from '../../src/api-client.js'; +import { saveCredentials, loadCredentials } from '../../src/auth/index.js'; function mockClient(): ApiClient { return { @@ -13,146 +14,146 @@ function mockClient(): ApiClient { 'github--default': { command: 'npx', args: ['-y', '@anthropic/github-mcp'] }, }, })), - post: vi.fn(async () => ({})), + post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })), put: vi.fn(async () => ({})), delete: vi.fn(async () => {}), } as unknown as ApiClient; } -describe('claude command', () => { +describe('config claude-generate', () => { let client: ReturnType; let output: string[]; let tmpDir: string; - const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + const log = (...args: string[]) => output.push(args.join(' ')); beforeEach(() => { client = mockClient(); output = []; - tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-')); + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-claude-')); }); - describe('generate', () => { - it('generates .mcp.json from project config', async () => { - const outPath = join(tmpDir, '.mcp.json'); - const cmd = createClaudeCommand({ client, log }); - await cmd.parseAsync(['generate', 'proj-1', '-o', outPath], { from: 'user' }); - - expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config'); - const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(written.mcpServers['slack--default']).toBeDefined(); - expect(output.join('\n')).toContain('2 server(s)'); - - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('prints to stdout with --stdout', async () => { - const cmd = createClaudeCommand({ client, log }); - await cmd.parseAsync(['generate', 'proj-1', '--stdout'], { from: 'user' }); - - expect(output[0]).toContain('mcpServers'); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('merges with existing .mcp.json', async () => { - const outPath = join(tmpDir, '.mcp.json'); - writeFileSync(outPath, JSON.stringify({ - mcpServers: { 'existing--server': { command: 'echo', args: [] } }, - })); - - const cmd = createClaudeCommand({ client, log }); - await cmd.parseAsync(['generate', 'proj-1', '-o', outPath, '--merge'], { from: 'user' }); - - const written = JSON.parse(readFileSync(outPath, 'utf-8')); - expect(written.mcpServers['existing--server']).toBeDefined(); - expect(written.mcpServers['slack--default']).toBeDefined(); - expect(output.join('\n')).toContain('3 server(s)'); - - rmSync(tmpDir, { recursive: true, force: true }); - }); + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); }); - describe('show', () => { - it('shows servers in .mcp.json', () => { - const filePath = join(tmpDir, '.mcp.json'); - writeFileSync(filePath, JSON.stringify({ - mcpServers: { - 'slack': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { TOKEN: 'x' } }, - }, - })); + it('generates .mcp.json from project config', async () => { + const outPath = join(tmpDir, '.mcp.json'); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' }); - const cmd = createClaudeCommand({ client, log }); - cmd.parseAsync(['show', '-p', filePath], { from: 'user' }); - - expect(output.join('\n')).toContain('slack'); - expect(output.join('\n')).toContain('npx -y @anthropic/slack-mcp'); - expect(output.join('\n')).toContain('TOKEN'); - - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('handles missing file', () => { - const cmd = createClaudeCommand({ client, log }); - cmd.parseAsync(['show', '-p', join(tmpDir, 'nonexistent.json')], { from: 'user' }); - - expect(output.join('\n')).toContain('No .mcp.json found'); - rmSync(tmpDir, { recursive: true, force: true }); - }); + expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config'); + const written = JSON.parse(readFileSync(outPath, 'utf-8')); + expect(written.mcpServers['slack--default']).toBeDefined(); + expect(output.join('\n')).toContain('2 server(s)'); }); - describe('add', () => { - it('adds a server entry', () => { - const filePath = join(tmpDir, '.mcp.json'); - const cmd = createClaudeCommand({ client, log }); - cmd.parseAsync(['add', 'my-server', '-c', 'npx', '-a', '-y', 'my-pkg', '-p', filePath], { from: 'user' }); + it('prints to stdout with --stdout', async () => { + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '--stdout'], { from: 'user' }); - const written = JSON.parse(readFileSync(filePath, 'utf-8')); - expect(written.mcpServers['my-server']).toEqual({ - command: 'npx', - args: ['-y', 'my-pkg'], - }); - - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('adds server with env vars', () => { - const filePath = join(tmpDir, '.mcp.json'); - const cmd = createClaudeCommand({ client, log }); - cmd.parseAsync(['add', 'my-server', '-c', 'node', '-e', 'KEY=val', 'SECRET=abc', '-p', filePath], { from: 'user' }); - - const written = JSON.parse(readFileSync(filePath, 'utf-8')); - expect(written.mcpServers['my-server'].env).toEqual({ KEY: 'val', SECRET: 'abc' }); - - rmSync(tmpDir, { recursive: true, force: true }); - }); + expect(output[0]).toContain('mcpServers'); }); - describe('remove', () => { - it('removes a server entry', () => { - const filePath = join(tmpDir, '.mcp.json'); - writeFileSync(filePath, JSON.stringify({ - mcpServers: { 'slack': { command: 'npx', args: [] }, 'github': { command: 'npx', args: [] } }, - })); + it('merges with existing .mcp.json', async () => { + const outPath = join(tmpDir, '.mcp.json'); + writeFileSync(outPath, JSON.stringify({ + mcpServers: { 'existing--server': { command: 'echo', args: [] } }, + })); - const cmd = createClaudeCommand({ client, log }); - cmd.parseAsync(['remove', 'slack', '-p', filePath], { from: 'user' }); + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' }); - const written = JSON.parse(readFileSync(filePath, 'utf-8')); - expect(written.mcpServers['slack']).toBeUndefined(); - expect(written.mcpServers['github']).toBeDefined(); - expect(output.join('\n')).toContain("Removed 'slack'"); - - rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('reports when server not found', () => { - const filePath = join(tmpDir, '.mcp.json'); - writeFileSync(filePath, JSON.stringify({ mcpServers: {} })); - - const cmd = createClaudeCommand({ client, log }); - cmd.parseAsync(['remove', 'nonexistent', '-p', filePath], { from: 'user' }); - - expect(output.join('\n')).toContain('not found'); - rmSync(tmpDir, { recursive: true, force: true }); - }); + const written = JSON.parse(readFileSync(outPath, 'utf-8')); + expect(written.mcpServers['existing--server']).toBeDefined(); + expect(written.mcpServers['slack--default']).toBeDefined(); + expect(output.join('\n')).toContain('3 server(s)'); + }); +}); + +describe('config impersonate', () => { + let client: ReturnType; + let output: string[]; + let tmpDir: string; + const log = (...args: string[]) => output.push(args.join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-impersonate-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('impersonates a user and saves backup', async () => { + saveCredentials({ token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com' }, { configDir: tmpDir }); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/auth/impersonate', { email: 'other@test.com' }); + expect(output.join('\n')).toContain('Impersonating other@test.com'); + + const creds = loadCredentials({ configDir: tmpDir }); + expect(creds!.user).toBe('other@test.com'); + expect(creds!.token).toBe('impersonated-tok'); + + // Backup exists + const backup = JSON.parse(readFileSync(join(tmpDir, 'credentials-backup'), 'utf-8')); + expect(backup.user).toBe('admin@test.com'); + }); + + it('quits impersonation and restores backup', async () => { + // Set up current (impersonated) credentials + saveCredentials({ token: 'impersonated-tok', mcpdUrl: 'http://localhost:3100', user: 'other@test.com' }, { configDir: tmpDir }); + // Set up backup (original) credentials + writeFileSync(join(tmpDir, 'credentials-backup'), JSON.stringify({ + token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com', + })); + + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' }); + + expect(output.join('\n')).toContain('Returned to admin@test.com'); + + const creds = loadCredentials({ configDir: tmpDir }); + expect(creds!.user).toBe('admin@test.com'); + expect(creds!.token).toBe('admin-tok'); + }); + + it('errors when not logged in', async () => { + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' }); + + expect(output.join('\n')).toContain('Not logged in'); + }); + + it('errors when quitting with no backup', async () => { + const cmd = createConfigCommand( + { configDeps: { configDir: tmpDir }, log }, + { client, credentialsDeps: { configDir: tmpDir }, log }, + ); + await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' }); + + expect(output.join('\n')).toContain('No impersonation session to quit'); }); }); diff --git a/src/cli/tests/commands/create.test.ts b/src/cli/tests/commands/create.test.ts index f2b091f..2598ca9 100644 --- a/src/cli/tests/commands/create.test.ts +++ b/src/cli/tests/commands/create.test.ts @@ -175,6 +175,7 @@ describe('create command', () => { expect(client.post).toHaveBeenCalledWith('/api/v1/projects', { name: 'my-project', description: 'A test project', + proxyMode: 'direct', }); expect(output.join('\n')).toContain("project 'test' created"); }); @@ -185,6 +186,7 @@ describe('create command', () => { expect(client.post).toHaveBeenCalledWith('/api/v1/projects', { name: 'minimal', description: '', + proxyMode: 'direct', }); }); @@ -193,8 +195,256 @@ describe('create command', () => { vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never); const cmd = createCreateCommand({ client, log }); await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' }); - expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated' }); + expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated', proxyMode: 'direct' }); expect(output.join('\n')).toContain("project 'my-proj' updated"); }); }); + + describe('create user', () => { + it('creates a user with password and name', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'usr-1', email: 'alice@test.com' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'user', 'alice@test.com', + '--password', 'secret123', + '--name', 'Alice', + ], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/users', { + email: 'alice@test.com', + password: 'secret123', + name: 'Alice', + }); + expect(output.join('\n')).toContain("user 'alice@test.com' created"); + }); + + it('does not send role field (RBAC is the auth mechanism)', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'usr-1', email: 'admin@test.com' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'user', 'admin@test.com', + '--password', 'pass123', + ], { from: 'user' }); + + const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record; + expect(callBody).not.toHaveProperty('role'); + }); + + it('requires --password', async () => { + const cmd = createCreateCommand({ client, log }); + await expect(cmd.parseAsync(['user', 'alice@test.com'], { from: 'user' })).rejects.toThrow('--password is required'); + }); + + it('throws on 409 without --force', async () => { + vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"User already exists"}')); + const cmd = createCreateCommand({ client, log }); + await expect( + cmd.parseAsync(['user', 'alice@test.com', '--password', 'pass'], { from: 'user' }), + ).rejects.toThrow('API error 409'); + }); + + it('updates existing user on 409 with --force', async () => { + vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"User already exists"}')); + vi.mocked(client.get).mockResolvedValueOnce([{ id: 'usr-1', email: 'alice@test.com' }] as never); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'user', 'alice@test.com', '--password', 'newpass', '--name', 'Alice New', '--force', + ], { from: 'user' }); + + expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', { + password: 'newpass', + name: 'Alice New', + }); + expect(output.join('\n')).toContain("user 'alice@test.com' updated"); + }); + }); + + describe('create group', () => { + it('creates a group with members', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'grp-1', name: 'dev-team' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'group', 'dev-team', + '--description', 'Development team', + '--member', 'alice@test.com', + '--member', 'bob@test.com', + ], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/groups', { + name: 'dev-team', + description: 'Development team', + members: ['alice@test.com', 'bob@test.com'], + }); + expect(output.join('\n')).toContain("group 'dev-team' created"); + }); + + it('creates a group with no members', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'grp-1', name: 'empty-group' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['group', 'empty-group'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/groups', { + name: 'empty-group', + members: [], + }); + }); + + it('throws on 409 without --force', async () => { + vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Group already exists"}')); + const cmd = createCreateCommand({ client, log }); + await expect( + cmd.parseAsync(['group', 'dev-team'], { from: 'user' }), + ).rejects.toThrow('API error 409'); + }); + + it('updates existing group on 409 with --force', async () => { + vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Group already exists"}')); + vi.mocked(client.get).mockResolvedValueOnce([{ id: 'grp-1', name: 'dev-team' }] as never); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'group', 'dev-team', '--member', 'new@test.com', '--force', + ], { from: 'user' }); + + expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', { + members: ['new@test.com'], + }); + expect(output.join('\n')).toContain("group 'dev-team' updated"); + }); + }); + + describe('create rbac', () => { + it('creates an RBAC definition with subjects and bindings', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'developers' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'rbac', 'developers', + '--subject', 'User:alice@test.com', + '--subject', 'Group:dev-team', + '--binding', 'edit:servers', + '--binding', 'view:instances', + ], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', { + name: 'developers', + subjects: [ + { kind: 'User', name: 'alice@test.com' }, + { kind: 'Group', name: 'dev-team' }, + ], + roleBindings: [ + { role: 'edit', resource: 'servers' }, + { role: 'view', resource: 'instances' }, + ], + }); + expect(output.join('\n')).toContain("rbac 'developers' created"); + }); + + it('creates an RBAC definition with wildcard resource', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'admins' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'rbac', 'admins', + '--subject', 'User:admin@test.com', + '--binding', 'edit:*', + ], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', { + name: 'admins', + subjects: [{ kind: 'User', name: 'admin@test.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + }); + }); + + it('creates an RBAC definition with empty subjects and bindings', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'empty' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['rbac', 'empty'], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', { + name: 'empty', + subjects: [], + roleBindings: [], + }); + }); + + it('throws on invalid subject format', async () => { + const cmd = createCreateCommand({ client, log }); + await expect( + cmd.parseAsync(['rbac', 'bad', '--subject', 'no-colon'], { from: 'user' }), + ).rejects.toThrow('Invalid subject format'); + }); + + it('throws on invalid binding format', async () => { + const cmd = createCreateCommand({ client, log }); + await expect( + cmd.parseAsync(['rbac', 'bad', '--binding', 'no-colon'], { from: 'user' }), + ).rejects.toThrow('Invalid binding format'); + }); + + it('throws on 409 without --force', async () => { + vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"RBAC already exists"}')); + const cmd = createCreateCommand({ client, log }); + await expect( + cmd.parseAsync(['rbac', 'developers', '--subject', 'User:a@b.com', '--binding', 'edit:servers'], { from: 'user' }), + ).rejects.toThrow('API error 409'); + }); + + it('updates existing RBAC on 409 with --force', async () => { + vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"RBAC already exists"}')); + vi.mocked(client.get).mockResolvedValueOnce([{ id: 'rbac-1', name: 'developers' }] as never); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'rbac', 'developers', + '--subject', 'User:new@test.com', + '--binding', 'edit:*', + '--force', + ], { from: 'user' }); + + expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', { + subjects: [{ kind: 'User', name: 'new@test.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + }); + expect(output.join('\n')).toContain("rbac 'developers' updated"); + }); + + it('creates an RBAC definition with operation bindings', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'ops' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'rbac', 'ops', + '--subject', 'Group:ops-team', + '--binding', 'edit:servers', + '--operation', 'logs', + '--operation', 'backup', + ], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', { + name: 'ops', + subjects: [{ kind: 'Group', name: 'ops-team' }], + roleBindings: [ + { role: 'edit', resource: 'servers' }, + { role: 'run', action: 'logs' }, + { role: 'run', action: 'backup' }, + ], + }); + expect(output.join('\n')).toContain("rbac 'ops' created"); + }); + + it('creates an RBAC definition with name-scoped binding', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'ha-viewer' }); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'rbac', 'ha-viewer', + '--subject', 'User:alice@test.com', + '--binding', 'view:servers:my-ha', + ], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', { + name: 'ha-viewer', + subjects: [{ kind: 'User', name: 'alice@test.com' }], + roleBindings: [ + { role: 'view', resource: 'servers', name: 'my-ha' }, + ], + }); + }); + }); }); diff --git a/src/cli/tests/commands/describe.test.ts b/src/cli/tests/commands/describe.test.ts index 18225cc..b04fb24 100644 --- a/src/cli/tests/commands/describe.test.ts +++ b/src/cli/tests/commands/describe.test.ts @@ -287,4 +287,410 @@ describe('describe command', () => { expect(text).toContain('list_datasources'); expect(text).toContain('mcpctl create server my-grafana --from-template=grafana'); }); + + it('shows user detail (no Role field — RBAC is the auth mechanism)', async () => { + const deps = makeDeps({ + id: 'usr-1', + email: 'alice@test.com', + name: 'Alice Smith', + provider: null, + createdAt: '2025-01-01', + updatedAt: '2025-01-15', + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'user', 'usr-1']); + + expect(deps.fetchResource).toHaveBeenCalledWith('users', 'usr-1'); + const text = deps.output.join('\n'); + expect(text).toContain('=== User: alice@test.com ==='); + expect(text).toContain('Email:'); + expect(text).toContain('alice@test.com'); + expect(text).toContain('Name:'); + expect(text).toContain('Alice Smith'); + expect(text).not.toContain('Role:'); + expect(text).toContain('Provider:'); + expect(text).toContain('local'); + expect(text).toContain('ID:'); + expect(text).toContain('usr-1'); + }); + + it('shows user with no name as dash', async () => { + const deps = makeDeps({ + id: 'usr-2', + email: 'bob@test.com', + name: null, + provider: 'oidc', + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'user', 'usr-2']); + + const text = deps.output.join('\n'); + expect(text).toContain('=== User: bob@test.com ==='); + expect(text).toContain('Name:'); + expect(text).toContain('-'); + expect(text).not.toContain('Role:'); + expect(text).toContain('oidc'); + }); + + it('shows group detail with members', async () => { + const deps = makeDeps({ + id: 'grp-1', + name: 'dev-team', + description: 'Development team', + members: [ + { user: { email: 'alice@test.com' }, createdAt: '2025-01-01' }, + { user: { email: 'bob@test.com' }, createdAt: '2025-01-02' }, + ], + createdAt: '2025-01-01', + updatedAt: '2025-01-15', + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'group', 'grp-1']); + + expect(deps.fetchResource).toHaveBeenCalledWith('groups', 'grp-1'); + const text = deps.output.join('\n'); + expect(text).toContain('=== Group: dev-team ==='); + expect(text).toContain('Name:'); + expect(text).toContain('dev-team'); + expect(text).toContain('Description:'); + expect(text).toContain('Development team'); + expect(text).toContain('Members:'); + expect(text).toContain('EMAIL'); + expect(text).toContain('ADDED'); + expect(text).toContain('alice@test.com'); + expect(text).toContain('bob@test.com'); + expect(text).toContain('ID:'); + expect(text).toContain('grp-1'); + }); + + it('shows group detail with no members', async () => { + const deps = makeDeps({ + id: 'grp-2', + name: 'empty-group', + description: '', + members: [], + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'group', 'grp-2']); + + const text = deps.output.join('\n'); + expect(text).toContain('=== Group: empty-group ==='); + // No Members section when empty + expect(text).not.toContain('EMAIL'); + }); + + it('shows RBAC detail with subjects and bindings', async () => { + const deps = makeDeps({ + id: 'rbac-1', + name: 'developers', + subjects: [ + { kind: 'User', name: 'alice@test.com' }, + { kind: 'Group', name: 'dev-team' }, + ], + roleBindings: [ + { role: 'edit', resource: 'servers' }, + { role: 'view', resource: 'instances' }, + { role: 'view', resource: 'projects' }, + ], + createdAt: '2025-01-01', + updatedAt: '2025-01-15', + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']); + + expect(deps.fetchResource).toHaveBeenCalledWith('rbac', 'rbac-1'); + const text = deps.output.join('\n'); + expect(text).toContain('=== RBAC: developers ==='); + expect(text).toContain('Name:'); + expect(text).toContain('developers'); + // Subjects section + expect(text).toContain('Subjects:'); + expect(text).toContain('KIND'); + expect(text).toContain('NAME'); + expect(text).toContain('User'); + expect(text).toContain('alice@test.com'); + expect(text).toContain('Group'); + expect(text).toContain('dev-team'); + // Role Bindings section + expect(text).toContain('Resource Bindings:'); + expect(text).toContain('ROLE'); + expect(text).toContain('RESOURCE'); + expect(text).toContain('edit'); + expect(text).toContain('servers'); + expect(text).toContain('view'); + expect(text).toContain('instances'); + expect(text).toContain('projects'); + expect(text).toContain('ID:'); + expect(text).toContain('rbac-1'); + }); + + it('shows RBAC detail with wildcard resource', async () => { + const deps = makeDeps({ + id: 'rbac-2', + name: 'admins', + subjects: [{ kind: 'User', name: 'admin@test.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-2']); + + const text = deps.output.join('\n'); + expect(text).toContain('=== RBAC: admins ==='); + expect(text).toContain('edit'); + expect(text).toContain('*'); + }); + + it('shows RBAC detail with empty subjects and bindings', async () => { + const deps = makeDeps({ + id: 'rbac-3', + name: 'empty-rbac', + subjects: [], + roleBindings: [], + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-3']); + + const text = deps.output.join('\n'); + expect(text).toContain('=== RBAC: empty-rbac ==='); + // No Subjects or Role Bindings sections when empty + expect(text).not.toContain('KIND'); + expect(text).not.toContain('ROLE'); + expect(text).not.toContain('RESOURCE'); + }); + + it('shows RBAC detail with mixed resource and operation bindings', async () => { + const deps = makeDeps({ + id: 'rbac-1', + name: 'admin-access', + subjects: [{ kind: 'Group', name: 'admin' }], + roleBindings: [ + { role: 'edit', resource: '*' }, + { role: 'run', resource: 'projects' }, + { role: 'run', action: 'logs' }, + { role: 'run', action: 'backup' }, + ], + createdAt: '2025-01-01', + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('Resource Bindings:'); + expect(text).toContain('edit'); + expect(text).toContain('*'); + expect(text).toContain('run'); + expect(text).toContain('projects'); + expect(text).toContain('Operations:'); + expect(text).toContain('ACTION'); + expect(text).toContain('logs'); + expect(text).toContain('backup'); + }); + + it('shows RBAC detail with name-scoped resource binding', async () => { + const deps = makeDeps({ + id: 'rbac-1', + name: 'ha-viewer', + subjects: [{ kind: 'User', name: 'alice@test.com' }], + roleBindings: [ + { role: 'view', resource: 'servers', name: 'my-ha' }, + { role: 'edit', resource: 'secrets' }, + ], + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('Resource Bindings:'); + expect(text).toContain('NAME'); + expect(text).toContain('my-ha'); + expect(text).toContain('view'); + expect(text).toContain('servers'); + }); + + it('shows user with direct RBAC permissions', async () => { + const deps = makeDeps({ + id: 'usr-1', + email: 'alice@test.com', + name: 'Alice', + provider: null, + }); + vi.mocked(deps.client.get) + .mockResolvedValueOnce([] as never) // users list (resolveNameOrId) + .mockResolvedValueOnce([ // RBAC defs + { + name: 'dev-access', + subjects: [{ kind: 'User', name: 'alice@test.com' }], + roleBindings: [ + { role: 'edit', resource: 'servers' }, + { role: 'run', action: 'logs' }, + ], + }, + ] as never) + .mockResolvedValueOnce([] as never); // groups + + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'user', 'usr-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('=== User: alice@test.com ==='); + expect(text).toContain('Access:'); + expect(text).toContain('Direct (dev-access)'); + expect(text).toContain('Resources:'); + expect(text).toContain('edit'); + expect(text).toContain('servers'); + expect(text).toContain('Operations:'); + expect(text).toContain('logs'); + }); + + it('shows user with inherited group permissions', async () => { + const deps = makeDeps({ + id: 'usr-1', + email: 'bob@test.com', + name: 'Bob', + provider: null, + }); + vi.mocked(deps.client.get) + .mockResolvedValueOnce([] as never) // users list + .mockResolvedValueOnce([ // RBAC defs + { + name: 'team-perms', + subjects: [{ kind: 'Group', name: 'dev-team' }], + roleBindings: [ + { role: 'view', resource: '*' }, + { role: 'run', action: 'backup' }, + ], + }, + ] as never) + .mockResolvedValueOnce([ // groups + { name: 'dev-team', members: [{ user: { email: 'bob@test.com' } }] }, + ] as never); + + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'user', 'usr-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('Groups:'); + expect(text).toContain('dev-team'); + expect(text).toContain('Access:'); + expect(text).toContain('Inherited (dev-team)'); + expect(text).toContain('view'); + expect(text).toContain('*'); + expect(text).toContain('backup'); + }); + + it('shows user with no permissions', async () => { + const deps = makeDeps({ + id: 'usr-1', + email: 'nobody@test.com', + name: null, + provider: null, + }); + vi.mocked(deps.client.get) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([] as never); + + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'user', 'usr-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('Access: (none)'); + }); + + it('shows group with RBAC permissions', async () => { + const deps = makeDeps({ + id: 'grp-1', + name: 'admin', + description: 'Admin group', + members: [{ user: { email: 'alice@test.com' } }], + }); + vi.mocked(deps.client.get) + .mockResolvedValueOnce([] as never) // groups list (resolveNameOrId) + .mockResolvedValueOnce([ // RBAC defs + { + name: 'admin-access', + subjects: [{ kind: 'Group', name: 'admin' }], + roleBindings: [ + { role: 'edit', resource: '*' }, + { role: 'run', action: 'backup' }, + { role: 'run', action: 'restore' }, + ], + }, + ] as never); + + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'group', 'grp-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('=== Group: admin ==='); + expect(text).toContain('Access:'); + expect(text).toContain('Granted (admin-access)'); + expect(text).toContain('edit'); + expect(text).toContain('*'); + expect(text).toContain('backup'); + expect(text).toContain('restore'); + }); + + it('shows group with name-scoped permissions', async () => { + const deps = makeDeps({ + id: 'grp-1', + name: 'ha-team', + description: 'HA team', + members: [], + }); + vi.mocked(deps.client.get) + .mockResolvedValueOnce([] as never) + .mockResolvedValueOnce([ // RBAC defs + { + name: 'ha-access', + subjects: [{ kind: 'Group', name: 'ha-team' }], + roleBindings: [ + { role: 'edit', resource: 'servers', name: 'my-ha' }, + { role: 'view', resource: 'secrets' }, + ], + }, + ] as never); + + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'group', 'grp-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('Access:'); + expect(text).toContain('Granted (ha-access)'); + expect(text).toContain('my-ha'); + expect(text).toContain('NAME'); + }); + + it('outputs user detail as JSON', async () => { + const deps = makeDeps({ id: 'usr-1', email: 'alice@test.com', name: 'Alice', role: 'ADMIN' }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'user', 'usr-1', '-o', 'json']); + + const parsed = JSON.parse(deps.output[0] ?? ''); + expect(parsed.email).toBe('alice@test.com'); + expect(parsed.role).toBe('ADMIN'); + }); + + it('outputs group detail as YAML', async () => { + const deps = makeDeps({ id: 'grp-1', name: 'dev-team', description: 'Devs' }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'group', 'grp-1', '-o', 'yaml']); + + expect(deps.output[0]).toContain('name: dev-team'); + }); + + it('outputs rbac detail as JSON', async () => { + const deps = makeDeps({ + id: 'rbac-1', + name: 'devs', + subjects: [{ kind: 'User', name: 'a@b.com' }], + roleBindings: [{ role: 'edit', resource: 'servers' }], + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1', '-o', 'json']); + + const parsed = JSON.parse(deps.output[0] ?? ''); + expect(parsed.subjects).toHaveLength(1); + expect(parsed.roleBindings[0].role).toBe('edit'); + }); }); diff --git a/src/cli/tests/commands/get.test.ts b/src/cli/tests/commands/get.test.ts index e01de8b..83734e0 100644 --- a/src/cli/tests/commands/get.test.ts +++ b/src/cli/tests/commands/get.test.ts @@ -85,4 +85,173 @@ describe('get command', () => { await cmd.parseAsync(['node', 'test', 'servers']); expect(deps.output[0]).toContain('No servers found'); }); + + it('lists users with correct columns (no ROLE column)', async () => { + const deps = makeDeps([ + { id: 'usr-1', email: 'alice@test.com', name: 'Alice', provider: null }, + { id: 'usr-2', email: 'bob@test.com', name: null, provider: 'oidc' }, + ]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'users']); + + expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined); + const text = deps.output.join('\n'); + expect(text).toContain('EMAIL'); + expect(text).toContain('NAME'); + expect(text).not.toContain('ROLE'); + expect(text).toContain('PROVIDER'); + expect(text).toContain('alice@test.com'); + expect(text).toContain('Alice'); + expect(text).toContain('bob@test.com'); + expect(text).toContain('oidc'); + }); + + it('resolves user alias', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'user']); + expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined); + }); + + it('lists groups with correct columns', async () => { + const deps = makeDeps([ + { + id: 'grp-1', + name: 'dev-team', + description: 'Developers', + members: [{ user: { email: 'alice@test.com' } }, { user: { email: 'bob@test.com' } }], + }, + { id: 'grp-2', name: 'ops-team', description: 'Operations', members: [] }, + ]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'groups']); + + expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined); + const text = deps.output.join('\n'); + expect(text).toContain('NAME'); + expect(text).toContain('MEMBERS'); + expect(text).toContain('DESCRIPTION'); + expect(text).toContain('dev-team'); + expect(text).toContain('2'); + expect(text).toContain('ops-team'); + expect(text).toContain('0'); + }); + + it('resolves group alias', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'group']); + expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined); + }); + + it('lists rbac definitions with correct columns', async () => { + const deps = makeDeps([ + { + id: 'rbac-1', + name: 'admins', + subjects: [{ kind: 'User', name: 'admin@test.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + }, + ]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'rbac']); + + expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined); + const text = deps.output.join('\n'); + expect(text).toContain('NAME'); + expect(text).toContain('SUBJECTS'); + expect(text).toContain('BINDINGS'); + expect(text).toContain('admins'); + expect(text).toContain('User:admin@test.com'); + expect(text).toContain('edit:*'); + }); + + it('resolves rbac-definition alias', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'rbac-definition']); + expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined); + }); + + it('lists projects with new columns', async () => { + const deps = makeDeps([{ + id: 'proj-1', + name: 'smart-home', + description: 'Home automation', + 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']); + + 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 () => { + const deps = makeDeps([ + { + id: 'rbac-1', + name: 'admin-access', + subjects: [{ kind: 'Group', name: 'admin' }], + roleBindings: [ + { role: 'edit', resource: '*' }, + { role: 'run', action: 'logs' }, + { role: 'run', action: 'backup' }, + ], + }, + ]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'rbac']); + + const text = deps.output.join('\n'); + expect(text).toContain('edit:*'); + expect(text).toContain('run>logs'); + expect(text).toContain('run>backup'); + }); + + it('displays name-scoped resource bindings', async () => { + const deps = makeDeps([ + { + id: 'rbac-1', + name: 'ha-viewer', + subjects: [{ kind: 'User', name: 'alice@test.com' }], + roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }], + }, + ]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'rbac']); + + const text = deps.output.join('\n'); + expect(text).toContain('view:servers:my-ha'); + }); + + it('shows no results message for empty users list', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'users']); + expect(deps.output[0]).toContain('No users found'); + }); + + it('shows no results message for empty groups list', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'groups']); + expect(deps.output[0]).toContain('No groups found'); + }); + + it('shows no results message for empty rbac list', async () => { + const deps = makeDeps([]); + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'rbac']); + expect(deps.output[0]).toContain('No rbac found'); + }); }); diff --git a/src/cli/tests/commands/project.test.ts b/src/cli/tests/commands/project.test.ts index 911a1b6..c15c242 100644 --- a/src/cli/tests/commands/project.test.ts +++ b/src/cli/tests/commands/project.test.ts @@ -1,17 +1,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createProjectCommand } from '../../src/commands/project.js'; -import type { ApiClient } from '../../src/api-client.js'; +import { createCreateCommand } from '../../src/commands/create.js'; +import { createGetCommand } from '../../src/commands/get.js'; +import { createDescribeCommand } from '../../src/commands/describe.js'; +import { type ApiClient, ApiError } from '../../src/api-client.js'; function mockClient(): ApiClient { return { get: vi.fn(async () => []), - post: vi.fn(async () => ({ id: 'proj-1', name: 'my-project' })), + post: vi.fn(async () => ({ id: 'new-id', name: 'test' })), put: vi.fn(async () => ({})), delete: vi.fn(async () => {}), } as unknown as ApiClient; } -describe('project command', () => { +describe('project with new fields', () => { let client: ReturnType; let output: string[]; const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); @@ -21,9 +23,116 @@ describe('project command', () => { output = []; }); - it('creates command with alias', () => { - const cmd = createProjectCommand({ client, log }); - expect(cmd.name()).toBe('project'); - expect(cmd.alias()).toBe('proj'); + describe('create project with enhanced options', () => { + it('creates project with proxy mode and servers', async () => { + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'project', 'smart-home', + '-d', 'Smart home project', + '--proxy-mode', 'filtered', + '--llm-provider', 'gemini-cli', + '--llm-model', 'gemini-2.0-flash', + '--server', 'my-grafana', + '--server', 'my-ha', + ], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ + name: 'smart-home', + description: 'Smart home project', + proxyMode: 'filtered', + llmProvider: 'gemini-cli', + llmModel: 'gemini-2.0-flash', + servers: ['my-grafana', 'my-ha'], + })); + }); + + 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' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ + proxyMode: 'direct', + })); + }); + }); + + describe('get projects shows new columns', () => { + it('shows MODE, SERVERS, MEMBERS columns', async () => { + const deps = { + output: [] as string[], + fetchResource: vi.fn(async () => [{ + id: 'proj-1', + name: 'smart-home', + description: 'Test', + 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(' ')), + }; + const cmd = createGetCommand(deps); + await cmd.parseAsync(['node', 'test', 'projects']); + + 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 () => { + const deps = { + output: [] as string[], + client: mockClient(), + fetchResource: vi.fn(async () => ({ + id: 'proj-1', + name: 'smart-home', + description: 'Smart home', + proxyMode: 'filtered', + llmProvider: 'gemini-cli', + llmModel: 'gemini-2.0-flash', + ownerId: 'user-1', + servers: [ + { 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', + })), + log: (...args: string[]) => deps.output.push(args.join(' ')), + }; + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'project', 'proj-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('=== Project: smart-home ==='); + expect(text).toContain('filtered'); + 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/cli/tests/e2e/cli-commands.test.ts b/src/cli/tests/e2e/cli-commands.test.ts index 8d08c40..26e4a91 100644 --- a/src/cli/tests/e2e/cli-commands.test.ts +++ b/src/cli/tests/e2e/cli-commands.test.ts @@ -21,35 +21,44 @@ describe('CLI command registration (e2e)', () => { expect(commandNames).toContain('apply'); expect(commandNames).toContain('create'); expect(commandNames).toContain('edit'); - expect(commandNames).toContain('claude'); - expect(commandNames).toContain('project'); expect(commandNames).toContain('backup'); expect(commandNames).toContain('restore'); }); - it('instance command is removed (use get/delete/logs instead)', () => { + it('old project and claude top-level commands are removed', () => { const program = createProgram(); const commandNames = program.commands.map((c) => c.name()); + expect(commandNames).not.toContain('claude'); + expect(commandNames).not.toContain('project'); expect(commandNames).not.toContain('instance'); }); - it('claude command has config management subcommands', () => { + it('config command has claude-generate and impersonate subcommands', () => { const program = createProgram(); - const claude = program.commands.find((c) => c.name() === 'claude'); - expect(claude).toBeDefined(); + const config = program.commands.find((c) => c.name() === 'config'); + expect(config).toBeDefined(); - const subcommands = claude!.commands.map((c) => c.name()); - expect(subcommands).toContain('generate'); - expect(subcommands).toContain('show'); - expect(subcommands).toContain('add'); - expect(subcommands).toContain('remove'); + const subcommands = config!.commands.map((c) => c.name()); + expect(subcommands).toContain('claude-generate'); + expect(subcommands).toContain('impersonate'); + expect(subcommands).toContain('view'); + expect(subcommands).toContain('set'); + expect(subcommands).toContain('path'); + expect(subcommands).toContain('reset'); }); - it('project command exists with alias', () => { + it('create command has user, group, rbac subcommands', () => { const program = createProgram(); - const project = program.commands.find((c) => c.name() === 'project'); - expect(project).toBeDefined(); - expect(project!.alias()).toBe('proj'); + const create = program.commands.find((c) => c.name() === 'create'); + expect(create).toBeDefined(); + + const subcommands = create!.commands.map((c) => c.name()); + expect(subcommands).toContain('server'); + expect(subcommands).toContain('secret'); + expect(subcommands).toContain('project'); + expect(subcommands).toContain('user'); + expect(subcommands).toContain('group'); + expect(subcommands).toContain('rbac'); }); it('displays version', () => { diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index be685e6..92f2ca8 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -15,13 +15,17 @@ model User { name String? passwordHash String role Role @default(USER) + provider String? + externalId String? version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - sessions Session[] - auditLogs AuditLog[] - projects Project[] + sessions Session[] + auditLogs AuditLog[] + ownedProjects Project[] + projectMemberships ProjectMember[] + groupMemberships GroupMember[] @@index([email]) } @@ -71,6 +75,7 @@ model McpServer { templateVersion String? instances McpInstance[] + projects ProjectServer[] @@index([name]) } @@ -117,23 +122,95 @@ model Secret { @@index([name]) } +// ── Groups ── + +model Group { + id String @id @default(cuid()) + name String @unique + description String @default("") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + members GroupMember[] + + @@index([name]) +} + +model GroupMember { + id String @id @default(cuid()) + groupId String + userId String + createdAt DateTime @default(now()) + + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([groupId, userId]) + @@index([groupId]) + @@index([userId]) +} + +// ── RBAC Definitions ── + +model RbacDefinition { + id String @id @default(cuid()) + name String @unique + subjects Json @default("[]") + roleBindings Json @default("[]") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([name]) +} + // ── Projects ── model Project { id String @id @default(cuid()) name String @unique description String @default("") + proxyMode String @default("direct") + llmProvider String? + llmModel String? ownerId String version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + servers ProjectServer[] + members ProjectMember[] @@index([name]) @@index([ownerId]) } +model ProjectServer { + id String @id @default(cuid()) + projectId String + serverId String + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@unique([projectId, serverId]) +} + +model ProjectMember { + id String @id @default(cuid()) + projectId String + userId String + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([projectId, userId]) +} + // ── MCP Instances (running containers) ── model McpInstance { diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index 951e7f7..56c28c1 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -49,10 +49,15 @@ export async function clearAllTables(client: PrismaClient): Promise { // Delete in order respecting foreign keys await client.auditLog.deleteMany(); await client.mcpInstance.deleteMany(); + await client.projectServer.deleteMany(); + await client.projectMember.deleteMany(); await client.secret.deleteMany(); await client.session.deleteMany(); await client.project.deleteMany(); await client.mcpServer.deleteMany(); await client.mcpTemplate.deleteMany(); + await client.groupMember.deleteMany(); + await client.group.deleteMany(); + await client.rbacDefinition.deleteMany(); await client.user.deleteMany(); } diff --git a/src/db/tests/models.test.ts b/src/db/tests/models.test.ts index e4c7218..7465a54 100644 --- a/src/db/tests/models.test.ts +++ b/src/db/tests/models.test.ts @@ -29,6 +29,29 @@ async function createUser(overrides: { email?: string; name?: string; role?: 'US }); } +async function createGroup(overrides: { name?: string; description?: string } = {}) { + return prisma.group.create({ + data: { + name: overrides.name ?? `group-${Date.now()}`, + description: overrides.description ?? 'Test group', + }, + }); +} + +async function createProject(overrides: { name?: string; ownerId?: string } = {}) { + let ownerId = overrides.ownerId; + if (!ownerId) { + const user = await createUser(); + ownerId = user.id; + } + return prisma.project.create({ + data: { + name: overrides.name ?? `project-${Date.now()}`, + ownerId, + }, + }); +} + async function createServer(overrides: { name?: string; transport?: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP' } = {}) { return prisma.mcpServer.create({ data: { @@ -310,3 +333,236 @@ describe('AuditLog', () => { expect(logs).toHaveLength(0); }); }); + +// ── User SSO fields ── + +describe('User SSO fields', () => { + it('stores provider and externalId', async () => { + const user = await prisma.user.create({ + data: { + email: 'sso@example.com', + passwordHash: 'hash', + provider: 'oidc', + externalId: 'ext-123', + }, + }); + expect(user.provider).toBe('oidc'); + expect(user.externalId).toBe('ext-123'); + }); + + it('defaults provider and externalId to null', async () => { + const user = await createUser(); + expect(user.provider).toBeNull(); + expect(user.externalId).toBeNull(); + }); +}); + +// ── Group model ── + +describe('Group', () => { + it('creates a group with defaults', async () => { + const group = await createGroup(); + expect(group.id).toBeDefined(); + expect(group.version).toBe(1); + }); + + it('enforces unique name', async () => { + await createGroup({ name: 'devs' }); + await expect(createGroup({ name: 'devs' })).rejects.toThrow(); + }); + + it('creates group members', async () => { + const group = await createGroup(); + const user = await createUser(); + const member = await prisma.groupMember.create({ + data: { groupId: group.id, userId: user.id }, + }); + expect(member.groupId).toBe(group.id); + expect(member.userId).toBe(user.id); + }); + + it('enforces unique group-user pair', async () => { + const group = await createGroup(); + const user = await createUser(); + await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } }); + await expect( + prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } }), + ).rejects.toThrow(); + }); + + it('cascades delete when group is deleted', async () => { + const group = await createGroup(); + const user = await createUser(); + await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } }); + await prisma.group.delete({ where: { id: group.id } }); + const members = await prisma.groupMember.findMany({ where: { groupId: group.id } }); + expect(members).toHaveLength(0); + }); +}); + +// ── RbacDefinition model ── + +describe('RbacDefinition', () => { + it('creates with defaults', async () => { + const rbac = await prisma.rbacDefinition.create({ + data: { name: 'test-rbac' }, + }); + expect(rbac.subjects).toEqual([]); + expect(rbac.roleBindings).toEqual([]); + expect(rbac.version).toBe(1); + }); + + it('enforces unique name', async () => { + await prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } }); + await expect(prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } })).rejects.toThrow(); + }); + + it('stores subjects as JSON', async () => { + const rbac = await prisma.rbacDefinition.create({ + data: { + name: 'with-subjects', + subjects: [{ kind: 'User', name: 'alice@test.com' }, { kind: 'Group', name: 'devs' }], + }, + }); + const subjects = rbac.subjects as Array<{ kind: string; name: string }>; + expect(subjects).toHaveLength(2); + expect(subjects[0].kind).toBe('User'); + }); + + it('stores roleBindings as JSON', async () => { + const rbac = await prisma.rbacDefinition.create({ + data: { + name: 'with-bindings', + roleBindings: [{ role: 'editor', resource: 'servers' }], + }, + }); + const bindings = rbac.roleBindings as Array<{ role: string; resource: string }>; + expect(bindings).toHaveLength(1); + expect(bindings[0].role).toBe('editor'); + }); + + it('updates subjects and roleBindings', async () => { + const rbac = await prisma.rbacDefinition.create({ data: { name: 'updatable-rbac' } }); + const updated = await prisma.rbacDefinition.update({ + where: { id: rbac.id }, + data: { + subjects: [{ kind: 'User', name: 'bob@test.com' }], + roleBindings: [{ role: 'admin', resource: '*' }], + }, + }); + expect((updated.subjects as unknown[]).length).toBe(1); + expect((updated.roleBindings as unknown[]).length).toBe(1); + }); +}); + +// ── ProjectServer model ── + +describe('ProjectServer', () => { + it('links project to server', async () => { + const project = await createProject(); + const server = await createServer(); + const ps = await prisma.projectServer.create({ + data: { projectId: project.id, serverId: server.id }, + }); + expect(ps.projectId).toBe(project.id); + expect(ps.serverId).toBe(server.id); + }); + + it('enforces unique project-server pair', async () => { + const project = await createProject(); + const server = await createServer(); + await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }); + await expect( + prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }), + ).rejects.toThrow(); + }); + + it('cascades delete when project is deleted', async () => { + const project = await createProject(); + const server = await createServer(); + await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }); + await prisma.project.delete({ where: { id: project.id } }); + const links = await prisma.projectServer.findMany({ where: { projectId: project.id } }); + expect(links).toHaveLength(0); + }); + + it('cascades delete when server is deleted', async () => { + const project = await createProject(); + const server = await createServer(); + await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }); + await prisma.mcpServer.delete({ where: { id: server.id } }); + const links = await prisma.projectServer.findMany({ where: { serverId: server.id } }); + expect(links).toHaveLength(0); + }); +}); + +// ── ProjectMember model ── + +describe('ProjectMember', () => { + it('links project to user with role', async () => { + const user = await createUser(); + const project = await createProject({ ownerId: user.id }); + const pm = await prisma.projectMember.create({ + data: { projectId: project.id, userId: user.id, role: 'admin' }, + }); + expect(pm.role).toBe('admin'); + }); + + it('defaults role to member', async () => { + const user = await createUser(); + const project = await createProject({ ownerId: user.id }); + const pm = await prisma.projectMember.create({ + data: { projectId: project.id, userId: user.id }, + }); + expect(pm.role).toBe('member'); + }); + + it('enforces unique project-user pair', async () => { + const user = await createUser(); + const project = await createProject({ ownerId: user.id }); + await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } }); + await expect( + prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } }), + ).rejects.toThrow(); + }); + + it('cascades delete when project is deleted', async () => { + const user = await createUser(); + const project = await createProject({ ownerId: user.id }); + await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } }); + await prisma.project.delete({ where: { id: project.id } }); + const members = await prisma.projectMember.findMany({ where: { projectId: project.id } }); + expect(members).toHaveLength(0); + }); +}); + +// ── Project new fields ── + +describe('Project new fields', () => { + it('defaults proxyMode to direct', async () => { + const project = await createProject(); + expect(project.proxyMode).toBe('direct'); + }); + + it('stores proxyMode, llmProvider, llmModel', async () => { + const user = await createUser(); + const project = await prisma.project.create({ + data: { + name: 'filtered-project', + ownerId: user.id, + proxyMode: 'filtered', + llmProvider: 'gemini-cli', + llmModel: 'gemini-2.0-flash', + }, + }); + expect(project.proxyMode).toBe('filtered'); + expect(project.llmProvider).toBe('gemini-cli'); + expect(project.llmModel).toBe('gemini-2.0-flash'); + }); + + it('defaults llmProvider and llmModel to null', async () => { + const project = await createProject(); + expect(project.llmProvider).toBeNull(); + expect(project.llmModel).toBeNull(); + }); +}); diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index cca9960..4ed867c 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -14,6 +14,9 @@ import { ProjectRepository, AuditLogRepository, TemplateRepository, + RbacDefinitionRepository, + UserRepository, + GroupRepository, } from './repositories/index.js'; import { McpServerService, @@ -30,7 +33,13 @@ import { McpProxyService, TemplateService, HealthProbeRunner, + RbacDefinitionService, + RbacService, + UserService, + GroupService, } from './services/index.js'; +import type { RbacAction } from './services/index.js'; +import { createAuthMiddleware } from './middleware/auth.js'; import { registerMcpServerRoutes, registerSecretRoutes, @@ -42,8 +51,72 @@ import { registerAuthRoutes, registerMcpProxyRoutes, registerTemplateRoutes, + registerRbacRoutes, + registerUserRoutes, + registerGroupRoutes, } from './routes/index.js'; +type PermissionCheck = + | { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string } + | { kind: 'operation'; operation: string } + | { kind: 'skip' }; + +/** + * Map an HTTP method + URL to a permission check. + * Returns 'skip' for URLs that should not be RBAC-checked. + */ +function mapUrlToPermission(method: string, url: string): PermissionCheck { + const match = url.match(/^\/api\/v1\/([a-z-]+)/); + if (!match) return { kind: 'skip' }; + + const segment = match[1] as string; + + // Operations (non-resource endpoints) + if (segment === 'backup') return { kind: 'operation', operation: 'backup' }; + if (segment === 'restore') return { kind: 'operation', operation: 'restore' }; + if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' }; + + const resourceMap: Record = { + 'servers': 'servers', + 'instances': 'instances', + 'secrets': 'secrets', + 'projects': 'projects', + 'templates': 'templates', + 'users': 'users', + 'groups': 'groups', + 'rbac': 'rbac', + 'audit-logs': 'rbac', + 'mcp': 'servers', + }; + + const resource = resourceMap[segment]; + if (resource === undefined) return { kind: 'skip' }; + + // Map HTTP method to action + let action: RbacAction; + switch (method) { + case 'GET': + case 'HEAD': + action = 'view'; + break; + case 'POST': + action = 'create'; + break; + case 'DELETE': + action = 'delete'; + break; + default: // PUT, PATCH + action = 'edit'; + break; + } + + // Extract resource name/ID from URL (3rd segment: /api/v1/servers/:nameOrId) + const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/); + const resourceName = nameMatch?.[1]; + + return { kind: 'resource', resource, action, resourceName }; +} + async function main(): Promise { const config = loadConfigFromEnv(); @@ -82,6 +155,9 @@ async function main(): Promise { const projectRepo = new ProjectRepository(prisma); const auditLogRepo = new AuditLogRepository(prisma); const templateRepo = new TemplateRepository(prisma); + const rbacDefinitionRepo = new RbacDefinitionRepository(prisma); + const userRepo = new UserRepository(prisma); + const groupRepo = new GroupRepository(prisma); // Orchestrator const orchestrator = new DockerContainerManager(); @@ -91,15 +167,24 @@ 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); + const projectService = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo); const auditLogService = new AuditLogService(auditLogRepo); const metricsCollector = new MetricsCollector(); const healthAggregator = new HealthAggregator(metricsCollector, orchestrator); - const backupService = new BackupService(serverRepo, projectRepo, secretRepo); - const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo); + const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo); + const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo); const authService = new AuthService(prisma); const templateService = new TemplateService(templateRepo); const mcpProxyService = new McpProxyService(instanceRepo, serverRepo); + const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo); + const rbacService = new RbacService(rbacDefinitionRepo, prisma); + const userService = new UserService(userRepo); + const groupService = new GroupService(groupRepo, userRepo); + + // Auth middleware for global hooks + const authMiddleware = createAuthMiddleware({ + findSession: (token) => authService.findSession(token), + }); // Server const app = await createServer(config, { @@ -115,6 +200,43 @@ async function main(): Promise { }, }); + // ── Global auth hook ── + // Runs on all /api/v1/* routes EXCEPT auth endpoints and health checks. + // Tests that use createServer() directly are NOT affected — this hook + // is only registered here in main.ts. + app.addHook('preHandler', async (request, reply) => { + const url = request.url; + // Skip auth for health, auth, and root + if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return; + if (!url.startsWith('/api/v1/')) return; + + // Run auth middleware + await authMiddleware(request, reply); + }); + + // ── Global RBAC hook ── + // Runs after the auth hook. Maps URL to resource+action and checks permissions. + app.addHook('preHandler', async (request, reply) => { + if (reply.sent) return; // Auth hook already rejected + const url = request.url; + if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return; + if (!url.startsWith('/api/v1/')) return; + if (request.userId === undefined) return; // Auth hook will handle 401 + + const check = mapUrlToPermission(request.method, url); + if (check.kind === 'skip') return; + + let allowed: boolean; + if (check.kind === 'operation') { + allowed = await rbacService.canRunOperation(request.userId, check.operation); + } else { + allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName); + } + if (!allowed) { + reply.code(403).send({ error: 'Forbidden' }); + } + }); + // Routes registerMcpServerRoutes(app, serverService, instanceService); registerTemplateRoutes(app, templateService); @@ -124,12 +246,15 @@ async function main(): Promise { registerAuditLogRoutes(app, auditLogService); registerHealthMonitoringRoutes(app, { healthAggregator, metricsCollector }); registerBackupRoutes(app, { backupService, restoreService }); - registerAuthRoutes(app, { authService }); + registerAuthRoutes(app, { authService, userService, groupService, rbacDefinitionService, rbacService }); registerMcpProxyRoutes(app, { mcpProxyService, auditLogService, authDeps: { findSession: (token) => authService.findSession(token) }, }); + registerRbacRoutes(app, rbacDefinitionService); + registerUserRoutes(app, userService); + registerGroupRoutes(app, groupService); // Start await app.listen({ port: config.port, host: config.host }); diff --git a/src/mcpd/src/middleware/rbac.ts b/src/mcpd/src/middleware/rbac.ts new file mode 100644 index 0000000..3576118 --- /dev/null +++ b/src/mcpd/src/middleware/rbac.ts @@ -0,0 +1,36 @@ +import type { FastifyRequest, FastifyReply } from 'fastify'; +import type { RbacService, RbacAction } from '../services/rbac.service.js'; + +export function createRbacMiddleware(rbacService: RbacService) { + function requirePermission(resource: string, action: RbacAction, resourceName?: string) { + return async (request: FastifyRequest, reply: FastifyReply): Promise => { + if (request.userId === undefined) { + reply.code(401).send({ error: 'Authentication required' }); + return; + } + + const allowed = await rbacService.canAccess(request.userId, action, resource, resourceName); + if (!allowed) { + reply.code(403).send({ error: 'Forbidden' }); + return; + } + }; + } + + function requireOperation(operation: string) { + return async (request: FastifyRequest, reply: FastifyReply): Promise => { + if (request.userId === undefined) { + reply.code(401).send({ error: 'Authentication required' }); + return; + } + + const allowed = await rbacService.canRunOperation(request.userId, operation); + if (!allowed) { + reply.code(403).send({ error: 'Forbidden' }); + return; + } + }; + } + + return { requirePermission, requireOperation }; +} diff --git a/src/mcpd/src/repositories/group.repository.ts b/src/mcpd/src/repositories/group.repository.ts new file mode 100644 index 0000000..e20168f --- /dev/null +++ b/src/mcpd/src/repositories/group.repository.ts @@ -0,0 +1,93 @@ +import type { PrismaClient, Group } from '@prisma/client'; + +export interface GroupWithMembers extends Group { + members: Array<{ id: string; user: { id: string; email: string; name: string | null } }>; +} + +export interface IGroupRepository { + findAll(): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + create(data: { name: string; description?: string }): Promise; + update(id: string, data: { description?: string }): Promise; + delete(id: string): Promise; + setMembers(groupId: string, userIds: string[]): Promise; + findGroupsForUser(userId: string): Promise>; +} + +const MEMBERS_INCLUDE = { + members: { + select: { + id: true, + user: { + select: { id: true, email: true, name: true }, + }, + }, + }, +} as const; + +export class GroupRepository implements IGroupRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(): Promise { + return this.prisma.group.findMany({ + orderBy: { name: 'asc' }, + include: MEMBERS_INCLUDE, + }); + } + + async findById(id: string): Promise { + return this.prisma.group.findUnique({ + where: { id }, + include: MEMBERS_INCLUDE, + }); + } + + async findByName(name: string): Promise { + return this.prisma.group.findUnique({ + where: { name }, + include: MEMBERS_INCLUDE, + }); + } + + async create(data: { name: string; description?: string }): Promise { + const createData: Record = { name: data.name }; + if (data.description !== undefined) createData['description'] = data.description; + return this.prisma.group.create({ + data: createData as Parameters[0]['data'], + }); + } + + async update(id: string, data: { description?: string }): Promise { + const updateData: Record = {}; + if (data.description !== undefined) updateData['description'] = data.description; + return this.prisma.group.update({ where: { id }, data: updateData }); + } + + async delete(id: string): Promise { + await this.prisma.group.delete({ where: { id } }); + } + + async setMembers(groupId: string, userIds: string[]): Promise { + await this.prisma.$transaction(async (tx) => { + await tx.groupMember.deleteMany({ where: { groupId } }); + if (userIds.length > 0) { + await tx.groupMember.createMany({ + data: userIds.map((userId) => ({ groupId, userId })), + }); + } + }); + } + + async findGroupsForUser(userId: string): Promise> { + const memberships = await this.prisma.groupMember.findMany({ + where: { userId }, + select: { + group: { + select: { id: true, name: true }, + }, + }, + }); + return memberships.map((m) => m.group); + } +} diff --git a/src/mcpd/src/repositories/index.ts b/src/mcpd/src/repositories/index.ts index 4d09960..bacf4cd 100644 --- a/src/mcpd/src/repositories/index.ts +++ b/src/mcpd/src/repositories/index.ts @@ -1,9 +1,15 @@ export type { IMcpServerRepository, IMcpInstanceRepository, ISecretRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js'; export { McpServerRepository } from './mcp-server.repository.js'; export { SecretRepository } from './secret.repository.js'; -export type { IProjectRepository } from './project.repository.js'; +export type { IProjectRepository, ProjectWithRelations } from './project.repository.js'; export { ProjectRepository } from './project.repository.js'; export { McpInstanceRepository } from './mcp-instance.repository.js'; export { AuditLogRepository } from './audit-log.repository.js'; export type { ITemplateRepository } from './template.repository.js'; export { TemplateRepository } from './template.repository.js'; +export type { IRbacDefinitionRepository } from './rbac-definition.repository.js'; +export { RbacDefinitionRepository } from './rbac-definition.repository.js'; +export type { IUserRepository, SafeUser } from './user.repository.js'; +export { UserRepository } from './user.repository.js'; +export type { IGroupRepository, GroupWithMembers } from './group.repository.js'; +export { GroupRepository } from './group.repository.js'; diff --git a/src/mcpd/src/repositories/project.repository.ts b/src/mcpd/src/repositories/project.repository.ts index 97acf10..0e718e6 100644 --- a/src/mcpd/src/repositories/project.repository.ts +++ b/src/mcpd/src/repositories/project.repository.ts @@ -1,49 +1,89 @@ import type { PrismaClient, Project } from '@prisma/client'; -import type { CreateProjectInput, UpdateProjectInput } from '../validation/project.schema.js'; + +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 { - findAll(ownerId?: string): Promise; - findById(id: string): Promise; - findByName(name: string): Promise; - create(data: CreateProjectInput & { ownerId: string }): Promise; - update(id: string, data: UpdateProjectInput): Promise; + findAll(ownerId?: string): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise; + update(id: string, data: Record): Promise; delete(id: string): Promise; + setServers(projectId: string, serverIds: string[]): Promise; + setMembers(projectId: string, userIds: string[]): Promise; } export class ProjectRepository implements IProjectRepository { constructor(private readonly prisma: PrismaClient) {} - async findAll(ownerId?: string): Promise { + async findAll(ownerId?: string): Promise { const where = ownerId !== undefined ? { ownerId } : {}; - return this.prisma.project.findMany({ where, orderBy: { name: 'asc' } }); + return this.prisma.project.findMany({ where, orderBy: { name: 'asc' }, include: PROJECT_INCLUDE }) as unknown as Promise; } - async findById(id: string): Promise { - return this.prisma.project.findUnique({ where: { id } }); + async findById(id: string): Promise { + return this.prisma.project.findUnique({ where: { id }, include: PROJECT_INCLUDE }) as unknown as Promise; } - async findByName(name: string): Promise { - return this.prisma.project.findUnique({ where: { name } }); + async findByName(name: string): Promise { + return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise; } - async create(data: CreateProjectInput & { ownerId: string }): Promise { + async create(data: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise { + const createData: Record = { + name: data.name, + description: data.description, + ownerId: data.ownerId, + proxyMode: data.proxyMode, + }; + if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider; + if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel; + return this.prisma.project.create({ - data: { - name: data.name, - description: data.description, - ownerId: data.ownerId, - }, - }); + data: createData as Parameters[0]['data'], + include: PROJECT_INCLUDE, + }) as unknown as Promise; } - async update(id: string, data: UpdateProjectInput): Promise { - const updateData: Record = {}; - if (data.description !== undefined) updateData['description'] = data.description; - return this.prisma.project.update({ where: { id }, data: updateData }); + async update(id: string, data: Record): Promise { + return this.prisma.project.update({ + where: { id }, + data, + include: PROJECT_INCLUDE, + }) as unknown as Promise; } async delete(id: string): Promise { await this.prisma.project.delete({ where: { id } }); } + async setServers(projectId: string, serverIds: string[]): Promise { + await this.prisma.$transaction(async (tx) => { + await tx.projectServer.deleteMany({ where: { projectId } }); + if (serverIds.length > 0) { + await tx.projectServer.createMany({ + data: serverIds.map((serverId) => ({ projectId, serverId })), + }); + } + }); + } + + 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 })), + }); + } + }); + } } diff --git a/src/mcpd/src/repositories/rbac-definition.repository.ts b/src/mcpd/src/repositories/rbac-definition.repository.ts new file mode 100644 index 0000000..2dccf3a --- /dev/null +++ b/src/mcpd/src/repositories/rbac-definition.repository.ts @@ -0,0 +1,48 @@ +import type { PrismaClient, RbacDefinition } from '@prisma/client'; +import type { CreateRbacDefinitionInput, UpdateRbacDefinitionInput } from '../validation/rbac-definition.schema.js'; + +export interface IRbacDefinitionRepository { + findAll(): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + create(data: CreateRbacDefinitionInput): Promise; + update(id: string, data: UpdateRbacDefinitionInput): Promise; + delete(id: string): Promise; +} + +export class RbacDefinitionRepository implements IRbacDefinitionRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(): Promise { + return this.prisma.rbacDefinition.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.rbacDefinition.findUnique({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.prisma.rbacDefinition.findUnique({ where: { name } }); + } + + async create(data: CreateRbacDefinitionInput): Promise { + return this.prisma.rbacDefinition.create({ + data: { + name: data.name, + subjects: data.subjects, + roleBindings: data.roleBindings, + }, + }); + } + + async update(id: string, data: UpdateRbacDefinitionInput): Promise { + const updateData: Record = {}; + if (data.subjects !== undefined) updateData['subjects'] = data.subjects; + if (data.roleBindings !== undefined) updateData['roleBindings'] = data.roleBindings; + return this.prisma.rbacDefinition.update({ where: { id }, data: updateData }); + } + + async delete(id: string): Promise { + await this.prisma.rbacDefinition.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/user.repository.ts b/src/mcpd/src/repositories/user.repository.ts new file mode 100644 index 0000000..7f20f08 --- /dev/null +++ b/src/mcpd/src/repositories/user.repository.ts @@ -0,0 +1,76 @@ +import type { PrismaClient, User } from '@prisma/client'; + +/** User without the passwordHash field — safe for API responses. */ +export type SafeUser = Omit; + +export interface IUserRepository { + findAll(): Promise; + findById(id: string): Promise; + findByEmail(email: string, includeHash?: boolean): Promise | Promise; + create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise; + delete(id: string): Promise; + count(): Promise; +} + +/** Fields to select when passwordHash must be excluded. */ +const safeSelect = { + id: true, + email: true, + name: true, + role: true, + provider: true, + externalId: true, + version: true, + createdAt: true, + updatedAt: true, +} as const; + +export class UserRepository implements IUserRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(): Promise { + return this.prisma.user.findMany({ + select: safeSelect, + orderBy: { email: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.user.findUnique({ + where: { id }, + select: safeSelect, + }); + } + + async findByEmail(email: string, includeHash?: boolean): Promise { + if (includeHash === true) { + return this.prisma.user.findUnique({ where: { email } }); + } + return this.prisma.user.findUnique({ + where: { email }, + select: safeSelect, + }); + } + + async create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise { + const createData: Record = { + email: data.email, + passwordHash: data.passwordHash, + }; + if (data.name !== undefined) createData['name'] = data.name; + if (data.role !== undefined) createData['role'] = data.role; + + return this.prisma.user.create({ + data: createData as Parameters[0]['data'], + select: safeSelect, + }); + } + + async delete(id: string): Promise { + await this.prisma.user.delete({ where: { id } }); + } + + async count(): Promise { + return this.prisma.user.count(); + } +} diff --git a/src/mcpd/src/routes/auth.ts b/src/mcpd/src/routes/auth.ts index 6f2abb3..7a68420 100644 --- a/src/mcpd/src/routes/auth.ts +++ b/src/mcpd/src/routes/auth.ts @@ -1,15 +1,76 @@ import type { FastifyInstance } from 'fastify'; import type { AuthService } from '../services/auth.service.js'; +import type { UserService } from '../services/user.service.js'; +import type { GroupService } from '../services/group.service.js'; +import type { RbacDefinitionService } from '../services/rbac-definition.service.js'; +import type { RbacService } from '../services/rbac.service.js'; import { createAuthMiddleware } from '../middleware/auth.js'; +import { createRbacMiddleware } from '../middleware/rbac.js'; export interface AuthRouteDeps { authService: AuthService; + userService: UserService; + groupService: GroupService; + rbacDefinitionService: RbacDefinitionService; + rbacService: RbacService; } export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): void { const authMiddleware = createAuthMiddleware({ findSession: (token) => deps.authService.findSession(token), }); + const { requireOperation } = createRbacMiddleware(deps.rbacService); + + // GET /api/v1/auth/status — unauthenticated, returns whether any users exist + app.get('/api/v1/auth/status', async () => { + const count = await deps.userService.count(); + return { hasUsers: count > 0 }; + }); + + // POST /api/v1/auth/bootstrap — only works when no users exist (first-run setup) + app.post('/api/v1/auth/bootstrap', async (request, reply) => { + const count = await deps.userService.count(); + if (count > 0) { + reply.code(409).send({ error: 'Users already exist. Use login instead.' }); + return; + } + + const { email, password, name } = request.body as { email: string; password: string; name?: string }; + + // Create the first admin user + await deps.userService.create({ + email, + password, + ...(name !== undefined ? { name } : {}), + }); + + // Create "admin" group and add the first user to it + await deps.groupService.create({ + name: 'admin', + description: 'Bootstrap admin group', + members: [email], + }); + + // Create bootstrap RBAC: full resource access + all operations + await deps.rbacDefinitionService.create({ + name: 'bootstrap-admin', + subjects: [{ kind: 'Group', name: 'admin' }], + roleBindings: [ + { role: 'edit', resource: '*' }, + { role: 'run', resource: '*' }, + { role: 'run', action: 'impersonate' }, + { role: 'run', action: 'logs' }, + { role: 'run', action: 'backup' }, + { role: 'run', action: 'restore' }, + { role: 'run', action: 'audit-purge' }, + ], + }); + + // Auto-login so the caller gets a token immediately + const session = await deps.authService.login(email, password); + reply.code(201); + return session; + }); // POST /api/v1/auth/login — no auth required app.post<{ @@ -28,4 +89,15 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v await deps.authService.logout(token); return { success: true }; }); + + // POST /api/v1/auth/impersonate — requires auth + run:impersonate operation + app.post( + '/api/v1/auth/impersonate', + { preHandler: [authMiddleware, requireOperation('impersonate')] }, + async (request) => { + const { email } = request.body as { email: string }; + const result = await deps.authService.impersonate(email); + return result; + }, + ); } diff --git a/src/mcpd/src/routes/backup.ts b/src/mcpd/src/routes/backup.ts index acfcc6a..aa84dd5 100644 --- a/src/mcpd/src/routes/backup.ts +++ b/src/mcpd/src/routes/backup.ts @@ -13,7 +13,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo app.post<{ Body: { password?: string; - resources?: Array<'servers' | 'secrets' | 'projects'>; + resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac'>; }; }>('/api/v1/backup', async (request) => { const opts: BackupOptions = {}; @@ -51,7 +51,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo const result = await deps.restoreService.restore(bundle, restoreOpts); - if (result.errors.length > 0 && result.serversCreated === 0 && result.secretsCreated === 0 && result.projectsCreated === 0) { + if (result.errors.length > 0 && result.serversCreated === 0 && result.secretsCreated === 0 && result.projectsCreated === 0 && result.usersCreated === 0 && result.groupsCreated === 0 && result.rbacCreated === 0) { reply.code(422); } diff --git a/src/mcpd/src/routes/groups.ts b/src/mcpd/src/routes/groups.ts new file mode 100644 index 0000000..7a0dc50 --- /dev/null +++ b/src/mcpd/src/routes/groups.ts @@ -0,0 +1,35 @@ +import type { FastifyInstance } from 'fastify'; +import type { GroupService } from '../services/group.service.js'; + +export function registerGroupRoutes( + app: FastifyInstance, + service: GroupService, +): void { + app.get('/api/v1/groups', async () => { + return service.list(); + }); + + app.get<{ Params: { id: string } }>('/api/v1/groups/:id', async (request) => { + // Try by ID first, fall back to name lookup + try { + return await service.getById(request.params.id); + } catch { + return service.getByName(request.params.id); + } + }); + + app.post('/api/v1/groups', async (request, reply) => { + const group = await service.create(request.body); + reply.code(201); + return group; + }); + + app.put<{ Params: { id: string } }>('/api/v1/groups/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/groups/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); +} diff --git a/src/mcpd/src/routes/index.ts b/src/mcpd/src/routes/index.ts index c7bf00a..f2d4056 100644 --- a/src/mcpd/src/routes/index.ts +++ b/src/mcpd/src/routes/index.ts @@ -14,3 +14,6 @@ export type { AuthRouteDeps } from './auth.js'; export { registerMcpProxyRoutes } from './mcp-proxy.js'; export type { McpProxyRouteDeps } from './mcp-proxy.js'; export { registerTemplateRoutes } from './templates.js'; +export { registerRbacRoutes } from './rbac-definitions.js'; +export { registerUserRoutes } from './users.js'; +export { registerGroupRoutes } from './groups.js'; diff --git a/src/mcpd/src/routes/projects.ts b/src/mcpd/src/routes/projects.ts index b026219..416f735 100644 --- a/src/mcpd/src/routes/projects.ts +++ b/src/mcpd/src/routes/projects.ts @@ -8,7 +8,7 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ }); app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => { - return service.getById(request.params.id); + return service.resolveAndGet(request.params.id); }); app.post('/api/v1/projects', async (request, reply) => { @@ -19,11 +19,24 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ }); app.put<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => { - return service.update(request.params.id, request.body); + const project = await service.resolveAndGet(request.params.id); + return service.update(project.id, request.body); }); app.delete<{ Params: { id: string } }>('/api/v1/projects/:id', async (request, reply) => { - await service.delete(request.params.id); + const project = await service.resolveAndGet(request.params.id); + await service.delete(project.id); reply.code(204); }); + + // Generate .mcp.json for a project + app.get<{ Params: { id: string } }>('/api/v1/projects/:id/mcp-config', async (request) => { + return service.generateMcpConfig(request.params.id); + }); + + // 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); + return project.servers.map((ps) => ps.server); + }); } diff --git a/src/mcpd/src/routes/rbac-definitions.ts b/src/mcpd/src/routes/rbac-definitions.ts new file mode 100644 index 0000000..9f0c37b --- /dev/null +++ b/src/mcpd/src/routes/rbac-definitions.ts @@ -0,0 +1,30 @@ +import type { FastifyInstance } from 'fastify'; +import type { RbacDefinitionService } from '../services/rbac-definition.service.js'; + +export function registerRbacRoutes( + app: FastifyInstance, + service: RbacDefinitionService, +): void { + app.get('/api/v1/rbac', async () => { + return service.list(); + }); + + app.get<{ Params: { id: string } }>('/api/v1/rbac/:id', async (request) => { + return service.getById(request.params.id); + }); + + app.post('/api/v1/rbac', async (request, reply) => { + const def = await service.create(request.body); + reply.code(201); + return def; + }); + + app.put<{ Params: { id: string } }>('/api/v1/rbac/:id', async (request) => { + return service.update(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/rbac/:id', async (request, reply) => { + await service.delete(request.params.id); + reply.code(204); + }); +} diff --git a/src/mcpd/src/routes/users.ts b/src/mcpd/src/routes/users.ts new file mode 100644 index 0000000..80adaf1 --- /dev/null +++ b/src/mcpd/src/routes/users.ts @@ -0,0 +1,31 @@ +import type { FastifyInstance } from 'fastify'; +import type { UserService } from '../services/user.service.js'; + +export function registerUserRoutes( + app: FastifyInstance, + service: UserService, +): void { + app.get('/api/v1/users', async () => { + return service.list(); + }); + + app.get<{ Params: { id: string } }>('/api/v1/users/:id', async (request) => { + // Support lookup by email (contains @) or by id + const idOrEmail = request.params.id; + if (idOrEmail.includes('@')) { + return service.getByEmail(idOrEmail); + } + return service.getById(idOrEmail); + }); + + app.post('/api/v1/users', async (request, reply) => { + const user = await service.create(request.body); + reply.code(201); + return user; + }); + + app.delete<{ Params: { id: string } }>('/api/v1/users/:id', async (_request, reply) => { + await service.delete(_request.params.id); + reply.code(204); + }); +} diff --git a/src/mcpd/src/services/auth.service.ts b/src/mcpd/src/services/auth.service.ts index f53d591..88ed958 100644 --- a/src/mcpd/src/services/auth.service.ts +++ b/src/mcpd/src/services/auth.service.ts @@ -63,4 +63,32 @@ export class AuthService { } return { userId: session.userId, expiresAt: session.expiresAt }; } + + /** + * Create a session for a user by email without requiring their password. + * Used for admin impersonation. + */ + async impersonate(email: string): Promise { + const user = await this.prisma.user.findUnique({ where: { email } }); + if (user === null) { + throw new AuthenticationError('User not found'); + } + + const token = randomUUID(); + const expiresAt = new Date(Date.now() + SESSION_TTL_MS); + + await this.prisma.session.create({ + data: { + token, + userId: user.id, + expiresAt, + }, + }); + + return { + token, + expiresAt, + user: { id: user.id, email: user.email, role: user.role }, + }; + } } diff --git a/src/mcpd/src/services/backup/backup-service.ts b/src/mcpd/src/services/backup/backup-service.ts index c2a97cb..47f8123 100644 --- a/src/mcpd/src/services/backup/backup-service.ts +++ b/src/mcpd/src/services/backup/backup-service.ts @@ -1,5 +1,8 @@ import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js'; import type { IProjectRepository } from '../../repositories/project.repository.js'; +import type { IUserRepository } from '../../repositories/user.repository.js'; +import type { IGroupRepository } from '../../repositories/group.repository.js'; +import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js'; import { encrypt, isSensitiveKey } from './crypto.js'; import type { EncryptedPayload } from './crypto.js'; import { APP_VERSION } from '@mcpctl/shared'; @@ -12,6 +15,9 @@ export interface BackupBundle { servers: BackupServer[]; secrets: BackupSecret[]; projects: BackupProject[]; + users?: BackupUser[]; + groups?: BackupGroup[]; + rbacBindings?: BackupRbacBinding[]; encryptedSecrets?: EncryptedPayload; } @@ -33,11 +39,35 @@ export interface BackupSecret { export interface BackupProject { name: string; description: string; + proxyMode?: string; + llmProvider?: string | null; + llmModel?: string | null; + serverNames?: string[]; + members?: string[]; +} + +export interface BackupUser { + email: string; + name: string | null; + role: string; + provider: string | null; +} + +export interface BackupGroup { + name: string; + description: string; + memberEmails: string[]; +} + +export interface BackupRbacBinding { + name: string; + subjects: unknown; + roleBindings: unknown; } export interface BackupOptions { password?: string; - resources?: Array<'servers' | 'secrets' | 'projects'>; + resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac'>; } export class BackupService { @@ -45,14 +75,20 @@ export class BackupService { private serverRepo: IMcpServerRepository, private projectRepo: IProjectRepository, private secretRepo: ISecretRepository, + private userRepo?: IUserRepository, + private groupRepo?: IGroupRepository, + private rbacRepo?: IRbacDefinitionRepository, ) {} async createBackup(options?: BackupOptions): Promise { - const resources = options?.resources ?? ['servers', 'secrets', 'projects']; + const resources = options?.resources ?? ['servers', 'secrets', 'projects', 'users', 'groups', 'rbac']; let servers: BackupServer[] = []; let secrets: BackupSecret[] = []; let projects: BackupProject[] = []; + let users: BackupUser[] = []; + let groups: BackupGroup[] = []; + let rbacBindings: BackupRbacBinding[] = []; if (resources.includes('servers')) { const allServers = await this.serverRepo.findAll(); @@ -80,6 +116,39 @@ export class BackupService { projects = allProjects.map((proj) => ({ name: proj.name, description: proj.description, + proxyMode: proj.proxyMode, + llmProvider: proj.llmProvider, + llmModel: proj.llmModel, + serverNames: proj.servers.map((ps) => ps.server.name), + members: proj.members.map((pm) => pm.user.email), + })); + } + + if (resources.includes('users') && this.userRepo) { + const allUsers = await this.userRepo.findAll(); + users = allUsers.map((u) => ({ + email: u.email, + name: u.name, + role: u.role, + provider: u.provider, + })); + } + + if (resources.includes('groups') && this.groupRepo) { + const allGroups = await this.groupRepo.findAll(); + groups = allGroups.map((g) => ({ + name: g.name, + description: g.description, + memberEmails: g.members.map((m) => m.user.email), + })); + } + + if (resources.includes('rbac') && this.rbacRepo) { + const allRbac = await this.rbacRepo.findAll(); + rbacBindings = allRbac.map((r) => ({ + name: r.name, + subjects: r.subjects, + roleBindings: r.roleBindings, })); } @@ -91,6 +160,9 @@ export class BackupService { servers, secrets, projects, + users, + groups, + rbacBindings, }; if (options?.password && secrets.length > 0) { diff --git a/src/mcpd/src/services/backup/restore-service.ts b/src/mcpd/src/services/backup/restore-service.ts index ff0235d..8c56738 100644 --- a/src/mcpd/src/services/backup/restore-service.ts +++ b/src/mcpd/src/services/backup/restore-service.ts @@ -1,5 +1,8 @@ import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js'; import type { IProjectRepository } from '../../repositories/project.repository.js'; +import type { IUserRepository } from '../../repositories/user.repository.js'; +import type { IGroupRepository } from '../../repositories/group.repository.js'; +import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js'; import { decrypt } from './crypto.js'; import type { BackupBundle } from './backup-service.js'; @@ -17,6 +20,12 @@ export interface RestoreResult { secretsSkipped: number; projectsCreated: number; projectsSkipped: number; + usersCreated: number; + usersSkipped: number; + groupsCreated: number; + groupsSkipped: number; + rbacCreated: number; + rbacSkipped: number; errors: string[]; } @@ -25,6 +34,9 @@ export class RestoreService { private serverRepo: IMcpServerRepository, private projectRepo: IProjectRepository, private secretRepo: ISecretRepository, + private userRepo?: IUserRepository, + private groupRepo?: IGroupRepository, + private rbacRepo?: IRbacDefinitionRepository, ) {} validateBundle(bundle: unknown): bundle is BackupBundle { @@ -36,6 +48,7 @@ export class RestoreService { Array.isArray(b['secrets']) && Array.isArray(b['projects']) ); + // users, groups, rbacBindings are optional for backwards compatibility } async restore(bundle: BackupBundle, options?: RestoreOptions): Promise { @@ -47,6 +60,12 @@ export class RestoreService { secretsSkipped: 0, projectsCreated: 0, projectsSkipped: 0, + usersCreated: 0, + usersSkipped: 0, + groupsCreated: 0, + groupsSkipped: 0, + rbacCreated: 0, + rbacSkipped: 0, errors: [], }; @@ -78,6 +97,37 @@ export class RestoreService { } } + // Restore order: secrets → servers → users → groups → projects → rbacBindings + + // Restore secrets + for (const secret of bundle.secrets) { + try { + const existing = await this.secretRepo.findByName(secret.name); + if (existing) { + if (strategy === 'fail') { + result.errors.push(`Secret "${secret.name}" already exists`); + return result; + } + if (strategy === 'skip') { + result.secretsSkipped++; + continue; + } + // overwrite + await this.secretRepo.update(existing.id, { data: secret.data }); + result.secretsCreated++; + continue; + } + + await this.secretRepo.create({ + name: secret.name, + data: secret.data, + }); + result.secretsCreated++; + } catch (err) { + result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`); + } + } + // Restore servers for (const server of bundle.servers) { try { @@ -121,36 +171,75 @@ export class RestoreService { } } - // Restore secrets - for (const secret of bundle.secrets) { - try { - const existing = await this.secretRepo.findByName(secret.name); - if (existing) { - if (strategy === 'fail') { - result.errors.push(`Secret "${secret.name}" already exists`); - return result; - } - if (strategy === 'skip') { - result.secretsSkipped++; + // Restore users + if (bundle.users && this.userRepo) { + for (const user of bundle.users) { + try { + const existing = await this.userRepo.findByEmail(user.email); + if (existing) { + if (strategy === 'fail') { + result.errors.push(`User "${user.email}" already exists`); + return result; + } + result.usersSkipped++; continue; } - // overwrite - await this.secretRepo.update(existing.id, { data: secret.data }); - result.secretsCreated++; - continue; - } - await this.secretRepo.create({ - name: secret.name, - data: secret.data, - }); - result.secretsCreated++; - } catch (err) { - result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`); + // Create with placeholder passwordHash — user must reset password + const createData: { email: string; passwordHash: string; name?: string; role?: string } = { + email: user.email, + passwordHash: '__RESTORED_MUST_RESET__', + role: user.role, + }; + if (user.name !== null) createData.name = user.name; + await this.userRepo.create(createData); + result.usersCreated++; + } catch (err) { + result.errors.push(`Failed to restore user "${user.email}": ${err instanceof Error ? err.message : String(err)}`); + } } } - // Restore projects + // Restore groups + if (bundle.groups && this.groupRepo && this.userRepo) { + for (const group of bundle.groups) { + try { + const existing = await this.groupRepo.findByName(group.name); + if (existing) { + if (strategy === 'fail') { + result.errors.push(`Group "${group.name}" already exists`); + return result; + } + if (strategy === 'skip') { + result.groupsSkipped++; + continue; + } + // overwrite: update description and re-set members + await this.groupRepo.update(existing.id, { description: group.description }); + if (group.memberEmails.length > 0) { + const memberIds = await this.resolveUserEmails(group.memberEmails); + await this.groupRepo.setMembers(existing.id, memberIds); + } + result.groupsCreated++; + continue; + } + + const created = await this.groupRepo.create({ + name: group.name, + description: group.description, + }); + if (group.memberEmails.length > 0) { + const memberIds = await this.resolveUserEmails(group.memberEmails); + await this.groupRepo.setMembers(created.id, memberIds); + } + result.groupsCreated++; + } catch (err) { + result.errors.push(`Failed to restore group "${group.name}": ${err instanceof Error ? err.message : String(err)}`); + } + } + } + + // Restore projects (enriched) for (const project of bundle.projects) { try { const existing = await this.projectRepo.findByName(project.name); @@ -164,22 +253,120 @@ export class RestoreService { continue; } // overwrite - await this.projectRepo.update(existing.id, { description: project.description }); + const updateData: Record = { description: project.description }; + if (project.proxyMode) updateData['proxyMode'] = project.proxyMode; + if (project.llmProvider !== undefined) updateData['llmProvider'] = project.llmProvider; + if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel; + await this.projectRepo.update(existing.id, updateData); + + // Re-link servers and members + 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; } - await this.projectRepo.create({ + const projectCreateData: { name: string; description: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string } = { name: project.name, description: project.description, ownerId: 'system', - }); + proxyMode: project.proxyMode ?? 'direct', + }; + if (project.llmProvider != null) projectCreateData.llmProvider = project.llmProvider; + if (project.llmModel != null) projectCreateData.llmModel = project.llmModel; + const created = await this.projectRepo.create(projectCreateData); + + // Link servers + if (project.serverNames && project.serverNames.length > 0) { + 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) { result.errors.push(`Failed to restore project "${project.name}": ${err instanceof Error ? err.message : String(err)}`); } } + // Restore RBAC bindings + if (bundle.rbacBindings && this.rbacRepo) { + for (const rbac of bundle.rbacBindings) { + try { + const existing = await this.rbacRepo.findByName(rbac.name); + if (existing) { + if (strategy === 'fail') { + result.errors.push(`RBAC binding "${rbac.name}" already exists`); + return result; + } + if (strategy === 'skip') { + result.rbacSkipped++; + continue; + } + // overwrite + await this.rbacRepo.update(existing.id, { + subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>, + roleBindings: rbac.roleBindings as Array<{ role: string; resource: string } | { role: 'run'; action: string }>, + }); + result.rbacCreated++; + continue; + } + + await this.rbacRepo.create({ + name: rbac.name, + subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>, + roleBindings: rbac.roleBindings as Array<{ role: string; resource: string } | { role: 'run'; action: string }>, + }); + result.rbacCreated++; + } catch (err) { + result.errors.push(`Failed to restore RBAC binding "${rbac.name}": ${err instanceof Error ? err.message : String(err)}`); + } + } + } + return result; } + + /** Resolve email addresses to user IDs via the user repository. */ + private async resolveUserEmails(emails: string[]): Promise { + const ids: string[] = []; + for (const email of emails) { + const user = await this.userRepo!.findByEmail(email); + if (user) ids.push(user.id); + } + return ids; + } + + /** Resolve server names to server IDs via the server repository. */ + private async resolveServerNames(names: string[]): Promise { + const ids: string[] = []; + for (const name of names) { + const server = await this.serverRepo.findByName(name); + if (server) ids.push(server.id); + } + 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/group.service.ts b/src/mcpd/src/services/group.service.ts new file mode 100644 index 0000000..d2076de --- /dev/null +++ b/src/mcpd/src/services/group.service.ts @@ -0,0 +1,89 @@ +import type { GroupWithMembers, IGroupRepository } from '../repositories/group.repository.js'; +import type { IUserRepository } from '../repositories/user.repository.js'; +import { CreateGroupSchema, UpdateGroupSchema } from '../validation/group.schema.js'; +import { NotFoundError, ConflictError } from './mcp-server.service.js'; + +export class GroupService { + constructor( + private readonly groupRepo: IGroupRepository, + private readonly userRepo: IUserRepository, + ) {} + + async list(): Promise { + return this.groupRepo.findAll(); + } + + async getById(id: string): Promise { + const group = await this.groupRepo.findById(id); + if (group === null) { + throw new NotFoundError(`Group not found: ${id}`); + } + return group; + } + + async getByName(name: string): Promise { + const group = await this.groupRepo.findByName(name); + if (group === null) { + throw new NotFoundError(`Group not found: ${name}`); + } + return group; + } + + async create(input: unknown): Promise { + const data = CreateGroupSchema.parse(input); + + const existing = await this.groupRepo.findByName(data.name); + if (existing !== null) { + throw new ConflictError(`Group already exists: ${data.name}`); + } + + const group = await this.groupRepo.create({ + name: data.name, + description: data.description, + }); + + if (data.members.length > 0) { + const userIds = await this.resolveEmails(data.members); + await this.groupRepo.setMembers(group.id, userIds); + } + + const result = await this.groupRepo.findById(group.id); + // Should always exist since we just created it + return result!; + } + + async update(id: string, input: unknown): Promise { + const data = UpdateGroupSchema.parse(input); + + // Verify exists + await this.getById(id); + + if (data.description !== undefined) { + await this.groupRepo.update(id, { description: data.description }); + } + + if (data.members !== undefined) { + const userIds = await this.resolveEmails(data.members); + await this.groupRepo.setMembers(id, userIds); + } + + return this.getById(id); + } + + async delete(id: string): Promise { + await this.getById(id); + await this.groupRepo.delete(id); + } + + private async resolveEmails(emails: string[]): Promise { + const userIds: string[] = []; + for (const email of emails) { + const user = await this.userRepo.findByEmail(email); + if (user === null) { + throw new NotFoundError(`User not found: ${email}`); + } + userIds.push(user.id); + } + return userIds; + } +} diff --git a/src/mcpd/src/services/index.ts b/src/mcpd/src/services/index.ts index f775088..c6816d6 100644 --- a/src/mcpd/src/services/index.ts +++ b/src/mcpd/src/services/index.ts @@ -27,3 +27,8 @@ export type { McpProxyRequest, McpProxyResponse } from './mcp-proxy-service.js'; export { TemplateService } from './template.service.js'; export { HealthProbeRunner } from './health-probe.service.js'; export type { HealthCheckSpec, ProbeResult } from './health-probe.service.js'; +export { RbacDefinitionService } from './rbac-definition.service.js'; +export { RbacService } from './rbac.service.js'; +export type { RbacAction, Permission } from './rbac.service.js'; +export { UserService } from './user.service.js'; +export { GroupService } from './group.service.js'; diff --git a/src/mcpd/src/services/mcp-config-generator.ts b/src/mcpd/src/services/mcp-config-generator.ts index 35e1513..e47f3a9 100644 --- a/src/mcpd/src/services/mcp-config-generator.ts +++ b/src/mcpd/src/services/mcp-config-generator.ts @@ -1,8 +1,10 @@ import type { McpServer } from '@prisma/client'; export interface McpConfigServer { - command: string; - args: string[]; + command?: string; + args?: string[]; + url?: string; + headers?: Record; env?: Record; } @@ -19,16 +21,24 @@ export function generateMcpConfig( const mcpServers: Record = {}; for (const { server, resolvedEnv } of servers) { - const config: McpConfigServer = { - command: 'npx', - args: ['-y', server.packageName ?? server.name], - }; + if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') { + // Point at mcpd proxy URL for non-STDIO transports + mcpServers[server.name] = { + url: `http://localhost:3100/api/v1/mcp/proxy/${server.name}`, + }; + } else { + // STDIO — npx command approach + const config: McpConfigServer = { + command: 'npx', + args: ['-y', server.packageName ?? server.name], + }; - if (Object.keys(resolvedEnv).length > 0) { - config.env = resolvedEnv; + if (Object.keys(resolvedEnv).length > 0) { + config.env = resolvedEnv; + } + + mcpServers[server.name] = config; } - - mcpServers[server.name] = config; } return { mcpServers }; diff --git a/src/mcpd/src/services/project.service.ts b/src/mcpd/src/services/project.service.ts index 72a88bf..5274176 100644 --- a/src/mcpd/src/services/project.service.ts +++ b/src/mcpd/src/services/project.service.ts @@ -1,18 +1,26 @@ -import type { Project } from '@prisma/client'; -import type { IProjectRepository } from '../repositories/project.repository.js'; +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'; +import { generateMcpConfig } from './mcp-config-generator.js'; +import type { McpConfig } from './mcp-config-generator.js'; export class ProjectService { constructor( private readonly projectRepo: IProjectRepository, + private readonly serverRepo: IMcpServerRepository, + private readonly secretRepo: ISecretRepository, + private readonly userRepo: IUserRepository, ) {} - async list(ownerId?: string): Promise { + async list(ownerId?: string): Promise { return this.projectRepo.findAll(ownerId); } - async getById(id: string): Promise { + async getById(id: string): Promise { const project = await this.projectRepo.findById(id); if (project === null) { throw new NotFoundError(`Project not found: ${id}`); @@ -20,7 +28,20 @@ export class ProjectService { return project; } - async create(input: unknown, ownerId: string): Promise { + /** Resolve by ID or name. */ + async resolveAndGet(idOrName: string): Promise { + // Try by ID first + const byId = await this.projectRepo.findById(idOrName); + if (byId !== null) return byId; + + // Fall back to name + const byName = await this.projectRepo.findByName(idOrName); + if (byName !== null) return byName; + + throw new NotFoundError(`Project not found: ${idOrName}`); + } + + async create(input: unknown, ownerId: string): Promise { const data = CreateProjectSchema.parse(input); const existing = await this.projectRepo.findByName(data.name); @@ -28,17 +49,111 @@ export class ProjectService { throw new ConflictError(`Project already exists: ${data.name}`); } - return this.projectRepo.create({ ...data, ownerId }); + // 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, + ownerId, + proxyMode: data.proxyMode, + ...(data.llmProvider !== undefined ? { llmProvider: data.llmProvider } : {}), + ...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}), + }); + + // Link servers and members + 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); } - async update(id: string, input: unknown): Promise { + async update(id: string, input: unknown): Promise { const data = UpdateProjectSchema.parse(input); - await this.getById(id); - return this.projectRepo.update(id, data); + const project = await this.getById(id); + + // Build update data for scalar fields + const updateData: Record = {}; + if (data.description !== undefined) updateData['description'] = data.description; + if (data.proxyMode !== undefined) updateData['proxyMode'] = data.proxyMode; + if (data.llmProvider !== undefined) updateData['llmProvider'] = data.llmProvider; + if (data.llmModel !== undefined) updateData['llmModel'] = data.llmModel; + + // Update scalar fields if any changed + if (Object.keys(updateData).length > 0) { + await this.projectRepo.update(project.id, updateData); + } + + // Update servers if provided + if (data.servers !== undefined) { + const serverIds = await this.resolveServerNames(data.servers); + 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); } async delete(id: string): Promise { await this.getById(id); await this.projectRepo.delete(id); } + + async generateMcpConfig(idOrName: string): Promise { + const project = await this.resolveAndGet(idOrName); + + if (project.proxyMode === 'filtered') { + // Single entry pointing at mcplocal proxy + return { + mcpServers: { + [project.name]: { + url: `http://localhost:3100/api/v1/mcp/proxy/project/${project.name}`, + }, + }, + }; + } + + // Direct mode: fetch full servers and resolve env + const serverEntries: Array<{ server: McpServer; resolvedEnv: Record }> = []; + + for (const ps of project.servers) { + const server = await this.serverRepo.findById(ps.server.id); + if (server === null) continue; + + const resolvedEnv = await resolveServerEnv(server, this.secretRepo); + serverEntries.push({ server, resolvedEnv }); + } + + return generateMcpConfig(serverEntries); + } + + private async resolveServerNames(names: string[]): Promise { + return Promise.all(names.map(async (name) => { + const server = await this.serverRepo.findByName(name); + if (server === null) throw new NotFoundError(`Server not found: ${name}`); + 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-definition.service.ts b/src/mcpd/src/services/rbac-definition.service.ts new file mode 100644 index 0000000..943a313 --- /dev/null +++ b/src/mcpd/src/services/rbac-definition.service.ts @@ -0,0 +1,54 @@ +import type { RbacDefinition } from '@prisma/client'; +import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js'; +import { CreateRbacDefinitionSchema, UpdateRbacDefinitionSchema } from '../validation/rbac-definition.schema.js'; +import { NotFoundError, ConflictError } from './mcp-server.service.js'; + +export class RbacDefinitionService { + constructor(private readonly repo: IRbacDefinitionRepository) {} + + async list(): Promise { + return this.repo.findAll(); + } + + async getById(id: string): Promise { + const def = await this.repo.findById(id); + if (def === null) { + throw new NotFoundError(`RbacDefinition not found: ${id}`); + } + return def; + } + + async getByName(name: string): Promise { + const def = await this.repo.findByName(name); + if (def === null) { + throw new NotFoundError(`RbacDefinition not found: ${name}`); + } + return def; + } + + async create(input: unknown): Promise { + const data = CreateRbacDefinitionSchema.parse(input); + + const existing = await this.repo.findByName(data.name); + if (existing !== null) { + throw new ConflictError(`RbacDefinition already exists: ${data.name}`); + } + + return this.repo.create(data); + } + + async update(id: string, input: unknown): Promise { + const data = UpdateRbacDefinitionSchema.parse(input); + + // Verify exists + await this.getById(id); + + return this.repo.update(id, data); + } + + async delete(id: string): Promise { + // Verify exists + await this.getById(id); + await this.repo.delete(id); + } +} diff --git a/src/mcpd/src/services/rbac.service.ts b/src/mcpd/src/services/rbac.service.ts new file mode 100644 index 0000000..4cbb8f6 --- /dev/null +++ b/src/mcpd/src/services/rbac.service.ts @@ -0,0 +1,130 @@ +import type { PrismaClient } from '@prisma/client'; +import type { IRbacDefinitionRepository } from '../repositories/rbac-definition.repository.js'; +import { + normalizeResource, + isResourceBinding, + isOperationBinding, + type RbacSubject, + type RbacRoleBinding, +} from '../validation/rbac-definition.schema.js'; + +export type RbacAction = 'view' | 'create' | 'delete' | 'edit' | 'run'; + +export interface ResourcePermission { + role: string; + resource: string; + name?: string; +} + +export interface OperationPermission { + role: 'run'; + action: string; +} + +export type Permission = ResourcePermission | OperationPermission; + +/** Maps roles to the set of actions they grant. */ +const ROLE_ACTIONS: Record = { + edit: ['view', 'create', 'delete', 'edit'], + view: ['view'], + create: ['create'], + delete: ['delete'], + run: ['run'], +}; + +export class RbacService { + constructor( + private readonly rbacRepo: IRbacDefinitionRepository, + private readonly prisma: PrismaClient, + ) {} + + /** + * Check whether a user is allowed to perform an action on a resource. + * @param resourceName — optional specific resource name (e.g. 'my-ha'). + * If provided, name-scoped bindings only match when their name equals this. + * If omitted (listing), name-scoped bindings still grant access. + */ + async canAccess(userId: string, action: RbacAction, resource: string, resourceName?: string): Promise { + const permissions = await this.getPermissions(userId); + const normalized = normalizeResource(resource); + + for (const perm of permissions) { + if (!('resource' in perm)) continue; + const actions = ROLE_ACTIONS[perm.role]; + if (actions === undefined) continue; + if (!actions.includes(action)) continue; + const permResource = normalizeResource(perm.resource); + if (permResource !== '*' && permResource !== normalized) continue; + // Name-scoped check: if binding has a name AND caller specified a resourceName, must match + if (perm.name !== undefined && resourceName !== undefined && perm.name !== resourceName) continue; + return true; + } + + return false; + } + + /** + * Check whether a user is allowed to perform a named operation. + * Operations require an explicit 'run' role binding with a matching action. + */ + async canRunOperation(userId: string, operation: string): Promise { + const permissions = await this.getPermissions(userId); + + for (const perm of permissions) { + if ('action' in perm && perm.role === 'run' && perm.action === operation) { + return true; + } + } + + return false; + } + + /** + * Collect all permissions for a user across all matching RbacDefinitions. + */ + async getPermissions(userId: string): Promise { + // 1. Resolve user email + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { email: true }, + }); + if (user === null) return []; + + // 2. Resolve group names the user belongs to + const memberships = await this.prisma.groupMember.findMany({ + where: { userId }, + select: { group: { select: { name: true } } }, + }); + const groupNames = memberships.map((m) => m.group.name); + + // 3. Load all RbacDefinitions + const definitions = await this.rbacRepo.findAll(); + + // 4. Find definitions where user is a subject + const permissions: Permission[] = []; + for (const def of definitions) { + const subjects = def.subjects as RbacSubject[]; + const matched = subjects.some((s) => { + if (s.kind === 'User') return s.name === user.email; + if (s.kind === 'Group') return groupNames.includes(s.name); + return false; + }); + + if (!matched) continue; + + // 5. Collect roleBindings + const bindings = def.roleBindings as RbacRoleBinding[]; + for (const binding of bindings) { + if (isResourceBinding(binding)) { + const perm: ResourcePermission = { role: binding.role, resource: binding.resource }; + if (binding.name !== undefined) perm.name = binding.name; + permissions.push(perm); + } else if (isOperationBinding(binding)) { + permissions.push({ role: 'run', action: binding.action }); + } + } + } + + return permissions; + } +} diff --git a/src/mcpd/src/services/user.service.ts b/src/mcpd/src/services/user.service.ts new file mode 100644 index 0000000..7c9e9ca --- /dev/null +++ b/src/mcpd/src/services/user.service.ts @@ -0,0 +1,60 @@ +import bcrypt from 'bcrypt'; +import type { IUserRepository, SafeUser } from '../repositories/user.repository.js'; +import { CreateUserSchema } from '../validation/user.schema.js'; +import { NotFoundError, ConflictError } from './mcp-server.service.js'; + +const SALT_ROUNDS = 10; + +export class UserService { + constructor(private readonly userRepo: IUserRepository) {} + + async list(): Promise { + return this.userRepo.findAll(); + } + + async getById(id: string): Promise { + const user = await this.userRepo.findById(id); + if (user === null) { + throw new NotFoundError(`User not found: ${id}`); + } + return user; + } + + async getByEmail(email: string): Promise { + const user = await this.userRepo.findByEmail(email); + if (user === null) { + throw new NotFoundError(`User not found: ${email}`); + } + return user; + } + + async create(input: unknown): Promise { + const data = CreateUserSchema.parse(input); + + const existing = await this.userRepo.findByEmail(data.email); + if (existing !== null) { + throw new ConflictError(`User already exists: ${data.email}`); + } + + const passwordHash = await bcrypt.hash(data.password, SALT_ROUNDS); + + const createData: { email: string; passwordHash: string; name?: string } = { + email: data.email, + passwordHash, + }; + if (data.name !== undefined) { + createData.name = data.name; + } + + return this.userRepo.create(createData); + } + + async delete(id: string): Promise { + await this.getById(id); + await this.userRepo.delete(id); + } + + async count(): Promise { + return this.userRepo.count(); + } +} diff --git a/src/mcpd/src/validation/group.schema.ts b/src/mcpd/src/validation/group.schema.ts new file mode 100644 index 0000000..71f201d --- /dev/null +++ b/src/mcpd/src/validation/group.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const CreateGroupSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), + description: z.string().max(1000).default(''), + members: z.array(z.string().email()).default([]), +}); + +export const UpdateGroupSchema = z.object({ + description: z.string().max(1000).optional(), + members: z.array(z.string().email()).optional(), +}); + +export type CreateGroupInput = z.infer; +export type UpdateGroupInput = z.infer; diff --git a/src/mcpd/src/validation/index.ts b/src/mcpd/src/validation/index.ts index a183575..74a0ee6 100644 --- a/src/mcpd/src/validation/index.ts +++ b/src/mcpd/src/validation/index.ts @@ -1,4 +1,6 @@ export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js'; export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js'; export { CreateProjectSchema, UpdateProjectSchema } from './project.schema.js'; -export type { CreateProjectInput, UpdateProjectInput } from './project.schema.js'; +export type { CreateProjectInput, UpdateProjectInput, ProjectMemberInput } from './project.schema.js'; +export { CreateRbacDefinitionSchema, UpdateRbacDefinitionSchema, RbacSubjectSchema, RbacRoleBindingSchema, RBAC_ROLES, RBAC_RESOURCES } from './rbac-definition.schema.js'; +export type { CreateRbacDefinitionInput, UpdateRbacDefinitionInput, RbacSubject, RbacRoleBinding } from './rbac-definition.schema.js'; diff --git a/src/mcpd/src/validation/project.schema.ts b/src/mcpd/src/validation/project.schema.ts index 355f7d7..0d62ecb 100644 --- a/src/mcpd/src/validation/project.schema.ts +++ b/src/mcpd/src/validation/project.schema.ts @@ -3,10 +3,23 @@ import { z } from 'zod'; export const CreateProjectSchema = z.object({ name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), description: z.string().max(1000).default(''), -}); + proxyMode: z.enum(['direct', 'filtered']).default('direct'), + 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"' }, +); export const UpdateProjectSchema = z.object({ description: z.string().max(1000).optional(), + proxyMode: z.enum(['direct', 'filtered']).optional(), + 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 new file mode 100644 index 0000000..9806ebc --- /dev/null +++ b/src/mcpd/src/validation/rbac-definition.schema.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; + +export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run'] as const; +export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac'] as const; + +/** Singular→plural map for resource names. */ +const RESOURCE_ALIASES: Record = { + server: 'servers', + instance: 'instances', + secret: 'secrets', + project: 'projects', + template: 'templates', + user: 'users', + group: 'groups', +}; + +/** Normalize a resource name to its canonical plural form. */ +export function normalizeResource(resource: string): string { + return RESOURCE_ALIASES[resource] ?? resource; +} + +export const RbacSubjectSchema = z.object({ + kind: z.enum(['User', 'Group']), + name: z.string().min(1), +}); + +/** Resource binding: role grants access to a resource type (optionally scoped to a named instance). */ +export const ResourceBindingSchema = z.object({ + role: z.enum(RBAC_ROLES), + resource: z.string().min(1).transform(normalizeResource), + name: z.string().min(1).optional(), +}); + +/** Operation binding: 'run' role grants access to a named operation. */ +export const OperationBindingSchema = z.object({ + role: z.literal('run'), + action: z.string().min(1), +}); + +/** Union of both binding types. */ +export const RbacRoleBindingSchema = z.union([ + ResourceBindingSchema, + OperationBindingSchema, +]); + +export type RbacSubject = z.infer; +export type ResourceBinding = z.infer; +export type OperationBinding = z.infer; +export type RbacRoleBinding = z.infer; + +export function isResourceBinding(b: RbacRoleBinding): b is ResourceBinding { + return 'resource' in b; +} + +export function isOperationBinding(b: RbacRoleBinding): b is OperationBinding { + return 'action' in b; +} + +export const CreateRbacDefinitionSchema = z.object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), + subjects: z.array(RbacSubjectSchema).min(1), + roleBindings: z.array(RbacRoleBindingSchema).min(1), +}); + +export const UpdateRbacDefinitionSchema = z.object({ + subjects: z.array(RbacSubjectSchema).min(1).optional(), + roleBindings: z.array(RbacRoleBindingSchema).min(1).optional(), +}); + +export type CreateRbacDefinitionInput = z.infer; +export type UpdateRbacDefinitionInput = z.infer; diff --git a/src/mcpd/src/validation/user.schema.ts b/src/mcpd/src/validation/user.schema.ts new file mode 100644 index 0000000..3db8be4 --- /dev/null +++ b/src/mcpd/src/validation/user.schema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const CreateUserSchema = z.object({ + email: z.string().email(), + password: z.string().min(8).max(128), + name: z.string().max(100).optional(), +}); + +export const UpdateUserSchema = z.object({ + name: z.string().max(100).optional(), + password: z.string().min(8).max(128).optional(), +}); + +export type CreateUserInput = z.infer; +export type UpdateUserInput = z.infer; diff --git a/src/mcpd/tests/auth-bootstrap.test.ts b/src/mcpd/tests/auth-bootstrap.test.ts new file mode 100644 index 0000000..051bff6 --- /dev/null +++ b/src/mcpd/tests/auth-bootstrap.test.ts @@ -0,0 +1,424 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { registerAuthRoutes } from '../src/routes/auth.js'; +import { errorHandler } from '../src/middleware/error-handler.js'; +import type { AuthService, LoginResult } from '../src/services/auth.service.js'; +import type { UserService } from '../src/services/user.service.js'; +import type { GroupService } from '../src/services/group.service.js'; +import type { RbacDefinitionService } from '../src/services/rbac-definition.service.js'; +import type { RbacService, RbacAction } from '../src/services/rbac.service.js'; +import type { SafeUser } from '../src/repositories/user.repository.js'; +import type { RbacDefinition } from '@prisma/client'; + +let app: FastifyInstance; + +afterEach(async () => { + if (app) await app.close(); +}); + +function makeLoginResult(overrides?: Partial): LoginResult { + return { + token: 'test-token-123', + expiresAt: new Date(Date.now() + 86400_000), + user: { id: 'user-1', email: 'admin@example.com', role: 'user' }, + ...overrides, + }; +} + +function makeSafeUser(overrides?: Partial): SafeUser { + return { + id: 'user-1', + email: 'admin@example.com', + name: null, + role: 'user', + provider: 'local', + externalId: null, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeRbacDef(overrides?: Partial): RbacDefinition { + return { + id: 'rbac-1', + name: 'bootstrap-admin', + subjects: [{ kind: 'Group', name: 'admin' }], + roleBindings: [ + { role: 'edit', resource: '*' }, + { role: 'run', resource: '*' }, + { role: 'run', action: 'impersonate' }, + { role: 'run', action: 'logs' }, + { role: 'run', action: 'backup' }, + { role: 'run', action: 'restore' }, + { role: 'run', action: 'audit-purge' }, + ], + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +interface MockDeps { + authService: { + login: ReturnType; + logout: ReturnType; + findSession: ReturnType; + impersonate: ReturnType; + }; + userService: { + count: ReturnType; + create: ReturnType; + list: ReturnType; + getById: ReturnType; + getByEmail: ReturnType; + delete: ReturnType; + }; + groupService: { + create: ReturnType; + list: ReturnType; + getById: ReturnType; + getByName: ReturnType; + update: ReturnType; + delete: ReturnType; + }; + rbacDefinitionService: { + create: ReturnType; + list: ReturnType; + getById: ReturnType; + getByName: ReturnType; + update: ReturnType; + delete: ReturnType; + }; + rbacService: { + canAccess: ReturnType; + canRunOperation: ReturnType; + getPermissions: ReturnType; + }; +} + +function createMockDeps(): MockDeps { + return { + authService: { + login: vi.fn(async () => makeLoginResult()), + logout: vi.fn(async () => {}), + findSession: vi.fn(async () => null), + impersonate: vi.fn(async () => makeLoginResult({ token: 'impersonated-token' })), + }, + userService: { + count: vi.fn(async () => 0), + create: vi.fn(async () => makeSafeUser()), + list: vi.fn(async () => []), + getById: vi.fn(async () => makeSafeUser()), + getByEmail: vi.fn(async () => makeSafeUser()), + delete: vi.fn(async () => {}), + }, + groupService: { + create: vi.fn(async () => ({ id: 'grp-1', name: 'admin', description: 'Bootstrap admin group', members: [] })), + list: vi.fn(async () => []), + getById: vi.fn(async () => null), + getByName: vi.fn(async () => null), + update: vi.fn(async () => null), + delete: vi.fn(async () => {}), + }, + rbacDefinitionService: { + create: vi.fn(async () => makeRbacDef()), + list: vi.fn(async () => []), + getById: vi.fn(async () => makeRbacDef()), + getByName: vi.fn(async () => null), + update: vi.fn(async () => makeRbacDef()), + delete: vi.fn(async () => {}), + }, + rbacService: { + canAccess: vi.fn(async () => false), + canRunOperation: vi.fn(async () => false), + getPermissions: vi.fn(async () => []), + }, + }; +} + +function createApp(deps: MockDeps): Promise { + app = Fastify({ logger: false }); + app.setErrorHandler(errorHandler); + registerAuthRoutes(app, deps as unknown as { + authService: AuthService; + userService: UserService; + groupService: GroupService; + rbacDefinitionService: RbacDefinitionService; + rbacService: RbacService; + }); + return app.ready(); +} + +describe('Auth Bootstrap', () => { + describe('GET /api/v1/auth/status', () => { + it('returns hasUsers: false when no users exist', async () => { + const deps = createMockDeps(); + deps.userService.count.mockResolvedValue(0); + await createApp(deps); + + const res = await app.inject({ method: 'GET', url: '/api/v1/auth/status' }); + expect(res.statusCode).toBe(200); + expect(res.json<{ hasUsers: boolean }>().hasUsers).toBe(false); + }); + + it('returns hasUsers: true when users exist', async () => { + const deps = createMockDeps(); + deps.userService.count.mockResolvedValue(1); + await createApp(deps); + + const res = await app.inject({ method: 'GET', url: '/api/v1/auth/status' }); + expect(res.statusCode).toBe(200); + expect(res.json<{ hasUsers: boolean }>().hasUsers).toBe(true); + }); + }); + + describe('POST /api/v1/auth/bootstrap', () => { + it('creates admin user, admin group, RBAC definition targeting group, and returns session token', async () => { + const deps = createMockDeps(); + deps.userService.count.mockResolvedValue(0); + await createApp(deps); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/bootstrap', + payload: { email: 'admin@example.com', password: 'securepass123' }, + }); + + expect(res.statusCode).toBe(201); + const body = res.json(); + expect(body.token).toBe('test-token-123'); + expect(body.user.email).toBe('admin@example.com'); + + // Verify user was created + expect(deps.userService.create).toHaveBeenCalledWith({ + email: 'admin@example.com', + password: 'securepass123', + }); + + // Verify admin group was created with the user as member + expect(deps.groupService.create).toHaveBeenCalledWith({ + name: 'admin', + description: 'Bootstrap admin group', + members: ['admin@example.com'], + }); + + // Verify RBAC definition targets the Group, not the User + expect(deps.rbacDefinitionService.create).toHaveBeenCalledWith({ + name: 'bootstrap-admin', + subjects: [{ kind: 'Group', name: 'admin' }], + roleBindings: [ + { role: 'edit', resource: '*' }, + { role: 'run', resource: '*' }, + { role: 'run', action: 'impersonate' }, + { role: 'run', action: 'logs' }, + { role: 'run', action: 'backup' }, + { role: 'run', action: 'restore' }, + { role: 'run', action: 'audit-purge' }, + ], + }); + + // Verify auto-login was called + expect(deps.authService.login).toHaveBeenCalledWith('admin@example.com', 'securepass123'); + }); + + it('passes name when provided', async () => { + const deps = createMockDeps(); + deps.userService.count.mockResolvedValue(0); + await createApp(deps); + + await app.inject({ + method: 'POST', + url: '/api/v1/auth/bootstrap', + payload: { email: 'admin@example.com', password: 'securepass123', name: 'Admin User' }, + }); + + expect(deps.userService.create).toHaveBeenCalledWith({ + email: 'admin@example.com', + password: 'securepass123', + name: 'Admin User', + }); + }); + + it('returns 409 when users already exist', async () => { + const deps = createMockDeps(); + deps.userService.count.mockResolvedValue(1); + await createApp(deps); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/bootstrap', + payload: { email: 'admin@example.com', password: 'securepass123' }, + }); + + expect(res.statusCode).toBe(409); + expect(res.json<{ error: string }>().error).toContain('Users already exist'); + + // Should NOT have created user, group, or RBAC + expect(deps.userService.create).not.toHaveBeenCalled(); + expect(deps.groupService.create).not.toHaveBeenCalled(); + expect(deps.rbacDefinitionService.create).not.toHaveBeenCalled(); + }); + + it('validates email and password via UserService', async () => { + const deps = createMockDeps(); + deps.userService.count.mockResolvedValue(0); + // Simulate Zod validation error from UserService + deps.userService.create.mockRejectedValue( + Object.assign(new Error('Validation error'), { statusCode: 400, issues: [] }), + ); + await createApp(deps); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/bootstrap', + payload: { email: 'not-an-email', password: 'short' }, + }); + + // The error handler should handle the validation error + expect(res.statusCode).toBeGreaterThanOrEqual(400); + }); + }); + + describe('POST /api/v1/auth/login', () => { + it('logs in successfully', async () => { + const deps = createMockDeps(); + await createApp(deps); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/login', + payload: { email: 'admin@example.com', password: 'securepass123' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().token).toBe('test-token-123'); + }); + }); + + describe('POST /api/v1/auth/logout', () => { + it('logs out with valid token', async () => { + const deps = createMockDeps(); + deps.authService.findSession.mockResolvedValue({ + userId: 'user-1', + expiresAt: new Date(Date.now() + 86400_000), + }); + await createApp(deps); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/logout', + headers: { authorization: 'Bearer valid-token' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json<{ success: boolean }>().success).toBe(true); + expect(deps.authService.logout).toHaveBeenCalledWith('valid-token'); + }); + + it('returns 401 without auth', async () => { + const deps = createMockDeps(); + await createApp(deps); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/logout', + }); + + expect(res.statusCode).toBe(401); + }); + }); + + describe('POST /api/v1/auth/impersonate', () => { + it('creates session for target user when caller is admin', async () => { + const deps = createMockDeps(); + // Auth: valid session + deps.authService.findSession.mockResolvedValue({ + userId: 'admin-user-id', + expiresAt: new Date(Date.now() + 86400_000), + }); + // RBAC: allow impersonate operation + deps.rbacService.canRunOperation.mockResolvedValue(true); + // Impersonate returns token for target + deps.authService.impersonate.mockResolvedValue( + makeLoginResult({ token: 'impersonated-token', user: { id: 'user-2', email: 'target@example.com', role: 'user' } }), + ); + await createApp(deps); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/impersonate', + headers: { authorization: 'Bearer admin-token' }, + payload: { email: 'target@example.com' }, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.token).toBe('impersonated-token'); + expect(body.user.email).toBe('target@example.com'); + expect(deps.authService.impersonate).toHaveBeenCalledWith('target@example.com'); + }); + + it('returns 401 without auth', async () => { + const deps = createMockDeps(); + await createApp(deps); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/impersonate', + payload: { email: 'target@example.com' }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('returns 403 when caller lacks admin permission on users', async () => { + const deps = createMockDeps(); + // Auth: valid session + deps.authService.findSession.mockResolvedValue({ + userId: 'non-admin-id', + expiresAt: new Date(Date.now() + 86400_000), + }); + // RBAC: deny + deps.rbacService.canRunOperation.mockResolvedValue(false); + await createApp(deps); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/impersonate', + headers: { authorization: 'Bearer regular-token' }, + payload: { email: 'target@example.com' }, + }); + + expect(res.statusCode).toBe(403); + }); + + it('returns 401 when impersonation target does not exist', async () => { + const deps = createMockDeps(); + // Auth: valid session + deps.authService.findSession.mockResolvedValue({ + userId: 'admin-user-id', + expiresAt: new Date(Date.now() + 86400_000), + }); + // RBAC: allow + deps.rbacService.canRunOperation.mockResolvedValue(true); + // Impersonate fails — user not found + const authError = new Error('User not found'); + (authError as Error & { statusCode: number }).statusCode = 401; + deps.authService.impersonate.mockRejectedValue(authError); + await createApp(deps); + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/auth/impersonate', + headers: { authorization: 'Bearer admin-token' }, + payload: { email: 'nonexistent@example.com' }, + }); + + expect(res.statusCode).toBe(401); + }); + }); +}); diff --git a/src/mcpd/tests/backup.test.ts b/src/mcpd/tests/backup.test.ts index 9576cb4..b2cdc79 100644 --- a/src/mcpd/tests/backup.test.ts +++ b/src/mcpd/tests/backup.test.ts @@ -6,6 +6,9 @@ import { encrypt, decrypt, isSensitiveKey } from '../src/services/backup/crypto. import { registerBackupRoutes } from '../src/routes/backup.js'; import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js'; import type { IProjectRepository } from '../src/repositories/project.repository.js'; +import type { IUserRepository } from '../src/repositories/user.repository.js'; +import type { IGroupRepository } from '../src/repositories/group.repository.js'; +import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js'; // Mock data const mockServers = [ @@ -31,8 +34,33 @@ const mockSecrets = [ const mockProjects = [ { - id: 'proj1', name: 'my-project', description: 'Test project', + 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' } }], + }, +]; + +const mockUsers = [ + { id: 'u1', email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() }, + { id: 'u2', email: 'bob@test.com', name: null, role: 'USER', provider: 'oidc', externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() }, +]; + +const mockGroups = [ + { + id: 'g1', name: 'dev-team', description: 'Developers', version: 1, createdAt: new Date(), updatedAt: new Date(), + members: [ + { id: 'gm1', user: { id: 'u1', email: 'alice@test.com', name: 'Alice' } }, + { id: 'gm2', user: { id: 'u2', email: 'bob@test.com', name: null } }, + ], + }, +]; + +const mockRbacDefinitions = [ + { + id: 'rbac1', name: 'admins', version: 1, createdAt: new Date(), updatedAt: new Date(), + subjects: [{ kind: 'User', name: 'alice@test.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], }, ]; @@ -63,9 +91,46 @@ 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, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])), + create: vi.fn(async (data) => ({ id: 'new-proj', ...data, servers: [], members: [], 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 () => {}), + }; +} + +function mockUserRepo(): IUserRepository { + return { + findAll: vi.fn(async () => [...mockUsers]), + findById: vi.fn(async (id: string) => mockUsers.find((u) => u.id === id) ?? null), + findByEmail: vi.fn(async (email: string) => mockUsers.find((u) => u.email === email) ?? null), + create: vi.fn(async (data) => ({ id: 'new-u', ...data, provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockUsers[0])), + delete: vi.fn(async () => {}), + count: vi.fn(async () => mockUsers.length), + }; +} + +function mockGroupRepo(): IGroupRepository { + return { + findAll: vi.fn(async () => [...mockGroups]), + findById: vi.fn(async (id: string) => mockGroups.find((g) => g.id === id) ?? null), + findByName: vi.fn(async (name: string) => mockGroups.find((g) => g.name === name) ?? null), + create: vi.fn(async (data) => ({ id: 'new-g', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockGroups[0])), + update: vi.fn(async (id, data) => ({ ...mockGroups.find((g) => g.id === id)!, ...data })), + delete: vi.fn(async () => {}), + setMembers: vi.fn(async () => {}), + findGroupsForUser: vi.fn(async () => []), + }; +} + +function mockRbacRepo(): IRbacDefinitionRepository { + return { + findAll: vi.fn(async () => [...mockRbacDefinitions]), + findById: vi.fn(async (id: string) => mockRbacDefinitions.find((r) => r.id === id) ?? null), + findByName: vi.fn(async (name: string) => mockRbacDefinitions.find((r) => r.name === name) ?? null), + create: vi.fn(async (data) => ({ id: 'new-rbac', ...data, version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockRbacDefinitions[0])), + update: vi.fn(async (id, data) => ({ ...mockRbacDefinitions.find((r) => r.id === id)!, ...data })), + delete: vi.fn(async () => {}), }; } @@ -110,7 +175,7 @@ describe('BackupService', () => { let backupService: BackupService; beforeEach(() => { - backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo()); + backupService = new BackupService(mockServerRepo(), mockProjectRepo(), mockSecretRepo(), mockUserRepo(), mockGroupRepo(), mockRbacRepo()); }); it('creates backup with all resources', async () => { @@ -126,11 +191,51 @@ describe('BackupService', () => { expect(bundle.projects[0]!.name).toBe('my-project'); }); + it('includes users in backup', async () => { + const bundle = await backupService.createBackup(); + expect(bundle.users).toHaveLength(2); + expect(bundle.users![0]!.email).toBe('alice@test.com'); + expect(bundle.users![0]!.role).toBe('ADMIN'); + expect(bundle.users![1]!.email).toBe('bob@test.com'); + expect(bundle.users![1]!.provider).toBe('oidc'); + }); + + it('includes groups in backup with member emails', async () => { + const bundle = await backupService.createBackup(); + expect(bundle.groups).toHaveLength(1); + expect(bundle.groups![0]!.name).toBe('dev-team'); + expect(bundle.groups![0]!.memberEmails).toEqual(['alice@test.com', 'bob@test.com']); + }); + + it('includes rbac bindings in backup', async () => { + const bundle = await backupService.createBackup(); + expect(bundle.rbacBindings).toHaveLength(1); + expect(bundle.rbacBindings![0]!.name).toBe('admins'); + expect(bundle.rbacBindings![0]!.subjects).toEqual([{ kind: 'User', name: 'alice@test.com' }]); + }); + + it('includes enriched projects with server names and members', 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 () => { const bundle = await backupService.createBackup({ resources: ['servers'] }); expect(bundle.servers).toHaveLength(2); expect(bundle.secrets).toHaveLength(0); expect(bundle.projects).toHaveLength(0); + expect(bundle.users).toHaveLength(0); + expect(bundle.groups).toHaveLength(0); + expect(bundle.rbacBindings).toHaveLength(0); + }); + + it('filters to only users', async () => { + const bundle = await backupService.createBackup({ resources: ['users'] }); + expect(bundle.servers).toHaveLength(0); + expect(bundle.users).toHaveLength(2); }); it('encrypts sensitive secret values when password provided', async () => { @@ -150,13 +255,22 @@ describe('BackupService', () => { (emptySecretRepo.findAll as ReturnType).mockResolvedValue([]); const emptyProjectRepo = mockProjectRepo(); (emptyProjectRepo.findAll as ReturnType).mockResolvedValue([]); + const emptyUserRepo = mockUserRepo(); + (emptyUserRepo.findAll as ReturnType).mockResolvedValue([]); + const emptyGroupRepo = mockGroupRepo(); + (emptyGroupRepo.findAll as ReturnType).mockResolvedValue([]); + const emptyRbacRepo = mockRbacRepo(); + (emptyRbacRepo.findAll as ReturnType).mockResolvedValue([]); - const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo); + const service = new BackupService(emptyServerRepo, emptyProjectRepo, emptySecretRepo, emptyUserRepo, emptyGroupRepo, emptyRbacRepo); const bundle = await service.createBackup(); expect(bundle.servers).toHaveLength(0); expect(bundle.secrets).toHaveLength(0); expect(bundle.projects).toHaveLength(0); + expect(bundle.users).toHaveLength(0); + expect(bundle.groups).toHaveLength(0); + expect(bundle.rbacBindings).toHaveLength(0); }); }); @@ -165,16 +279,25 @@ describe('RestoreService', () => { let serverRepo: IMcpServerRepository; let secretRepo: ISecretRepository; let projectRepo: IProjectRepository; + let userRepo: IUserRepository; + let groupRepo: IGroupRepository; + let rbacRepo: IRbacDefinitionRepository; beforeEach(() => { serverRepo = mockServerRepo(); secretRepo = mockSecretRepo(); projectRepo = mockProjectRepo(); + userRepo = mockUserRepo(); + groupRepo = mockGroupRepo(); + rbacRepo = mockRbacRepo(); // Default: nothing exists yet (serverRepo.findByName as ReturnType).mockResolvedValue(null); (secretRepo.findByName as ReturnType).mockResolvedValue(null); (projectRepo.findByName as ReturnType).mockResolvedValue(null); - restoreService = new RestoreService(serverRepo, projectRepo, secretRepo); + (userRepo.findByEmail as ReturnType).mockResolvedValue(null); + (groupRepo.findByName as ReturnType).mockResolvedValue(null); + (rbacRepo.findByName as ReturnType).mockResolvedValue(null); + restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacRepo); }); const validBundle = { @@ -187,6 +310,23 @@ describe('RestoreService', () => { projects: [{ name: 'test-proj', description: 'Test' }], }; + const fullBundle = { + ...validBundle, + users: [ + { email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null }, + { email: 'bob@test.com', name: null, role: 'USER', provider: 'oidc' }, + ], + groups: [ + { name: 'dev-team', description: 'Developers', memberEmails: ['alice@test.com', 'bob@test.com'] }, + ], + rbacBindings: [ + { name: 'admins', subjects: [{ kind: 'User', name: 'alice@test.com' }], roleBindings: [{ role: 'edit', resource: '*' }] }, + ], + projects: [ + { name: 'test-proj', description: 'Test', proxyMode: 'filtered', llmProvider: 'openai', llmModel: 'gpt-4', serverNames: ['github'], members: ['alice@test.com'] }, + ], + }; + it('validates valid bundle', () => { expect(restoreService.validateBundle(validBundle)).toBe(true); }); @@ -197,6 +337,11 @@ describe('RestoreService', () => { expect(restoreService.validateBundle({ version: '1' })).toBe(false); }); + it('validates old bundles without new fields (backwards compatibility)', () => { + expect(restoreService.validateBundle(validBundle)).toBe(true); + // Old bundle has no users/groups/rbacBindings — should still validate + }); + it('restores all resources', async () => { const result = await restoreService.restore(validBundle); @@ -209,6 +354,104 @@ describe('RestoreService', () => { expect(projectRepo.create).toHaveBeenCalled(); }); + it('restores users', async () => { + const result = await restoreService.restore(fullBundle); + + expect(result.usersCreated).toBe(2); + expect(userRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'alice@test.com', + name: 'Alice', + role: 'ADMIN', + passwordHash: '__RESTORED_MUST_RESET__', + })); + expect(userRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + email: 'bob@test.com', + role: 'USER', + })); + }); + + it('restores groups with member resolution', async () => { + // After users are created, simulate they can be found by email + let callCount = 0; + (userRepo.findByEmail as ReturnType).mockImplementation(async (email: string) => { + // First calls during user restore return null (user doesn't exist yet) + // Later calls during group member resolution return the created user + callCount++; + if (callCount > 2) { + // After user creation phase, simulate finding created users + if (email === 'alice@test.com') return { id: 'new-u-alice', email }; + if (email === 'bob@test.com') return { id: 'new-u-bob', email }; + } + return null; + }); + + const result = await restoreService.restore(fullBundle); + + expect(result.groupsCreated).toBe(1); + expect(groupRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'dev-team', + description: 'Developers', + })); + expect(groupRepo.setMembers).toHaveBeenCalled(); + }); + + it('restores rbac bindings', async () => { + const result = await restoreService.restore(fullBundle); + + expect(result.rbacCreated).toBe(1); + expect(rbacRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'admins', + subjects: [{ kind: 'User', name: 'alice@test.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + })); + }); + + it('restores enriched projects with server and member linking', async () => { + // Simulate servers exist (restored in prior step) + (serverRepo.findByName as ReturnType).mockResolvedValue(null); + // After server restore, we can find them + let serverCallCount = 0; + (serverRepo.findByName as ReturnType).mockImplementation(async (name: string) => { + serverCallCount++; + // During server restore phase, first call returns null (server doesn't exist) + // During project restore phase, server should be found + if (serverCallCount > 1 && name === 'github') return { id: 'restored-s1', name: 'github' }; + 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); + expect(projectRepo.create).toHaveBeenCalledWith(expect.objectContaining({ + name: 'test-proj', + proxyMode: 'filtered', + llmProvider: 'openai', + llmModel: 'gpt-4', + })); + expect(projectRepo.setServers).toHaveBeenCalled(); + expect(projectRepo.setMembers).toHaveBeenCalled(); + }); + + it('restores old bundle without users/groups/rbac', async () => { + const result = await restoreService.restore(validBundle); + + expect(result.serversCreated).toBe(1); + expect(result.secretsCreated).toBe(1); + expect(result.projectsCreated).toBe(1); + expect(result.usersCreated).toBe(0); + expect(result.groupsCreated).toBe(0); + expect(result.rbacCreated).toBe(0); + expect(result.errors).toHaveLength(0); + }); + it('skips existing resources with skip strategy', async () => { (serverRepo.findByName as ReturnType).mockResolvedValue(mockServers[0]); const result = await restoreService.restore(validBundle, { conflictStrategy: 'skip' }); @@ -218,6 +461,33 @@ describe('RestoreService', () => { expect(serverRepo.create).not.toHaveBeenCalled(); }); + it('skips existing users', async () => { + (userRepo.findByEmail as ReturnType).mockResolvedValue(mockUsers[0]); + const bundle = { ...validBundle, users: [{ email: 'alice@test.com', name: 'Alice', role: 'ADMIN', provider: null }] }; + const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' }); + + expect(result.usersSkipped).toBe(1); + expect(result.usersCreated).toBe(0); + }); + + it('skips existing groups', async () => { + (groupRepo.findByName as ReturnType).mockResolvedValue(mockGroups[0]); + const bundle = { ...validBundle, groups: [{ name: 'dev-team', description: 'Devs', memberEmails: [] }] }; + const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' }); + + expect(result.groupsSkipped).toBe(1); + expect(result.groupsCreated).toBe(0); + }); + + it('skips existing rbac bindings', async () => { + (rbacRepo.findByName as ReturnType).mockResolvedValue(mockRbacDefinitions[0]); + const bundle = { ...validBundle, rbacBindings: [{ name: 'admins', subjects: [], roleBindings: [] }] }; + const result = await restoreService.restore(bundle, { conflictStrategy: 'skip' }); + + expect(result.rbacSkipped).toBe(1); + expect(result.rbacCreated).toBe(0); + }); + it('aborts on conflict with fail strategy', async () => { (serverRepo.findByName as ReturnType).mockResolvedValue(mockServers[0]); const result = await restoreService.restore(validBundle, { conflictStrategy: 'fail' }); @@ -233,6 +503,18 @@ describe('RestoreService', () => { expect(serverRepo.update).toHaveBeenCalled(); }); + it('overwrites existing rbac bindings', async () => { + (rbacRepo.findByName as ReturnType).mockResolvedValue(mockRbacDefinitions[0]); + const bundle = { + ...validBundle, + rbacBindings: [{ name: 'admins', subjects: [{ kind: 'User', name: 'new@test.com' }], roleBindings: [{ role: 'view', resource: 'servers' }] }], + }; + const result = await restoreService.restore(bundle, { conflictStrategy: 'overwrite' }); + + expect(result.rbacCreated).toBe(1); + expect(rbacRepo.update).toHaveBeenCalled(); + }); + it('fails restore with encrypted bundle and no password', async () => { const encBundle = { ...validBundle, encrypted: true, encryptedSecrets: encrypt('{}', 'pw') }; const result = await restoreService.restore(encBundle); @@ -262,6 +544,26 @@ describe('RestoreService', () => { const result = await restoreService.restore(encBundle, { password: 'wrong' }); expect(result.errors[0]).toContain('Failed to decrypt'); }); + + it('restores in correct order: secrets → servers → users → groups → projects → rbac', async () => { + const callOrder: string[] = []; + (secretRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('secret'); return { id: 'sec' }; }); + (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: [] }; }); + (rbacRepo.create as ReturnType).mockImplementation(async () => { callOrder.push('rbac'); return { id: 'rbac' }; }); + + await restoreService.restore(fullBundle); + + expect(callOrder[0]).toBe('secret'); + expect(callOrder[1]).toBe('server'); + expect(callOrder[2]).toBe('user'); + expect(callOrder[3]).toBe('user'); // second user + expect(callOrder[4]).toBe('group'); + expect(callOrder[5]).toBe('project'); + expect(callOrder[6]).toBe('rbac'); + }); }); describe('Backup Routes', () => { @@ -272,7 +574,7 @@ describe('Backup Routes', () => { const sRepo = mockServerRepo(); const secRepo = mockSecretRepo(); const prRepo = mockProjectRepo(); - backupService = new BackupService(sRepo, prRepo, secRepo); + backupService = new BackupService(sRepo, prRepo, secRepo, mockUserRepo(), mockGroupRepo(), mockRbacRepo()); const rSRepo = mockServerRepo(); (rSRepo.findByName as ReturnType).mockResolvedValue(null); @@ -280,7 +582,13 @@ describe('Backup Routes', () => { (rSecRepo.findByName as ReturnType).mockResolvedValue(null); const rPrRepo = mockProjectRepo(); (rPrRepo.findByName as ReturnType).mockResolvedValue(null); - restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo); + const rUserRepo = mockUserRepo(); + (rUserRepo.findByEmail as ReturnType).mockResolvedValue(null); + const rGroupRepo = mockGroupRepo(); + (rGroupRepo.findByName as ReturnType).mockResolvedValue(null); + const rRbacRepo = mockRbacRepo(); + (rRbacRepo.findByName as ReturnType).mockResolvedValue(null); + restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo, rUserRepo, rGroupRepo, rRbacRepo); }); async function buildApp() { @@ -289,7 +597,7 @@ describe('Backup Routes', () => { return app; } - it('POST /api/v1/backup returns bundle', async () => { + it('POST /api/v1/backup returns bundle with new resource types', async () => { const app = await buildApp(); const res = await app.inject({ method: 'POST', @@ -303,6 +611,9 @@ describe('Backup Routes', () => { expect(body.servers).toBeDefined(); expect(body.secrets).toBeDefined(); expect(body.projects).toBeDefined(); + expect(body.users).toBeDefined(); + expect(body.groups).toBeDefined(); + expect(body.rbacBindings).toBeDefined(); }); it('POST /api/v1/restore imports bundle', async () => { @@ -318,6 +629,9 @@ describe('Backup Routes', () => { expect(res.statusCode).toBe(200); const body = res.json(); expect(body.serversCreated).toBeDefined(); + expect(body.usersCreated).toBeDefined(); + expect(body.groupsCreated).toBeDefined(); + expect(body.rbacCreated).toBeDefined(); }); it('POST /api/v1/restore rejects invalid bundle', async () => { diff --git a/src/mcpd/tests/group-service.test.ts b/src/mcpd/tests/group-service.test.ts new file mode 100644 index 0000000..2f00301 --- /dev/null +++ b/src/mcpd/tests/group-service.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GroupService } from '../src/services/group.service.js'; +import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; +import type { IGroupRepository, GroupWithMembers } from '../src/repositories/group.repository.js'; +import type { IUserRepository, SafeUser } from '../src/repositories/user.repository.js'; +import type { Group } from '@prisma/client'; + +function makeGroup(overrides: Partial = {}): Group { + return { + id: 'grp-1', + name: 'developers', + description: 'Dev team', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeGroupWithMembers(overrides: Partial = {}, members: GroupWithMembers['members'] = []): GroupWithMembers { + return { + ...makeGroup(overrides), + members, + }; +} + +function makeUser(overrides: Partial = {}): SafeUser { + return { + id: 'user-1', + email: 'alice@example.com', + name: 'Alice', + role: 'USER', + provider: null, + externalId: null, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function mockGroupRepo(): IGroupRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async (data) => makeGroup({ name: data.name, description: data.description ?? '' })), + update: vi.fn(async (id, data) => makeGroup({ id, description: data.description ?? '' })), + delete: vi.fn(async () => {}), + setMembers: vi.fn(async () => {}), + findGroupsForUser: vi.fn(async () => []), + }; +} + +function mockUserRepo(): IUserRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByEmail: vi.fn(async () => null), + create: vi.fn(async () => makeUser()), + delete: vi.fn(async () => {}), + count: vi.fn(async () => 0), + }; +} + +describe('GroupService', () => { + let groupRepo: ReturnType; + let userRepo: ReturnType; + let service: GroupService; + + beforeEach(() => { + groupRepo = mockGroupRepo(); + userRepo = mockUserRepo(); + service = new GroupService(groupRepo, userRepo); + }); + + describe('list', () => { + it('returns empty list', async () => { + const result = await service.list(); + expect(result).toEqual([]); + expect(groupRepo.findAll).toHaveBeenCalled(); + }); + + it('returns groups with members', async () => { + const groups = [ + makeGroupWithMembers({ id: 'g1', name: 'admins' }, [ + { id: 'gm-1', user: { id: 'u1', email: 'a@b.com', name: 'A' } }, + ]), + ]; + vi.mocked(groupRepo.findAll).mockResolvedValue(groups); + const result = await service.list(); + expect(result).toHaveLength(1); + expect(result[0].members).toHaveLength(1); + }); + }); + + describe('create', () => { + it('creates a group without members', async () => { + const created = makeGroupWithMembers({ name: 'my-group', description: '' }, []); + vi.mocked(groupRepo.findById).mockResolvedValue(created); + + const result = await service.create({ name: 'my-group' }); + expect(result.name).toBe('my-group'); + expect(groupRepo.create).toHaveBeenCalledWith({ name: 'my-group', description: '' }); + expect(groupRepo.setMembers).not.toHaveBeenCalled(); + }); + + it('creates a group with members', async () => { + const alice = makeUser({ id: 'u-alice', email: 'alice@example.com' }); + const bob = makeUser({ id: 'u-bob', email: 'bob@example.com', name: 'Bob' }); + vi.mocked(userRepo.findByEmail).mockImplementation(async (email: string) => { + if (email === 'alice@example.com') return alice; + if (email === 'bob@example.com') return bob; + return null; + }); + + const created = makeGroupWithMembers({ name: 'team' }, [ + { id: 'gm-1', user: { id: 'u-alice', email: 'alice@example.com', name: 'Alice' } }, + { id: 'gm-2', user: { id: 'u-bob', email: 'bob@example.com', name: 'Bob' } }, + ]); + vi.mocked(groupRepo.findById).mockResolvedValue(created); + + const result = await service.create({ + name: 'team', + members: ['alice@example.com', 'bob@example.com'], + }); + + expect(groupRepo.setMembers).toHaveBeenCalledWith('grp-1', ['u-alice', 'u-bob']); + expect(result.members).toHaveLength(2); + }); + + it('throws ConflictError when name exists', async () => { + vi.mocked(groupRepo.findByName).mockResolvedValue(makeGroupWithMembers({ name: 'taken' })); + await expect(service.create({ name: 'taken' })).rejects.toThrow(ConflictError); + }); + + it('throws NotFoundError for unknown member email', async () => { + vi.mocked(userRepo.findByEmail).mockResolvedValue(null); + await expect( + service.create({ name: 'team', members: ['unknown@example.com'] }), + ).rejects.toThrow(NotFoundError); + }); + + it('validates input', async () => { + await expect(service.create({ name: '' })).rejects.toThrow(); + await expect(service.create({ name: 'UPPERCASE' })).rejects.toThrow(); + }); + }); + + describe('getById', () => { + it('returns group when found', async () => { + const group = makeGroupWithMembers({ id: 'g1' }); + vi.mocked(groupRepo.findById).mockResolvedValue(group); + const result = await service.getById('g1'); + expect(result.id).toBe('g1'); + }); + + it('throws NotFoundError when not found', async () => { + await expect(service.getById('missing')).rejects.toThrow(NotFoundError); + }); + }); + + describe('getByName', () => { + it('returns group when found', async () => { + const group = makeGroupWithMembers({ name: 'admins' }); + vi.mocked(groupRepo.findByName).mockResolvedValue(group); + const result = await service.getByName('admins'); + expect(result.name).toBe('admins'); + }); + + it('throws NotFoundError when not found', async () => { + await expect(service.getByName('missing')).rejects.toThrow(NotFoundError); + }); + }); + + describe('update', () => { + it('updates description', async () => { + const group = makeGroupWithMembers({ id: 'g1' }); + vi.mocked(groupRepo.findById).mockResolvedValue(group); + + const updated = makeGroupWithMembers({ id: 'g1', description: 'new desc' }); + // After update, getById is called again to return fresh data + vi.mocked(groupRepo.findById).mockResolvedValue(updated); + + const result = await service.update('g1', { description: 'new desc' }); + expect(groupRepo.update).toHaveBeenCalledWith('g1', { description: 'new desc' }); + expect(result.description).toBe('new desc'); + }); + + it('updates members (full replacement)', async () => { + const group = makeGroupWithMembers({ id: 'g1' }, [ + { id: 'gm-1', user: { id: 'u-old', email: 'old@example.com', name: 'Old' } }, + ]); + vi.mocked(groupRepo.findById).mockResolvedValue(group); + + const alice = makeUser({ id: 'u-alice', email: 'alice@example.com' }); + vi.mocked(userRepo.findByEmail).mockResolvedValue(alice); + + const updated = makeGroupWithMembers({ id: 'g1' }, [ + { id: 'gm-2', user: { id: 'u-alice', email: 'alice@example.com', name: 'Alice' } }, + ]); + vi.mocked(groupRepo.findById).mockResolvedValueOnce(group).mockResolvedValue(updated); + + const result = await service.update('g1', { members: ['alice@example.com'] }); + expect(groupRepo.setMembers).toHaveBeenCalledWith('g1', ['u-alice']); + expect(result.members).toHaveLength(1); + }); + + it('throws NotFoundError when group not found', async () => { + await expect(service.update('missing', { description: 'x' })).rejects.toThrow(NotFoundError); + }); + + it('throws NotFoundError for unknown member email on update', async () => { + const group = makeGroupWithMembers({ id: 'g1' }); + vi.mocked(groupRepo.findById).mockResolvedValue(group); + vi.mocked(userRepo.findByEmail).mockResolvedValue(null); + + await expect( + service.update('g1', { members: ['unknown@example.com'] }), + ).rejects.toThrow(NotFoundError); + }); + }); + + describe('delete', () => { + it('deletes group', async () => { + const group = makeGroupWithMembers({ id: 'g1' }); + vi.mocked(groupRepo.findById).mockResolvedValue(group); + await service.delete('g1'); + expect(groupRepo.delete).toHaveBeenCalledWith('g1'); + }); + + it('throws NotFoundError when group not found', async () => { + await expect(service.delete('missing')).rejects.toThrow(NotFoundError); + }); + }); + + describe('group includes resolved member info', () => { + it('members include user id, email, and name', async () => { + const group = makeGroupWithMembers({ id: 'g1', name: 'team' }, [ + { id: 'gm-1', user: { id: 'u1', email: 'alice@example.com', name: 'Alice' } }, + { id: 'gm-2', user: { id: 'u2', email: 'bob@example.com', name: null } }, + ]); + vi.mocked(groupRepo.findById).mockResolvedValue(group); + + const result = await service.getById('g1'); + expect(result.members[0].user).toEqual({ id: 'u1', email: 'alice@example.com', name: 'Alice' }); + expect(result.members[1].user).toEqual({ id: 'u2', email: 'bob@example.com', name: null }); + }); + }); +}); diff --git a/src/mcpd/tests/mcp-config-generator.test.ts b/src/mcpd/tests/mcp-config-generator.test.ts index 4b3332f..9aee0a6 100644 --- a/src/mcpd/tests/mcp-config-generator.test.ts +++ b/src/mcpd/tests/mcp-config-generator.test.ts @@ -11,10 +11,17 @@ function makeServer(overrides: Partial = {}): McpServer { dockerImage: null, transport: 'STDIO', repositoryUrl: null, + externalUrl: null, + command: null, + containerPort: null, + replicas: 1, env: [], + healthCheck: null, version: 1, createdAt: new Date(), updatedAt: new Date(), + templateName: null, + templateVersion: null, ...overrides, }; } @@ -25,7 +32,7 @@ describe('generateMcpConfig', () => { expect(result).toEqual({ mcpServers: {} }); }); - it('generates config for a single server', () => { + it('generates config for a single STDIO server', () => { const result = generateMcpConfig([ { server: makeServer(), resolvedEnv: {} }, ]); @@ -34,7 +41,7 @@ describe('generateMcpConfig', () => { expect(result.mcpServers['slack']?.args).toEqual(['-y', '@anthropic/slack-mcp']); }); - it('includes resolved env when present', () => { + it('includes resolved env when present for STDIO server', () => { const result = generateMcpConfig([ { server: makeServer(), resolvedEnv: { SLACK_TEAM_ID: 'T123' } }, ]); @@ -67,4 +74,35 @@ describe('generateMcpConfig', () => { ]); expect(result.mcpServers['slack']?.args).toEqual(['-y', 'slack']); }); + + it('generates URL-based config for SSE servers', () => { + const server = makeServer({ name: 'sse-server', transport: 'SSE' }); + const result = generateMcpConfig([ + { server, resolvedEnv: { TOKEN: 'abc' } }, + ]); + const config = result.mcpServers['sse-server']; + expect(config?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/sse-server'); + expect(config?.command).toBeUndefined(); + expect(config?.args).toBeUndefined(); + expect(config?.env).toBeUndefined(); + }); + + it('generates URL-based config for STREAMABLE_HTTP servers', () => { + const server = makeServer({ name: 'stream-server', transport: 'STREAMABLE_HTTP' }); + const result = generateMcpConfig([ + { server, resolvedEnv: {} }, + ]); + const config = result.mcpServers['stream-server']; + expect(config?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/stream-server'); + expect(config?.command).toBeUndefined(); + }); + + it('mixes STDIO and SSE servers correctly', () => { + const result = generateMcpConfig([ + { server: makeServer({ name: 'stdio-srv', transport: 'STDIO' }), resolvedEnv: {} }, + { server: makeServer({ name: 'sse-srv', transport: 'SSE' }), resolvedEnv: {} }, + ]); + expect(result.mcpServers['stdio-srv']?.command).toBe('npx'); + expect(result.mcpServers['sse-srv']?.url).toBeDefined(); + }); }); diff --git a/src/mcpd/tests/project-service.test.ts b/src/mcpd/tests/project-service.test.ts index 5dbad2d..6d88c16 100644 --- a/src/mcpd/tests/project-service.test.ts +++ b/src/mcpd/tests/project-service.test.ts @@ -1,66 +1,403 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ProjectService } from '../src/services/project.service.js'; import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; -import type { IProjectRepository } 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 { IUserRepository } from '../src/repositories/user.repository.js'; +import type { McpServer } from '@prisma/client'; + +function makeProject(overrides: Partial = {}): ProjectWithRelations { + return { + id: 'proj-1', + name: 'test-project', + description: '', + ownerId: 'user-1', + proxyMode: 'direct', + llmProvider: null, + llmModel: null, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + servers: [], + members: [], + ...overrides, + }; +} + +function makeServer(overrides: Partial = {}): McpServer { + return { + id: 'srv-1', + name: 'test-server', + description: '', + packageName: '@mcp/test', + dockerImage: null, + transport: 'STDIO', + repositoryUrl: null, + externalUrl: null, + command: null, + containerPort: null, + replicas: 1, + env: [], + healthCheck: null, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + templateName: null, + templateVersion: null, + ...overrides, + }; +} + function mockProjectRepo(): IProjectRepository { return { findAll: vi.fn(async () => []), findById: vi.fn(async () => null), findByName: vi.fn(async () => null), - create: vi.fn(async (data) => ({ - id: 'proj-1', + create: vi.fn(async (data) => makeProject({ name: data.name, - description: data.description ?? '', + description: data.description, ownerId: data.ownerId, - version: 1, - createdAt: new Date(), - updatedAt: new Date(), + proxyMode: data.proxyMode, + llmProvider: data.llmProvider ?? null, + llmModel: data.llmModel ?? null, })), - update: vi.fn(async (id) => ({ - id, name: 'test', description: '', ownerId: 'u1', version: 2, - createdAt: new Date(), updatedAt: new Date(), + update: vi.fn(async (_id, data) => makeProject({ ...data as Partial })), + delete: vi.fn(async () => {}), + setServers: vi.fn(async () => {}), + setMembers: vi.fn(async () => {}), + }; +} + +function mockServerRepo(): IMcpServerRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async () => makeServer()), + update: vi.fn(async () => makeServer()), + delete: vi.fn(async () => {}), + }; +} + +function mockSecretRepo(): ISecretRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })), + update: vi.fn(async () => ({ id: 'sec-1', name: 'test', data: {}, version: 1, createdAt: new Date(), updatedAt: new Date() })), + delete: vi.fn(async () => {}), + }; +} + +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(); - service = new ProjectService(projectRepo); + serverRepo = mockServerRepo(); + secretRepo = mockSecretRepo(); + userRepo = mockUserRepo(); + service = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo); }); describe('create', () => { - it('creates a project', async () => { + it('creates a basic project', async () => { + // After create, getById is called to re-fetch with relations + const created = makeProject({ name: 'my-project', ownerId: 'user-1' }); + vi.mocked(projectRepo.findById).mockResolvedValue(created); + const result = await service.create({ name: 'my-project' }, 'user-1'); expect(result.name).toBe('my-project'); expect(result.ownerId).toBe('user-1'); + expect(projectRepo.create).toHaveBeenCalled(); }); it('throws ConflictError when name exists', async () => { - vi.mocked(projectRepo.findByName).mockResolvedValue({ id: '1' } as never); + vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject()); await expect(service.create({ name: 'taken' }, 'u1')).rejects.toThrow(ConflictError); }); it('validates input', async () => { await expect(service.create({ name: '' }, 'u1')).rejects.toThrow(); }); + + it('creates project with servers (resolves names)', async () => { + const srv1 = makeServer({ id: 'srv-1', name: 'github' }); + const srv2 = makeServer({ id: 'srv-2', name: 'slack' }); + vi.mocked(serverRepo.findByName).mockImplementation(async (name) => { + if (name === 'github') return srv1; + if (name === 'slack') return srv2; + return null; + }); + + const created = makeProject({ id: 'proj-new' }); + vi.mocked(projectRepo.create).mockResolvedValue(created); + vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ + id: 'proj-new', + servers: [ + { id: 'ps-1', server: { id: 'srv-1', name: 'github' } }, + { id: 'ps-2', server: { id: 'srv-2', name: 'slack' } }, + ], + })); + + const result = await service.create({ name: 'my-project', servers: ['github', 'slack'] }, 'user-1'); + expect(projectRepo.setServers).toHaveBeenCalledWith('proj-new', ['srv-1', 'srv-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 () => { + const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' }); + vi.mocked(projectRepo.create).mockResolvedValue(created); + vi.mocked(projectRepo.findById).mockResolvedValue(created); + + const result = await service.create({ + name: 'filtered-proj', + proxyMode: 'filtered', + llmProvider: 'openai', + }, 'user-1'); + + expect(result.proxyMode).toBe('filtered'); + expect(result.llmProvider).toBe('openai'); + }); + + it('rejects filtered project without llmProvider', async () => { + await expect( + service.create({ name: 'bad-proj', proxyMode: 'filtered' }, 'user-1'), + ).rejects.toThrow(); + }); + + it('throws NotFoundError when server name resolution fails', async () => { + vi.mocked(serverRepo.findByName).mockResolvedValue(null); + + await expect( + service.create({ name: 'my-project', servers: ['nonexistent'] }, 'user-1'), + ).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', () => { it('throws NotFoundError when not found', async () => { await expect(service.getById('missing')).rejects.toThrow(NotFoundError); }); + + it('returns project when found', async () => { + const proj = makeProject({ id: 'found' }); + vi.mocked(projectRepo.findById).mockResolvedValue(proj); + const result = await service.getById('found'); + expect(result.id).toBe('found'); + }); + }); + + describe('resolveAndGet', () => { + it('finds by ID first', async () => { + const proj = makeProject({ id: 'proj-id' }); + vi.mocked(projectRepo.findById).mockResolvedValue(proj); + const result = await service.resolveAndGet('proj-id'); + expect(result.id).toBe('proj-id'); + }); + + it('falls back to name when ID not found', async () => { + vi.mocked(projectRepo.findById).mockResolvedValue(null); + const proj = makeProject({ name: 'my-name' }); + vi.mocked(projectRepo.findByName).mockResolvedValue(proj); + const result = await service.resolveAndGet('my-name'); + expect(result.name).toBe('my-name'); + }); + + it('throws NotFoundError when neither ID nor name found', async () => { + await expect(service.resolveAndGet('nothing')).rejects.toThrow(NotFoundError); + }); + }); + + describe('update', () => { + it('updates servers (full replacement)', async () => { + const existing = makeProject({ id: 'proj-1' }); + vi.mocked(projectRepo.findById).mockResolvedValue(existing); + + const srv = makeServer({ id: 'srv-new', name: 'new-srv' }); + vi.mocked(serverRepo.findByName).mockResolvedValue(srv); + + await service.update('proj-1', { servers: ['new-srv'] }); + 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); + + await service.update('proj-1', { proxyMode: 'filtered', llmProvider: 'anthropic' }); + expect(projectRepo.update).toHaveBeenCalledWith('proj-1', { + proxyMode: 'filtered', + llmProvider: 'anthropic', + }); + }); }); describe('delete', () => { it('deletes project', async () => { - vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never); + vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' })); await service.delete('p1'); expect(projectRepo.delete).toHaveBeenCalledWith('p1'); }); + + it('throws NotFoundError when project does not exist', async () => { + await expect(service.delete('missing')).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' }); + const project = makeProject({ + id: 'proj-1', + name: 'my-proj', + proxyMode: 'direct', + servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }], + }); + + vi.mocked(projectRepo.findById).mockResolvedValue(project); + vi.mocked(serverRepo.findById).mockResolvedValue(srv); + + const config = await service.generateMcpConfig('proj-1'); + expect(config.mcpServers['github']).toBeDefined(); + expect(config.mcpServers['github']?.command).toBe('npx'); + expect(config.mcpServers['github']?.args).toEqual(['-y', '@mcp/github']); + }); + + it('generates direct mode config with SSE servers (URL-based)', async () => { + const srv = makeServer({ id: 'srv-2', name: 'sse-server', transport: 'SSE' }); + const project = makeProject({ + id: 'proj-1', + proxyMode: 'direct', + servers: [{ id: 'ps-1', server: { id: 'srv-2', name: 'sse-server' } }], + }); + + vi.mocked(projectRepo.findById).mockResolvedValue(project); + vi.mocked(serverRepo.findById).mockResolvedValue(srv); + + const config = await service.generateMcpConfig('proj-1'); + expect(config.mcpServers['sse-server']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/sse-server'); + expect(config.mcpServers['sse-server']?.command).toBeUndefined(); + }); + + it('generates filtered mode config (single mcplocal entry)', async () => { + const project = makeProject({ + id: 'proj-1', + name: 'filtered-proj', + proxyMode: 'filtered', + llmProvider: 'openai', + servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }], + }); + + vi.mocked(projectRepo.findById).mockResolvedValue(project); + + const config = await service.generateMcpConfig('proj-1'); + expect(Object.keys(config.mcpServers)).toHaveLength(1); + expect(config.mcpServers['filtered-proj']?.url).toBe('http://localhost:3100/api/v1/mcp/proxy/project/filtered-proj'); + }); + + it('resolves by name for mcp-config', async () => { + const project = makeProject({ + id: 'proj-1', + name: 'my-proj', + proxyMode: 'direct', + servers: [], + }); + + vi.mocked(projectRepo.findById).mockResolvedValue(null); + vi.mocked(projectRepo.findByName).mockResolvedValue(project); + + const config = await service.generateMcpConfig('my-proj'); + expect(config.mcpServers).toEqual({}); + }); + + it('includes env for STDIO servers', async () => { + const srv = makeServer({ + id: 'srv-1', + name: 'github', + transport: 'STDIO', + env: [{ name: 'GITHUB_TOKEN', value: 'tok123' }], + }); + const project = makeProject({ + id: 'proj-1', + proxyMode: 'direct', + servers: [{ id: 'ps-1', server: { id: 'srv-1', name: 'github' } }], + }); + + vi.mocked(projectRepo.findById).mockResolvedValue(project); + vi.mocked(serverRepo.findById).mockResolvedValue(srv); + + const config = await service.generateMcpConfig('proj-1'); + expect(config.mcpServers['github']?.env?.['GITHUB_TOKEN']).toBe('tok123'); + }); }); }); diff --git a/src/mcpd/tests/rbac-definition-service.test.ts b/src/mcpd/tests/rbac-definition-service.test.ts new file mode 100644 index 0000000..6fefb8b --- /dev/null +++ b/src/mcpd/tests/rbac-definition-service.test.ts @@ -0,0 +1,229 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RbacDefinitionService } from '../src/services/rbac-definition.service.js'; +import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; +import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js'; +import type { RbacDefinition } from '@prisma/client'; + +function makeDef(overrides: Partial = {}): RbacDefinition { + return { + id: 'def-1', + name: 'test-rbac', + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function mockRepo(): IRbacDefinitionRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async (data) => makeDef({ name: data.name, subjects: data.subjects, roleBindings: data.roleBindings })), + update: vi.fn(async (id, data) => makeDef({ id, ...data })), + delete: vi.fn(async () => {}), + }; +} + +describe('RbacDefinitionService', () => { + let repo: ReturnType; + let service: RbacDefinitionService; + + beforeEach(() => { + repo = mockRepo(); + service = new RbacDefinitionService(repo); + }); + + describe('list', () => { + it('returns all definitions', async () => { + const defs = await service.list(); + expect(repo.findAll).toHaveBeenCalled(); + expect(defs).toEqual([]); + }); + }); + + describe('getById', () => { + it('returns definition when found', async () => { + const def = makeDef(); + vi.mocked(repo.findById).mockResolvedValue(def); + const result = await service.getById('def-1'); + expect(result.id).toBe('def-1'); + }); + + it('throws NotFoundError when not found', async () => { + await expect(service.getById('missing')).rejects.toThrow(NotFoundError); + }); + }); + + describe('getByName', () => { + it('returns definition when found', async () => { + const def = makeDef(); + vi.mocked(repo.findByName).mockResolvedValue(def); + const result = await service.getByName('test-rbac'); + expect(result.name).toBe('test-rbac'); + }); + + it('throws NotFoundError when not found', async () => { + await expect(service.getByName('missing')).rejects.toThrow(NotFoundError); + }); + }); + + describe('create', () => { + it('creates a definition with valid input', async () => { + const result = await service.create({ + name: 'new-rbac', + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + }); + expect(result.name).toBe('new-rbac'); + expect(repo.create).toHaveBeenCalled(); + }); + + it('throws ConflictError when name exists', async () => { + vi.mocked(repo.findByName).mockResolvedValue(makeDef()); + await expect( + service.create({ + name: 'test-rbac', + subjects: [{ kind: 'User', name: 'bob@example.com' }], + roleBindings: [{ role: 'view', resource: 'servers' }], + }), + ).rejects.toThrow(ConflictError); + }); + + it('throws on missing subjects', async () => { + await expect( + service.create({ + name: 'bad-rbac', + subjects: [], + roleBindings: [{ role: 'view', resource: 'servers' }], + }), + ).rejects.toThrow(); + }); + + it('throws on missing roleBindings', async () => { + await expect( + service.create({ + name: 'bad-rbac', + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [], + }), + ).rejects.toThrow(); + }); + + it('throws on invalid role', async () => { + await expect( + service.create({ + name: 'bad-rbac', + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'superadmin', resource: '*' }], + }), + ).rejects.toThrow(); + }); + + it('throws on invalid subject kind', async () => { + await expect( + service.create({ + name: 'bad-rbac', + subjects: [{ kind: 'Robot', name: 'bot-1' }], + roleBindings: [{ role: 'view', resource: 'servers' }], + }), + ).rejects.toThrow(); + }); + + it('throws on invalid name format', async () => { + await expect( + service.create({ + name: 'Invalid Name!', + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'view', resource: 'servers' }], + }), + ).rejects.toThrow(); + }); + + it('normalizes singular resource names to plural', async () => { + await service.create({ + name: 'singular-rbac', + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [ + { role: 'view', resource: 'server' }, + { role: 'edit', resource: 'secret', name: 'my-secret' }, + ], + }); + const call = vi.mocked(repo.create).mock.calls[0]![0]; + expect(call.roleBindings[0]!.resource).toBe('servers'); + expect(call.roleBindings[1]!.resource).toBe('secrets'); + expect(call.roleBindings[1]!.name).toBe('my-secret'); + }); + + it('creates a definition with operation bindings', async () => { + const result = await service.create({ + name: 'ops-rbac', + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'run', action: 'logs' }], + }); + expect(result.name).toBe('ops-rbac'); + expect(repo.create).toHaveBeenCalled(); + const call = vi.mocked(repo.create).mock.calls[0]![0]; + expect(call.roleBindings[0]!.action).toBe('logs'); + }); + + it('creates a definition with mixed resource and operation bindings', async () => { + const result = await service.create({ + name: 'mixed-rbac', + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [ + { role: 'view', resource: 'servers' }, + { role: 'run', action: 'logs' }, + ], + }); + expect(result.name).toBe('mixed-rbac'); + expect(repo.create).toHaveBeenCalled(); + const call = vi.mocked(repo.create).mock.calls[0]![0]; + expect(call.roleBindings).toHaveLength(2); + expect(call.roleBindings[0]!.resource).toBe('servers'); + expect(call.roleBindings[1]!.action).toBe('logs'); + }); + + it('creates a definition with name-scoped resource binding', async () => { + const result = await service.create({ + name: 'scoped-rbac', + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }], + }); + expect(result.name).toBe('scoped-rbac'); + expect(repo.create).toHaveBeenCalled(); + const call = vi.mocked(repo.create).mock.calls[0]![0]; + expect(call.roleBindings[0]!.resource).toBe('servers'); + expect(call.roleBindings[0]!.name).toBe('my-ha'); + }); + }); + + describe('update', () => { + it('updates an existing definition', async () => { + vi.mocked(repo.findById).mockResolvedValue(makeDef()); + await service.update('def-1', { subjects: [{ kind: 'User', name: 'bob@example.com' }] }); + expect(repo.update).toHaveBeenCalledWith('def-1', { + subjects: [{ kind: 'User', name: 'bob@example.com' }], + }); + }); + + it('throws NotFoundError when definition does not exist', async () => { + await expect(service.update('missing', {})).rejects.toThrow(NotFoundError); + }); + }); + + describe('delete', () => { + it('deletes an existing definition', async () => { + vi.mocked(repo.findById).mockResolvedValue(makeDef()); + await service.delete('def-1'); + expect(repo.delete).toHaveBeenCalledWith('def-1'); + }); + + it('throws NotFoundError when definition does not exist', async () => { + await expect(service.delete('missing')).rejects.toThrow(NotFoundError); + }); + }); +}); diff --git a/src/mcpd/tests/rbac.test.ts b/src/mcpd/tests/rbac.test.ts new file mode 100644 index 0000000..a93cfcc --- /dev/null +++ b/src/mcpd/tests/rbac.test.ts @@ -0,0 +1,683 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RbacService } from '../src/services/rbac.service.js'; +import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js'; +import type { RbacDefinition, PrismaClient } from '@prisma/client'; + +function makeDef(overrides: Partial = {}): RbacDefinition { + return { + id: 'def-1', + name: 'test-rbac', + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function mockRepo(definitions: RbacDefinition[] = []): IRbacDefinitionRepository { + return { + findAll: vi.fn(async () => definitions), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(async () => makeDef()), + update: vi.fn(async () => makeDef()), + delete: vi.fn(async () => {}), + }; +} + +interface MockPrisma { + user: { findUnique: ReturnType }; + groupMember: { findMany: ReturnType }; +} + +function mockPrisma(overrides?: Partial): PrismaClient { + return { + user: { + findUnique: overrides?.user?.findUnique ?? vi.fn(async () => null), + }, + groupMember: { + findMany: overrides?.groupMember?.findMany ?? vi.fn(async () => []), + }, + } as unknown as PrismaClient; +} + +describe('RbacService', () => { + describe('canAccess — edit:* (wildcard resource)', () => { + let service: RbacService; + + beforeEach(() => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + service = new RbacService(repo, prisma); + }); + + it('can view servers', async () => { + expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true); + }); + + it('can edit users', async () => { + expect(await service.canAccess('user-1', 'edit', 'users')).toBe(true); + }); + + it('can create resources (edit includes create)', async () => { + expect(await service.canAccess('user-1', 'create', 'servers')).toBe(true); + }); + + it('can delete resources (edit includes delete)', async () => { + expect(await service.canAccess('user-1', 'delete', 'secrets')).toBe(true); + }); + + it('cannot run resources (edit does not include run)', async () => { + expect(await service.canAccess('user-1', 'run', 'projects')).toBe(false); + }); + + it('can edit any resource (wildcard)', async () => { + expect(await service.canAccess('user-1', 'edit', 'secrets')).toBe(true); + expect(await service.canAccess('user-1', 'edit', 'projects')).toBe(true); + expect(await service.canAccess('user-1', 'edit', 'instances')).toBe(true); + }); + }); + + describe('canAccess — edit:servers', () => { + let service: RbacService; + + beforeEach(() => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'bob@example.com' }], + roleBindings: [{ role: 'edit', resource: 'servers' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'bob@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + service = new RbacService(repo, prisma); + }); + + it('can view servers', async () => { + expect(await service.canAccess('user-2', 'view', 'servers')).toBe(true); + }); + + it('can edit servers', async () => { + expect(await service.canAccess('user-2', 'edit', 'servers')).toBe(true); + }); + + it('can create servers (edit includes create)', async () => { + expect(await service.canAccess('user-2', 'create', 'servers')).toBe(true); + }); + + it('can delete servers (edit includes delete)', async () => { + expect(await service.canAccess('user-2', 'delete', 'servers')).toBe(true); + }); + + it('cannot edit users (wrong resource)', async () => { + expect(await service.canAccess('user-2', 'edit', 'users')).toBe(false); + }); + }); + + describe('canAccess — view:servers', () => { + let service: RbacService; + + beforeEach(() => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'carol@example.com' }], + roleBindings: [{ role: 'view', resource: 'servers' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'carol@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + service = new RbacService(repo, prisma); + }); + + it('can view servers', async () => { + expect(await service.canAccess('user-3', 'view', 'servers')).toBe(true); + }); + + it('cannot edit servers', async () => { + expect(await service.canAccess('user-3', 'edit', 'servers')).toBe(false); + }); + + it('cannot create servers', async () => { + expect(await service.canAccess('user-3', 'create', 'servers')).toBe(false); + }); + + it('cannot delete servers', async () => { + expect(await service.canAccess('user-3', 'delete', 'servers')).toBe(false); + }); + }); + + describe('canAccess — create role', () => { + let service: RbacService; + + beforeEach(() => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'dan@example.com' }], + roleBindings: [{ role: 'create', resource: 'servers' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'dan@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + service = new RbacService(repo, prisma); + }); + + it('can create servers', async () => { + expect(await service.canAccess('user-d', 'create', 'servers')).toBe(true); + }); + + it('cannot view servers', async () => { + expect(await service.canAccess('user-d', 'view', 'servers')).toBe(false); + }); + + it('cannot delete servers', async () => { + expect(await service.canAccess('user-d', 'delete', 'servers')).toBe(false); + }); + + it('cannot edit servers', async () => { + expect(await service.canAccess('user-d', 'edit', 'servers')).toBe(false); + }); + }); + + describe('canAccess — delete role', () => { + let service: RbacService; + + beforeEach(() => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'eve@example.com' }], + roleBindings: [{ role: 'delete', resource: 'secrets' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'eve@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + service = new RbacService(repo, prisma); + }); + + it('can delete secrets', async () => { + expect(await service.canAccess('user-e', 'delete', 'secrets')).toBe(true); + }); + + it('cannot create secrets', async () => { + expect(await service.canAccess('user-e', 'create', 'secrets')).toBe(false); + }); + + it('cannot view secrets', async () => { + expect(await service.canAccess('user-e', 'view', 'secrets')).toBe(false); + }); + }); + + describe('canAccess — run role on resource', () => { + let service: RbacService; + + beforeEach(() => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'frank@example.com' }], + roleBindings: [{ role: 'run', resource: 'projects' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'frank@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + service = new RbacService(repo, prisma); + }); + + it('can run projects', async () => { + expect(await service.canAccess('user-f', 'run', 'projects')).toBe(true); + }); + + it('cannot view projects (run does not include view)', async () => { + expect(await service.canAccess('user-f', 'view', 'projects')).toBe(false); + }); + + it('cannot run servers (wrong resource)', async () => { + expect(await service.canAccess('user-f', 'run', 'servers')).toBe(false); + }); + }); + + describe('canAccess — no matching binding', () => { + it('returns false when user has no matching definitions', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'other@example.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'nobody@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + const service = new RbacService(repo, prisma); + + expect(await service.canAccess('user-x', 'view', 'servers')).toBe(false); + }); + + it('returns false when user does not exist', async () => { + const repo = mockRepo([makeDef()]); + const prisma = mockPrisma(); // user.findUnique returns null + const service = new RbacService(repo, prisma); + + expect(await service.canAccess('nonexistent', 'view', 'servers')).toBe(false); + }); + }); + + describe('canAccess — empty subjects', () => { + it('matches nobody when subjects is empty', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [], + roleBindings: [{ role: 'edit', resource: '*' }], + }), + ]); + 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', 'servers')).toBe(false); + }); + }); + + describe('canAccess — group membership', () => { + it('grants access through group subject', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'Group', name: 'devs' }], + roleBindings: [{ role: 'edit', resource: 'servers' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'dave@example.com' })) }, + groupMember: { + findMany: vi.fn(async () => [{ group: { name: 'devs' } }]), + }, + }); + const service = new RbacService(repo, prisma); + + expect(await service.canAccess('user-4', 'view', 'servers')).toBe(true); + expect(await service.canAccess('user-4', 'edit', 'servers')).toBe(true); + expect(await service.canAccess('user-4', 'run', 'servers')).toBe(false); + }); + + it('denies access when user is not in the group', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'Group', name: 'devs' }], + roleBindings: [{ role: 'edit', resource: 'servers' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'eve@example.com' })) }, + groupMember: { + findMany: vi.fn(async () => [{ group: { name: 'ops' } }]), + }, + }); + const service = new RbacService(repo, prisma); + + expect(await service.canAccess('user-5', 'view', 'servers')).toBe(false); + }); + }); + + describe('canAccess — multiple definitions (union)', () => { + it('unions permissions from multiple matching definitions', async () => { + const repo = mockRepo([ + makeDef({ + id: 'def-1', + name: 'rbac-viewers', + subjects: [{ kind: 'User', name: 'frank@example.com' }], + roleBindings: [{ role: 'view', resource: 'servers' }], + }), + makeDef({ + id: 'def-2', + name: 'rbac-editors', + subjects: [{ kind: 'User', name: 'frank@example.com' }], + roleBindings: [{ role: 'edit', resource: 'secrets' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'frank@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + const service = new RbacService(repo, prisma); + + // From def-1: view on servers + expect(await service.canAccess('user-6', 'view', 'servers')).toBe(true); + expect(await service.canAccess('user-6', 'edit', 'servers')).toBe(false); + + // From def-2: edit on secrets (includes view, create, delete) + expect(await service.canAccess('user-6', 'view', 'secrets')).toBe(true); + expect(await service.canAccess('user-6', 'edit', 'secrets')).toBe(true); + expect(await service.canAccess('user-6', 'create', 'secrets')).toBe(true); + + // No permission on other resources + expect(await service.canAccess('user-6', 'view', 'users')).toBe(false); + }); + }); + + describe('canAccess — mixed user and group subjects', () => { + it('matches on either user or group subject', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [ + { kind: 'User', name: 'grace@example.com' }, + { kind: 'Group', name: 'admins' }, + ], + roleBindings: [{ role: 'edit', resource: '*' }], + }), + ]); + + // Test user match (not in group) + const prismaUser = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'grace@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + const serviceUser = new RbacService(repo, prismaUser); + expect(await serviceUser.canAccess('user-7', 'edit', 'servers')).toBe(true); + + // Test group match (different email) + const prismaGroup = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'hank@example.com' })) }, + groupMember: { findMany: vi.fn(async () => [{ group: { name: 'admins' } }]) }, + }); + const serviceGroup = new RbacService(repo, prismaGroup); + expect(await serviceGroup.canAccess('user-8', 'edit', 'servers')).toBe(true); + }); + }); + + describe('canAccess — singular resource names', () => { + it('normalizes singular resource in binding to match plural check', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'edit', resource: 'server' }], + }), + ]); + 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', 'edit', 'servers')).toBe(true); + expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true); + }); + + it('normalizes singular resource in check to match plural binding', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'edit', resource: 'servers' }], + }), + ]); + 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', 'edit', 'server')).toBe(true); + expect(await service.canAccess('user-1', 'view', 'instance')).toBe(false); + }); + }); + + describe('canAccess — name-scoped resource bindings', () => { + let service: RbacService; + + beforeEach(() => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + service = new RbacService(repo, prisma); + }); + + it('allows access to the named resource', async () => { + expect(await service.canAccess('user-1', 'view', 'servers', 'my-ha')).toBe(true); + }); + + it('denies access to a different named resource', async () => { + expect(await service.canAccess('user-1', 'view', 'servers', 'other-server')).toBe(false); + }); + + it('allows listing (no resourceName specified)', async () => { + expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true); + }); + }); + + describe('canAccess — unnamed binding matches any resourceName', () => { + let service: RbacService; + + beforeEach(() => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'view', resource: 'servers' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + service = new RbacService(repo, prisma); + }); + + it('allows access to any named resource', async () => { + expect(await service.canAccess('user-1', 'view', 'servers', 'any-server')).toBe(true); + }); + + it('allows listing', async () => { + expect(await service.canAccess('user-1', 'view', 'servers')).toBe(true); + }); + }); + + describe('canRunOperation', () => { + it('grants operation when run:action binding matches', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'run', action: 'logs' }], + }), + ]); + 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.canRunOperation('user-1', 'logs')).toBe(true); + }); + + it('denies operation when action does not match', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'run', action: 'logs' }], + }), + ]); + 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.canRunOperation('user-1', 'backup')).toBe(false); + }); + + it('ignores resource bindings (only checks operation bindings)', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + }), + ]); + 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.canRunOperation('user-1', 'logs')).toBe(false); + }); + }); + + describe('mixed resource + operation bindings', () => { + let service: RbacService; + + beforeEach(() => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'Group', name: 'admin' }], + roleBindings: [ + { role: 'edit', resource: '*' }, + { role: 'run', resource: '*' }, + { role: 'run', action: 'impersonate' }, + { role: 'run', action: 'logs' }, + { role: 'run', action: 'backup' }, + ], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'admin@example.com' })) }, + groupMember: { findMany: vi.fn(async () => [{ group: { name: 'admin' } }]) }, + }); + service = new RbacService(repo, prisma); + }); + + it('can access resources', async () => { + expect(await service.canAccess('user-1', 'edit', 'servers')).toBe(true); + expect(await service.canAccess('user-1', 'view', 'users')).toBe(true); + expect(await service.canAccess('user-1', 'run', 'projects')).toBe(true); + }); + + it('can run operations', async () => { + expect(await service.canRunOperation('user-1', 'impersonate')).toBe(true); + expect(await service.canRunOperation('user-1', 'logs')).toBe(true); + expect(await service.canRunOperation('user-1', 'backup')).toBe(true); + }); + + it('cannot run undefined operations', async () => { + expect(await service.canRunOperation('user-1', 'destroy-all')).toBe(false); + }); + }); + + describe('getPermissions', () => { + it('returns all permissions for a user', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [ + { role: 'edit', resource: '*' }, + { role: 'view', resource: 'secrets' }, + ], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + const service = new RbacService(repo, prisma); + + const perms = await service.getPermissions('user-1'); + expect(perms).toEqual([ + { role: 'edit', resource: '*' }, + { role: 'view', resource: 'secrets' }, + ]); + }); + + it('returns mixed resource and operation permissions', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [ + { role: 'edit', resource: 'servers' }, + { role: 'run', action: 'logs' }, + ], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + const service = new RbacService(repo, prisma); + + const perms = await service.getPermissions('user-1'); + expect(perms).toEqual([ + { role: 'edit', resource: 'servers' }, + { role: 'run', action: 'logs' }, + ]); + }); + + it('includes name field in name-scoped permissions', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'alice@example.com' }], + roleBindings: [ + { role: 'view', resource: 'servers', name: 'my-ha' }, + ], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + const service = new RbacService(repo, prisma); + + const perms = await service.getPermissions('user-1'); + expect(perms).toEqual([ + { role: 'view', resource: 'servers', name: 'my-ha' }, + ]); + }); + + it('returns empty for unknown user', async () => { + const repo = mockRepo([makeDef()]); + const prisma = mockPrisma(); + const service = new RbacService(repo, prisma); + + const perms = await service.getPermissions('nonexistent'); + expect(perms).toEqual([]); + }); + + it('returns empty when no definitions match', async () => { + const repo = mockRepo([ + makeDef({ + subjects: [{ kind: 'User', name: 'other@example.com' }], + roleBindings: [{ role: 'edit', resource: '*' }], + }), + ]); + const prisma = mockPrisma({ + user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) }, + groupMember: { findMany: vi.fn(async () => []) }, + }); + const service = new RbacService(repo, prisma); + + const perms = await service.getPermissions('user-1'); + expect(perms).toEqual([]); + }); + }); +}); diff --git a/src/mcpd/tests/user-service.test.ts b/src/mcpd/tests/user-service.test.ts new file mode 100644 index 0000000..0ebd16f --- /dev/null +++ b/src/mcpd/tests/user-service.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { UserService } from '../src/services/user.service.js'; +import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js'; +import type { IUserRepository, SafeUser } from '../src/repositories/user.repository.js'; + +function makeSafeUser(overrides: Partial = {}): SafeUser { + return { + id: 'user-1', + email: 'alice@example.com', + name: 'Alice', + role: 'USER', + provider: null, + externalId: null, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function mockUserRepo(): IUserRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByEmail: vi.fn(async () => null), + create: vi.fn(async (data) => + makeSafeUser({ email: data.email, name: data.name ?? null }), + ), + delete: vi.fn(async () => {}), + count: vi.fn(async () => 0), + }; +} + +describe('UserService', () => { + let repo: ReturnType; + let service: UserService; + + beforeEach(() => { + repo = mockUserRepo(); + service = new UserService(repo); + }); + + // ── list ────────────────────────────────────────────────── + + describe('list', () => { + it('returns empty array when no users', async () => { + const result = await service.list(); + expect(result).toEqual([]); + expect(repo.findAll).toHaveBeenCalledOnce(); + }); + + it('returns all users', async () => { + const users = [ + makeSafeUser({ id: 'u1', email: 'a@b.com' }), + makeSafeUser({ id: 'u2', email: 'c@d.com' }), + ]; + vi.mocked(repo.findAll).mockResolvedValue(users); + + const result = await service.list(); + expect(result).toHaveLength(2); + expect(result[0]!.email).toBe('a@b.com'); + }); + }); + + // ── create ──────────────────────────────────────────────── + + describe('create', () => { + it('creates a user and hashes password', async () => { + const result = await service.create({ + email: 'alice@example.com', + password: 'securePass123', + }); + + expect(result.email).toBe('alice@example.com'); + expect(repo.create).toHaveBeenCalledOnce(); + + // Verify the passwordHash was generated (not the plain password) + const createCall = vi.mocked(repo.create).mock.calls[0]![0]!; + expect(createCall.passwordHash).toBeDefined(); + expect(createCall.passwordHash).not.toBe('securePass123'); + expect(createCall.passwordHash.startsWith('$2b$')).toBe(true); + }); + + it('creates a user with optional name', async () => { + await service.create({ + email: 'bob@example.com', + password: 'securePass123', + name: 'Bob', + }); + + const createCall = vi.mocked(repo.create).mock.calls[0]![0]!; + expect(createCall.email).toBe('bob@example.com'); + expect(createCall.name).toBe('Bob'); + }); + + it('returns user without passwordHash', async () => { + const result = await service.create({ + email: 'alice@example.com', + password: 'securePass123', + }); + + // SafeUser type should not have passwordHash + expect(result).not.toHaveProperty('passwordHash'); + }); + + it('throws ConflictError when email already exists', async () => { + vi.mocked(repo.findByEmail).mockResolvedValue(makeSafeUser()); + + await expect( + service.create({ email: 'alice@example.com', password: 'securePass123' }), + ).rejects.toThrow(ConflictError); + }); + + it('throws ZodError for invalid email', async () => { + await expect( + service.create({ email: 'not-an-email', password: 'securePass123' }), + ).rejects.toThrow(); + }); + + it('throws ZodError for short password', async () => { + await expect( + service.create({ email: 'a@b.com', password: 'short' }), + ).rejects.toThrow(); + }); + + it('throws ZodError for missing email', async () => { + await expect( + service.create({ password: 'securePass123' }), + ).rejects.toThrow(); + }); + + it('throws ZodError for password exceeding max length', async () => { + await expect( + service.create({ email: 'a@b.com', password: 'x'.repeat(129) }), + ).rejects.toThrow(); + }); + }); + + // ── getById ─────────────────────────────────────────────── + + describe('getById', () => { + it('returns user when found', async () => { + const user = makeSafeUser(); + vi.mocked(repo.findById).mockResolvedValue(user); + + const result = await service.getById('user-1'); + expect(result.email).toBe('alice@example.com'); + expect(repo.findById).toHaveBeenCalledWith('user-1'); + }); + + it('throws NotFoundError when not found', async () => { + await expect(service.getById('missing')).rejects.toThrow(NotFoundError); + }); + }); + + // ── getByEmail ──────────────────────────────────────────── + + describe('getByEmail', () => { + it('returns user when found', async () => { + const user = makeSafeUser(); + vi.mocked(repo.findByEmail).mockResolvedValue(user); + + const result = await service.getByEmail('alice@example.com'); + expect(result.email).toBe('alice@example.com'); + expect(repo.findByEmail).toHaveBeenCalledWith('alice@example.com'); + }); + + it('throws NotFoundError when not found', async () => { + await expect(service.getByEmail('nobody@example.com')).rejects.toThrow(NotFoundError); + }); + }); + + // ── delete ──────────────────────────────────────────────── + + describe('delete', () => { + it('deletes user by id', async () => { + vi.mocked(repo.findById).mockResolvedValue(makeSafeUser()); + + await service.delete('user-1'); + expect(repo.delete).toHaveBeenCalledWith('user-1'); + }); + + it('throws NotFoundError when user does not exist', async () => { + await expect(service.delete('missing')).rejects.toThrow(NotFoundError); + }); + }); + + // ── count ───────────────────────────────────────────────── + + describe('count', () => { + it('returns 0 when no users', async () => { + const result = await service.count(); + expect(result).toBe(0); + }); + + it('returns 1 when one user exists', async () => { + vi.mocked(repo.count).mockResolvedValue(1); + const result = await service.count(); + expect(result).toBe(1); + }); + + it('returns correct count for multiple users', async () => { + vi.mocked(repo.count).mockResolvedValue(5); + const result = await service.count(); + expect(result).toBe(5); + }); + }); +}); diff --git a/src/mcplocal/src/discovery.ts b/src/mcplocal/src/discovery.ts index 791a443..df70a9c 100644 --- a/src/mcplocal/src/discovery.ts +++ b/src/mcplocal/src/discovery.ts @@ -15,6 +15,40 @@ interface McpdServer { */ export async function refreshUpstreams(router: McpRouter, mcpdClient: McpdClient): Promise { const servers = await mcpdClient.get('/api/v1/servers'); + return syncUpstreams(router, mcpdClient, servers); +} + +/** + * Discovers MCP servers scoped to a project and registers them as upstreams. + * Uses the project-servers endpoint that returns only servers linked to the project. + * + * @param authToken - Optional bearer token forwarded to mcpd for RBAC checks. + */ +export async function refreshProjectUpstreams( + router: McpRouter, + mcpdClient: McpdClient, + projectName: string, + authToken?: string, +): Promise { + const path = `/api/v1/projects/${encodeURIComponent(projectName)}/servers`; + + let servers: McpdServer[]; + if (authToken) { + // Forward the client's auth token to mcpd so RBAC applies + const result = await mcpdClient.forward('GET', path, '', undefined); + if (result.status >= 400) { + throw new Error(`Failed to fetch project servers: ${result.status}`); + } + servers = result.body as McpdServer[]; + } else { + servers = await mcpdClient.get(path); + } + + return syncUpstreams(router, mcpdClient, servers); +} + +/** Shared sync logic: reconcile a router's upstreams with a server list. */ +function syncUpstreams(router: McpRouter, mcpdClient: McpdClient, servers: McpdServer[]): string[] { const registered: string[] = []; // Remove stale upstreams diff --git a/src/mcplocal/src/http/index.ts b/src/mcplocal/src/http/index.ts index 3a5cf63..274a655 100644 --- a/src/mcplocal/src/http/index.ts +++ b/src/mcplocal/src/http/index.ts @@ -5,3 +5,4 @@ export type { HttpConfig } from './config.js'; export { McpdClient, AuthenticationError, ConnectionError } from './mcpd-client.js'; export { registerProxyRoutes } from './routes/proxy.js'; export { registerMcpEndpoint } from './mcp-endpoint.js'; +export { registerProjectMcpEndpoint } from './project-mcp-endpoint.js'; diff --git a/src/mcplocal/src/http/mcpd-client.ts b/src/mcplocal/src/http/mcpd-client.ts index 58eab7e..1b31026 100644 --- a/src/mcplocal/src/http/mcpd-client.ts +++ b/src/mcplocal/src/http/mcpd-client.ts @@ -49,16 +49,20 @@ export class McpdClient { /** * Forward a raw request to mcpd. Returns the status code and body * so the proxy route can relay them directly. + * + * @param authOverride - If provided, used as the Bearer token instead of the + * service token. This allows forwarding end-user tokens for RBAC enforcement. */ async forward( method: string, path: string, query: string, body: unknown | undefined, + authOverride?: string, ): Promise<{ status: number; body: unknown }> { const url = `${this.baseUrl}${path}${query ? `?${query}` : ''}`; const headers: Record = { - 'Authorization': `Bearer ${this.token}`, + 'Authorization': `Bearer ${authOverride ?? this.token}`, 'Accept': 'application/json', }; diff --git a/src/mcplocal/src/http/project-mcp-endpoint.ts b/src/mcplocal/src/http/project-mcp-endpoint.ts new file mode 100644 index 0000000..4b5ae54 --- /dev/null +++ b/src/mcplocal/src/http/project-mcp-endpoint.ts @@ -0,0 +1,131 @@ +/** + * Project-scoped Streamable HTTP MCP protocol endpoint. + * + * Exposes per-project MCP endpoints at /projects/:projectName/mcp so + * Claude Code can connect to a specific project's servers only. + * + * Each project gets its own McpRouter instance (cached with TTL). + * Sessions are managed per-project. + */ +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; +import { McpRouter } from '../router.js'; +import { refreshProjectUpstreams } from '../discovery.js'; +import type { McpdClient } from './mcpd-client.js'; +import type { JsonRpcRequest } from '../types.js'; + +interface ProjectCacheEntry { + router: McpRouter; + lastRefresh: number; +} + +interface SessionEntry { + transport: StreamableHTTPServerTransport; + projectName: string; +} + +const CACHE_TTL_MS = 60_000; // 60 seconds + +export function registerProjectMcpEndpoint(app: FastifyInstance, mcpdClient: McpdClient): void { + const projectCache = new Map(); + const sessions = new Map(); + + async function getOrCreateRouter(projectName: string, authToken?: string): Promise { + const existing = projectCache.get(projectName); + const now = Date.now(); + + if (existing && (now - existing.lastRefresh) < CACHE_TTL_MS) { + return existing.router; + } + + // Create new router or refresh existing one + const router = existing?.router ?? new McpRouter(); + await refreshProjectUpstreams(router, mcpdClient, projectName, authToken); + + projectCache.set(projectName, { router, lastRefresh: now }); + return router; + } + + // POST /projects/:projectName/mcp — JSON-RPC requests + app.post<{ Params: { projectName: string } }>('/projects/:projectName/mcp', async (request, reply) => { + const { projectName } = request.params; + const sessionId = request.headers['mcp-session-id'] as string | undefined; + const authToken = (request.headers['authorization'] as string | undefined)?.replace(/^Bearer\s+/i, ''); + + if (sessionId && sessions.has(sessionId)) { + const session = sessions.get(sessionId)!; + await session.transport.handleRequest(request.raw, reply.raw, request.body); + reply.hijack(); + return; + } + + if (sessionId && !sessions.has(sessionId)) { + reply.code(404).send({ error: 'Session not found' }); + return; + } + + // New session — get/create project router + let router: McpRouter; + try { + router = await getOrCreateRouter(projectName, authToken); + } catch (err) { + reply.code(502).send({ error: `Failed to load project: ${err instanceof Error ? err.message : String(err)}` }); + return; + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, { transport, projectName }); + }, + }); + + transport.onmessage = async (message: JSONRPCMessage) => { + if ('method' in message && 'id' in message) { + const response = await router.route(message as unknown as JsonRpcRequest); + await transport.send(response as unknown as JSONRPCMessage); + } + }; + + transport.onclose = () => { + const id = transport.sessionId; + if (id) { + sessions.delete(id); + } + }; + + await transport.handleRequest(request.raw, reply.raw, request.body); + reply.hijack(); + }); + + // GET /projects/:projectName/mcp — SSE stream + app.get<{ Params: { projectName: string } }>('/projects/:projectName/mcp', async (request, reply) => { + const sessionId = request.headers['mcp-session-id'] as string | undefined; + + if (!sessionId || !sessions.has(sessionId)) { + reply.code(400).send({ error: 'Invalid or missing session ID' }); + return; + } + + const session = sessions.get(sessionId)!; + await session.transport.handleRequest(request.raw, reply.raw); + reply.hijack(); + }); + + // DELETE /projects/:projectName/mcp — Session cleanup + app.delete<{ Params: { projectName: string } }>('/projects/:projectName/mcp', async (request, reply) => { + const sessionId = request.headers['mcp-session-id'] as string | undefined; + + if (!sessionId || !sessions.has(sessionId)) { + reply.code(400).send({ error: 'Invalid or missing session ID' }); + return; + } + + const session = sessions.get(sessionId)!; + await session.transport.handleRequest(request.raw, reply.raw); + sessions.delete(sessionId); + reply.hijack(); + }); +} diff --git a/src/mcplocal/src/http/routes/proxy.ts b/src/mcplocal/src/http/routes/proxy.ts index 7eff72e..985f6e6 100644 --- a/src/mcplocal/src/http/routes/proxy.ts +++ b/src/mcplocal/src/http/routes/proxy.ts @@ -16,8 +16,13 @@ export function registerProxyRoutes(app: FastifyInstance, client: McpdClient): v ? (request.body as unknown) : undefined; + // Forward the user's auth token to mcpd so RBAC applies per-user. + // If no user token is present, mcpd will use its auth hook to reject. + const authHeader = request.headers['authorization'] as string | undefined; + const userToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined; + try { - const result = await client.forward(request.method, path, querystring, body); + const result = await client.forward(request.method, path, querystring, body, userToken); return reply.code(result.status).send(result.body); } catch (err: unknown) { if (err instanceof AuthenticationError) { diff --git a/src/mcplocal/src/http/server.ts b/src/mcplocal/src/http/server.ts index 6f77ca2..01ab7fc 100644 --- a/src/mcplocal/src/http/server.ts +++ b/src/mcplocal/src/http/server.ts @@ -6,6 +6,7 @@ import type { HttpConfig } from './config.js'; import { McpdClient } from './mcpd-client.js'; import { registerProxyRoutes } from './routes/proxy.js'; import { registerMcpEndpoint } from './mcp-endpoint.js'; +import { registerProjectMcpEndpoint } from './project-mcp-endpoint.js'; import type { McpRouter } from '../router.js'; import type { HealthMonitor } from '../health.js'; import type { TieredHealthMonitor } from '../health/tiered.js'; @@ -85,5 +86,8 @@ export async function createHttpServer( // Streamable HTTP MCP protocol endpoint at /mcp registerMcpEndpoint(app, deps.router); + // Project-scoped MCP endpoint at /projects/:projectName/mcp + registerProjectMcpEndpoint(app, mcpdClient); + return app; } diff --git a/src/mcplocal/tests/project-discovery.test.ts b/src/mcplocal/tests/project-discovery.test.ts new file mode 100644 index 0000000..3ae9287 --- /dev/null +++ b/src/mcplocal/tests/project-discovery.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi } from 'vitest'; +import { refreshProjectUpstreams } from '../src/discovery.js'; +import { McpRouter } from '../src/router.js'; + +function mockMcpdClient(servers: Array<{ id: string; name: string; transport: string }>) { + return { + baseUrl: 'http://test:3100', + token: 'test-token', + get: vi.fn(async () => servers), + post: vi.fn(async () => ({ result: {} })), + put: vi.fn(), + delete: vi.fn(), + forward: vi.fn(async () => ({ status: 200, body: servers })), + }; +} + +describe('refreshProjectUpstreams', () => { + it('registers project-scoped servers as upstreams', async () => { + const router = new McpRouter(); + const client = mockMcpdClient([ + { id: 'srv-1', name: 'grafana', transport: 'stdio' }, + { id: 'srv-2', name: 'ha', transport: 'stdio' }, + ]); + + const registered = await refreshProjectUpstreams(router, client as any, 'smart-home'); + expect(registered).toEqual(['grafana', 'ha']); + expect(router.getUpstreamNames()).toContain('grafana'); + expect(router.getUpstreamNames()).toContain('ha'); + expect(client.get).toHaveBeenCalledWith('/api/v1/projects/smart-home/servers'); + }); + + it('removes stale upstreams on refresh', async () => { + const router = new McpRouter(); + + // First refresh: 2 servers + const client1 = mockMcpdClient([ + { id: 'srv-1', name: 'grafana', transport: 'stdio' }, + { id: 'srv-2', name: 'ha', transport: 'stdio' }, + ]); + await refreshProjectUpstreams(router, client1 as any, 'smart-home'); + expect(router.getUpstreamNames()).toHaveLength(2); + + // Second refresh: only 1 server + const client2 = mockMcpdClient([ + { id: 'srv-1', name: 'grafana', transport: 'stdio' }, + ]); + await refreshProjectUpstreams(router, client2 as any, 'smart-home'); + expect(router.getUpstreamNames()).toEqual(['grafana']); + }); + + it('forwards auth token via forward() method', async () => { + const router = new McpRouter(); + const servers = [{ id: 'srv-1', name: 'grafana', transport: 'stdio' }]; + const client = mockMcpdClient(servers); + + await refreshProjectUpstreams(router, client as any, 'smart-home', 'user-token-123'); + expect(client.forward).toHaveBeenCalledWith('GET', '/api/v1/projects/smart-home/servers', '', undefined); + expect(router.getUpstreamNames()).toContain('grafana'); + }); + + it('throws on failed project fetch', async () => { + const router = new McpRouter(); + const client = mockMcpdClient([]); + client.forward.mockResolvedValue({ status: 403, body: { error: 'Forbidden' } }); + + await expect( + refreshProjectUpstreams(router, client as any, 'secret-project', 'bad-token'), + ).rejects.toThrow('Failed to fetch project servers: 403'); + }); + + it('URL-encodes project name', async () => { + const router = new McpRouter(); + const client = mockMcpdClient([]); + + await refreshProjectUpstreams(router, client as any, 'my project'); + expect(client.get).toHaveBeenCalledWith('/api/v1/projects/my%20project/servers'); + }); + + it('handles empty project server list', async () => { + const router = new McpRouter(); + const client = mockMcpdClient([]); + + const registered = await refreshProjectUpstreams(router, client as any, 'empty-project'); + expect(registered).toEqual([]); + expect(router.getUpstreamNames()).toHaveLength(0); + }); +}); diff --git a/src/mcplocal/tests/project-mcp-endpoint.test.ts b/src/mcplocal/tests/project-mcp-endpoint.test.ts new file mode 100644 index 0000000..1551d32 --- /dev/null +++ b/src/mcplocal/tests/project-mcp-endpoint.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js'; + +// Mock discovery module — we don't want real HTTP calls +vi.mock('../src/discovery.js', () => ({ + refreshProjectUpstreams: vi.fn(async () => ['mock-server']), +})); + +import { refreshProjectUpstreams } from '../src/discovery.js'; + +function mockMcpdClient() { + return { + baseUrl: 'http://test:3100', + token: 'test-token', + get: vi.fn(async () => []), + post: vi.fn(async () => ({})), + put: vi.fn(), + delete: vi.fn(), + forward: vi.fn(async () => ({ status: 200, body: [] })), + }; +} + +describe('registerProjectMcpEndpoint', () => { + let app: FastifyInstance; + + beforeEach(async () => { + vi.clearAllMocks(); + app = Fastify(); + registerProjectMcpEndpoint(app, mockMcpdClient() as any); + await app.ready(); + }); + + it('registers POST /projects/:projectName/mcp route', async () => { + // The endpoint should exist and attempt to handle MCP protocol + const res = await app.inject({ + method: 'POST', + url: '/projects/smart-home/mcp', + payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + headers: { 'content-type': 'application/json' }, + }); + + // The StreamableHTTPServerTransport hijacks the response, + // so we may get a 200 or the transport handles it directly + expect(res.statusCode).not.toBe(404); + }); + + it('calls refreshProjectUpstreams with project name', async () => { + await app.inject({ + method: 'POST', + url: '/projects/smart-home/mcp', + payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + headers: { 'content-type': 'application/json' }, + }); + + expect(refreshProjectUpstreams).toHaveBeenCalledWith( + expect.any(Object), // McpRouter instance + expect.any(Object), // McpdClient + 'smart-home', + undefined, // no auth token + ); + }); + + it('forwards auth token from Authorization header', async () => { + await app.inject({ + method: 'POST', + url: '/projects/secure-project/mcp', + payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + headers: { + 'content-type': 'application/json', + 'authorization': 'Bearer my-token-123', + }, + }); + + expect(refreshProjectUpstreams).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + 'secure-project', + 'my-token-123', + ); + }); + + it('returns 502 when project discovery fails', async () => { + vi.mocked(refreshProjectUpstreams).mockRejectedValueOnce(new Error('Forbidden')); + + const res = await app.inject({ + method: 'POST', + url: '/projects/bad-project/mcp', + payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + headers: { 'content-type': 'application/json' }, + }); + + expect(res.statusCode).toBe(502); + expect(res.json().error).toContain('Failed to load project'); + }); + + it('returns 404 for unknown session ID', async () => { + const res = await app.inject({ + method: 'POST', + url: '/projects/smart-home/mcp', + payload: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, + headers: { + 'content-type': 'application/json', + 'mcp-session-id': 'nonexistent-session', + }, + }); + + expect(res.statusCode).toBe(404); + }); + + it('returns 400 for GET without session', async () => { + const res = await app.inject({ + method: 'GET', + url: '/projects/smart-home/mcp', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('session'); + }); + + it('returns 400 for DELETE without session', async () => { + const res = await app.inject({ + method: 'DELETE', + url: '/projects/smart-home/mcp', + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('session'); + }); + + it('caches project router across requests', async () => { + // Two requests to the same project should reuse the router + await app.inject({ + method: 'POST', + url: '/projects/smart-home/mcp', + payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + headers: { 'content-type': 'application/json' }, + }); + + await app.inject({ + method: 'POST', + url: '/projects/smart-home/mcp', + payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} }, + headers: { 'content-type': 'application/json' }, + }); + + // refreshProjectUpstreams should only be called once (cached) + expect(refreshProjectUpstreams).toHaveBeenCalledTimes(1); + }); + + it('creates separate routers for different projects', async () => { + await app.inject({ + method: 'POST', + url: '/projects/project-a/mcp', + payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, + headers: { 'content-type': 'application/json' }, + }); + + await app.inject({ + method: 'POST', + url: '/projects/project-b/mcp', + payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} }, + headers: { 'content-type': 'application/json' }, + }); + + // Two different projects should trigger two refreshes + expect(refreshProjectUpstreams).toHaveBeenCalledTimes(2); + expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-a', undefined); + expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-b', undefined); + }); +}); -- 2.49.1