- Remove ProjectMember model entirely (RBAC manages project access) - Add 'expose' RBAC role for /mcp-config endpoint access (edit implies expose) - Rename CLI flags: --llm-provider → --proxy-mode-llm-provider, --llm-model → --proxy-mode-llm-model - Add attach-server / detach-server CLI commands (mcpctl --project NAME attach-server SERVER) - Add POST/DELETE /api/v1/projects/:id/servers endpoints for server attach/detach - Remove members from backup/restore, apply, get, describe - Prisma migration to drop ProjectMember table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
188 lines
5.3 KiB
TypeScript
188 lines
5.3 KiB
TypeScript
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';
|
|
|
|
export interface BackupBundle {
|
|
version: string;
|
|
mcpctlVersion: string;
|
|
createdAt: string;
|
|
encrypted: boolean;
|
|
servers: BackupServer[];
|
|
secrets: BackupSecret[];
|
|
projects: BackupProject[];
|
|
users?: BackupUser[];
|
|
groups?: BackupGroup[];
|
|
rbacBindings?: BackupRbacBinding[];
|
|
encryptedSecrets?: EncryptedPayload;
|
|
}
|
|
|
|
export interface BackupServer {
|
|
name: string;
|
|
description: string;
|
|
packageName: string | null;
|
|
dockerImage: string | null;
|
|
transport: string;
|
|
repositoryUrl: string | null;
|
|
env: unknown;
|
|
}
|
|
|
|
export interface BackupSecret {
|
|
name: string;
|
|
data: Record<string, string>;
|
|
}
|
|
|
|
export interface BackupProject {
|
|
name: string;
|
|
description: string;
|
|
proxyMode?: string;
|
|
llmProvider?: string | null;
|
|
llmModel?: string | null;
|
|
serverNames?: 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' | 'users' | 'groups' | 'rbac'>;
|
|
}
|
|
|
|
export class BackupService {
|
|
constructor(
|
|
private serverRepo: IMcpServerRepository,
|
|
private projectRepo: IProjectRepository,
|
|
private secretRepo: ISecretRepository,
|
|
private userRepo?: IUserRepository,
|
|
private groupRepo?: IGroupRepository,
|
|
private rbacRepo?: IRbacDefinitionRepository,
|
|
) {}
|
|
|
|
async createBackup(options?: BackupOptions): Promise<BackupBundle> {
|
|
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();
|
|
servers = allServers.map((s) => ({
|
|
name: s.name,
|
|
description: s.description,
|
|
packageName: s.packageName,
|
|
dockerImage: s.dockerImage,
|
|
transport: s.transport,
|
|
repositoryUrl: s.repositoryUrl,
|
|
env: s.env,
|
|
}));
|
|
}
|
|
|
|
if (resources.includes('secrets')) {
|
|
const allSecrets = await this.secretRepo.findAll();
|
|
secrets = allSecrets.map((s) => ({
|
|
name: s.name,
|
|
data: s.data as Record<string, string>,
|
|
}));
|
|
}
|
|
|
|
if (resources.includes('projects')) {
|
|
const allProjects = await this.projectRepo.findAll();
|
|
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),
|
|
}));
|
|
}
|
|
|
|
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,
|
|
}));
|
|
}
|
|
|
|
const bundle: BackupBundle = {
|
|
version: '1',
|
|
mcpctlVersion: APP_VERSION,
|
|
createdAt: new Date().toISOString(),
|
|
encrypted: false,
|
|
servers,
|
|
secrets,
|
|
projects,
|
|
users,
|
|
groups,
|
|
rbacBindings,
|
|
};
|
|
|
|
if (options?.password && secrets.length > 0) {
|
|
// Collect sensitive values from secrets and encrypt them
|
|
const sensitiveData: Record<string, string> = {};
|
|
for (const secret of secrets) {
|
|
for (const [key, value] of Object.entries(secret.data)) {
|
|
if (isSensitiveKey(key)) {
|
|
const secretKey = `secret:${secret.name}:${key}`;
|
|
sensitiveData[secretKey] = value;
|
|
secret.data[key] = `__ENCRYPTED:${secretKey}__`;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(sensitiveData).length > 0) {
|
|
bundle.encrypted = true;
|
|
bundle.encryptedSecrets = encrypt(JSON.stringify(sensitiveData), options.password);
|
|
}
|
|
}
|
|
|
|
return bundle;
|
|
}
|
|
}
|