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:
@@ -153,7 +153,7 @@ async function defaultConfirm(message: string, defaultValue?: boolean): Promise<
|
||||
return answer as boolean;
|
||||
}
|
||||
|
||||
const defaultPrompt: ConfigSetupPrompt = {
|
||||
export const defaultPrompt: ConfigSetupPrompt = {
|
||||
select: defaultSelect,
|
||||
input: defaultInput,
|
||||
password: defaultPassword,
|
||||
|
||||
231
src/cli/src/commands/create-secretbackend-wizard.ts
Normal file
231
src/cli/src/commands/create-secretbackend-wizard.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Interactive wizard that provisions an OpenBao backend end-to-end:
|
||||
*
|
||||
* 1. Asks the user for the OpenBao URL + admin/root token.
|
||||
* 2. Verifies connectivity (`/sys/health`).
|
||||
* 3. Ensures KV v2 is mounted at `<mount>/`.
|
||||
* 4. Writes policy `app-mcpd` scoped to `<mount>/{data,metadata}/<prefix>/*`
|
||||
* plus the self-rotation paths.
|
||||
* 5. Ensures a token role `app-mcpd-role` with `period=720h, renewable=true`.
|
||||
* 6. Mints the first periodic token via that role.
|
||||
* 7. Stores the token as a plaintext `Secret` on mcpd.
|
||||
* 8. Creates the `SecretBackend` row with rotation config pointing at the role.
|
||||
* 9. Kicks an initial rotate via `POST /api/v1/secretbackends/:id/rotate`
|
||||
* to seed `tokenMeta` + prove the self-rotation policy works.
|
||||
* 10. (Optional) promotes the new backend to default.
|
||||
* 11. Prints the migration command for the user to run.
|
||||
*
|
||||
* Admin token is used only for steps 2–6 and is never persisted.
|
||||
*
|
||||
* All prompts go through `ConfigSetupPrompt` (from `config-setup.ts`) so the
|
||||
* wizard is testable without real stdin.
|
||||
*/
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
import {
|
||||
verifyHealth,
|
||||
ensureKvV2,
|
||||
writePolicy,
|
||||
ensureTokenRole,
|
||||
mintRoleToken,
|
||||
testWriteReadDelete,
|
||||
buildAppMcpdPolicyHcl,
|
||||
type VaultDeps,
|
||||
} from '@mcpctl/shared';
|
||||
import { type ConfigSetupPrompt, defaultPrompt } from './config-setup.js';
|
||||
|
||||
export interface WizardDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
prompt?: ConfigSetupPrompt;
|
||||
/** Overridable for tests. Forwarded to all vault HTTP calls. */
|
||||
fetch?: typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
export interface WizardInput {
|
||||
/** Backend name. Required — supplied via `mcpctl create secretbackend <name> --wizard`. */
|
||||
name: string;
|
||||
/** Pre-filled via flags for CI; falls back to prompt. */
|
||||
url?: string | undefined;
|
||||
adminToken?: string | undefined;
|
||||
mount?: string | undefined;
|
||||
pathPrefix?: string | undefined;
|
||||
policyName?: string | undefined;
|
||||
tokenRole?: string | undefined;
|
||||
promoteToDefault?: boolean | undefined;
|
||||
/** If set, skip the test write/read/delete (for dev/debugging only). */
|
||||
skipSmoke?: boolean | undefined;
|
||||
}
|
||||
|
||||
export async function runSecretBackendOpenbaoWizard(
|
||||
input: WizardInput,
|
||||
deps: WizardDeps,
|
||||
): Promise<void> {
|
||||
const prompt = deps.prompt ?? defaultPrompt;
|
||||
const log = deps.log;
|
||||
|
||||
const url = input.url ?? await prompt.input('OpenBao URL', 'https://bao.ad.itaz.eu');
|
||||
const adminToken = input.adminToken ?? await prompt.password('OpenBao admin / root token');
|
||||
if (adminToken === '') throw new Error('admin token is required');
|
||||
|
||||
const vaultDeps: VaultDeps = {};
|
||||
if (deps.fetch !== undefined) vaultDeps.fetch = deps.fetch;
|
||||
|
||||
// 1. Health check.
|
||||
log(' → checking OpenBao health …');
|
||||
const health = await verifyHealth(url, adminToken, vaultDeps);
|
||||
if (!health.initialized || health.sealed) {
|
||||
throw new Error(`OpenBao is not ready (initialized=${String(health.initialized)}, sealed=${String(health.sealed)})`);
|
||||
}
|
||||
log(` ok (version ${health.version})`);
|
||||
|
||||
const mount = input.mount ?? await prompt.input('KV v2 mount', 'secret');
|
||||
const pathPrefix = input.pathPrefix ?? await prompt.input('Path prefix under mount', 'mcpd');
|
||||
const policyName = input.policyName ?? await prompt.input('Policy name', 'app-mcpd');
|
||||
const tokenRole = input.tokenRole ?? await prompt.input('Token role name', 'app-mcpd-role');
|
||||
|
||||
// 2. Enable KV v2 if needed.
|
||||
log(` → ensuring KV v2 at ${mount}/ …`);
|
||||
const created = await ensureKvV2(url, adminToken, mount, vaultDeps);
|
||||
log(` ${created ? 'mounted' : 'already mounted'}`);
|
||||
|
||||
// 3. Write policy.
|
||||
log(` → writing policy '${policyName}' …`);
|
||||
const hcl = buildAppMcpdPolicyHcl({ mount, pathPrefix, tokenRole });
|
||||
await writePolicy(url, adminToken, policyName, hcl, vaultDeps);
|
||||
log(` written (scope: ${mount}/{data,metadata}/${pathPrefix}/* + self-rotation paths)`);
|
||||
|
||||
// 4. Ensure token role.
|
||||
log(` → ensuring token role '${tokenRole}' (period=720h, renewable) …`);
|
||||
await ensureTokenRole(url, adminToken, tokenRole, {
|
||||
allowedPolicies: [policyName],
|
||||
period: 720 * 3600,
|
||||
renewable: true,
|
||||
orphan: false,
|
||||
}, vaultDeps);
|
||||
log(' ok');
|
||||
|
||||
// 5. Mint the first periodic token using the admin token.
|
||||
log(' → minting first periodic token …');
|
||||
const minted = await mintRoleToken(url, adminToken, tokenRole, vaultDeps);
|
||||
if (!minted.renewable) {
|
||||
throw new Error(`minted token is not renewable — the role '${tokenRole}' config is wrong`);
|
||||
}
|
||||
log(` minted (accessor ${minted.accessor.slice(0, 12)}…)`);
|
||||
|
||||
// 6. Smoke test with the minted token before committing to mcpd.
|
||||
if (input.skipSmoke !== true) {
|
||||
log(' → smoke-testing write/read/delete with the minted token …');
|
||||
await testWriteReadDelete(url, minted.clientToken, mount, `${pathPrefix}/.__mcpctl_wizard_smoke__`, vaultDeps);
|
||||
log(' ok');
|
||||
}
|
||||
|
||||
// 7. Store token on mcpd as a plaintext Secret.
|
||||
const credsSecretName = `${input.name}-creds`;
|
||||
log(` → creating Secret '${credsSecretName}' on mcpd (plaintext) …`);
|
||||
await createSecret(deps.client, credsSecretName, { token: minted.clientToken });
|
||||
|
||||
// 8. Create SecretBackend row (non-default by default; promote later).
|
||||
log(` → creating SecretBackend '${input.name}' …`);
|
||||
const backendBody = {
|
||||
name: input.name,
|
||||
type: 'openbao',
|
||||
config: {
|
||||
url,
|
||||
auth: 'token',
|
||||
mount,
|
||||
pathPrefix,
|
||||
tokenSecretRef: { name: credsSecretName, key: 'token' },
|
||||
rotation: {
|
||||
enabled: true,
|
||||
tokenRole,
|
||||
intervalHours: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
const backend = await deps.client.post<{ id: string; name: string }>('/api/v1/secretbackends', backendBody);
|
||||
log(` created (id: ${backend.id})`);
|
||||
|
||||
// 9. Kick initial rotation so tokenMeta is populated + self-rotation is proven.
|
||||
// This uses the FIRST token (just-minted) to mint its successor. The old
|
||||
// first token is then revoked by accessor.
|
||||
log(' → running initial rotation (seeds tokenMeta) …');
|
||||
try {
|
||||
await deps.client.post(`/api/v1/secretbackends/${backend.id}/rotate`, {});
|
||||
log(' rotated — tokenMeta populated');
|
||||
} catch (err) {
|
||||
log(` warn: initial rotation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
log(' backend is still usable; rotation will retry on the 24h loop');
|
||||
}
|
||||
|
||||
// 10. Optional promote.
|
||||
const promote = input.promoteToDefault
|
||||
?? await prompt.confirm(`Promote '${input.name}' to default backend?`, true);
|
||||
if (promote) {
|
||||
await deps.client.post(`/api/v1/secretbackends/${backend.id}/default`, {});
|
||||
log(` promoted '${input.name}' to default`);
|
||||
}
|
||||
|
||||
// 11. Migration hint.
|
||||
log('');
|
||||
await printMigrationHint(deps.client, input.name, log);
|
||||
|
||||
log('');
|
||||
log(`Describe the new backend: mcpctl --direct describe secretbackend ${input.name}`);
|
||||
log(`Force a rotation manually: mcpctl --direct rotate secretbackend ${input.name}`);
|
||||
}
|
||||
|
||||
async function createSecret(
|
||||
client: ApiClient,
|
||||
name: string,
|
||||
data: Record<string, string>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await client.post('/api/v1/secrets', { name, data });
|
||||
} catch (err) {
|
||||
// 409 → secret already exists with this name. Update its data instead so
|
||||
// re-running the wizard with the same --name is idempotent.
|
||||
const status = (err as { status?: number }).status;
|
||||
if (status !== 409) throw err;
|
||||
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secrets'))
|
||||
.find((s) => s.name === name);
|
||||
if (existing === undefined) throw err;
|
||||
await client.put(`/api/v1/secrets/${existing.id}`, { data });
|
||||
}
|
||||
}
|
||||
|
||||
async function printMigrationHint(
|
||||
client: ApiClient,
|
||||
newBackendName: string,
|
||||
log: (...args: unknown[]) => void,
|
||||
): Promise<void> {
|
||||
// Find the current default backend name (likely 'default') so the hint
|
||||
// points at a real source.
|
||||
let defaultName = 'default';
|
||||
try {
|
||||
const rows = await client.get<Array<{ name: string; isDefault: boolean }>>('/api/v1/secretbackends');
|
||||
const d = rows.find((r) => r.isDefault);
|
||||
if (d !== undefined && d.name !== newBackendName) defaultName = d.name;
|
||||
} catch {
|
||||
/* fall through with 'default' guess */
|
||||
}
|
||||
|
||||
// Count candidate secrets.
|
||||
try {
|
||||
const body = await client.post<{ candidates: Array<{ name: string }> }>(
|
||||
'/api/v1/secrets/migrate',
|
||||
{ from: defaultName, to: newBackendName, dryRun: true },
|
||||
);
|
||||
const n = body.candidates.length;
|
||||
if (n === 0) {
|
||||
log(`No secrets to migrate — '${defaultName}' is empty.`);
|
||||
return;
|
||||
}
|
||||
log(`You have ${String(n)} secret(s) on '${defaultName}'. To migrate them to '${newBackendName}':`);
|
||||
log('');
|
||||
log(` mcpctl --direct migrate secrets --from ${defaultName} --to ${newBackendName} --dry-run`);
|
||||
log(` mcpctl --direct migrate secrets --from ${defaultName} --to ${newBackendName}`);
|
||||
} catch (err) {
|
||||
log(`(could not dry-run migration: ${err instanceof Error ? err.message : String(err)})`);
|
||||
log(`Manual command: mcpctl --direct migrate secrets --from ${defaultName} --to ${newBackendName}`);
|
||||
}
|
||||
}
|
||||
@@ -325,9 +325,32 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('--auth-mount <path>', "openbao kubernetes auth: vault auth method mount path (default: 'kubernetes')")
|
||||
.option('--sa-token-path <path>', "openbao kubernetes auth: filesystem path to projected SA token (default: '/var/run/secrets/kubernetes.io/serviceaccount/token')")
|
||||
.option('--config <entry>', 'Extra config as key=value (repeat for multiple)', collect, [])
|
||||
.option('--wizard', 'Interactive wizard (openbao only): provision policy + token role, mint token, store on mcpd, suggest migration')
|
||||
.option('--admin-token <token>', "openbao wizard: OpenBao admin/root token (prompted if omitted). Used only for provisioning; NEVER persisted.")
|
||||
.option('--policy-name <name>', "openbao wizard: name for the policy created on OpenBao (default: 'app-mcpd')")
|
||||
.option('--token-role <name>', "openbao wizard: name for the token role created on OpenBao (default: 'app-mcpd-role')")
|
||||
.option('--no-promote-default', 'openbao wizard: do not promote this backend to default after creation')
|
||||
.option('--force', 'Update if already exists')
|
||||
.action(async (name: string, opts) => {
|
||||
const type = opts.type as string;
|
||||
// Wizard path — delegates to create-secretbackend-wizard.ts.
|
||||
if (opts.wizard === true) {
|
||||
if (type !== 'openbao') {
|
||||
throw new Error(`--wizard is only supported for --type openbao (got '${type}')`);
|
||||
}
|
||||
const { runSecretBackendOpenbaoWizard } = await import('./create-secretbackend-wizard.js');
|
||||
const wizardInput: Parameters<typeof runSecretBackendOpenbaoWizard>[0] = { name };
|
||||
if (opts.url !== undefined) wizardInput.url = opts.url as string;
|
||||
if (opts.adminToken !== undefined) wizardInput.adminToken = opts.adminToken as string;
|
||||
if (opts.mount !== undefined) wizardInput.mount = opts.mount as string;
|
||||
if (opts.pathPrefix !== undefined) wizardInput.pathPrefix = opts.pathPrefix as string;
|
||||
if (opts.policyName !== undefined) wizardInput.policyName = opts.policyName as string;
|
||||
if (opts.tokenRole !== undefined) wizardInput.tokenRole = opts.tokenRole as string;
|
||||
// `--no-promote-default` → opts.promoteDefault === false (commander negated flag)
|
||||
if (opts.promoteDefault !== undefined) wizardInput.promoteToDefault = opts.promoteDefault as boolean;
|
||||
await runSecretBackendOpenbaoWizard(wizardInput, { client, log });
|
||||
return;
|
||||
}
|
||||
const config: Record<string, unknown> = {};
|
||||
|
||||
if (type === 'openbao') {
|
||||
|
||||
@@ -297,6 +297,12 @@ function formatSecretBackendDetail(backend: Record<string, unknown>): string {
|
||||
}
|
||||
}
|
||||
|
||||
const tokenMeta = (backend.tokenMeta ?? {}) as Record<string, unknown>;
|
||||
if (tokenMeta.rotatable === true) {
|
||||
lines.push('');
|
||||
lines.push(...formatTokenHealth(tokenMeta));
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${backend.id}`);
|
||||
@@ -306,6 +312,66 @@ function formatSecretBackendDetail(backend: Record<string, unknown>): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Token health section for a wizard-provisioned openbao backend.
|
||||
* Returns an array of lines (caller pushes them). Stale = no successful
|
||||
* rotation in >26h (2h grace over the nominal 24h cadence).
|
||||
*/
|
||||
function formatTokenHealth(meta: Record<string, unknown>): string[] {
|
||||
const lines: string[] = [];
|
||||
const generatedAt = parseIso(meta.generatedAt);
|
||||
const nextRenewalAt = parseIso(meta.nextRenewalAt);
|
||||
const validUntil = parseIso(meta.validUntil);
|
||||
const lastRotationAt = parseIso(meta.lastRotationAt);
|
||||
const lastError = meta.lastRotationError as string | null | undefined;
|
||||
const now = Date.now();
|
||||
|
||||
const STALE_GRACE_MS = 26 * 3600 * 1000;
|
||||
const staleByAge = lastRotationAt !== null && (now - lastRotationAt.getTime()) > STALE_GRACE_MS;
|
||||
const hasError = typeof lastError === 'string' && lastError !== '';
|
||||
|
||||
let status: string;
|
||||
if (hasError && staleByAge) status = 'ERROR (stale)';
|
||||
else if (staleByAge) status = 'STALE — no successful rotation in the last cycle';
|
||||
else if (hasError) status = 'WARNING — last rotation hit an error but token is still fresh';
|
||||
else status = 'healthy';
|
||||
|
||||
lines.push(`Token health: ${status}`);
|
||||
if (generatedAt !== null) {
|
||||
lines.push(` ${pad('Generated:', 16)}${generatedAt.toISOString()}${describeAge(generatedAt, now)}`);
|
||||
}
|
||||
if (nextRenewalAt !== null) {
|
||||
lines.push(` ${pad('Next renewal:', 16)}${nextRenewalAt.toISOString()}${describeAge(nextRenewalAt, now)}`);
|
||||
}
|
||||
if (validUntil !== null) {
|
||||
lines.push(` ${pad('Valid until:', 16)}${validUntil.toISOString()}${describeAge(validUntil, now)}`);
|
||||
}
|
||||
if (lastRotationAt !== null) {
|
||||
lines.push(` ${pad('Last rotation:', 16)}${lastRotationAt.toISOString()}${describeAge(lastRotationAt, now)}`);
|
||||
}
|
||||
if (hasError) {
|
||||
lines.push(` ${pad('Last error:', 16)}${lastError}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function parseIso(v: unknown): Date | null {
|
||||
if (typeof v !== 'string' || v === '') return null;
|
||||
const d = new Date(v);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function describeAge(target: Date, now: number): string {
|
||||
const diffMs = target.getTime() - now;
|
||||
const abs = Math.abs(diffMs);
|
||||
const hours = Math.round(abs / 3600_000);
|
||||
const days = Math.round(abs / 86_400_000);
|
||||
if (abs < 60_000) return ' (just now)';
|
||||
if (abs < 3600_000) return ` (${String(Math.round(abs / 60_000))} min ${diffMs < 0 ? 'ago' : 'away'})`;
|
||||
if (hours < 48) return ` (${String(hours)}h ${diffMs < 0 ? 'ago' : 'away'})`;
|
||||
return ` (${String(days)}d ${diffMs < 0 ? 'ago' : 'away'})`;
|
||||
}
|
||||
|
||||
function formatTemplateDetail(template: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Template: ${template.name} ===`);
|
||||
|
||||
50
src/cli/src/commands/rotate.ts
Normal file
50
src/cli/src/commands/rotate.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* `mcpctl rotate secretbackend <name>` — force an immediate token rotation on
|
||||
* a wizard-provisioned OpenBao backend.
|
||||
*
|
||||
* Hits `POST /api/v1/secretbackends/:id/rotate` after resolving name → id.
|
||||
* Gated server-side by the `rotate-secretbackend` operation.
|
||||
*/
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
import { resolveNameOrId } from './shared.js';
|
||||
|
||||
export interface RotateCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createRotateCommand(deps: RotateCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('rotate')
|
||||
.description('Force rotation of a credential-rotating resource (currently: secretbackend)');
|
||||
|
||||
cmd.command('secretbackend')
|
||||
.alias('sb')
|
||||
.description('Rotate the vault token on an OpenBao SecretBackend (wizard-provisioned)')
|
||||
.argument('<name>', 'SecretBackend name or id')
|
||||
.action(async (nameOrId: string) => {
|
||||
const id = await resolveNameOrId(client, 'secretbackends', nameOrId);
|
||||
const res = await client.post<{ ok?: boolean; tokenMeta?: Record<string, unknown>; error?: string }>(
|
||||
`/api/v1/secretbackends/${id}/rotate`,
|
||||
{},
|
||||
);
|
||||
if (res.ok !== true) {
|
||||
throw new Error(`rotation failed: ${res.error ?? 'unknown error'}`);
|
||||
}
|
||||
log(`secretbackend '${nameOrId}' rotated.`);
|
||||
const meta = res.tokenMeta ?? {};
|
||||
if (typeof meta.generatedAt === 'string') {
|
||||
log(` generated: ${meta.generatedAt}`);
|
||||
}
|
||||
if (typeof meta.nextRenewalAt === 'string') {
|
||||
log(` next renewal: ${meta.nextRenewalAt}`);
|
||||
}
|
||||
if (typeof meta.validUntil === 'string') {
|
||||
log(` valid until: ${meta.validUntil}`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ 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 { createRotateCommand } from './commands/rotate.js';
|
||||
import { ApiClient, ApiError } from './api-client.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
import { loadCredentials } from './auth/index.js';
|
||||
@@ -255,6 +256,11 @@ export function createProgram(): Command {
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createRotateCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
|
||||
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