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

@@ -26,6 +26,9 @@ import { SecretMigrateService } from './services/secret-migrate.service.js';
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
import { registerSecretMigrateRoutes } from './routes/secret-migrate.js';
import { SecretBackendRotator } from './services/secret-backend-rotator.service.js';
import { SecretBackendRotatorLoop } from './services/secret-backend-rotator-loop.js';
import { registerSecretBackendRotateRoutes } from './routes/secret-backend-rotate.js';
import { LlmRepository } from './repositories/llm.repository.js';
import { LlmService } from './services/llm.service.js';
import { LlmAdapterRegistry } from './services/llm/dispatcher.js';
@@ -106,6 +109,12 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
// /api/v1/secrets/migrate is a bulk cross-backend operation — treat as op, not a plain secret write.
if (url.startsWith('/api/v1/secrets/migrate')) return { kind: 'operation', operation: 'migrate-secrets' };
// /api/v1/secretbackends/:id/rotate — manual rotation trigger. Operation so
// only explicitly-granted callers can force it (the loop itself bypasses
// RBAC by calling the rotator in-process).
if (/^\/api\/v1\/secretbackends\/[^/?]+\/rotate/.test(url)) {
return { kind: 'operation', operation: 'rotate-secretbackend' };
}
// /api/v1/llms/:name/infer → `run:llms:<name>` (not the default create:llms).
const inferMatch = url.match(/^\/api\/v1\/llms\/([^/?]+)\/infer/);
@@ -231,7 +240,7 @@ async function migrateAdminRole(rbacRepo: InstanceType<typeof RbacDefinitionRepo
// Add operation bindings (idempotent — only for wildcard admin)
const hasWildcard = bindings.some((b) => b['role'] === 'admin' && b['resource'] === '*');
if (hasWildcard) {
const ops = ['impersonate', 'logs', 'backup', 'restore', 'audit-purge'];
const ops = ['impersonate', 'logs', 'backup', 'restore', 'audit-purge', 'migrate-secrets', 'rotate-secretbackend'];
for (const op of ops) {
if (!newBindings.some((b) => b['action'] === op)) {
newBindings.push({ role: 'run', action: op });
@@ -341,6 +350,14 @@ async function main(): Promise<void> {
});
const secretService = new SecretService(secretRepo, secretBackendService);
const secretMigrateService = new SecretMigrateService(secretRepo, secretBackendService);
const secretBackendRotator = new SecretBackendRotator({
backends: secretBackendService,
secrets: secretService,
});
const secretBackendRotatorLoop = new SecretBackendRotatorLoop({
backends: secretBackendService,
rotator: secretBackendRotator,
});
const llmService = new LlmService(llmRepo, secretService);
const llmAdapters = new LlmAdapterRegistry();
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService);
@@ -482,6 +499,7 @@ async function main(): Promise<void> {
registerTemplateRoutes(app, templateService);
registerSecretRoutes(app, secretService);
registerSecretBackendRoutes(app, secretBackendService);
registerSecretBackendRotateRoutes(app, secretBackendRotator);
registerSecretMigrateRoutes(app, secretMigrateService);
registerLlmRoutes(app, llmService);
registerLlmInferRoutes(app, {
@@ -641,11 +659,19 @@ async function main(): Promise<void> {
);
healthProbeRunner.start(15_000);
// SecretBackend token rotator — wakes up for wizard-provisioned openbao
// backends only, noop for the rest. Errors inside the loop are logged +
// surfaced in `describe secretbackend`, never kill the process.
secretBackendRotatorLoop.start().catch((err: unknown) => {
app.log.error({ err }, 'secret-backend rotator loop failed to start');
});
// Graceful shutdown
setupGracefulShutdown(app, {
disconnectDb: async () => {
clearInterval(reconcileTimer);
healthProbeRunner.stop();
secretBackendRotatorLoop.stop();
gitBackup.stop();
await prisma.$disconnect();
},

View File

@@ -12,6 +12,7 @@ export interface UpdateSecretBackendInput {
config?: Record<string, unknown>;
isDefault?: boolean;
description?: string;
tokenMeta?: Record<string, unknown>;
}
export interface ISecretBackendRepository {
@@ -79,6 +80,7 @@ export class SecretBackendRepository implements ISecretBackendRepository {
if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
if (data.description !== undefined) updateData.description = data.description;
if (data.tokenMeta !== undefined) updateData.tokenMeta = data.tokenMeta as Prisma.InputJsonValue;
return tx.secretBackend.update({ where: { id }, data: updateData });
});
}

View File

@@ -0,0 +1,29 @@
/**
* POST /api/v1/secretbackends/:id/rotate — force an immediate rotation.
*
* Used by the wizard (final verify step) + operators troubleshooting a
* stale backend. RBAC handled in the global hook via the operation
* `rotate-secretbackend` (see `main.ts:mapUrlToPermission`).
*/
import type { FastifyInstance } from 'fastify';
import type { SecretBackendRotator } from '../services/secret-backend-rotator.service.js';
import { NotFoundError } from '../services/mcp-server.service.js';
export function registerSecretBackendRotateRoutes(
app: FastifyInstance,
rotator: SecretBackendRotator,
): void {
app.post<{ Params: { id: string } }>('/api/v1/secretbackends/:id/rotate', async (request, reply) => {
try {
const tokenMeta = await rotator.rotateOne(request.params.id);
return { ok: true, tokenMeta };
} catch (err) {
if (err instanceof NotFoundError) {
reply.code(404);
return { error: err.message };
}
reply.code(502);
return { error: err instanceof Error ? err.message : String(err) };
}
});
}

View File

@@ -0,0 +1,129 @@
/**
* Background loop that drives `SecretBackendRotator` on a 24h cadence.
*
* - On `start()`: scan all rotatable backends. For each that is overdue
* (never rotated OR last rotation > 24h ago), kick rotation immediately.
* Then schedule a per-backend setTimeout for the next tick.
* - On `stop()`: clear every pending timer. Called from the graceful-shutdown
* hook so restarts don't leak timers or interrupt an in-flight rotation.
*
* Jitter (±10 min by default) keeps multiple mcpd replicas from hammering
* OpenBao simultaneously if someone scales the Deployment up.
*
* Failures are swallowed with a warn log — the next scheduled tick will
* retry. The rotator service itself writes `lastRotationError` to the row
* so operators see the failure in `describe`.
*/
import type { SecretBackend } from '@prisma/client';
import type { SecretBackendService } from './secret-backend.service.js';
import type { SecretBackendRotator } from './secret-backend-rotator.service.js';
export interface SecretBackendRotatorLoopDeps {
backends: SecretBackendService;
rotator: SecretBackendRotator;
/** Millisecond jitter applied to the 24h base interval; defaults to ±600_000 (10 min). */
jitterMs?: number;
/** Override in tests. */
setTimeout?: (cb: () => void, ms: number) => NodeJS.Timeout;
clearTimeout?: (t: NodeJS.Timeout) => void;
log?: { info: (msg: string) => void; warn: (msg: string) => void };
}
const DEFAULT_INTERVAL_MS = 24 * 3600 * 1000;
const DEFAULT_JITTER_MS = 10 * 60 * 1000;
export class SecretBackendRotatorLoop {
private readonly timers = new Map<string, NodeJS.Timeout>();
private readonly setT: (cb: () => void, ms: number) => NodeJS.Timeout;
private readonly clearT: (t: NodeJS.Timeout) => void;
private readonly log: { info: (msg: string) => void; warn: (msg: string) => void };
private stopped = false;
constructor(private readonly deps: SecretBackendRotatorLoopDeps) {
this.setT = deps.setTimeout ?? ((cb, ms) => global.setTimeout(cb, ms));
this.clearT = deps.clearTimeout ?? ((t) => global.clearTimeout(t));
this.log = deps.log ?? {
// eslint-disable-next-line no-console
info: (m) => console.log(`[rotator] ${m}`),
// eslint-disable-next-line no-console
warn: (m) => console.warn(`[rotator] ${m}`),
};
}
async start(): Promise<void> {
const backends = (await this.deps.backends.list())
.filter((b) => this.deps.rotator.isRotatable(b));
if (backends.length === 0) {
this.log.info('no rotatable backends registered — loop idle');
return;
}
this.log.info(`starting rotation loop for ${String(backends.length)} backend(s)`);
for (const b of backends) {
if (this.deps.rotator.isOverdue(b)) {
this.log.info(`backend '${b.name}' is overdue — rotating now`);
this.runOnce(b.id, b.name).catch((err) => {
this.log.warn(`initial rotation of '${b.name}' failed: ${err instanceof Error ? err.message : String(err)}`);
});
}
this.schedule(b);
}
}
stop(): void {
this.stopped = true;
for (const [, t] of this.timers) this.clearT(t);
this.timers.clear();
this.log.info('rotation loop stopped');
}
/** Test hook — force a rotation + rescheduling for one backend. */
async rotateNow(backendId: string): Promise<void> {
const backend = await this.deps.backends.getById(backendId);
await this.runOnce(backendId, backend.name);
this.schedule(backend);
}
private schedule(backend: SecretBackend): void {
if (this.stopped) return;
// Clear any existing timer for this backend
const prev = this.timers.get(backend.id);
if (prev !== undefined) this.clearT(prev);
const delay = this.nextDelayMs(backend);
const t = this.setT(() => {
this.runOnce(backend.id, backend.name)
.catch((err) => this.log.warn(`scheduled rotation of '${backend.name}' failed: ${err instanceof Error ? err.message : String(err)}`))
.finally(() => {
// Re-fetch to pick up latest tokenMeta (nextRenewalAt) for the next delay calc.
if (this.stopped) return;
this.deps.backends.getById(backend.id)
.then((b) => this.schedule(b))
.catch((err) => this.log.warn(`re-schedule lookup for '${backend.name}' failed: ${err instanceof Error ? err.message : String(err)}`));
});
}, delay);
this.timers.set(backend.id, t);
}
private async runOnce(backendId: string, name: string): Promise<void> {
try {
await this.deps.rotator.rotateOne(backendId);
this.log.info(`rotated '${name}' successfully`);
} catch (err) {
// Error already recorded in tokenMeta by rotator; just log.
throw err;
}
}
private nextDelayMs(backend: SecretBackend): number {
const cfg = backend.config as { rotation?: { intervalHours?: number } };
const baseMs = cfg.rotation?.intervalHours !== undefined
? cfg.rotation.intervalHours * 3600 * 1000
: DEFAULT_INTERVAL_MS;
const jitter = this.deps.jitterMs ?? DEFAULT_JITTER_MS;
// Uniform in [-jitter, +jitter]
const offset = (Math.random() * 2 - 1) * jitter;
return Math.max(60_000, Math.floor(baseMs + offset));
}
}

View File

@@ -0,0 +1,186 @@
/**
* Rotator for wizard-provisioned OpenBao backends.
*
* Flow on every tick:
* 1. Read the CURRENT mcpd token from its backing plaintext Secret.
* 2. Use that token to mint a SUCCESSOR via `auth/token/create/<role>`
* (the `app-mcpd` policy grants the caller exactly this path).
* 3. Verify the successor with `auth/token/lookup-self`.
* 4. Persist the successor in the same Secret (overwriting the old value).
* 5. Revoke the predecessor by accessor (best-effort; old tokens expire on
* their own anyway).
* 6. Update `tokenMeta` on the SecretBackend row with the new timestamps.
*
* On any failure: old token remains in place, `tokenMeta.lastRotationError`
* is populated, the exception is re-thrown. Old tokens still have ~29 days
* of remaining TTL by design (ttl=720h, rotation cadence=24h), so a few
* days of rotation failures are survivable without a user outage.
*/
import type { SecretBackend } from '@prisma/client';
import {
mintRoleToken,
lookupSelf,
revokeAccessor,
type VaultDeps,
type MintedToken,
} from '@mcpctl/shared';
import type { SecretBackendService } from './secret-backend.service.js';
import type { SecretService } from './secret.service.js';
/** Shape of `SecretBackend.config` we require for rotation. */
export interface RotatableOpenBaoConfig {
url: string;
auth?: 'token';
mount?: string;
pathPrefix?: string;
namespace?: string;
tokenSecretRef: { name: string; key: string };
rotation: {
enabled: true;
tokenRole: string;
intervalHours?: number;
};
}
/** Shape we store in `SecretBackend.tokenMeta`. */
export interface TokenMeta {
generatedAt?: string;
nextRenewalAt?: string;
validUntil?: string;
lastRotationAt?: string;
lastRotationError?: string | null;
currentAccessor?: string;
rotatable?: boolean;
}
export interface SecretBackendRotatorDeps {
backends: SecretBackendService;
secrets: SecretService;
fetch?: typeof globalThis.fetch;
now?: () => Date;
}
export class SecretBackendRotator {
private readonly now: () => Date;
constructor(private readonly deps: SecretBackendRotatorDeps) {
this.now = deps.now ?? (() => new Date());
}
/** True iff this backend is a wizard-provisioned token-auth openbao with rotation enabled. */
isRotatable(backend: SecretBackend): boolean {
if (backend.type !== 'openbao') return false;
const cfg = backend.config as Partial<RotatableOpenBaoConfig>;
return (cfg.auth ?? 'token') === 'token'
&& cfg.rotation?.enabled === true
&& typeof cfg.rotation?.tokenRole === 'string'
&& typeof cfg.tokenSecretRef?.name === 'string';
}
/**
* Execute one rotation pass on the given backend. Returns the freshly
* recorded `tokenMeta`. Throws on any failure — callers decide whether to
* log + move on (loop) or propagate (manual trigger).
*/
async rotateOne(backendId: string): Promise<TokenMeta> {
const backend = await this.deps.backends.getById(backendId);
if (!this.isRotatable(backend)) {
throw new Error(`SecretBackend '${backend.name}' is not rotatable (need type=openbao, auth=token, rotation.enabled=true)`);
}
const cfg = backend.config as unknown as RotatableOpenBaoConfig;
const meta = (backend.tokenMeta as unknown as TokenMeta | null | undefined) ?? {};
const vaultDeps: VaultDeps = {};
if (this.deps.fetch !== undefined) vaultDeps.fetch = this.deps.fetch;
if (cfg.namespace !== undefined) vaultDeps.namespace = cfg.namespace;
// 1. Read current token from the backing plaintext Secret.
const secretRow = await this.deps.secrets.getByName(cfg.tokenSecretRef.name);
const data = await this.deps.secrets.resolveData(secretRow);
const currentToken = data[cfg.tokenSecretRef.key];
if (currentToken === undefined || currentToken === '') {
const err = new Error(`rotation: current token missing at ${cfg.tokenSecretRef.name}/${cfg.tokenSecretRef.key}`);
await this.recordError(backendId, meta, err.message);
throw err;
}
const oldAccessor = meta.currentAccessor;
let minted: MintedToken;
try {
// 2. Mint successor.
minted = await mintRoleToken(cfg.url, currentToken, cfg.rotation.tokenRole, vaultDeps);
if (!minted.renewable) {
throw new Error(`minted token from role '${cfg.rotation.tokenRole}' is not renewable — check the token role's renewable + period settings`);
}
// 3. Verify successor works (belt-and-suspenders — if bao returned a token
// that can't auth back, we'd lock ourselves out on persist).
await lookupSelf(cfg.url, minted.clientToken, vaultDeps);
// 4. Persist successor in the same Secret. Update in-place — we keep
// the other keys (if any) intact.
const nextData = { ...data, [cfg.tokenSecretRef.key]: minted.clientToken };
await this.deps.secrets.update(secretRow.id, { data: nextData });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await this.recordError(backendId, meta, msg);
throw err;
}
// 5. Revoke predecessor (best-effort — old tokens expire anyway).
if (oldAccessor !== undefined && oldAccessor !== '') {
try {
await revokeAccessor(cfg.url, minted.clientToken, oldAccessor, vaultDeps);
} catch (err) {
// Log but don't fail the rotation — the new token is already live.
const msg = err instanceof Error ? err.message : String(err);
// eslint-disable-next-line no-console
console.warn(`rotation: revoke old accessor '${oldAccessor}' on backend '${backend.name}' failed (continuing): ${msg}`);
}
}
// 6. Record success in tokenMeta.
const now = this.now();
const intervalHours = cfg.rotation.intervalHours ?? 24;
const nextMeta: TokenMeta = {
generatedAt: now.toISOString(),
nextRenewalAt: new Date(now.getTime() + intervalHours * 3600 * 1000).toISOString(),
validUntil: minted.leaseDuration > 0
? new Date(now.getTime() + minted.leaseDuration * 1000).toISOString()
: undefined as unknown as string, // typed but optional; undefined drops on JSON round-trip
lastRotationAt: now.toISOString(),
lastRotationError: null,
currentAccessor: minted.accessor,
rotatable: true,
};
// Strip undefined so JSON is clean.
const cleanMeta: Record<string, unknown> = {};
for (const [k, v] of Object.entries(nextMeta)) {
if (v !== undefined) cleanMeta[k] = v;
}
await this.deps.backends.updateTokenMeta(backendId, cleanMeta);
return nextMeta;
}
/** Is this backend overdue for rotation? Used by the loop on startup. */
isOverdue(backend: SecretBackend): boolean {
const meta = (backend.tokenMeta as unknown as TokenMeta | null | undefined) ?? {};
if (meta.lastRotationAt === undefined) return true;
const last = new Date(meta.lastRotationAt).getTime();
if (Number.isNaN(last)) return true;
const cfg = backend.config as Partial<RotatableOpenBaoConfig>;
const intervalHours = cfg.rotation?.intervalHours ?? 24;
return this.now().getTime() - last > intervalHours * 3600 * 1000;
}
private async recordError(backendId: string, prev: TokenMeta, message: string): Promise<void> {
const nextMeta: Record<string, unknown> = { ...prev, lastRotationError: message };
try {
await this.deps.backends.updateTokenMeta(backendId, nextMeta);
} catch (inner) {
// Don't mask the original error — just log the DB failure.
// eslint-disable-next-line no-console
console.warn(`rotation: failed to persist lastRotationError (${message}): ${inner instanceof Error ? inner.message : String(inner)}`);
}
}
}

View File

@@ -63,6 +63,16 @@ export class SecretBackendService {
return row;
}
/**
* Replace `tokenMeta` on a backend row. Called exclusively by the rotator
* service every time it mints or fails to mint a successor token. The field
* is runtime state (not user-managed config) so it bypasses the normal
* update path + doesn't invalidate the driver cache.
*/
async updateTokenMeta(id: string, tokenMeta: Record<string, unknown>): Promise<SecretBackend> {
return this.repo.update(id, { tokenMeta });
}
async setDefault(id: string): Promise<SecretBackend> {
await this.getById(id);
return this.repo.setAsDefault(id);

View File

@@ -0,0 +1,276 @@
import { describe, it, expect, vi } from 'vitest';
import { SecretBackendRotator } from '../src/services/secret-backend-rotator.service.js';
import type { SecretBackend, Secret } from '@prisma/client';
function makeBackend(overrides: Partial<SecretBackend> = {}): SecretBackend {
return {
id: 'backend-1',
name: 'bao',
type: 'openbao',
config: {
url: 'http://bao.example:8200',
auth: 'token',
mount: 'secret',
pathPrefix: 'mcpd',
tokenSecretRef: { name: 'bao-creds', key: 'token' },
rotation: { enabled: true, tokenRole: 'app-mcpd-role', intervalHours: 24 },
} as unknown as SecretBackend['config'],
tokenMeta: { rotatable: true } as unknown as SecretBackend['tokenMeta'],
isDefault: false,
description: '',
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeSecret(overrides: Partial<Secret> = {}): Secret {
return {
id: 'sec-1',
name: 'bao-creds',
backendId: 'backend-plaintext',
data: { token: 'old.token.value' },
externalRef: '',
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
interface MockState {
backend: SecretBackend;
secret: Secret;
secretData: Record<string, string>;
lastTokenMeta: Record<string, unknown> | null;
lastSecretUpdate: Record<string, unknown> | null;
}
function mockDeps(state: MockState, vaultResponses: Array<{ match: RegExp; status: number; body?: unknown }>) {
const fetchFn = vi.fn(async (url: string | URL, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${String(url)}`;
const match = vaultResponses.find((r) => r.match.test(key) || r.match.test(String(url)));
if (!match) throw new Error(`unexpected vault call: ${key}`);
const body = match.body !== undefined ? JSON.stringify(match.body) : '';
return new Response(body, { status: match.status });
});
const backends = {
getById: vi.fn(async (id: string) => {
if (id === state.backend.id) return state.backend;
throw new Error(`not found: ${id}`);
}),
updateTokenMeta: vi.fn(async (id: string, meta: Record<string, unknown>) => {
expect(id).toBe(state.backend.id);
state.lastTokenMeta = meta;
state.backend = { ...state.backend, tokenMeta: meta as unknown as SecretBackend['tokenMeta'] };
return state.backend;
}),
};
const secrets = {
getByName: vi.fn(async (name: string) => {
if (name === state.secret.name) return state.secret;
throw new Error(`secret not found: ${name}`);
}),
resolveData: vi.fn(async () => ({ ...state.secretData })),
update: vi.fn(async (id: string, input: { data: Record<string, string> }) => {
expect(id).toBe(state.secret.id);
state.secretData = { ...input.data };
state.lastSecretUpdate = input as unknown as Record<string, unknown>;
return state.secret;
}),
};
return { fetchFn, backends, secrets };
}
describe('SecretBackendRotator', () => {
it('isRotatable: true for wizard-provisioned openbao', () => {
const state: MockState = {
backend: makeBackend(),
secret: makeSecret(),
secretData: { token: 'x' },
lastTokenMeta: null,
lastSecretUpdate: null,
};
const { backends, secrets } = mockDeps(state, []);
const r = new SecretBackendRotator({
backends: backends as unknown as Parameters<typeof SecretBackendRotator.prototype.rotateOne>[0] extends never ? never : never,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
// Use a real rotator with both deps filled.
const rotator = new SecretBackendRotator({
backends: backends as never,
secrets: secrets as never,
});
expect(rotator.isRotatable(state.backend)).toBe(true);
expect(r).toBeDefined();
});
it('isRotatable: false for kubernetes-auth openbao', () => {
const state: MockState = {
backend: makeBackend({
config: {
url: 'http://bao', auth: 'kubernetes', role: 'r',
rotation: { enabled: true, tokenRole: 'app-mcpd-role' },
} as unknown as SecretBackend['config'],
}),
secret: makeSecret(),
secretData: {},
lastTokenMeta: null,
lastSecretUpdate: null,
};
const { backends, secrets } = mockDeps(state, []);
const rotator = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never });
expect(rotator.isRotatable(state.backend)).toBe(false);
});
it('rotateOne: mints → verifies → persists → revokes old → updates tokenMeta', async () => {
const state: MockState = {
backend: makeBackend({ tokenMeta: { rotatable: true, currentAccessor: 'old-accessor' } as unknown as SecretBackend['tokenMeta'] }),
secret: makeSecret({ data: { token: 'old.token.value' } as Secret['data'] }),
secretData: { token: 'old.token.value' },
lastTokenMeta: null,
lastSecretUpdate: null,
};
const { fetchFn, backends, secrets } = mockDeps(state, [
{ match: /POST .*auth\/token\/create\/app-mcpd-role$/, status: 200, body: { auth: { client_token: 'new.token.value', accessor: 'new-accessor', lease_duration: 720 * 3600, renewable: true } } },
{ match: /GET .*auth\/token\/lookup-self$/, status: 200, body: { data: { accessor: 'new-accessor', ttl: 720 * 3600 } } },
{ match: /POST .*auth\/token\/revoke-accessor$/, status: 200 },
]);
const rotator = new SecretBackendRotator({
backends: backends as never,
secrets: secrets as never,
fetch: fetchFn as unknown as typeof fetch,
now: () => new Date('2026-04-20T10:00:00Z'),
});
const meta = await rotator.rotateOne(state.backend.id);
// Correct order of HTTP calls: create (with OLD token) → lookup (with NEW token) → revoke (with NEW token)
const calls = fetchFn.mock.calls.map((c) => `${(c[1] as RequestInit).method ?? 'GET'} ${String(c[0])}`);
expect(calls[0]).toMatch(/POST .*create\/app-mcpd-role/);
expect(calls[1]).toMatch(/GET .*lookup-self/);
expect(calls[2]).toMatch(/POST .*revoke-accessor/);
expect((fetchFn.mock.calls[0]![1] as RequestInit).headers).toMatchObject({ 'X-Vault-Token': 'old.token.value' });
expect((fetchFn.mock.calls[1]![1] as RequestInit).headers).toMatchObject({ 'X-Vault-Token': 'new.token.value' });
expect((fetchFn.mock.calls[2]![1] as RequestInit).headers).toMatchObject({ 'X-Vault-Token': 'new.token.value' });
// Secret was updated BEFORE revoke — state reflects ordering by sequence above.
expect(state.secretData.token).toBe('new.token.value');
// tokenMeta carries fresh timestamps + accessor
expect(meta.currentAccessor).toBe('new-accessor');
expect(meta.lastRotationError).toBeNull();
expect(meta.generatedAt).toBe('2026-04-20T10:00:00.000Z');
expect(meta.nextRenewalAt).toBe('2026-04-21T10:00:00.000Z');
expect(meta.validUntil).toBe('2026-05-20T10:00:00.000Z');
expect(state.lastTokenMeta?.rotatable).toBe(true);
});
it('rotateOne: on mint failure, records lastRotationError and keeps old token', async () => {
const state: MockState = {
backend: makeBackend(),
secret: makeSecret({ data: { token: 'old.token' } as Secret['data'] }),
secretData: { token: 'old.token' },
lastTokenMeta: null,
lastSecretUpdate: null,
};
const { fetchFn, backends, secrets } = mockDeps(state, [
{ match: /create\/app-mcpd-role$/, status: 403, body: { errors: ['permission denied'] } },
]);
const rotator = new SecretBackendRotator({
backends: backends as never,
secrets: secrets as never,
fetch: fetchFn as unknown as typeof fetch,
});
await expect(rotator.rotateOne(state.backend.id)).rejects.toThrow(/HTTP 403/);
// Secret was NOT updated
expect(state.secretData.token).toBe('old.token');
expect(secrets.update).not.toHaveBeenCalled();
// tokenMeta records the error
expect(state.lastTokenMeta?.lastRotationError).toMatch(/HTTP 403/);
});
it('rotateOne: rejects when minted token is not renewable', async () => {
const state: MockState = {
backend: makeBackend(),
secret: makeSecret({ data: { token: 'old' } as Secret['data'] }),
secretData: { token: 'old' },
lastTokenMeta: null,
lastSecretUpdate: null,
};
const { fetchFn, backends, secrets } = mockDeps(state, [
{ match: /create\/app-mcpd-role$/, status: 200, body: { auth: { client_token: 'new', accessor: 'a', lease_duration: 100, renewable: false } } },
]);
const rotator = new SecretBackendRotator({
backends: backends as never,
secrets: secrets as never,
fetch: fetchFn as unknown as typeof fetch,
});
await expect(rotator.rotateOne(state.backend.id)).rejects.toThrow(/not renewable/);
expect(state.secretData.token).toBe('old');
});
it('rotateOne: continues despite revoke-accessor failure (old token expires anyway)', async () => {
const state: MockState = {
backend: makeBackend({ tokenMeta: { rotatable: true, currentAccessor: 'old-accessor' } as unknown as SecretBackend['tokenMeta'] }),
secret: makeSecret({ data: { token: 'old' } as Secret['data'] }),
secretData: { token: 'old' },
lastTokenMeta: null,
lastSecretUpdate: null,
};
const { fetchFn, backends, secrets } = mockDeps(state, [
{ match: /create\/app-mcpd-role$/, status: 200, body: { auth: { client_token: 'new', accessor: 'new-a', lease_duration: 3600, renewable: true } } },
{ match: /lookup-self$/, status: 200, body: { data: { accessor: 'new-a', ttl: 3600 } } },
{ match: /revoke-accessor$/, status: 502 },
]);
const rotator = new SecretBackendRotator({
backends: backends as never,
secrets: secrets as never,
fetch: fetchFn as unknown as typeof fetch,
});
const meta = await rotator.rotateOne(state.backend.id);
expect(state.secretData.token).toBe('new');
expect(meta.lastRotationError).toBeNull();
});
it('isOverdue: true when lastRotationAt missing or >24h old', () => {
const state: MockState = {
backend: makeBackend({ tokenMeta: { rotatable: true } as unknown as SecretBackend['tokenMeta'] }),
secret: makeSecret(),
secretData: {},
lastTokenMeta: null,
lastSecretUpdate: null,
};
const { backends, secrets } = mockDeps(state, []);
const now = () => new Date('2026-04-20T10:00:00Z');
const r = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never, now });
expect(r.isOverdue(state.backend)).toBe(true);
const fresh = { ...state.backend, tokenMeta: { rotatable: true, lastRotationAt: '2026-04-20T09:00:00Z' } as unknown as SecretBackend['tokenMeta'] };
expect(r.isOverdue(fresh)).toBe(false);
const stale = { ...state.backend, tokenMeta: { rotatable: true, lastRotationAt: '2026-04-18T10:00:00Z' } as unknown as SecretBackend['tokenMeta'] };
expect(r.isOverdue(stale)).toBe(true);
});
it('rotateOne: throws when backend is not rotatable', async () => {
const state: MockState = {
backend: makeBackend({ type: 'plaintext', config: {} as SecretBackend['config'] }),
secret: makeSecret(),
secretData: {},
lastTokenMeta: null,
lastSecretUpdate: null,
};
const { backends, secrets } = mockDeps(state, []);
const r = new SecretBackendRotator({ backends: backends as never, secrets: secrets as never });
await expect(r.rotateOne(state.backend.id)).rejects.toThrow(/not rotatable/);
});
});