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

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:
Michal
2026-04-18 19:29:55 +01:00
parent 6946250090
commit 029c3d5f34
34 changed files with 1686 additions and 138 deletions

View File

@@ -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 {

View File

@@ -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')

View File

@@ -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}`)

View File

@@ -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',
};
/**

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

View File

@@ -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',
};

View File

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