Files
mcpctl/src/mcpd/src/services/backup/restore-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

354 lines
13 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 type { RbacRoleBinding } from '../../validation/rbac-definition.schema.js';
import { decrypt } from './crypto.js';
import type { BackupBundle } from './backup-service.js';
export type ConflictStrategy = 'skip' | 'overwrite' | 'fail';
export interface RestoreOptions {
password?: string;
conflictStrategy?: ConflictStrategy;
}
export interface RestoreResult {
serversCreated: number;
serversSkipped: number;
secretsCreated: number;
secretsSkipped: number;
projectsCreated: number;
projectsSkipped: number;
usersCreated: number;
usersSkipped: number;
groupsCreated: number;
groupsSkipped: number;
rbacCreated: number;
rbacSkipped: number;
errors: string[];
}
export class RestoreService {
constructor(
private serverRepo: IMcpServerRepository,
private projectRepo: IProjectRepository,
private secretRepo: ISecretRepository,
private userRepo?: IUserRepository,
private groupRepo?: IGroupRepository,
private rbacRepo?: IRbacDefinitionRepository,
) {}
validateBundle(bundle: unknown): bundle is BackupBundle {
if (typeof bundle !== 'object' || bundle === null) return false;
const b = bundle as Record<string, unknown>;
return (
typeof b['version'] === 'string' &&
Array.isArray(b['servers']) &&
Array.isArray(b['secrets']) &&
Array.isArray(b['projects'])
);
// users, groups, rbacBindings are optional for backwards compatibility
}
async restore(bundle: BackupBundle, options?: RestoreOptions): Promise<RestoreResult> {
const strategy = options?.conflictStrategy ?? 'skip';
const result: RestoreResult = {
serversCreated: 0,
serversSkipped: 0,
secretsCreated: 0,
secretsSkipped: 0,
projectsCreated: 0,
projectsSkipped: 0,
usersCreated: 0,
usersSkipped: 0,
groupsCreated: 0,
groupsSkipped: 0,
rbacCreated: 0,
rbacSkipped: 0,
errors: [],
};
// Decrypt secrets if encrypted
let decryptedSecrets: Record<string, string> = {};
if (bundle.encrypted && bundle.encryptedSecrets) {
if (!options?.password) {
result.errors.push('Backup is encrypted but no password provided');
return result;
}
try {
decryptedSecrets = JSON.parse(decrypt(bundle.encryptedSecrets, options.password)) as Record<string, string>;
} catch {
result.errors.push('Failed to decrypt backup - incorrect password or corrupted data');
return result;
}
}
// Restore encrypted values into secret data
for (const secret of bundle.secrets) {
for (const [key, value] of Object.entries(secret.data)) {
if (typeof value === 'string' && value.startsWith('__ENCRYPTED:') && value.endsWith('__')) {
const secretKey = value.slice(12, -2);
const decrypted = decryptedSecrets[secretKey];
if (decrypted !== undefined) {
secret.data[key] = decrypted;
}
}
}
}
// 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 {
const existing = await this.serverRepo.findByName(server.name);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`Server "${server.name}" already exists`);
return result;
}
if (strategy === 'skip') {
result.serversSkipped++;
continue;
}
// overwrite
const updateData: Parameters<IMcpServerRepository['update']>[1] = {
description: server.description,
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
};
if (server.packageName) updateData.packageName = server.packageName;
if (server.dockerImage) updateData.dockerImage = server.dockerImage;
if (server.repositoryUrl) updateData.repositoryUrl = server.repositoryUrl;
await this.serverRepo.update(existing.id, updateData);
result.serversCreated++;
continue;
}
const createData: Parameters<IMcpServerRepository['create']>[0] = {
name: server.name,
description: server.description,
transport: server.transport as 'STDIO' | 'SSE' | 'STREAMABLE_HTTP',
replicas: (server as { replicas?: number }).replicas ?? 1,
env: (server.env ?? []) as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }>,
};
if (server.packageName) createData.packageName = server.packageName;
if (server.dockerImage) createData.dockerImage = server.dockerImage;
if (server.repositoryUrl) createData.repositoryUrl = server.repositoryUrl;
await this.serverRepo.create(createData);
result.serversCreated++;
} catch (err) {
result.errors.push(`Failed to restore server "${server.name}": ${err instanceof Error ? err.message : String(err)}`);
}
}
// 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;
}
// 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 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);
if (existing) {
if (strategy === 'fail') {
result.errors.push(`Project "${project.name}" already exists`);
return result;
}
if (strategy === 'skip') {
result.projectsSkipped++;
continue;
}
// overwrite
const updateData: Record<string, unknown> = { 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
if (project.serverNames && project.serverNames.length > 0) {
const serverIds = await this.resolveServerNames(project.serverNames);
await this.projectRepo.setServers(existing.id, serverIds);
}
result.projectsCreated++;
continue;
}
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);
}
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 RbacRoleBinding[],
});
result.rbacCreated++;
continue;
}
await this.rbacRepo.create({
name: rbac.name,
subjects: rbac.subjects as Array<{ kind: 'User' | 'Group'; name: string }>,
roleBindings: rbac.roleBindings as RbacRoleBinding[],
});
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<string[]> {
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<string[]> {
const ids: string[] = [];
for (const name of names) {
const server = await this.serverRepo.findByName(name);
if (server) ids.push(server.id);
}
return ids;
}
}