maestro/src/services/space-credential-transfer.ts
oss-sync a6d1879d63
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (c764df2f)
2026-06-29 01:32:25 +00:00

223 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type Database from 'better-sqlite3';
import { createRegistry } from '../mcp/registry.js';
import type { McpServerPublic } from '../mcp/types.js';
import { logger } from '../logger.js';
import { createConnectionRepo, type SshConnection } from '../ssh/connection-repo.js';
import { decryptPrivateKey, encryptPrivateKey } from '../ssh/crypto.js';
export interface TransferDeps {
db: Database.Database;
}
export interface TransferCandidate {
type: 'mcp' | 'ssh';
id: string;
name: string;
detail: string;
willSkip: boolean;
skipReason?: string;
}
export interface TransferResult {
copied: Array<{ type: 'mcp' | 'ssh'; id: string; name: string }>;
skipped: Array<{ type: 'mcp' | 'ssh'; name: string; reason: string }>;
}
/** 移し先に同名 or 同 url の MCP があるか。 */
function mcpSkipReason(target: McpServerPublic[], src: McpServerPublic): string | null {
if (target.some((t) => t.name === src.name)) return `同名のサーバが既にあります(${src.name}`;
if (target.some((t) => t.url === src.url)) return `同じ接続先が既にあります(${src.url}`;
return null;
}
export function listMcpCandidates(
deps: TransferDeps,
sourceSpaceId: string,
targetSpaceId: string,
): TransferCandidate[] {
const reg = createRegistry(deps.db);
const target = reg.listForSpace(targetSpaceId);
return reg.listForSpace(sourceSpaceId).map((s) => {
const reason = mcpSkipReason(target, s);
return {
type: 'mcp' as const,
id: s.id,
name: s.name,
detail: s.url,
willSkip: reason !== null,
skipReason: reason ?? undefined,
};
});
}
export function transferMcpServer(
deps: TransferDeps,
sourceId: string,
sourceSpaceId: string,
targetSpaceId: string,
actorId: string,
): { copied?: { type: 'mcp'; id: string; name: string }; skipped?: { type: 'mcp'; name: string; reason: string } } {
const reg = createRegistry(deps.db);
// 元WSへの所属を「復号せずに」確認するSSH 経路と対称。listForSpace は
// McpServerPublicシークレット非返却なので、他スペースの id では相手の
// シークレットをメモリに展開しないまま skip できるIDOR 対策の前段)。
if (!reg.listForSpace(sourceSpaceId).some((s) => s.id === sourceId)) {
// listPublic も McpServerPublicシークレット非返却。存在しない id か、
// 他スペースの id かを復号せずに区別して正確な理由を返すSSH 経路と対称)。
const existsElsewhere = reg.listPublic().some((s) => s.id === sourceId);
return {
skipped: {
type: 'mcp',
name: sourceId,
reason: existsElsewhere ? '指定元ワークスペースに属さない' : '見つかりません',
},
};
}
const src = reg.getDecrypted(sourceId);
if (!src) return { skipped: { type: 'mcp', name: sourceId, reason: '見つかりません' } };
const reason = mcpSkipReason(reg.listForSpace(targetSpaceId), src);
if (reason) return { skipped: { type: 'mcp', name: src.name, reason } };
const newId = `${src.name.toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 40)}-${Date.now().toString(36)}`;
reg.upsert({
id: newId,
name: src.name,
url: src.url,
authKind: src.authKind,
ownerId: null, // スペース所有owner 束縛しない)
spaceId: targetSpaceId,
oauthClientId: src.oauthClientId,
oauthClientSecret: src.oauthClientSecret,
oauthScopes: src.oauthScopes,
staticToken: src.staticToken ?? undefined,
authHeaderName: src.authHeaderName,
enabled: src.enabled,
createdBy: actorId,
});
logger.info(`[cred-transfer] mcp copied src=${sourceId} -> space=${targetSpaceId} id=${newId}`);
return { copied: { type: 'mcp', id: newId, name: src.name } };
}
// ────────────────────────────────────────────────────────────────────────────
// SSH 経路DEK ラウンドトリップ)
// ────────────────────────────────────────────────────────────────────────────
/** 移し先に同名 or 同接続先の SSH 接続があるか。 */
function sshSkipReason(target: SshConnection[], src: SshConnection): string | null {
if (target.some((t) => t.label === src.label)) return `同名の接続が既にあります(${src.label}`;
if (target.some((t) => t.host === src.host && t.username === src.username && t.port === src.port))
return `同じ接続先が既にあります(${src.username}@${src.host}:${src.port}`;
return null;
}
export function listSshCandidates(
deps: TransferDeps,
sourceSpaceId: string,
targetSpaceId: string,
): TransferCandidate[] {
const repo = createConnectionRepo(deps.db);
const target = repo.listBySpace(targetSpaceId);
return repo.listBySpace(sourceSpaceId).map((s) => {
const reason = sshSkipReason(target, s);
return {
type: 'ssh' as const,
id: s.id,
name: s.label,
detail: `${s.username}@${s.host}:${s.port}`,
willSkip: reason !== null,
skipReason: reason ?? undefined,
};
});
}
export function transferSshConnection(
deps: TransferDeps,
sourceId: string,
sourceSpaceId: string,
targetSpaceId: string,
actorId: string,
): { copied?: { type: 'ssh'; id: string; name: string }; skipped?: { type: 'ssh'; name: string; reason: string } } {
const repo = createConnectionRepo(deps.db);
const src = repo.resolveConnection(sourceId);
if (!src) return { skipped: { type: 'ssh', name: sourceId, reason: '見つかりません' } };
if (src.spaceId !== sourceSpaceId) {
return { skipped: { type: 'ssh', name: src.label, reason: '指定元ワークスペースに属さない' } };
}
const reason = sshSkipReason(repo.listBySpace(targetSpaceId), src);
if (reason) return { skipped: { type: 'ssh', name: src.label, reason } };
// 元WSのDEKで復号 → 移し先WSのDEKで再暗号化。平文は即クリア。
const pem = decryptPrivateKey(deps.db, src.ownerId, src.privateKeyEnc, src.spaceId);
let passphrase: Buffer | null = null;
try {
if (src.passphraseEnc) passphrase = decryptPrivateKey(deps.db, src.ownerId, src.passphraseEnc, src.spaceId);
const pk = encryptPrivateKey(deps.db, null, pem, targetSpaceId);
const pp = passphrase ? encryptPrivateKey(deps.db, null, passphrase, targetSpaceId) : null;
const created = repo.create({
ownerId: null, // スペース所有
spaceId: targetSpaceId,
label: src.label,
host: src.host,
port: src.port,
username: src.username,
privateKeyEnc: pk.blob,
passphraseEnc: pp ? pp.blob : null,
keyVersion: pk.keyVersion,
keyFingerprint: src.keyFingerprint ?? 'SHA256:unknown',
remotePathPrefix: src.remotePathPrefix,
allowRemoteUnrestricted: src.allowRemoteUnrestricted,
allowPrivateAddresses: src.allowPrivateAddresses,
commandDenyPatterns: src.commandDenyPatterns,
commandAllowPatterns: src.commandAllowPatterns,
});
logger.info(`[cred-transfer] ssh copied src=${sourceId} -> space=${targetSpaceId} id=${created.id}`);
return { copied: { type: 'ssh', id: created.id, name: src.label } };
} finally {
pem.fill(0);
passphrase?.fill(0);
}
}
// ────────────────────────────────────────────────────────────────────────────
// オーケストレーションMCP + SSH 統合)
// ────────────────────────────────────────────────────────────────────────────
/** スペース間転送の候補一覧を MCP・SSH それぞれ返す。 */
export function listTransferCandidates(
deps: TransferDeps,
sourceSpaceId: string,
targetSpaceId: string,
): { mcp: TransferCandidate[]; ssh: TransferCandidate[] } {
return {
mcp: listMcpCandidates(deps, sourceSpaceId, targetSpaceId),
ssh: listSshCandidates(deps, sourceSpaceId, targetSpaceId),
};
}
/** 指定した MCP サーバー・SSH 接続を一括転送し、copied/skipped のサマリを返す。 */
export function executeTransfer(
deps: TransferDeps,
opts: {
sourceSpaceId: string;
targetSpaceId: string;
actorId: string;
mcpServerIds: string[];
sshConnectionIds: string[];
},
): TransferResult {
return deps.db.transaction(() => {
const result: TransferResult = { copied: [], skipped: [] };
for (const id of opts.mcpServerIds) {
const r = transferMcpServer(deps, id, opts.sourceSpaceId, opts.targetSpaceId, opts.actorId);
if (r.copied) result.copied.push(r.copied);
if (r.skipped) result.skipped.push(r.skipped);
}
for (const id of opts.sshConnectionIds) {
const r = transferSshConnection(deps, id, opts.sourceSpaceId, opts.targetSpaceId, opts.actorId);
if (r.copied) result.copied.push(r.copied);
if (r.skipped) result.skipped.push(r.skipped);
}
return result;
})();
}