Files
mcpctl/src/mcpd/src/services/backup/backup-service.ts
Michal 783cf15179
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
feat: remove ProjectMember, add expose RBAC role, attach/detach-server commands
- Remove ProjectMember model entirely (RBAC manages project access)
- Add 'expose' RBAC role for /mcp-config endpoint access (edit implies expose)
- Rename CLI flags: --llm-provider → --proxy-mode-llm-provider, --llm-model → --proxy-mode-llm-model
- Add attach-server / detach-server CLI commands (mcpctl --project NAME attach-server SERVER)
- Add POST/DELETE /api/v1/projects/:id/servers endpoints for server attach/detach
- Remove members from backup/restore, apply, get, describe
- Prisma migration to drop ProjectMember table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:50:01 +00:00

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