feat(mcpd): pluggable SecretBackend abstraction + OpenBao driver + migrate
All checks were successful
CI/CD / typecheck (pull_request) Successful in 51s
CI/CD / lint (pull_request) Successful in 1m47s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / smoke (pull_request) Successful in 4m34s
CI/CD / build (pull_request) Successful in 3m50s
CI/CD / publish (pull_request) Has been skipped
All checks were successful
CI/CD / typecheck (pull_request) Successful in 51s
CI/CD / lint (pull_request) Successful in 1m47s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / smoke (pull_request) Successful in 4m34s
CI/CD / build (pull_request) Successful in 3m50s
CI/CD / publish (pull_request) Has been skipped
Why: API keys live in Postgres as plaintext JSON. A DB read exposes every credential in the system. Before centralising more secrets (LLM keys, etc.) we want to be able to point at an external KV store and drop DB access to sensitive rows. New model: - `SecretBackend` resource (CRUD + isDefault invariant) owns how a secret is stored. `Secret` gains `backendId` FK and `externalRef`. Reads/writes dispatch through a driver. - `plaintext` driver (near-noop, uses existing Secret.data column) is seeded as the `default` row at startup. Acts as trust root / bootstrap. - `openbao` driver (also HashiCorp Vault KV v2 compatible) talks plain HTTP, no SDK dependency. Auth via static token pulled from a plaintext-backed `Secret` through the injected SecretRefResolver. Caches resolved token. - `SecretMigrateService` moves secrets one-at-a-time: read → write dest → flip row → best-effort source delete. Interrupted runs are idempotent (skips secrets already on destination). CLI surface: - `mcpctl create|get|describe|delete secretbackend` + `--default` on create. - `mcpctl migrate secrets --from X --to Y [--names a,b] [--keep-source] [--dry-run]` - `apply -f` round-trips secretbackends (yaml/json multi-doc + grouped). - RBAC: `secretbackends` resource + `run:migrate-secrets` operation. - Fish + bash completions regenerated. docs/secret-backends.md covers the OpenBao policy, chicken-and-egg auth flow, and the migration semantics. Broke the circular dep (OpenBao needs SecretService to resolve its own token, SecretService needs SecretBackendService) with a deferred-resolver bridge in mcpd startup. 11 new driver unit tests; existing env-resolver/secret-route/ backup tests updated for the new service signatures. Full suite: 1792/1792. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,14 @@ const SecretSpecSchema = z.object({
|
||||
data: z.record(z.string()).default({}),
|
||||
});
|
||||
|
||||
const SecretBackendSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
description: z.string().default(''),
|
||||
isDefault: z.boolean().optional(),
|
||||
config: z.record(z.unknown()).default({}),
|
||||
});
|
||||
|
||||
const TemplateEnvEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
@@ -142,6 +150,7 @@ const McpTokenSpecSchema = z.object({
|
||||
});
|
||||
|
||||
const ApplyConfigSchema = z.object({
|
||||
secretbackends: z.array(SecretBackendSpecSchema).default([]),
|
||||
secrets: z.array(SecretSpecSchema).default([]),
|
||||
servers: z.array(ServerSpecSchema).default([]),
|
||||
users: z.array(UserSpecSchema).default([]),
|
||||
@@ -183,6 +192,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
|
||||
|
||||
if (opts.dryRun) {
|
||||
log('Dry run - would apply:');
|
||||
if (config.secretbackends.length > 0) log(` ${config.secretbackends.length} secretbackend(s)`);
|
||||
if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
|
||||
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
|
||||
if (config.users.length > 0) log(` ${config.users.length} user(s)`);
|
||||
@@ -229,6 +239,7 @@ const KIND_TO_RESOURCE: Record<string, string> = {
|
||||
promptrequest: 'promptrequests',
|
||||
serverattachment: 'serverattachments',
|
||||
mcptoken: 'mcptokens',
|
||||
secretbackend: 'secretbackends',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -324,6 +335,30 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
}
|
||||
}
|
||||
|
||||
// Apply secret backends first — secrets reference them.
|
||||
// When multiple backends claim isDefault: true, the server's atomic swap will
|
||||
// leave whichever was applied last as the effective default.
|
||||
for (const sb of config.secretbackends) {
|
||||
try {
|
||||
const existing = await cachedFindByName('secretbackends', sb.name);
|
||||
if (existing) {
|
||||
const updateBody: Record<string, unknown> = {
|
||||
config: sb.config,
|
||||
description: sb.description,
|
||||
};
|
||||
if (sb.isDefault !== undefined) updateBody.isDefault = sb.isDefault;
|
||||
await withRetry(() => client.put(`/api/v1/secretbackends/${existing.id}`, updateBody));
|
||||
log(`Updated secretbackend: ${sb.name}`);
|
||||
} else {
|
||||
await withRetry(() => client.post('/api/v1/secretbackends', sb));
|
||||
invalidateCache('secretbackends');
|
||||
log(`Created secretbackend: ${sb.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Error applying secretbackend '${sb.name}': ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply secrets
|
||||
for (const secret of config.secrets) {
|
||||
try {
|
||||
|
||||
@@ -88,7 +88,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('create')
|
||||
.description('Create a resource (server, secret, project, user, group, rbac, serverattachment, prompt)');
|
||||
.description('Create a resource (server, secret, secretbackend, project, user, group, rbac, serverattachment, prompt)');
|
||||
|
||||
// --- create server ---
|
||||
cmd.command('server')
|
||||
@@ -252,6 +252,70 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
}
|
||||
});
|
||||
|
||||
// --- create secretbackend ---
|
||||
cmd.command('secretbackend')
|
||||
.alias('sb')
|
||||
.description('Create a secret backend (plaintext, openbao)')
|
||||
.argument('<name>', 'Backend name (lowercase, hyphens allowed)')
|
||||
.requiredOption('--type <type>', 'Backend type (plaintext, openbao)')
|
||||
.option('--description <text>', 'Description')
|
||||
.option('--default', 'Promote this backend to default (atomically demotes the current one)')
|
||||
.option('--url <url>', 'openbao: vault URL (e.g. http://bao.example:8200)')
|
||||
.option('--namespace <ns>', 'openbao: X-Vault-Namespace header value')
|
||||
.option('--mount <mount>', 'openbao: KV v2 mount point (default: secret)')
|
||||
.option('--path-prefix <prefix>', 'openbao: path prefix under mount (default: mcpctl)')
|
||||
.option('--token-secret <ref>', 'openbao: token secret reference in SECRET/KEY form (e.g. bao-creds/token)')
|
||||
.option('--config <entry>', 'Extra config as key=value (repeat for multiple)', collect, [])
|
||||
.option('--force', 'Update if already exists')
|
||||
.action(async (name: string, opts) => {
|
||||
const type = opts.type as string;
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
if (type === 'openbao') {
|
||||
if (!opts.url) throw new Error('--url is required for openbao backend');
|
||||
if (!opts.tokenSecret) throw new Error('--token-secret is required for openbao backend (format: SECRET/KEY)');
|
||||
const slashIdx = (opts.tokenSecret as string).indexOf('/');
|
||||
if (slashIdx < 1) throw new Error(`Invalid --token-secret '${opts.tokenSecret as string}'. Expected SECRET_NAME/KEY_NAME`);
|
||||
config.url = opts.url;
|
||||
config.tokenSecretRef = {
|
||||
name: (opts.tokenSecret as string).slice(0, slashIdx),
|
||||
key: (opts.tokenSecret as string).slice(slashIdx + 1),
|
||||
};
|
||||
if (opts.namespace) config.namespace = opts.namespace;
|
||||
if (opts.mount) config.mount = opts.mount;
|
||||
if (opts.pathPrefix) config.pathPrefix = opts.pathPrefix;
|
||||
}
|
||||
|
||||
// Extra config key=value pairs (overwrite/extend above)
|
||||
for (const entry of opts.config as string[]) {
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx === -1) throw new Error(`Invalid --config '${entry}'. Expected key=value`);
|
||||
config[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { name, type, config };
|
||||
if (opts.description !== undefined) body.description = opts.description;
|
||||
if (opts.default) body.isDefault = true;
|
||||
|
||||
try {
|
||||
const row = await client.post<{ id: string; name: string }>('/api/v1/secretbackends', body);
|
||||
log(`secretbackend '${row.name}' created (id: ${row.id})`);
|
||||
if (opts.default) log(` promoted to default backend`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 409 && opts.force) {
|
||||
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secretbackends')).find((b) => b.name === name);
|
||||
if (!existing) throw err;
|
||||
const updateBody: Record<string, unknown> = { config };
|
||||
if (opts.description !== undefined) updateBody.description = opts.description;
|
||||
if (opts.default) updateBody.isDefault = true;
|
||||
await client.put(`/api/v1/secretbackends/${existing.id}`, updateBody);
|
||||
log(`secretbackend '${name}' updated (id: ${existing.id})`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- create project ---
|
||||
cmd.command('project')
|
||||
.description('Create a project')
|
||||
|
||||
@@ -218,6 +218,37 @@ function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatSecretBackendDetail(backend: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== SecretBackend: ${backend.name} ===`);
|
||||
lines.push(`${pad('Name:')}${backend.name}`);
|
||||
lines.push(`${pad('Type:')}${backend.type}`);
|
||||
lines.push(`${pad('Default:')}${backend.isDefault ? 'yes' : 'no'}`);
|
||||
if (backend.description) lines.push(`${pad('Description:')}${backend.description}`);
|
||||
|
||||
const config = backend.config as Record<string, unknown> | undefined;
|
||||
if (config && Object.keys(config).length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Config:');
|
||||
const keyW = Math.max(6, ...Object.keys(config).map((k) => k.length)) + 2;
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
let display: string;
|
||||
if (value === null || value === undefined) display = '-';
|
||||
else if (typeof value === 'object') display = JSON.stringify(value);
|
||||
else display = String(value);
|
||||
lines.push(` ${key.padEnd(keyW)}${display}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${backend.id}`);
|
||||
if (backend.createdAt) lines.push(` ${pad('Created:', 12)}${backend.createdAt}`);
|
||||
if (backend.updatedAt) lines.push(` ${pad('Updated:', 12)}${backend.updatedAt}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatTemplateDetail(template: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Template: ${template.name} ===`);
|
||||
@@ -806,6 +837,9 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
case 'templates':
|
||||
deps.log(formatTemplateDetail(item));
|
||||
break;
|
||||
case 'secretbackends':
|
||||
deps.log(formatSecretBackendDetail(item));
|
||||
break;
|
||||
case 'projects': {
|
||||
const projectPrompts = await deps.client
|
||||
.get<Array<{ name: string; priority: number; linkTarget: string | null }>>(`/api/v1/prompts?projectId=${item.id as string}`)
|
||||
|
||||
@@ -119,6 +119,23 @@ const rbacColumns: Column<RbacRow>[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
interface SecretBackendRow {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
isDefault: boolean;
|
||||
description: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const secretBackendColumns: Column<SecretBackendRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'TYPE', key: 'type', width: 14 },
|
||||
{ header: 'DEFAULT', key: (r) => r.isDefault ? '*' : '', width: 8 },
|
||||
{ header: 'DESCRIPTION', key: (r) => r.description || '-', width: 30 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
interface McpTokenRow {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -265,6 +282,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
||||
return proxymodelColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'mcptokens':
|
||||
return mcpTokenColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'secretbackends':
|
||||
return secretBackendColumns as unknown as Column<Record<string, unknown>>[];
|
||||
default:
|
||||
return [
|
||||
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
|
||||
@@ -287,6 +306,7 @@ const RESOURCE_KIND: Record<string, string> = {
|
||||
promptrequests: 'promptrequest',
|
||||
serverattachments: 'serverattachment',
|
||||
mcptokens: 'mcptoken',
|
||||
secretbackends: 'secretbackend',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
80
src/cli/src/commands/migrate.ts
Normal file
80
src/cli/src/commands/migrate.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
export interface MigrateCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
interface MigrateResult {
|
||||
migrated: Array<{ name: string }>;
|
||||
skipped: Array<{ name: string; reason: string }>;
|
||||
failed: Array<{ name: string; error: string }>;
|
||||
}
|
||||
|
||||
interface DryRunResult {
|
||||
dryRun: true;
|
||||
candidates: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level `mcpctl migrate <subcommand>` verb.
|
||||
*
|
||||
* Today only `secrets` is implemented (SecretBackend → SecretBackend move),
|
||||
* but the command is structured so new migrations can slot in.
|
||||
*
|
||||
* Per-secret atomicity is handled server-side — if this command is interrupted
|
||||
* mid-run, re-running is idempotent (skips secrets already on the destination).
|
||||
*/
|
||||
export function createMigrateCommand(deps: MigrateCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('migrate')
|
||||
.description('Move resources between backends (currently: secrets between SecretBackends)');
|
||||
|
||||
cmd.command('secrets')
|
||||
.description('Migrate secrets from one SecretBackend to another')
|
||||
.requiredOption('--from <name>', 'Source SecretBackend name')
|
||||
.requiredOption('--to <name>', 'Destination SecretBackend name')
|
||||
.option('--names <csv>', 'Comma-separated secret names (default: all)')
|
||||
.option('--keep-source', 'Leave the source copy intact (default: delete from source after write+commit)')
|
||||
.option('--dry-run', 'Show which secrets would be migrated without touching them')
|
||||
.action(async (opts) => {
|
||||
const body: Record<string, unknown> = { from: opts.from, to: opts.to };
|
||||
if (opts.names) body.names = (opts.names as string).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
if (opts.keepSource) body.keepSource = true;
|
||||
if (opts.dryRun) body.dryRun = true;
|
||||
|
||||
if (opts.dryRun) {
|
||||
const res = await client.post<DryRunResult>('/api/v1/secrets/migrate', body);
|
||||
if (res.candidates.length === 0) {
|
||||
log(`No secrets to migrate from '${opts.from as string}' to '${opts.to as string}'.`);
|
||||
return;
|
||||
}
|
||||
log(`Dry run — ${String(res.candidates.length)} secret(s) would be migrated from '${opts.from as string}' → '${opts.to as string}':`);
|
||||
for (const c of res.candidates) log(` - ${c.name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await client.post<MigrateResult>('/api/v1/secrets/migrate', body);
|
||||
|
||||
if (res.migrated.length > 0) {
|
||||
log(`Migrated ${String(res.migrated.length)} secret(s) from '${opts.from as string}' → '${opts.to as string}':`);
|
||||
for (const m of res.migrated) log(` ✓ ${m.name}`);
|
||||
}
|
||||
if (res.skipped.length > 0) {
|
||||
log(`Skipped ${String(res.skipped.length)}:`);
|
||||
for (const s of res.skipped) log(` - ${s.name}: ${s.reason}`);
|
||||
}
|
||||
if (res.failed.length > 0) {
|
||||
log(`Failed ${String(res.failed.length)}:`);
|
||||
for (const f of res.failed) log(` ✗ ${f.name}: ${f.error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (res.migrated.length === 0 && res.skipped.length === 0 && res.failed.length === 0) {
|
||||
log(`No secrets to migrate from '${opts.from as string}' to '${opts.to as string}'.`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -31,6 +31,9 @@ export const RESOURCE_ALIASES: Record<string, string> = {
|
||||
mcptokens: 'mcptokens',
|
||||
token: 'mcptokens',
|
||||
tokens: 'mcptokens',
|
||||
secretbackend: 'secretbackends',
|
||||
secretbackends: 'secretbackends',
|
||||
sb: 'secretbackends',
|
||||
all: 'all',
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createMcpCommand } from './commands/mcp.js';
|
||||
import { createPatchCommand } from './commands/patch.js';
|
||||
import { createConsoleCommand } from './commands/console/index.js';
|
||||
import { createCacheCommand } from './commands/cache.js';
|
||||
import { createMigrateCommand } from './commands/migrate.js';
|
||||
import { ApiClient, ApiError } from './api-client.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
import { loadCredentials } from './auth/index.js';
|
||||
@@ -249,6 +250,11 @@ export function createProgram(): Command {
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createMigrateCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user