diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 5072a29..5561fc3 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,7 +5,7 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply patch backup approve console cache test migrate" + local commands="status login logout config get describe delete logs create edit apply patch backup approve console cache test migrate rotate" local project_commands="get describe delete logs create edit attach-server detach-server" local global_opts="-v --version --daemon-url --direct -p --project -h --help" local resources="servers instances secrets secretbackends llms templates projects users groups rbac prompts promptrequests serverattachments proxymodels all" @@ -188,7 +188,7 @@ _mcpctl() { COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --force -h --help" -- "$cur")) ;; secretbackend) - COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --auth --token-secret --role --auth-mount --sa-token-path --config --force -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --auth --token-secret --role --auth-mount --sa-token-path --config --wizard --admin-token --policy-name --token-role --no-promote-default --force -h --help" -- "$cur")) ;; project) COMPREPLY=($(compgen -W "-d --description --proxy-model --prompt --llm --llm-model --gated --no-gated --server --force -h --help" -- "$cur")) @@ -350,6 +350,21 @@ _mcpctl() { esac fi return ;; + rotate) + local rotate_sub=$(_mcpctl_get_subcmd $subcmd_pos) + if [[ -z "$rotate_sub" ]]; then + COMPREPLY=($(compgen -W "secretbackend help" -- "$cur")) + else + case "$rotate_sub" in + secretbackend) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + esac + fi + return ;; help) COMPREPLY=($(compgen -W "$commands" -- "$cur")) return ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 742f31a..aadae5e 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply patch backup approve console cache test migrate +set -l commands status login logout config get describe delete logs create edit apply patch backup approve console cache test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server # Disable file completions by default @@ -235,6 +235,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a test -d 'Utilities for testing MCP endpoints and config' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a migrate -d 'Move resources between backends (currently: secrets between SecretBackends)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a rotate -d 'Force rotation of a credential-rotating resource (currently: secretbackend)' # Project-scoped commands (with --project) complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (servers, projects, instances, all)' @@ -342,6 +343,11 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l role -d ' complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l auth-mount -d 'openbao kubernetes auth: vault auth method mount path (default: \'kubernetes\')' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l sa-token-path -d 'openbao kubernetes auth: filesystem path to projected SA token (default: \'/var/run/secrets/kubernetes.io/serviceaccount/token\')' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l config -d 'Extra config as key=value (repeat for multiple)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l wizard -d 'Interactive wizard (openbao only): provision policy + token role, mint token, store on mcpd, suggest migration' +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l admin-token -d 'openbao wizard: OpenBao admin/root token (prompted if omitted). Used only for provisioning; NEVER persisted.' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l policy-name -d 'openbao wizard: name for the policy created on OpenBao (default: \'app-mcpd\')' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l token-role -d 'openbao wizard: name for the token role created on OpenBao (default: \'app-mcpd-role\')' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l no-promote-default -d 'openbao wizard: do not promote this backend to default after creation' complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l force -d 'Update if already exists' # create project options @@ -435,6 +441,10 @@ complete -c mcpctl -n "__mcpctl_subcmd_active migrate secrets" -l names -d 'Comm complete -c mcpctl -n "__mcpctl_subcmd_active migrate secrets" -l keep-source -d 'Leave the source copy intact (default: delete from source after write+commit)' complete -c mcpctl -n "__mcpctl_subcmd_active migrate secrets" -l dry-run -d 'Show which secrets would be migrated without touching them' +# rotate subcommands +set -l rotate_cmds secretbackend +complete -c mcpctl -n "__fish_seen_subcommand_from rotate; and not __fish_seen_subcommand_from $rotate_cmds" -a secretbackend -d 'Rotate the vault token on an OpenBao SecretBackend (wizard-provisioned)' + # status options complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x diff --git a/src/cli/src/commands/config-setup.ts b/src/cli/src/commands/config-setup.ts index 9eeb672..516e49c 100644 --- a/src/cli/src/commands/config-setup.ts +++ b/src/cli/src/commands/config-setup.ts @@ -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, diff --git a/src/cli/src/commands/create-secretbackend-wizard.ts b/src/cli/src/commands/create-secretbackend-wizard.ts new file mode 100644 index 0000000..2f99461 --- /dev/null +++ b/src/cli/src/commands/create-secretbackend-wizard.ts @@ -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 `/`. + * 4. Writes policy `app-mcpd` scoped to `/{data,metadata}//*` + * 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 --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 { + 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, +): Promise { + 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>('/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 { + // 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>('/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}`); + } +} diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index bc5d94b..41d6182 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -325,9 +325,32 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--auth-mount ', "openbao kubernetes auth: vault auth method mount path (default: 'kubernetes')") .option('--sa-token-path ', "openbao kubernetes auth: filesystem path to projected SA token (default: '/var/run/secrets/kubernetes.io/serviceaccount/token')") .option('--config ', '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 ', "openbao wizard: OpenBao admin/root token (prompted if omitted). Used only for provisioning; NEVER persisted.") + .option('--policy-name ', "openbao wizard: name for the policy created on OpenBao (default: 'app-mcpd')") + .option('--token-role ', "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[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 = {}; if (type === 'openbao') { diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index a986d35..bb48203 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -297,6 +297,12 @@ function formatSecretBackendDetail(backend: Record): string { } } + const tokenMeta = (backend.tokenMeta ?? {}) as Record; + 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 { 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[] { + 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 { const lines: string[] = []; lines.push(`=== Template: ${template.name} ===`); diff --git a/src/cli/src/commands/rotate.ts b/src/cli/src/commands/rotate.ts new file mode 100644 index 0000000..ffb6d84 --- /dev/null +++ b/src/cli/src/commands/rotate.ts @@ -0,0 +1,50 @@ +/** + * `mcpctl rotate secretbackend ` — 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('', 'SecretBackend name or id') + .action(async (nameOrId: string) => { + const id = await resolveNameOrId(client, 'secretbackends', nameOrId); + const res = await client.post<{ ok?: boolean; tokenMeta?: Record; 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; +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 2eac1e0..28fa193 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -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; } diff --git a/src/cli/tests/commands/create-secretbackend-wizard.test.ts b/src/cli/tests/commands/create-secretbackend-wizard.test.ts new file mode 100644 index 0000000..c5257e1 --- /dev/null +++ b/src/cli/tests/commands/create-secretbackend-wizard.test.ts @@ -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 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 { + 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; + password?: Record; + confirm?: Record; +}): 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 = {}; + 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)['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/); + }); +}); diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 90ef49f..ed33913 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -125,6 +125,12 @@ model SecretBackend { name String @unique type String // plaintext | openbao | (future: vault, aws-sm, ...) config Json @default("{}") // type-specific: url, mount, namespace, tokenSecretRef + // Runtime metadata for auto-rotating backend credentials (openbao token + // auth). Fields: generatedAt, nextRenewalAt, validUntil, lastRotationAt, + // lastRotationError, rotatable (true only for wizard-provisioned tokens). + // Empty object for backends that don't use rotation (plaintext, kubernetes + // auth, or static tokens). Managed entirely by the rotator service. + tokenMeta Json @default("{}") isDefault Boolean @default(false) // exactly one row has isDefault=true description String @default("") version Int @default(1) diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index eddafd4..5b2b0df 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -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:` (not the default create:llms). const inferMatch = url.match(/^\/api\/v1\/llms\/([^/?]+)\/infer/); @@ -231,7 +240,7 @@ async function migrateAdminRole(rbacRepo: InstanceType 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 { }); 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 { 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 { ); 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(); }, diff --git a/src/mcpd/src/repositories/secret-backend.repository.ts b/src/mcpd/src/repositories/secret-backend.repository.ts index e6d04bb..daa8c96 100644 --- a/src/mcpd/src/repositories/secret-backend.repository.ts +++ b/src/mcpd/src/repositories/secret-backend.repository.ts @@ -12,6 +12,7 @@ export interface UpdateSecretBackendInput { config?: Record; isDefault?: boolean; description?: string; + tokenMeta?: Record; } 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 }); }); } diff --git a/src/mcpd/src/routes/secret-backend-rotate.ts b/src/mcpd/src/routes/secret-backend-rotate.ts new file mode 100644 index 0000000..f059917 --- /dev/null +++ b/src/mcpd/src/routes/secret-backend-rotate.ts @@ -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) }; + } + }); +} diff --git a/src/mcpd/src/services/secret-backend-rotator-loop.ts b/src/mcpd/src/services/secret-backend-rotator-loop.ts new file mode 100644 index 0000000..82ce70b --- /dev/null +++ b/src/mcpd/src/services/secret-backend-rotator-loop.ts @@ -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(); + 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 { + 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 { + 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 { + 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)); + } +} diff --git a/src/mcpd/src/services/secret-backend-rotator.service.ts b/src/mcpd/src/services/secret-backend-rotator.service.ts new file mode 100644 index 0000000..3144688 --- /dev/null +++ b/src/mcpd/src/services/secret-backend-rotator.service.ts @@ -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/` + * (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; + 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 { + 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 = {}; + 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; + const intervalHours = cfg.rotation?.intervalHours ?? 24; + return this.now().getTime() - last > intervalHours * 3600 * 1000; + } + + private async recordError(backendId: string, prev: TokenMeta, message: string): Promise { + const nextMeta: Record = { ...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)}`); + } + } +} diff --git a/src/mcpd/src/services/secret-backend.service.ts b/src/mcpd/src/services/secret-backend.service.ts index 44e049d..fcf5d97 100644 --- a/src/mcpd/src/services/secret-backend.service.ts +++ b/src/mcpd/src/services/secret-backend.service.ts @@ -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): Promise { + return this.repo.update(id, { tokenMeta }); + } + async setDefault(id: string): Promise { await this.getById(id); return this.repo.setAsDefault(id); diff --git a/src/mcpd/tests/secret-backend-rotator.test.ts b/src/mcpd/tests/secret-backend-rotator.test.ts new file mode 100644 index 0000000..220f2b2 --- /dev/null +++ b/src/mcpd/tests/secret-backend-rotator.test.ts @@ -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 { + 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 { + 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; + lastTokenMeta: Record | null; + lastSecretUpdate: Record | 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) => { + 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 }) => { + expect(id).toBe(state.secret.id); + state.secretData = { ...input.data }; + state.lastSecretUpdate = input as unknown as Record; + 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[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/); + }); +}); diff --git a/src/shared/src/index.ts b/src/shared/src/index.ts index 6f36b42..d357499 100644 --- a/src/shared/src/index.ts +++ b/src/shared/src/index.ts @@ -5,3 +5,4 @@ export * from './utils/index.js'; export * from './secrets/index.js'; export * from './tokens/index.js'; export * from './mcp-http/index.js'; +export * from './vault/index.js'; diff --git a/src/shared/src/vault/client.ts b/src/shared/src/vault/client.ts new file mode 100644 index 0000000..ef79e9c --- /dev/null +++ b/src/shared/src/vault/client.ts @@ -0,0 +1,308 @@ +/** + * Thin HTTP wrappers around the OpenBao / Vault REST API. + * + * Used by: + * - the CLI wizard (admin-token-scoped calls: enable engine, write policy, + * create role, mint first token, smoke-test write/read) + * - the mcpd rotator (caller-token-scoped calls: mint successor, revoke + * predecessor, lookup-self for verification) + * + * Plain `fetch()` — no SDK dep, consistent with the OpenBaoDriver. All + * functions accept an injectable `fetch` in a deps arg so tests can mock. + */ + +export interface VaultDeps { + fetch?: typeof globalThis.fetch; + /** Optional Vault Enterprise namespace (X-Vault-Namespace header). */ + namespace?: string; +} + +export interface VaultHealth { + initialized: boolean; + sealed: boolean; + standby: boolean; + version: string; +} + +export interface MintedToken { + /** The raw client token (treat as secret — surface to user only in wizard transcript). */ + clientToken: string; + /** Accessor used to revoke without knowing the token value. */ + accessor: string; + /** TTL in seconds reported by Vault. For periodic tokens this is the period. */ + leaseDuration: number; + /** True iff Vault said the token is renewable. The wizard bails if false. */ + renewable: boolean; + policies: string[]; +} + +function baseUrl(url: string): string { + return url.replace(/\/+$/, ''); +} + +function headers(token: string | undefined, ns: string | undefined, withBody: boolean): Record { + const h: Record = {}; + if (token !== undefined && token !== '') h['X-Vault-Token'] = token; + if (ns !== undefined && ns !== '') h['X-Vault-Namespace'] = ns; + if (withBody) h['Content-Type'] = 'application/json'; + return h; +} + +async function readError(res: Response): Promise { + const text = await res.text().catch(() => ''); + try { + const parsed = JSON.parse(text) as { errors?: string[] }; + if (Array.isArray(parsed.errors) && parsed.errors.length > 0) return parsed.errors.join('; '); + } catch { /* fall through */ } + return text; +} + +/** GET /v1/sys/health. Returns a normalised shape; throws on network error. */ +export async function verifyHealth( + url: string, + adminToken: string, + deps: VaultDeps = {}, +): Promise { + const fetchImpl = deps.fetch ?? globalThis.fetch; + // /sys/health returns 200/429/472/473/501/503 depending on state. All are + // valid responses to parse; anything else is a hard error. + const res = await fetchImpl(`${baseUrl(url)}/v1/sys/health`, { + method: 'GET', + headers: headers(adminToken, deps.namespace, false), + }); + if (res.status >= 500 && res.status !== 501 && res.status !== 503) { + throw new Error(`vault health: HTTP ${String(res.status)} ${await readError(res)}`); + } + const body = await res.json() as Partial & { version?: string }; + return { + initialized: body.initialized ?? false, + sealed: body.sealed ?? false, + standby: body.standby ?? false, + version: body.version ?? 'unknown', + }; +} + +/** + * Enable KV v2 at `mount` if not already mounted there. Idempotent. + * Returns `true` if a mount was created, `false` if it was already present. + */ +export async function ensureKvV2( + url: string, + adminToken: string, + mount: string, + deps: VaultDeps = {}, +): Promise { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const clean = mount.replace(/^\/|\/$/g, ''); + // Check existing mounts + const listRes = await fetchImpl(`${baseUrl(url)}/v1/sys/mounts`, { + method: 'GET', + headers: headers(adminToken, deps.namespace, false), + }); + if (!listRes.ok) { + throw new Error(`vault list mounts: HTTP ${String(listRes.status)} ${await readError(listRes)}`); + } + const mounts = await listRes.json() as Record; + const key = `${clean}/`; + const existing = mounts[key]; + if (existing !== undefined) { + if (existing.type !== 'kv') { + throw new Error(`mount at '${clean}/' exists but is type '${String(existing.type)}', not kv`); + } + // Accept either v2 or unspecified (older Vault treats kv without options as v1 — surface a clear error). + if (existing.options?.version !== '2') { + throw new Error(`mount '${clean}/' is KV but not v2 (version='${String(existing.options?.version)}'). Use a different mount.`); + } + return false; + } + // Mount it + const mountRes = await fetchImpl(`${baseUrl(url)}/v1/sys/mounts/${clean}`, { + method: 'POST', + headers: headers(adminToken, deps.namespace, true), + body: JSON.stringify({ type: 'kv', options: { version: '2' } }), + }); + if (!mountRes.ok) { + throw new Error(`vault mount ${clean}: HTTP ${String(mountRes.status)} ${await readError(mountRes)}`); + } + return true; +} + +/** PUT /v1/sys/policies/acl/ with the provided HCL. Idempotent. */ +export async function writePolicy( + url: string, + adminToken: string, + name: string, + hcl: string, + deps: VaultDeps = {}, +): Promise { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const res = await fetchImpl(`${baseUrl(url)}/v1/sys/policies/acl/${encodeURIComponent(name)}`, { + method: 'PUT', + headers: headers(adminToken, deps.namespace, true), + body: JSON.stringify({ policy: hcl }), + }); + if (!res.ok) { + throw new Error(`vault write policy ${name}: HTTP ${String(res.status)} ${await readError(res)}`); + } +} + +export interface TokenRoleConfig { + allowedPolicies: string[]; + /** Seconds. For `period`, pass 0 to omit. */ + period?: number; + renewable?: boolean; + orphan?: boolean; +} + +/** POST /v1/auth/token/roles/. Idempotent: upserts the role config. */ +export async function ensureTokenRole( + url: string, + adminToken: string, + role: string, + cfg: TokenRoleConfig, + deps: VaultDeps = {}, +): Promise { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const body: Record = { + allowed_policies: cfg.allowedPolicies, + renewable: cfg.renewable ?? true, + orphan: cfg.orphan ?? false, + }; + if (cfg.period !== undefined && cfg.period > 0) body.period = cfg.period; + const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/roles/${encodeURIComponent(role)}`, { + method: 'POST', + headers: headers(adminToken, deps.namespace, true), + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`vault ensure role ${role}: HTTP ${String(res.status)} ${await readError(res)}`); + } +} + +/** + * POST /v1/auth/token/create/. Caller must hold a token with + * `create` on that path (admin, or a previously-minted successor). + */ +export async function mintRoleToken( + url: string, + callerToken: string, + role: string, + deps: VaultDeps = {}, +): Promise { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/create/${encodeURIComponent(role)}`, { + method: 'POST', + headers: headers(callerToken, deps.namespace, true), + body: JSON.stringify({}), + }); + if (!res.ok) { + throw new Error(`vault mint role-token ${role}: HTTP ${String(res.status)} ${await readError(res)}`); + } + const body = await res.json() as { + auth?: { + client_token?: string; + accessor?: string; + lease_duration?: number; + renewable?: boolean; + policies?: string[]; + }; + }; + const a = body.auth; + if (a?.client_token === undefined || a?.accessor === undefined) { + throw new Error(`vault mint role-token ${role}: response missing auth.client_token or accessor`); + } + return { + clientToken: a.client_token, + accessor: a.accessor, + leaseDuration: a.lease_duration ?? 0, + renewable: a.renewable ?? false, + policies: a.policies ?? [], + }; +} + +/** POST /v1/auth/token/revoke-accessor. Idempotent — revoking an unknown accessor returns 204. */ +export async function revokeAccessor( + url: string, + callerToken: string, + accessor: string, + deps: VaultDeps = {}, +): Promise { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/revoke-accessor`, { + method: 'POST', + headers: headers(callerToken, deps.namespace, true), + body: JSON.stringify({ accessor }), + }); + // 204 = revoked, 400 = already revoked/unknown (treat as noop) + if (!res.ok && res.status !== 400) { + throw new Error(`vault revoke-accessor: HTTP ${String(res.status)} ${await readError(res)}`); + } +} + +/** GET /v1/auth/token/lookup-self. Returns accessor + remaining TTL on the caller's token. */ +export async function lookupSelf( + url: string, + callerToken: string, + deps: VaultDeps = {}, +): Promise<{ accessor: string; ttl: number; policies: string[] }> { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const res = await fetchImpl(`${baseUrl(url)}/v1/auth/token/lookup-self`, { + method: 'GET', + headers: headers(callerToken, deps.namespace, false), + }); + if (!res.ok) { + throw new Error(`vault lookup-self: HTTP ${String(res.status)} ${await readError(res)}`); + } + const body = await res.json() as { data?: { accessor?: string; ttl?: number; policies?: string[] } }; + return { + accessor: body.data?.accessor ?? '', + ttl: body.data?.ttl ?? 0, + policies: body.data?.policies ?? [], + }; +} + +/** + * Round-trip smoke test: write a marker secret, read it back, delete metadata. + * Used by the wizard to prove the minted token's policy is wired correctly + * before reporting success to the user. + */ +export async function testWriteReadDelete( + url: string, + callerToken: string, + mount: string, + relPath: string, + deps: VaultDeps = {}, +): Promise { + const fetchImpl = deps.fetch ?? globalThis.fetch; + const dataUrl = `${baseUrl(url)}/v1/${mount}/data/${relPath.replace(/^\//, '')}`; + const metaUrl = `${baseUrl(url)}/v1/${mount}/metadata/${relPath.replace(/^\//, '')}`; + + const writeRes = await fetchImpl(dataUrl, { + method: 'POST', + headers: headers(callerToken, deps.namespace, true), + body: JSON.stringify({ data: { marker: 'mcpctl-smoke', at: new Date().toISOString() } }), + }); + if (!writeRes.ok) { + throw new Error(`vault smoke write ${relPath}: HTTP ${String(writeRes.status)} ${await readError(writeRes)}`); + } + + const readRes = await fetchImpl(dataUrl, { + method: 'GET', + headers: headers(callerToken, deps.namespace, false), + }); + if (!readRes.ok) { + throw new Error(`vault smoke read ${relPath}: HTTP ${String(readRes.status)} ${await readError(readRes)}`); + } + const readBody = await readRes.json() as { data?: { data?: { marker?: string } } }; + if (readBody.data?.data?.marker !== 'mcpctl-smoke') { + throw new Error(`vault smoke: read-back didn't match written marker`); + } + + const delRes = await fetchImpl(metaUrl, { + method: 'DELETE', + headers: headers(callerToken, deps.namespace, false), + }); + if (!delRes.ok && delRes.status !== 404) { + throw new Error(`vault smoke delete ${relPath}: HTTP ${String(delRes.status)} ${await readError(delRes)}`); + } +} diff --git a/src/shared/src/vault/index.ts b/src/shared/src/vault/index.ts new file mode 100644 index 0000000..6a8a2d8 --- /dev/null +++ b/src/shared/src/vault/index.ts @@ -0,0 +1,2 @@ +export * from './client.js'; +export * from './policy.js'; diff --git a/src/shared/src/vault/policy.ts b/src/shared/src/vault/policy.ts new file mode 100644 index 0000000..65b30cb --- /dev/null +++ b/src/shared/src/vault/policy.ts @@ -0,0 +1,35 @@ +/** + * OpenBao / Vault policy template for mcpd's wizard-provisioned backend. + * + * The policy is deliberately narrow: + * - Read/write/list/delete under `/{data,metadata}//*` + * - Self-rotation: mcpd can mint its successor via the dedicated token role + * and revoke its predecessor by accessor. + * + * Keeping the paths in one place lets the wizard and the rotator agree on + * exactly which capabilities the stored token has, and lets tests assert the + * generated HCL is stable. + */ + +export interface AppMcpdPolicyConfig { + /** KV v2 mount name. Default: 'secret'. */ + mount: string; + /** Path prefix under the mount (the directory mcpd is confined to). Default: 'mcpd'. */ + pathPrefix: string; + /** Token role name the policy allows self-rotation against. Default: 'app-mcpd-role'. */ + tokenRole: string; +} + +export function buildAppMcpdPolicyHcl(cfg: AppMcpdPolicyConfig): string { + const { mount, pathPrefix, tokenRole } = cfg; + const prefix = pathPrefix.replace(/^\/|\/$/g, ''); + return [ + `path "${mount}/data/${prefix}/*" { capabilities = ["create", "read", "update"] }`, + `path "${mount}/metadata/${prefix}/*" { capabilities = ["list", "delete"] }`, + `path "${mount}/metadata/${prefix}/" { capabilities = ["list"] }`, + `path "auth/token/create/${tokenRole}" { capabilities = ["create", "update"] }`, + `path "auth/token/revoke-accessor" { capabilities = ["update"] }`, + `path "auth/token/lookup-self" { capabilities = ["read"] }`, + '', + ].join('\n'); +} diff --git a/src/shared/tests/vault-client.test.ts b/src/shared/tests/vault-client.test.ts new file mode 100644 index 0000000..726725a --- /dev/null +++ b/src/shared/tests/vault-client.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + buildAppMcpdPolicyHcl, + verifyHealth, + ensureKvV2, + writePolicy, + ensureTokenRole, + mintRoleToken, + revokeAccessor, + lookupSelf, + testWriteReadDelete, +} from '../src/vault/index.js'; + +function mockFetch(responses: Array<{ match: RegExp; status: number; body?: unknown; text?: string }>): ReturnType { + return vi.fn(async (url: string | URL, init?: RequestInit) => { + const u = String(url); + const method = init?.method ?? 'GET'; + const match = responses.find((r) => r.match.test(`${method} ${u}`) || r.match.test(u)); + if (!match) throw new Error(`unexpected fetch: ${method} ${u}`); + const body = match.body !== undefined ? JSON.stringify(match.body) : (match.text ?? ''); + return new Response(body, { status: match.status, headers: { 'Content-Type': 'application/json' } }); + }); +} + +describe('buildAppMcpdPolicyHcl', () => { + it('emits stable HCL for the documented default', () => { + const hcl = buildAppMcpdPolicyHcl({ mount: 'secret', pathPrefix: 'mcpd', tokenRole: 'app-mcpd-role' }); + expect(hcl).toContain('path "secret/data/mcpd/*"'); + expect(hcl).toContain('path "secret/metadata/mcpd/*"'); + expect(hcl).toContain('path "auth/token/create/app-mcpd-role"'); + expect(hcl).toContain('path "auth/token/revoke-accessor"'); + expect(hcl).toContain('capabilities = ["read"]'); + }); + + it('normalises leading/trailing slashes in pathPrefix', () => { + const hcl = buildAppMcpdPolicyHcl({ mount: 'secret', pathPrefix: '/mcpd/', tokenRole: 'r' }); + expect(hcl).not.toContain('//'); + expect(hcl).toContain('path "secret/data/mcpd/*"'); + }); +}); + +describe('verifyHealth', () => { + it('returns normalised shape for a healthy unsealed vault', async () => { + const fetchFn = mockFetch([{ match: /\/v1\/sys\/health$/, status: 200, body: { initialized: true, sealed: false, standby: false, version: '2.5.2' } }]); + const h = await verifyHealth('http://bao.example:8200', 'root', { fetch: fetchFn as unknown as typeof fetch }); + expect(h).toEqual({ initialized: true, sealed: false, standby: false, version: '2.5.2' }); + }); + + it('throws on non-standard 5xx', async () => { + const fetchFn = vi.fn(async () => new Response('boom', { status: 502 })); + await expect(verifyHealth('http://x', 'root', { fetch: fetchFn as unknown as typeof fetch })).rejects.toThrow(/HTTP 502/); + }); +}); + +describe('ensureKvV2', () => { + it('returns false when mount already exists as kv v2', async () => { + const fetchFn = mockFetch([ + { match: /GET .*\/v1\/sys\/mounts$/, status: 200, body: { 'secret/': { type: 'kv', options: { version: '2' } } } }, + ]); + const created = await ensureKvV2('http://x', 'root', 'secret', { fetch: fetchFn as unknown as typeof fetch }); + expect(created).toBe(false); + }); + + it('mounts KV v2 when mount is missing', async () => { + const fetchFn = mockFetch([ + { match: /GET .*\/v1\/sys\/mounts$/, status: 200, body: {} }, + { match: /POST .*\/v1\/sys\/mounts\/secret$/, status: 200 }, + ]); + const created = await ensureKvV2('http://x', 'root', 'secret', { fetch: fetchFn as unknown as typeof fetch }); + expect(created).toBe(true); + }); + + it('rejects when mount exists but is kv v1', async () => { + const fetchFn = mockFetch([ + { match: /GET .*\/v1\/sys\/mounts$/, status: 200, body: { 'secret/': { type: 'kv', options: { version: '1' } } } }, + ]); + await expect(ensureKvV2('http://x', 'root', 'secret', { fetch: fetchFn as unknown as typeof fetch })) + .rejects.toThrow(/not v2/); + }); +}); + +describe('writePolicy', () => { + it('PUTs the HCL to /v1/sys/policies/acl/', async () => { + const fetchFn = mockFetch([{ match: /PUT .*\/v1\/sys\/policies\/acl\/app-mcpd$/, status: 200 }]); + await writePolicy('http://x', 'root', 'app-mcpd', 'path "x" {}', { fetch: fetchFn as unknown as typeof fetch }); + const [, init] = fetchFn.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe('PUT'); + const body = JSON.parse(init.body as string) as { policy: string }; + expect(body.policy).toContain('path "x"'); + }); +}); + +describe('ensureTokenRole', () => { + it('POSTs the role config with period + renewable', async () => { + const fetchFn = mockFetch([{ match: /POST .*\/v1\/auth\/token\/roles\/app-mcpd-role$/, status: 200 }]); + await ensureTokenRole('http://x', 'root', 'app-mcpd-role', { + allowedPolicies: ['app-mcpd'], + period: 720 * 3600, + renewable: true, + }, { fetch: fetchFn as unknown as typeof fetch }); + const [, init] = fetchFn.mock.calls[0] as [string, RequestInit]; + const sent = JSON.parse(init.body as string) as Record; + expect(sent.allowed_policies).toEqual(['app-mcpd']); + expect(sent.period).toBe(720 * 3600); + expect(sent.renewable).toBe(true); + expect(sent.orphan).toBe(false); + }); +}); + +describe('mintRoleToken', () => { + it('parses the auth block into a MintedToken', async () => { + const fetchFn = mockFetch([{ + match: /POST .*\/v1\/auth\/token\/create\/app-mcpd-role$/, + status: 200, + body: { auth: { client_token: 'hvs.CAE.xyz', accessor: 'acc-1', lease_duration: 2592000, renewable: true, policies: ['app-mcpd', 'default'] } }, + }]); + const m = await mintRoleToken('http://x', 'caller', 'app-mcpd-role', { fetch: fetchFn as unknown as typeof fetch }); + expect(m.clientToken).toBe('hvs.CAE.xyz'); + expect(m.accessor).toBe('acc-1'); + expect(m.leaseDuration).toBe(2592000); + expect(m.renewable).toBe(true); + expect(m.policies).toEqual(['app-mcpd', 'default']); + }); + + it('throws when the response is missing auth.client_token', async () => { + const fetchFn = mockFetch([{ match: /create\/r$/, status: 200, body: { auth: { accessor: 'acc' } } }]); + await expect(mintRoleToken('http://x', 'caller', 'r', { fetch: fetchFn as unknown as typeof fetch })) + .rejects.toThrow(/missing auth.client_token/); + }); +}); + +describe('revokeAccessor', () => { + it('swallows 400 (already revoked/unknown)', async () => { + const fetchFn = vi.fn(async () => new Response('{}', { status: 400 })); + await expect(revokeAccessor('http://x', 'caller', 'acc', { fetch: fetchFn as unknown as typeof fetch })) + .resolves.toBeUndefined(); + }); +}); + +describe('lookupSelf', () => { + it('extracts accessor + ttl from data block', async () => { + const fetchFn = mockFetch([{ + match: /lookup-self$/, + status: 200, + body: { data: { accessor: 'acc-7', ttl: 2591000, policies: ['app-mcpd'] } }, + }]); + const r = await lookupSelf('http://x', 'caller', { fetch: fetchFn as unknown as typeof fetch }); + expect(r).toEqual({ accessor: 'acc-7', ttl: 2591000, policies: ['app-mcpd'] }); + }); +}); + +describe('testWriteReadDelete', () => { + it('runs write→read→delete and succeeds on round-trip match', async () => { + const calls: string[] = []; + const fetchFn = vi.fn(async (url: string | URL, init?: RequestInit) => { + const u = String(url); + const m = init?.method ?? 'GET'; + calls.push(`${m} ${u}`); + if (m === 'POST') return new Response('{}', { status: 200 }); + if (m === 'GET') { + return new Response(JSON.stringify({ data: { data: { marker: 'mcpctl-smoke' } } }), { status: 200 }); + } + // DELETE + return new Response(null, { status: 200 }); + }); + await testWriteReadDelete('http://x', 'caller', 'secret', 'mcpd/smoke', { fetch: fetchFn as unknown as typeof fetch }); + expect(calls).toHaveLength(3); + expect(calls[0]).toMatch(/POST .*\/v1\/secret\/data\/mcpd\/smoke$/); + expect(calls[1]).toMatch(/GET .*\/v1\/secret\/data\/mcpd\/smoke$/); + expect(calls[2]).toMatch(/DELETE .*\/v1\/secret\/metadata\/mcpd\/smoke$/); + }); + + it('throws when read-back marker does not match', async () => { + const fetchFn = vi.fn(async (_u: string | URL, init?: RequestInit) => { + if ((init?.method ?? 'GET') === 'GET') { + return new Response(JSON.stringify({ data: { data: { marker: 'wrong' } } }), { status: 200 }); + } + return new Response('{}', { status: 200 }); + }); + await expect(testWriteReadDelete('http://x', 'c', 'secret', 'p', { fetch: fetchFn as unknown as typeof fetch })) + .rejects.toThrow(/didn't match written marker/); + }); +});