- 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>
354 lines
13 KiB
TypeScript
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;
|
|
}
|
|
|
|
}
|