Files
mcpctl/src/cli/src/commands/rotate.ts
Michal dd4246878d
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
feat(openbao): wizard-provisioning + daily token rotation
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>
2026-04-20 17:20:37 +01:00

51 lines
1.8 KiB
TypeScript

/**
* `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;
}