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
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:
150
src/cli/tests/commands/create-secretbackend-wizard.test.ts
Normal file
150
src/cli/tests/commands/create-secretbackend-wizard.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user