feat(openbao): wizard-provisioning + daily token rotation
Some checks failed
CI/CD / typecheck (pull_request) Successful in 55s
CI/CD / test (pull_request) Successful in 1m4s
CI/CD / lint (pull_request) Successful in 2m2s
CI/CD / smoke (pull_request) Failing after 1m36s
CI/CD / build (pull_request) Successful in 4m13s
CI/CD / publish (pull_request) Has been skipped

One-command setup replaces the 6-step manual flow — `mcpctl create
secretbackend bao --type openbao --wizard` takes the OpenBao admin token
once, provisions a narrow policy + token role, mints the first periodic
token, stores it on mcpd, verifies end-to-end, and prints the migration
command. The admin token is NEVER persisted.

The stored credential auto-rotates daily: mcpd mints a successor via the
token role (self-rotation capability is part of the policy it was issued
with), verifies the successor, writes it over the backing Secret, then
revokes the predecessor by accessor. TTL 720h means a week of rotation
failures still leaves 20+ days of runway.

Shared:
- New `@mcpctl/shared/vault` — pure HTTP wrappers (verifyHealth,
  ensureKvV2, writePolicy, ensureTokenRole, mintRoleToken, revokeAccessor,
  lookupSelf, testWriteReadDelete) and policy HCL builder.

mcpd:
- `tokenMeta Json @default("{}")` on SecretBackend. Self-healing schema
  migration — empty default lets `prisma db push` add the column cleanly.
- SecretBackendRotator.rotateOne: mint → verify → persist → revoke-old →
  update tokenMeta. Failures surface via `lastRotationError` on the row;
  the old token keeps working.
- SecretBackendRotatorLoop: on startup rotates overdue backends, schedules
  per-backend timers with ±10min jitter. Stops cleanly on shutdown.
- New `POST /api/v1/secretbackends/:id/rotate` (operation
  `rotate-secretbackend` — added to bootstrap-admin's auto-migrated ops
  alongside migrate-secrets, which was previously missing too).

CLI:
- `--wizard` on `create secretbackend` delegates to the interactive flow.
  All prompts can be pre-answered via flags (--url, --admin-token,
  --mount, --path-prefix, --policy-name, --token-role,
  --no-promote-default) for CI.
- `mcpctl rotate secretbackend <name>` — convenience verb; hits the new
  rotate endpoint.
- `describe secretbackend` renders a Token health section (healthy /
  STALE / WARNING / ERROR) with generated/renewal/expiry timestamps and
  last rotation error. Only shown when tokenMeta.rotatable is true — the
  existing k8s-auth + static-token backends don't surface it.

Tests: 15 vault-client unit tests (shared), 8 rotator unit tests (mcpd),
3 wizard flow tests (cli, including a regression test that the admin
token never appears in stdout). Full suite 1885/1885 (+32). Completions
regenerated for the new flags.

Out of scope (explicit): kubernetes-auth wizard, Vault Enterprise
namespaces in the wizard path, rotation for non-wizard static-token
backends. See plan file for details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-20 17:20:37 +01:00
parent 515206685b
commit dd4246878d
22 changed files with 1749 additions and 5 deletions

View File

@@ -0,0 +1,150 @@
import { describe, it, expect, vi } from 'vitest';
import { runSecretBackendOpenbaoWizard } from '../../src/commands/create-secretbackend-wizard.js';
import type { ApiClient } from '../../src/api-client.js';
import type { ConfigSetupPrompt } from '../../src/commands/config-setup.js';
function mockClient(handlers: Record<string, (body?: unknown) => unknown>): ApiClient {
const call = (method: 'GET' | 'POST' | 'PUT' | 'DELETE') => async (path: string, body?: unknown) => {
const handler = handlers[`${method} ${path}`] ?? handlers[path];
if (handler === undefined) throw new Error(`unmocked ${method} ${path}`);
return handler(body);
};
return {
get: call('GET'),
post: call('POST'),
put: call('PUT'),
delete: call('DELETE'),
} as unknown as ApiClient;
}
function vaultFetch(responses: Array<{ match: RegExp; status: number; body?: unknown }>): ReturnType<typeof vi.fn> {
return vi.fn(async (url: string | URL, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${String(url)}`;
const match = responses.find((r) => r.match.test(key) || r.match.test(String(url)));
if (!match) throw new Error(`unexpected vault fetch: ${key}`);
const body = match.body !== undefined ? JSON.stringify(match.body) : '';
return new Response(body, { status: match.status });
});
}
function scriptedPrompt(answers: {
input?: Record<string, string>;
password?: Record<string, string>;
confirm?: Record<string, boolean>;
}): ConfigSetupPrompt {
return {
async input(message, def) {
return answers.input?.[message] ?? def ?? '';
},
async password(message) {
return answers.password?.[message] ?? '';
},
async confirm(message, def) {
return answers.confirm?.[message] ?? def ?? true;
},
select: vi.fn(),
};
}
describe('runSecretBackendOpenbaoWizard', () => {
it('walks through provisioning and creates Secret + SecretBackend + triggers initial rotate', async () => {
const logs: string[] = [];
const log = (...args: unknown[]) => logs.push(args.map(String).join(' '));
const vaultResponses = [
{ match: /GET .*\/v1\/sys\/health$/, status: 200, body: { initialized: true, sealed: false, standby: false, version: '2.5.2' } },
{ match: /GET .*\/v1\/sys\/mounts$/, status: 200, body: { 'secret/': { type: 'kv', options: { version: '2' } } } },
{ match: /PUT .*\/v1\/sys\/policies\/acl\/app-mcpd$/, status: 200 },
{ match: /POST .*\/v1\/auth\/token\/roles\/app-mcpd-role$/, status: 200 },
{ match: /POST .*\/v1\/auth\/token\/create\/app-mcpd-role$/, status: 200, body: { auth: { client_token: 'hvs.AAA', accessor: 'acc-first', lease_duration: 2592000, renewable: true } } },
// smoke test: write / read / delete
{ match: /POST .*\/v1\/secret\/data\/mcpd\/\.__mcpctl_wizard_smoke__$/, status: 200 },
{ match: /GET .*\/v1\/secret\/data\/mcpd\/\.__mcpctl_wizard_smoke__$/, status: 200, body: { data: { data: { marker: 'mcpctl-smoke' } } } },
{ match: /DELETE .*\/v1\/secret\/metadata\/mcpd\/\.__mcpctl_wizard_smoke__$/, status: 200 },
];
const fetchFn = vaultFetch(vaultResponses);
const created: Record<string, unknown> = {};
const client = mockClient({
'POST /api/v1/secrets': (body) => { created.secret = body; return { id: 'sec-new', name: (body as { name: string }).name }; },
'POST /api/v1/secretbackends': (body) => { created.backend = body; return { id: 'backend-new', name: (body as { name: string }).name }; },
'POST /api/v1/secretbackends/backend-new/rotate': () => ({ ok: true, tokenMeta: { generatedAt: 'now' } }),
'POST /api/v1/secretbackends/backend-new/default': () => ({ id: 'backend-new' }),
'GET /api/v1/secretbackends': () => [{ name: 'default', isDefault: true }],
'POST /api/v1/secrets/migrate': () => ({ dryRun: true, candidates: [{ id: 's1', name: 'grafana-creds' }, { id: 's2', name: 'unifi-creds' }] }),
});
const prompt = scriptedPrompt({
input: {
'OpenBao URL': 'http://bao.example:8200',
'KV v2 mount': 'secret',
'Path prefix under mount': 'mcpd',
'Policy name': 'app-mcpd',
'Token role name': 'app-mcpd-role',
},
password: {
'OpenBao admin / root token': 'root.admin.token',
},
confirm: {
"Promote 'bao' to default backend?": true,
},
});
await runSecretBackendOpenbaoWizard(
{ name: 'bao' },
{ client, log, prompt, fetch: fetchFn as unknown as typeof fetch },
);
// Admin token used for the provisioning calls (first 5 vault requests)
const firstCallInit = fetchFn.mock.calls[0]![1] as RequestInit;
expect((firstCallInit.headers as Record<string, string>)['X-Vault-Token']).toBe('root.admin.token');
// Secret was created with the minted token value (hvs.AAA), not the admin token
expect(created.secret).toMatchObject({ name: 'bao-creds', data: { token: 'hvs.AAA' } });
// SecretBackend created with rotation config
expect(created.backend).toMatchObject({
name: 'bao',
type: 'openbao',
config: expect.objectContaining({
url: 'http://bao.example:8200',
auth: 'token',
tokenSecretRef: { name: 'bao-creds', key: 'token' },
rotation: expect.objectContaining({ enabled: true, tokenRole: 'app-mcpd-role' }),
}),
});
// Migration hint mentions both candidate count + the concrete command
const fullLog = logs.join('\n');
expect(fullLog).toContain("You have 2 secret(s) on 'default'");
expect(fullLog).toContain('mcpctl --direct migrate secrets --from default --to bao');
// Admin token never appears in the log (critical)
expect(fullLog).not.toContain('root.admin.token');
});
it('rejects when admin token is empty', async () => {
const prompt = scriptedPrompt({
input: { 'OpenBao URL': 'http://x' },
password: { 'OpenBao admin / root token': '' },
});
await expect(runSecretBackendOpenbaoWizard(
{ name: 'bao' },
{ client: mockClient({}), log: () => {}, prompt, fetch: vi.fn() as unknown as typeof fetch },
)).rejects.toThrow(/admin token is required/);
});
it('rejects when vault is sealed', async () => {
const fetchFn = vaultFetch([
{ match: /\/sys\/health$/, status: 200, body: { initialized: true, sealed: true, standby: false, version: '2.5.2' } },
]);
const prompt = scriptedPrompt({
input: { 'OpenBao URL': 'http://x' },
password: { 'OpenBao admin / root token': 't' },
});
await expect(runSecretBackendOpenbaoWizard(
{ name: 'bao' },
{ client: mockClient({}), log: () => {}, prompt, fetch: fetchFn as unknown as typeof fetch },
)).rejects.toThrow(/not ready/);
});
});