Compare commits

...

10 Commits

Author SHA1 Message Date
Michal
dd4246878d 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>
2026-04-20 17:20:37 +01:00
Michal
515206685b feat(openbao): kubernetes ServiceAccount auth — no static token in DB
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / test (push) Successful in 1m5s
CI/CD / typecheck (push) Successful in 2m8s
CI/CD / smoke (push) Failing after 3m38s
CI/CD / build (push) Successful in 4m15s
CI/CD / publish (push) Has been skipped
Why: requiring a static OpenBao root token to live (even once-bootstrap) on
the plaintext backend is the weakest link in the chain. With the bao-side
Kubernetes auth method enabled, mcpd's pod can authenticate using its own
projected SA token, exchange it for a short-lived Vault client token, and
keep the database free of any vault credentials at all.

Driver changes (src/mcpd/src/services/secret-backends/openbao.ts):
- New `OpenBaoConfig.auth = 'token' | 'kubernetes'`. Defaults to 'token' so
  existing rows keep working. Both shapes share url + mount + pathPrefix +
  namespace; auth-specific fields are mutually exclusive in the config schema.
- Kubernetes auth flow: read JWT from /var/run/secrets/.../token, POST to
  /v1/auth/<authMount>/login {role, jwt}, cache the returned client_token
  for `lease_duration - 60s` (grace window), then re-login.
- One-shot 403-retry: if a request comes back 403 (revoked / clock skew),
  purge cache and retry the original request once with a fresh login.
- Reads + writes go through the same getToken() path so token-auth is
  unchanged for existing deployments.

CLI (src/cli/src/commands/create.ts):
- `mcpctl create secretbackend bao --type openbao --auth kubernetes \
     --url https://bao.example:8200 --role mcpctl`
- Optional `--auth-mount` (default 'kubernetes') + `--sa-token-path` (default
  the standard projected-token path) for non-default deployments.
- Token-auth path unchanged: `--auth token --token-secret SECRET/KEY`
  (or omit `--auth` since 'token' is the default).

Validation (factory.ts) gates on the auth strategy: each path enforces its
own required fields and produces a clear error if misconfigured.

Tests: 6 new k8s-auth unit cases (login wire shape, lease-based caching,
custom authMount, 403-on-login, missing-role rejection, missing-tokenSecretRef
rejection). Full suite 1859/1859. Completions regenerated for the new flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:23:05 +01:00
Michal
a21220b6f6 fix(deploy): self-healing pre-migrate bootstrap for SecretBackend rollout
Some checks failed
CI/CD / typecheck (push) Successful in 51s
CI/CD / lint (push) Successful in 1m42s
CI/CD / test (push) Successful in 1m6s
CI/CD / smoke (push) Failing after 3m41s
CI/CD / build (push) Successful in 4m31s
CI/CD / publish (push) Has been skipped
Why: clusters upgrading from the pre-SecretBackend schema crash-loop on the
first rollout. `prisma db push` applies the Phase 0 migration as three
sequential steps — add Secret.backendId column (default ''), create
SecretBackend table, add FK — and the FK fails because empty-string values
reference no row in the empty SecretBackend table. This happened on the live
cluster today; I fixed it by hand with psql. This PR makes the fix
automatic so a fresh cluster or anyone replaying the migration doesn't hit
the same trap.

- New `src/db/src/scripts/pre-migrate-bootstrap.ts` — idempotent node script.
  Checks if SecretBackend table exists; if so, ensures a default row exists
  (insert on conflict noop), then backfills any Secret.backendId = '' to
  point at it. Uses Prisma raw queries so it runs against a partially-
  migrated schema.

- `deploy/entrypoint.sh` now catches a failed first push, runs the
  bootstrap, and retries. Fresh installs and fully-migrated clusters take
  the happy path (one push, no bootstrap needed). Pre-Phase-0 upgrades take
  the healing path (push fails → bootstrap seeds → retry succeeds).

- The bootstrap is deliberately non-fatal — even on unexpected errors it
  logs and exits 0 so the retry still runs. If that retry also fails, the
  push error surfaces normally and the pod crash-loops visibly rather than
  silently starting in a half-migrated state.

Verified the idempotent path logically: on the already-bootstrapped cluster
(1 backend row, 0 empty-backendId Secrets), the script's UPDATE matches
zero rows and the INSERT hits ON CONFLICT DO NOTHING — pure no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:59:07 +01:00
Michal
d5236171cc fix(smoke): use json output for llm apiKeyRef assertion
Some checks failed
CI/CD / lint (push) Successful in 51s
CI/CD / typecheck (push) Successful in 1m42s
CI/CD / test (push) Successful in 1m5s
CI/CD / smoke (push) Has started running
CI/CD / publish (push) Has been cancelled
CI/CD / build (push) Has been cancelled
The table KEY column truncates at ~34 chars so `secret://<name>/<key>` wasn't
appearing verbatim in stdout — the assertion was correct but brittle against
presentation choices. Switched to `-o json` where the ref round-trips as a
structured object, which is what actually matters.

Caught by the live-cluster smoke run right after Phase 0-4 rolled out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:55:39 +01:00
Michal
860033d3de fix(db): make Secret.backendId default to empty string for rollout migration
Some checks failed
CI/CD / typecheck (push) Successful in 53s
CI/CD / lint (push) Successful in 1m44s
CI/CD / test (push) Successful in 1m5s
CI/CD / smoke (push) Failing after 3m43s
CI/CD / build (push) Failing after 6m52s
CI/CD / publish (push) Has been skipped
Why: `prisma db push` refused to add the required `backendId` column on
clusters with pre-existing Secret rows — it can't assign NOT NULL without a
default, and the cluster DB had 9 live rows. The mcpd pod crash-looped
during the Phase 0 rollout because of this.

Empty-string default lets the schema apply cleanly; `bootstrapSecretBackends`
(which runs on every startup) then rewrites those empty values to the
seeded `default` plaintext backend's id. New writes via SecretService always
carry a real FK immediately, so the empty-string state only exists during
the one-shot migration window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 22:45:08 +01:00
e27a0e695e Merge pull request 'feat(project): Project.llmProvider as Llm reference' (#55) from feat/project-llm-ref into main
Some checks failed
CI/CD / lint (push) Successful in 52s
CI/CD / test (push) Successful in 1m4s
CI/CD / typecheck (push) Successful in 1m52s
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
2026-04-19 21:39:54 +00:00
2155910f1c Merge pull request 'feat(mcplocal): RBAC-bounded vllm-managed failover' (#54) from feat/llm-failover into main
Some checks failed
CI/CD / typecheck (push) Has been cancelled
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / lint (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-04-19 21:39:47 +00:00
d217eadd13 Merge pull request 'feat(mcpd): LLM inference proxy + OpenAI/Anthropic adapters' (#53) from feat/llm-infer into main
Some checks failed
CI/CD / lint (push) Has started running
CI/CD / typecheck (push) Has started running
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-04-19 21:39:39 +00:00
9e3507752f Merge pull request 'feat(mcpd): Llm resource — CRUD + CLI + apply' (#52) from feat/llm into main
Some checks failed
CI/CD / lint (push) Has started running
CI/CD / typecheck (push) Has been cancelled
CI/CD / test (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-04-19 21:39:27 +00:00
97ac1e75ef Merge pull request 'feat(mcpd): pluggable SecretBackend + OpenBao driver + migrate' (#51) from feat/secretbackend into main
Some checks failed
CI/CD / lint (push) Has started running
CI/CD / test (push) Has been cancelled
CI/CD / typecheck (push) Has been cancelled
CI/CD / smoke (push) Has been cancelled
CI/CD / build (push) Has been cancelled
CI/CD / publish (push) Has been cancelled
2026-04-19 21:39:17 +00:00
28 changed files with 2177 additions and 38 deletions

View File

@@ -5,7 +5,7 @@ _mcpctl() {
local cur prev words cword local cur prev words cword
_init_completion || return _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 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 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" 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")) COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --force -h --help" -- "$cur"))
;; ;;
secretbackend) secretbackend)
COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --token-secret --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) project)
COMPREPLY=($(compgen -W "-d --description --proxy-model --prompt --llm --llm-model --gated --no-gated --server --force -h --help" -- "$cur")) 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 esac
fi fi
return ;; 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) help)
COMPREPLY=($(compgen -W "$commands" -- "$cur")) COMPREPLY=($(compgen -W "$commands" -- "$cur"))
return ;; return ;;

View File

@@ -4,7 +4,7 @@
# Erase any stale completions from previous versions # Erase any stale completions from previous versions
complete -c mcpctl -e 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 set -l project_commands get describe delete logs create edit attach-server detach-server
# Disable file completions by default # 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 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 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 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) # 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)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (servers, projects, instances, all)'
@@ -336,8 +337,17 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l url -d 'o
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l namespace -d 'openbao: X-Vault-Namespace header value' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l namespace -d 'openbao: X-Vault-Namespace header value' -x
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l mount -d 'openbao: KV v2 mount point (default: secret)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l mount -d 'openbao: KV v2 mount point (default: secret)' -x
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l path-prefix -d 'openbao: path prefix under mount (default: mcpctl)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l path-prefix -d 'openbao: path prefix under mount (default: mcpctl)' -x
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l token-secret -d 'openbao: token secret reference in SECRET/KEY form (e.g. bao-creds/token)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l auth -d 'openbao: auth method — \'token\' (default) or \'kubernetes\'' -x
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l token-secret -d 'openbao token auth: token secret reference in SECRET/KEY form (e.g. bao-creds/token)' -x
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l role -d 'openbao kubernetes auth: vault role to login as (e.g. \'mcpctl\')' -x
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 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' complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l force -d 'Update if already exists'
# create project options # create project options
@@ -431,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 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' 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 # status options
complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x

View File

@@ -1,8 +1,23 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Self-healing schema push:
# 1. Try once — for fresh installs and already-migrated clusters this is all
# that's needed.
# 2. On failure (typically a Phase 0 upgrade where the new SecretBackend FK
# can't attach because pre-existing Secret rows reference nothing), run
# the pre-migrate bootstrap to seed a default SecretBackend + backfill
# Secret.backendId, then retry.
# 3. If the retry still fails, let the error surface so the pod crashes
# visibly rather than starting in a half-migrated state.
echo "mcpd: pushing database schema..." echo "mcpd: pushing database schema..."
pnpm -F @mcpctl/db exec prisma db push --schema=prisma/schema.prisma --accept-data-loss 2>&1 if pnpm -F @mcpctl/db exec prisma db push --schema=prisma/schema.prisma --accept-data-loss 2>&1; then
:
else
echo "mcpd: schema push failed — running pre-migrate bootstrap + retrying..."
node src/db/dist/scripts/pre-migrate-bootstrap.js || true
pnpm -F @mcpctl/db exec prisma db push --schema=prisma/schema.prisma --accept-data-loss 2>&1
fi
echo "mcpd: seeding templates..." echo "mcpd: seeding templates..."
TEMPLATES_DIR=templates node src/mcpd/dist/seed-runner.js TEMPLATES_DIR=templates node src/mcpd/dist/seed-runner.js

View File

@@ -153,7 +153,7 @@ async function defaultConfirm(message: string, defaultValue?: boolean): Promise<
return answer as boolean; return answer as boolean;
} }
const defaultPrompt: ConfigSetupPrompt = { export const defaultPrompt: ConfigSetupPrompt = {
select: defaultSelect, select: defaultSelect,
input: defaultInput, input: defaultInput,
password: defaultPassword, password: defaultPassword,

View File

@@ -0,0 +1,231 @@
/**
* Interactive wizard that provisions an OpenBao backend end-to-end:
*
* 1. Asks the user for the OpenBao URL + admin/root token.
* 2. Verifies connectivity (`/sys/health`).
* 3. Ensures KV v2 is mounted at `<mount>/`.
* 4. Writes policy `app-mcpd` scoped to `<mount>/{data,metadata}/<prefix>/*`
* plus the self-rotation paths.
* 5. Ensures a token role `app-mcpd-role` with `period=720h, renewable=true`.
* 6. Mints the first periodic token via that role.
* 7. Stores the token as a plaintext `Secret` on mcpd.
* 8. Creates the `SecretBackend` row with rotation config pointing at the role.
* 9. Kicks an initial rotate via `POST /api/v1/secretbackends/:id/rotate`
* to seed `tokenMeta` + prove the self-rotation policy works.
* 10. (Optional) promotes the new backend to default.
* 11. Prints the migration command for the user to run.
*
* Admin token is used only for steps 26 and is never persisted.
*
* All prompts go through `ConfigSetupPrompt` (from `config-setup.ts`) so the
* wizard is testable without real stdin.
*/
import type { ApiClient } from '../api-client.js';
import {
verifyHealth,
ensureKvV2,
writePolicy,
ensureTokenRole,
mintRoleToken,
testWriteReadDelete,
buildAppMcpdPolicyHcl,
type VaultDeps,
} from '@mcpctl/shared';
import { type ConfigSetupPrompt, defaultPrompt } from './config-setup.js';
export interface WizardDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
prompt?: ConfigSetupPrompt;
/** Overridable for tests. Forwarded to all vault HTTP calls. */
fetch?: typeof globalThis.fetch;
}
export interface WizardInput {
/** Backend name. Required — supplied via `mcpctl create secretbackend <name> --wizard`. */
name: string;
/** Pre-filled via flags for CI; falls back to prompt. */
url?: string | undefined;
adminToken?: string | undefined;
mount?: string | undefined;
pathPrefix?: string | undefined;
policyName?: string | undefined;
tokenRole?: string | undefined;
promoteToDefault?: boolean | undefined;
/** If set, skip the test write/read/delete (for dev/debugging only). */
skipSmoke?: boolean | undefined;
}
export async function runSecretBackendOpenbaoWizard(
input: WizardInput,
deps: WizardDeps,
): Promise<void> {
const prompt = deps.prompt ?? defaultPrompt;
const log = deps.log;
const url = input.url ?? await prompt.input('OpenBao URL', 'https://bao.ad.itaz.eu');
const adminToken = input.adminToken ?? await prompt.password('OpenBao admin / root token');
if (adminToken === '') throw new Error('admin token is required');
const vaultDeps: VaultDeps = {};
if (deps.fetch !== undefined) vaultDeps.fetch = deps.fetch;
// 1. Health check.
log(' → checking OpenBao health …');
const health = await verifyHealth(url, adminToken, vaultDeps);
if (!health.initialized || health.sealed) {
throw new Error(`OpenBao is not ready (initialized=${String(health.initialized)}, sealed=${String(health.sealed)})`);
}
log(` ok (version ${health.version})`);
const mount = input.mount ?? await prompt.input('KV v2 mount', 'secret');
const pathPrefix = input.pathPrefix ?? await prompt.input('Path prefix under mount', 'mcpd');
const policyName = input.policyName ?? await prompt.input('Policy name', 'app-mcpd');
const tokenRole = input.tokenRole ?? await prompt.input('Token role name', 'app-mcpd-role');
// 2. Enable KV v2 if needed.
log(` → ensuring KV v2 at ${mount}/ …`);
const created = await ensureKvV2(url, adminToken, mount, vaultDeps);
log(` ${created ? 'mounted' : 'already mounted'}`);
// 3. Write policy.
log(` → writing policy '${policyName}' …`);
const hcl = buildAppMcpdPolicyHcl({ mount, pathPrefix, tokenRole });
await writePolicy(url, adminToken, policyName, hcl, vaultDeps);
log(` written (scope: ${mount}/{data,metadata}/${pathPrefix}/* + self-rotation paths)`);
// 4. Ensure token role.
log(` → ensuring token role '${tokenRole}' (period=720h, renewable) …`);
await ensureTokenRole(url, adminToken, tokenRole, {
allowedPolicies: [policyName],
period: 720 * 3600,
renewable: true,
orphan: false,
}, vaultDeps);
log(' ok');
// 5. Mint the first periodic token using the admin token.
log(' → minting first periodic token …');
const minted = await mintRoleToken(url, adminToken, tokenRole, vaultDeps);
if (!minted.renewable) {
throw new Error(`minted token is not renewable — the role '${tokenRole}' config is wrong`);
}
log(` minted (accessor ${minted.accessor.slice(0, 12)}…)`);
// 6. Smoke test with the minted token before committing to mcpd.
if (input.skipSmoke !== true) {
log(' → smoke-testing write/read/delete with the minted token …');
await testWriteReadDelete(url, minted.clientToken, mount, `${pathPrefix}/.__mcpctl_wizard_smoke__`, vaultDeps);
log(' ok');
}
// 7. Store token on mcpd as a plaintext Secret.
const credsSecretName = `${input.name}-creds`;
log(` → creating Secret '${credsSecretName}' on mcpd (plaintext) …`);
await createSecret(deps.client, credsSecretName, { token: minted.clientToken });
// 8. Create SecretBackend row (non-default by default; promote later).
log(` → creating SecretBackend '${input.name}' …`);
const backendBody = {
name: input.name,
type: 'openbao',
config: {
url,
auth: 'token',
mount,
pathPrefix,
tokenSecretRef: { name: credsSecretName, key: 'token' },
rotation: {
enabled: true,
tokenRole,
intervalHours: 24,
},
},
};
const backend = await deps.client.post<{ id: string; name: string }>('/api/v1/secretbackends', backendBody);
log(` created (id: ${backend.id})`);
// 9. Kick initial rotation so tokenMeta is populated + self-rotation is proven.
// This uses the FIRST token (just-minted) to mint its successor. The old
// first token is then revoked by accessor.
log(' → running initial rotation (seeds tokenMeta) …');
try {
await deps.client.post(`/api/v1/secretbackends/${backend.id}/rotate`, {});
log(' rotated — tokenMeta populated');
} catch (err) {
log(` warn: initial rotation failed: ${err instanceof Error ? err.message : String(err)}`);
log(' backend is still usable; rotation will retry on the 24h loop');
}
// 10. Optional promote.
const promote = input.promoteToDefault
?? await prompt.confirm(`Promote '${input.name}' to default backend?`, true);
if (promote) {
await deps.client.post(`/api/v1/secretbackends/${backend.id}/default`, {});
log(` promoted '${input.name}' to default`);
}
// 11. Migration hint.
log('');
await printMigrationHint(deps.client, input.name, log);
log('');
log(`Describe the new backend: mcpctl --direct describe secretbackend ${input.name}`);
log(`Force a rotation manually: mcpctl --direct rotate secretbackend ${input.name}`);
}
async function createSecret(
client: ApiClient,
name: string,
data: Record<string, string>,
): Promise<void> {
try {
await client.post('/api/v1/secrets', { name, data });
} catch (err) {
// 409 → secret already exists with this name. Update its data instead so
// re-running the wizard with the same --name is idempotent.
const status = (err as { status?: number }).status;
if (status !== 409) throw err;
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secrets'))
.find((s) => s.name === name);
if (existing === undefined) throw err;
await client.put(`/api/v1/secrets/${existing.id}`, { data });
}
}
async function printMigrationHint(
client: ApiClient,
newBackendName: string,
log: (...args: unknown[]) => void,
): Promise<void> {
// Find the current default backend name (likely 'default') so the hint
// points at a real source.
let defaultName = 'default';
try {
const rows = await client.get<Array<{ name: string; isDefault: boolean }>>('/api/v1/secretbackends');
const d = rows.find((r) => r.isDefault);
if (d !== undefined && d.name !== newBackendName) defaultName = d.name;
} catch {
/* fall through with 'default' guess */
}
// Count candidate secrets.
try {
const body = await client.post<{ candidates: Array<{ name: string }> }>(
'/api/v1/secrets/migrate',
{ from: defaultName, to: newBackendName, dryRun: true },
);
const n = body.candidates.length;
if (n === 0) {
log(`No secrets to migrate — '${defaultName}' is empty.`);
return;
}
log(`You have ${String(n)} secret(s) on '${defaultName}'. To migrate them to '${newBackendName}':`);
log('');
log(` mcpctl --direct migrate secrets --from ${defaultName} --to ${newBackendName} --dry-run`);
log(` mcpctl --direct migrate secrets --from ${defaultName} --to ${newBackendName}`);
} catch (err) {
log(`(could not dry-run migration: ${err instanceof Error ? err.message : String(err)})`);
log(`Manual command: mcpctl --direct migrate secrets --from ${defaultName} --to ${newBackendName}`);
}
}

View File

@@ -319,23 +319,64 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
.option('--namespace <ns>', 'openbao: X-Vault-Namespace header value') .option('--namespace <ns>', 'openbao: X-Vault-Namespace header value')
.option('--mount <mount>', 'openbao: KV v2 mount point (default: secret)') .option('--mount <mount>', 'openbao: KV v2 mount point (default: secret)')
.option('--path-prefix <prefix>', 'openbao: path prefix under mount (default: mcpctl)') .option('--path-prefix <prefix>', 'openbao: path prefix under mount (default: mcpctl)')
.option('--token-secret <ref>', 'openbao: token secret reference in SECRET/KEY form (e.g. bao-creds/token)') .option('--auth <method>', "openbao: auth method — 'token' (default) or 'kubernetes'")
.option('--token-secret <ref>', 'openbao token auth: token secret reference in SECRET/KEY form (e.g. bao-creds/token)')
.option('--role <name>', "openbao kubernetes auth: vault role to login as (e.g. 'mcpctl')")
.option('--auth-mount <path>', "openbao kubernetes auth: vault auth method mount path (default: 'kubernetes')")
.option('--sa-token-path <path>', "openbao kubernetes auth: filesystem path to projected SA token (default: '/var/run/secrets/kubernetes.io/serviceaccount/token')")
.option('--config <entry>', 'Extra config as key=value (repeat for multiple)', collect, []) .option('--config <entry>', 'Extra config as key=value (repeat for multiple)', collect, [])
.option('--wizard', 'Interactive wizard (openbao only): provision policy + token role, mint token, store on mcpd, suggest migration')
.option('--admin-token <token>', "openbao wizard: OpenBao admin/root token (prompted if omitted). Used only for provisioning; NEVER persisted.")
.option('--policy-name <name>', "openbao wizard: name for the policy created on OpenBao (default: 'app-mcpd')")
.option('--token-role <name>', "openbao wizard: name for the token role created on OpenBao (default: 'app-mcpd-role')")
.option('--no-promote-default', 'openbao wizard: do not promote this backend to default after creation')
.option('--force', 'Update if already exists') .option('--force', 'Update if already exists')
.action(async (name: string, opts) => { .action(async (name: string, opts) => {
const type = opts.type as string; const type = opts.type as string;
// Wizard path — delegates to create-secretbackend-wizard.ts.
if (opts.wizard === true) {
if (type !== 'openbao') {
throw new Error(`--wizard is only supported for --type openbao (got '${type}')`);
}
const { runSecretBackendOpenbaoWizard } = await import('./create-secretbackend-wizard.js');
const wizardInput: Parameters<typeof runSecretBackendOpenbaoWizard>[0] = { name };
if (opts.url !== undefined) wizardInput.url = opts.url as string;
if (opts.adminToken !== undefined) wizardInput.adminToken = opts.adminToken as string;
if (opts.mount !== undefined) wizardInput.mount = opts.mount as string;
if (opts.pathPrefix !== undefined) wizardInput.pathPrefix = opts.pathPrefix as string;
if (opts.policyName !== undefined) wizardInput.policyName = opts.policyName as string;
if (opts.tokenRole !== undefined) wizardInput.tokenRole = opts.tokenRole as string;
// `--no-promote-default` → opts.promoteDefault === false (commander negated flag)
if (opts.promoteDefault !== undefined) wizardInput.promoteToDefault = opts.promoteDefault as boolean;
await runSecretBackendOpenbaoWizard(wizardInput, { client, log });
return;
}
const config: Record<string, unknown> = {}; const config: Record<string, unknown> = {};
if (type === 'openbao') { if (type === 'openbao') {
if (!opts.url) throw new Error('--url is required for openbao backend'); if (!opts.url) throw new Error('--url is required for openbao backend');
if (!opts.tokenSecret) throw new Error('--token-secret is required for openbao backend (format: SECRET/KEY)'); const auth = (opts.auth as string | undefined) ?? 'token';
const slashIdx = (opts.tokenSecret as string).indexOf('/'); if (auth !== 'token' && auth !== 'kubernetes') {
if (slashIdx < 1) throw new Error(`Invalid --token-secret '${opts.tokenSecret as string}'. Expected SECRET_NAME/KEY_NAME`); throw new Error(`--auth must be 'token' or 'kubernetes' (got '${auth}')`);
}
config.url = opts.url; config.url = opts.url;
config.tokenSecretRef = { config.auth = auth;
name: (opts.tokenSecret as string).slice(0, slashIdx),
key: (opts.tokenSecret as string).slice(slashIdx + 1), if (auth === 'token') {
}; if (!opts.tokenSecret) throw new Error('--token-secret is required for openbao token auth (format: SECRET/KEY)');
const slashIdx = (opts.tokenSecret as string).indexOf('/');
if (slashIdx < 1) throw new Error(`Invalid --token-secret '${opts.tokenSecret as string}'. Expected SECRET_NAME/KEY_NAME`);
config.tokenSecretRef = {
name: (opts.tokenSecret as string).slice(0, slashIdx),
key: (opts.tokenSecret as string).slice(slashIdx + 1),
};
} else {
if (!opts.role) throw new Error("--role is required for openbao kubernetes auth (the vault role bound to this pod's ServiceAccount)");
config.role = opts.role;
if (opts.authMount) config.authMount = opts.authMount;
if (opts.saTokenPath) config.serviceAccountTokenPath = opts.saTokenPath;
}
if (opts.namespace) config.namespace = opts.namespace; if (opts.namespace) config.namespace = opts.namespace;
if (opts.mount) config.mount = opts.mount; if (opts.mount) config.mount = opts.mount;
if (opts.pathPrefix) config.pathPrefix = opts.pathPrefix; if (opts.pathPrefix) config.pathPrefix = opts.pathPrefix;

View File

@@ -297,6 +297,12 @@ function formatSecretBackendDetail(backend: Record<string, unknown>): string {
} }
} }
const tokenMeta = (backend.tokenMeta ?? {}) as Record<string, unknown>;
if (tokenMeta.rotatable === true) {
lines.push('');
lines.push(...formatTokenHealth(tokenMeta));
}
lines.push(''); lines.push('');
lines.push('Metadata:'); lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${backend.id}`); lines.push(` ${pad('ID:', 12)}${backend.id}`);
@@ -306,6 +312,66 @@ function formatSecretBackendDetail(backend: Record<string, unknown>): string {
return lines.join('\n'); return lines.join('\n');
} }
/**
* Render the Token health section for a wizard-provisioned openbao backend.
* Returns an array of lines (caller pushes them). Stale = no successful
* rotation in >26h (2h grace over the nominal 24h cadence).
*/
function formatTokenHealth(meta: Record<string, unknown>): string[] {
const lines: string[] = [];
const generatedAt = parseIso(meta.generatedAt);
const nextRenewalAt = parseIso(meta.nextRenewalAt);
const validUntil = parseIso(meta.validUntil);
const lastRotationAt = parseIso(meta.lastRotationAt);
const lastError = meta.lastRotationError as string | null | undefined;
const now = Date.now();
const STALE_GRACE_MS = 26 * 3600 * 1000;
const staleByAge = lastRotationAt !== null && (now - lastRotationAt.getTime()) > STALE_GRACE_MS;
const hasError = typeof lastError === 'string' && lastError !== '';
let status: string;
if (hasError && staleByAge) status = 'ERROR (stale)';
else if (staleByAge) status = 'STALE — no successful rotation in the last cycle';
else if (hasError) status = 'WARNING — last rotation hit an error but token is still fresh';
else status = 'healthy';
lines.push(`Token health: ${status}`);
if (generatedAt !== null) {
lines.push(` ${pad('Generated:', 16)}${generatedAt.toISOString()}${describeAge(generatedAt, now)}`);
}
if (nextRenewalAt !== null) {
lines.push(` ${pad('Next renewal:', 16)}${nextRenewalAt.toISOString()}${describeAge(nextRenewalAt, now)}`);
}
if (validUntil !== null) {
lines.push(` ${pad('Valid until:', 16)}${validUntil.toISOString()}${describeAge(validUntil, now)}`);
}
if (lastRotationAt !== null) {
lines.push(` ${pad('Last rotation:', 16)}${lastRotationAt.toISOString()}${describeAge(lastRotationAt, now)}`);
}
if (hasError) {
lines.push(` ${pad('Last error:', 16)}${lastError}`);
}
return lines;
}
function parseIso(v: unknown): Date | null {
if (typeof v !== 'string' || v === '') return null;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d;
}
function describeAge(target: Date, now: number): string {
const diffMs = target.getTime() - now;
const abs = Math.abs(diffMs);
const hours = Math.round(abs / 3600_000);
const days = Math.round(abs / 86_400_000);
if (abs < 60_000) return ' (just now)';
if (abs < 3600_000) return ` (${String(Math.round(abs / 60_000))} min ${diffMs < 0 ? 'ago' : 'away'})`;
if (hours < 48) return ` (${String(hours)}h ${diffMs < 0 ? 'ago' : 'away'})`;
return ` (${String(days)}d ${diffMs < 0 ? 'ago' : 'away'})`;
}
function formatTemplateDetail(template: Record<string, unknown>): string { function formatTemplateDetail(template: Record<string, unknown>): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`=== Template: ${template.name} ===`); lines.push(`=== Template: ${template.name} ===`);

View File

@@ -0,0 +1,50 @@
/**
* `mcpctl rotate secretbackend <name>` — force an immediate token rotation on
* a wizard-provisioned OpenBao backend.
*
* Hits `POST /api/v1/secretbackends/:id/rotate` after resolving name → id.
* Gated server-side by the `rotate-secretbackend` operation.
*/
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveNameOrId } from './shared.js';
export interface RotateCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createRotateCommand(deps: RotateCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('rotate')
.description('Force rotation of a credential-rotating resource (currently: secretbackend)');
cmd.command('secretbackend')
.alias('sb')
.description('Rotate the vault token on an OpenBao SecretBackend (wizard-provisioned)')
.argument('<name>', 'SecretBackend name or id')
.action(async (nameOrId: string) => {
const id = await resolveNameOrId(client, 'secretbackends', nameOrId);
const res = await client.post<{ ok?: boolean; tokenMeta?: Record<string, unknown>; error?: string }>(
`/api/v1/secretbackends/${id}/rotate`,
{},
);
if (res.ok !== true) {
throw new Error(`rotation failed: ${res.error ?? 'unknown error'}`);
}
log(`secretbackend '${nameOrId}' rotated.`);
const meta = res.tokenMeta ?? {};
if (typeof meta.generatedAt === 'string') {
log(` generated: ${meta.generatedAt}`);
}
if (typeof meta.nextRenewalAt === 'string') {
log(` next renewal: ${meta.nextRenewalAt}`);
}
if (typeof meta.validUntil === 'string') {
log(` valid until: ${meta.validUntil}`);
}
});
return cmd;
}

View File

@@ -19,6 +19,7 @@ import { createPatchCommand } from './commands/patch.js';
import { createConsoleCommand } from './commands/console/index.js'; import { createConsoleCommand } from './commands/console/index.js';
import { createCacheCommand } from './commands/cache.js'; import { createCacheCommand } from './commands/cache.js';
import { createMigrateCommand } from './commands/migrate.js'; import { createMigrateCommand } from './commands/migrate.js';
import { createRotateCommand } from './commands/rotate.js';
import { ApiClient, ApiError } from './api-client.js'; import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js'; import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js'; import { loadCredentials } from './auth/index.js';
@@ -255,6 +256,11 @@ export function createProgram(): Command {
log: (...args) => console.log(...args), log: (...args) => console.log(...args),
})); }));
program.addCommand(createRotateCommand({
client,
log: (...args) => console.log(...args),
}));
return program; return program;
} }

View File

@@ -0,0 +1,150 @@
import { describe, it, expect, vi } from 'vitest';
import { runSecretBackendOpenbaoWizard } from '../../src/commands/create-secretbackend-wizard.js';
import type { ApiClient } from '../../src/api-client.js';
import type { ConfigSetupPrompt } from '../../src/commands/config-setup.js';
function mockClient(handlers: Record<string, (body?: unknown) => unknown>): ApiClient {
const call = (method: 'GET' | 'POST' | 'PUT' | 'DELETE') => async (path: string, body?: unknown) => {
const handler = handlers[`${method} ${path}`] ?? handlers[path];
if (handler === undefined) throw new Error(`unmocked ${method} ${path}`);
return handler(body);
};
return {
get: call('GET'),
post: call('POST'),
put: call('PUT'),
delete: call('DELETE'),
} as unknown as ApiClient;
}
function vaultFetch(responses: Array<{ match: RegExp; status: number; body?: unknown }>): ReturnType<typeof vi.fn> {
return vi.fn(async (url: string | URL, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${String(url)}`;
const match = responses.find((r) => r.match.test(key) || r.match.test(String(url)));
if (!match) throw new Error(`unexpected vault fetch: ${key}`);
const body = match.body !== undefined ? JSON.stringify(match.body) : '';
return new Response(body, { status: match.status });
});
}
function scriptedPrompt(answers: {
input?: Record<string, string>;
password?: Record<string, string>;
confirm?: Record<string, boolean>;
}): ConfigSetupPrompt {
return {
async input(message, def) {
return answers.input?.[message] ?? def ?? '';
},
async password(message) {
return answers.password?.[message] ?? '';
},
async confirm(message, def) {
return answers.confirm?.[message] ?? def ?? true;
},
select: vi.fn(),
};
}
describe('runSecretBackendOpenbaoWizard', () => {
it('walks through provisioning and creates Secret + SecretBackend + triggers initial rotate', async () => {
const logs: string[] = [];
const log = (...args: unknown[]) => logs.push(args.map(String).join(' '));
const vaultResponses = [
{ match: /GET .*\/v1\/sys\/health$/, status: 200, body: { initialized: true, sealed: false, standby: false, version: '2.5.2' } },
{ match: /GET .*\/v1\/sys\/mounts$/, status: 200, body: { 'secret/': { type: 'kv', options: { version: '2' } } } },
{ match: /PUT .*\/v1\/sys\/policies\/acl\/app-mcpd$/, status: 200 },
{ match: /POST .*\/v1\/auth\/token\/roles\/app-mcpd-role$/, status: 200 },
{ match: /POST .*\/v1\/auth\/token\/create\/app-mcpd-role$/, status: 200, body: { auth: { client_token: 'hvs.AAA', accessor: 'acc-first', lease_duration: 2592000, renewable: true } } },
// smoke test: write / read / delete
{ match: /POST .*\/v1\/secret\/data\/mcpd\/\.__mcpctl_wizard_smoke__$/, status: 200 },
{ match: /GET .*\/v1\/secret\/data\/mcpd\/\.__mcpctl_wizard_smoke__$/, status: 200, body: { data: { data: { marker: 'mcpctl-smoke' } } } },
{ match: /DELETE .*\/v1\/secret\/metadata\/mcpd\/\.__mcpctl_wizard_smoke__$/, status: 200 },
];
const fetchFn = vaultFetch(vaultResponses);
const created: Record<string, unknown> = {};
const client = mockClient({
'POST /api/v1/secrets': (body) => { created.secret = body; return { id: 'sec-new', name: (body as { name: string }).name }; },
'POST /api/v1/secretbackends': (body) => { created.backend = body; return { id: 'backend-new', name: (body as { name: string }).name }; },
'POST /api/v1/secretbackends/backend-new/rotate': () => ({ ok: true, tokenMeta: { generatedAt: 'now' } }),
'POST /api/v1/secretbackends/backend-new/default': () => ({ id: 'backend-new' }),
'GET /api/v1/secretbackends': () => [{ name: 'default', isDefault: true }],
'POST /api/v1/secrets/migrate': () => ({ dryRun: true, candidates: [{ id: 's1', name: 'grafana-creds' }, { id: 's2', name: 'unifi-creds' }] }),
});
const prompt = scriptedPrompt({
input: {
'OpenBao URL': 'http://bao.example:8200',
'KV v2 mount': 'secret',
'Path prefix under mount': 'mcpd',
'Policy name': 'app-mcpd',
'Token role name': 'app-mcpd-role',
},
password: {
'OpenBao admin / root token': 'root.admin.token',
},
confirm: {
"Promote 'bao' to default backend?": true,
},
});
await runSecretBackendOpenbaoWizard(
{ name: 'bao' },
{ client, log, prompt, fetch: fetchFn as unknown as typeof fetch },
);
// Admin token used for the provisioning calls (first 5 vault requests)
const firstCallInit = fetchFn.mock.calls[0]![1] as RequestInit;
expect((firstCallInit.headers as Record<string, string>)['X-Vault-Token']).toBe('root.admin.token');
// Secret was created with the minted token value (hvs.AAA), not the admin token
expect(created.secret).toMatchObject({ name: 'bao-creds', data: { token: 'hvs.AAA' } });
// SecretBackend created with rotation config
expect(created.backend).toMatchObject({
name: 'bao',
type: 'openbao',
config: expect.objectContaining({
url: 'http://bao.example:8200',
auth: 'token',
tokenSecretRef: { name: 'bao-creds', key: 'token' },
rotation: expect.objectContaining({ enabled: true, tokenRole: 'app-mcpd-role' }),
}),
});
// Migration hint mentions both candidate count + the concrete command
const fullLog = logs.join('\n');
expect(fullLog).toContain("You have 2 secret(s) on 'default'");
expect(fullLog).toContain('mcpctl --direct migrate secrets --from default --to bao');
// Admin token never appears in the log (critical)
expect(fullLog).not.toContain('root.admin.token');
});
it('rejects when admin token is empty', async () => {
const prompt = scriptedPrompt({
input: { 'OpenBao URL': 'http://x' },
password: { 'OpenBao admin / root token': '' },
});
await expect(runSecretBackendOpenbaoWizard(
{ name: 'bao' },
{ client: mockClient({}), log: () => {}, prompt, fetch: vi.fn() as unknown as typeof fetch },
)).rejects.toThrow(/admin token is required/);
});
it('rejects when vault is sealed', async () => {
const fetchFn = vaultFetch([
{ match: /\/sys\/health$/, status: 200, body: { initialized: true, sealed: true, standby: false, version: '2.5.2' } },
]);
const prompt = scriptedPrompt({
input: { 'OpenBao URL': 'http://x' },
password: { 'OpenBao admin / root token': 't' },
});
await expect(runSecretBackendOpenbaoWizard(
{ name: 'bao' },
{ client: mockClient({}), log: () => {}, prompt, fetch: fetchFn as unknown as typeof fetch },
)).rejects.toThrow(/not ready/);
});
});

View File

@@ -125,6 +125,12 @@ model SecretBackend {
name String @unique name String @unique
type String // plaintext | openbao | (future: vault, aws-sm, ...) type String // plaintext | openbao | (future: vault, aws-sm, ...)
config Json @default("{}") // type-specific: url, mount, namespace, tokenSecretRef 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 isDefault Boolean @default(false) // exactly one row has isDefault=true
description String @default("") description String @default("")
version Int @default(1) version Int @default(1)
@@ -142,7 +148,12 @@ model SecretBackend {
model Secret { model Secret {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
backendId String // FK to SecretBackend — dispatches read/write // FK to SecretBackend. Default empty string lets `prisma db push` add the
// column to pre-existing rows without a data-loss reset; `bootstrapSecretBackends`
// then points any empty-string values at the seeded `default` plaintext backend
// on next mcpd startup. New rows written by SecretService always carry a
// valid FK immediately.
backendId String @default("")
data Json @default("{}") // populated by plaintext backend only data Json @default("{}") // populated by plaintext backend only
externalRef String @default("") // populated by non-plaintext backends (e.g. "mount/path#v3") externalRef String @default("") // populated by non-plaintext backends (e.g. "mount/path#v3")
version Int @default(1) version Int @default(1)

View File

@@ -0,0 +1,105 @@
/**
* Self-healing pre-migration step for the SecretBackend rollout (Phase 0).
*
* Why this exists: `prisma db push` applies schema changes sequentially. When
* a cluster upgrades from a pre-SecretBackend DB:
* 1. `Secret.backendId` column is added with `DEFAULT ''`
* 2. `SecretBackend` table is created (empty)
* 3. The FK `Secret.backendId → SecretBackend.id` is added — and FAILS
* because every Secret row now has `backendId = ''` which references no
* row in SecretBackend.
*
* This script runs AFTER a failed `prisma db push` attempt:
* - If SecretBackend table doesn't exist yet → noop (fresh install case;
* db push will create everything and the FK succeeds because there are
* no Secret rows to violate it).
* - If SecretBackend exists but is empty → insert a default plaintext row.
* - If any Secret rows have `backendId = ''` → point them at the default.
*
* Idempotent: safe to run multiple times. No-op on a fully-migrated cluster.
* Never throws; logs and exits 0 even on errors so the subsequent
* `prisma db push` retry is still attempted.
*/
import { PrismaClient, Prisma } from '@prisma/client';
const DEFAULT_ID = 'cdefault000backend00000001';
async function main(): Promise<void> {
const prisma = new PrismaClient();
try {
// Does the SecretBackend table exist yet? We check by querying the
// information_schema rather than catching Prisma's error — cleaner, and
// lets us distinguish "table missing" from "query succeeded but empty".
const tableExists = await prisma.$queryRaw<Array<{ exists: boolean }>>`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'SecretBackend'
) AS exists
`;
if (!tableExists[0]?.exists) {
console.log('bootstrap: SecretBackend table not present yet — skipping');
return;
}
// Ensure at least one row exists, marked isDefault.
const existingDefault = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT id FROM "SecretBackend" WHERE "isDefault" = true LIMIT 1
`;
let defaultId: string;
if (existingDefault.length === 0) {
await prisma.$executeRaw`
INSERT INTO "SecretBackend"
("id", "name", "type", "config", "isDefault", "description", "version", "createdAt", "updatedAt")
VALUES (
${DEFAULT_ID},
'default',
'plaintext',
'{}'::jsonb,
true,
'Default in-database plaintext backend. Seeded by pre-migrate-bootstrap.',
1,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT (name) DO NOTHING
`;
// Re-read — if there was an existing row with the same name but no
// isDefault flag we need its id, not the one we tried to insert.
const afterInsert = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT id FROM "SecretBackend" WHERE name = 'default' LIMIT 1
`;
if (afterInsert.length === 0) {
console.log('bootstrap: could not establish a default SecretBackend — bailing');
return;
}
defaultId = afterInsert[0]!.id;
// Make sure it's flagged default.
await prisma.$executeRaw`
UPDATE "SecretBackend" SET "isDefault" = true WHERE id = ${defaultId}
`;
console.log(`bootstrap: seeded default SecretBackend (id=${defaultId})`);
} else {
defaultId = existingDefault[0]!.id;
}
// Backfill Secret.backendId for any rows left with an empty value.
// Using $executeRaw returns affected row count.
const updated = await prisma.$executeRaw(
Prisma.sql`UPDATE "Secret" SET "backendId" = ${defaultId} WHERE "backendId" = ''`,
);
if (updated > 0) {
console.log(`bootstrap: backfilled ${updated} Secret row(s) with default backendId`);
}
} catch (err) {
// Never fail the deploy — worst case prisma db push tries again anyway.
// Log the error so it's visible in pod logs.
console.error('bootstrap: non-fatal error:', err instanceof Error ? err.message : err);
} finally {
await prisma.$disconnect();
}
}
main().catch((err: unknown) => {
console.error('bootstrap: fatal error (ignored):', err);
// Intentionally exit 0 — we don't want to block the deploy on this.
});

View File

@@ -26,6 +26,9 @@ import { SecretMigrateService } from './services/secret-migrate.service.js';
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js'; import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
import { registerSecretBackendRoutes } from './routes/secret-backends.js'; import { registerSecretBackendRoutes } from './routes/secret-backends.js';
import { registerSecretMigrateRoutes } from './routes/secret-migrate.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 { LlmRepository } from './repositories/llm.repository.js';
import { LlmService } from './services/llm.service.js'; import { LlmService } from './services/llm.service.js';
import { LlmAdapterRegistry } from './services/llm/dispatcher.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' }; 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. // /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' }; 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). // /api/v1/llms/:name/infer → `run:llms:<name>` (not the default create:llms).
const inferMatch = url.match(/^\/api\/v1\/llms\/([^/?]+)\/infer/); 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) // Add operation bindings (idempotent — only for wildcard admin)
const hasWildcard = bindings.some((b) => b['role'] === 'admin' && b['resource'] === '*'); const hasWildcard = bindings.some((b) => b['role'] === 'admin' && b['resource'] === '*');
if (hasWildcard) { 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) { for (const op of ops) {
if (!newBindings.some((b) => b['action'] === op)) { if (!newBindings.some((b) => b['action'] === op)) {
newBindings.push({ role: 'run', action: op }); newBindings.push({ role: 'run', action: op });
@@ -341,6 +350,14 @@ async function main(): Promise<void> {
}); });
const secretService = new SecretService(secretRepo, secretBackendService); const secretService = new SecretService(secretRepo, secretBackendService);
const secretMigrateService = new SecretMigrateService(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 llmService = new LlmService(llmRepo, secretService);
const llmAdapters = new LlmAdapterRegistry(); const llmAdapters = new LlmAdapterRegistry();
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService); const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService);
@@ -482,6 +499,7 @@ async function main(): Promise<void> {
registerTemplateRoutes(app, templateService); registerTemplateRoutes(app, templateService);
registerSecretRoutes(app, secretService); registerSecretRoutes(app, secretService);
registerSecretBackendRoutes(app, secretBackendService); registerSecretBackendRoutes(app, secretBackendService);
registerSecretBackendRotateRoutes(app, secretBackendRotator);
registerSecretMigrateRoutes(app, secretMigrateService); registerSecretMigrateRoutes(app, secretMigrateService);
registerLlmRoutes(app, llmService); registerLlmRoutes(app, llmService);
registerLlmInferRoutes(app, { registerLlmInferRoutes(app, {
@@ -641,11 +659,19 @@ async function main(): Promise<void> {
); );
healthProbeRunner.start(15_000); 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 // Graceful shutdown
setupGracefulShutdown(app, { setupGracefulShutdown(app, {
disconnectDb: async () => { disconnectDb: async () => {
clearInterval(reconcileTimer); clearInterval(reconcileTimer);
healthProbeRunner.stop(); healthProbeRunner.stop();
secretBackendRotatorLoop.stop();
gitBackup.stop(); gitBackup.stop();
await prisma.$disconnect(); await prisma.$disconnect();
}, },

View File

@@ -12,6 +12,7 @@ export interface UpdateSecretBackendInput {
config?: Record<string, unknown>; config?: Record<string, unknown>;
isDefault?: boolean; isDefault?: boolean;
description?: string; description?: string;
tokenMeta?: Record<string, unknown>;
} }
export interface ISecretBackendRepository { 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.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault; if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
if (data.description !== undefined) updateData.description = data.description; 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 }); 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; 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> { async setDefault(id: string): Promise<SecretBackend> {
await this.getById(id); await this.getById(id);
return this.repo.setAsDefault(id); return this.repo.setAsDefault(id);

View File

@@ -25,10 +25,26 @@ export function createDriver(row: SecretBackend, deps: DriverFactoryDeps): Secre
case 'openbao': { case 'openbao': {
const cfg = row.config as unknown as OpenBaoConfig; const cfg = row.config as unknown as OpenBaoConfig;
if (!cfg.url || !cfg.tokenSecretRef?.name || !cfg.tokenSecretRef?.key) { if (!cfg.url) {
throw new Error( throw new Error(`SecretBackend '${row.name}' (openbao): config.url is required`);
`SecretBackend '${row.name}' (openbao): config must provide url + tokenSecretRef {name, key}`, }
); const auth = cfg.auth ?? 'token';
if (auth === 'token') {
const t = cfg as Extract<OpenBaoConfig, { auth?: 'token' }>;
if (!t.tokenSecretRef?.name || !t.tokenSecretRef?.key) {
throw new Error(
`SecretBackend '${row.name}' (openbao token auth): config.tokenSecretRef {name, key} is required`,
);
}
} else if (auth === 'kubernetes') {
const k = cfg as Extract<OpenBaoConfig, { auth: 'kubernetes' }>;
if (!k.role) {
throw new Error(
`SecretBackend '${row.name}' (openbao kubernetes auth): config.role is required`,
);
}
} else {
throw new Error(`SecretBackend '${row.name}' (openbao): unknown auth '${String(auth)}'`);
} }
const driverDeps: { fetch?: typeof globalThis.fetch; secretRefResolver: SecretRefResolver } = { const driverDeps: { fetch?: typeof globalThis.fetch; secretRefResolver: SecretRefResolver } = {
secretRefResolver: deps.secretRefResolver, secretRefResolver: deps.secretRefResolver,

View File

@@ -8,33 +8,69 @@
* GET <url>/v1/<mount>/data/<path> -- read latest * GET <url>/v1/<mount>/data/<path> -- read latest
* DELETE <url>/v1/<mount>/metadata/<path> -- full delete (all versions) * DELETE <url>/v1/<mount>/metadata/<path> -- full delete (all versions)
* LIST <url>/v1/<mount>/metadata/ -- for migration * LIST <url>/v1/<mount>/metadata/ -- for migration
* POST <url>/v1/auth/<mount>/login -- kubernetes auth
* *
* Auth: static token for v1. The token is stored in a `Secret` on the * Auth strategies (`config.auth`):
* plaintext backend (see `config.tokenSecretRef = { name, key }`); the driver * - `token` (default): static token loaded once via the injected
* resolves it on construction via the injected `SecretRefResolver`. Follow-up * SecretRefResolver from a Secret on the plaintext backend
* work (not here) adds Kubernetes ServiceAccount auth. * (`tokenSecretRef = { name, key }`). Cached for the driver's lifetime —
* no expiry handling.
* - `kubernetes`: log in to OpenBao's Kubernetes auth method using the
* pod's projected ServiceAccount token. Vault returns a client token +
* lease TTL; we cache it and renew lazily on TTL expiry, with a 60s
* grace window. No static credentials in the database — the bao-side
* role binds to the mcpd ServiceAccount + namespace.
* *
* Path layout inside OpenBao: * Path layout inside OpenBao:
* <mount>/<pathPrefix>/<secretName> * <mount>/<pathPrefix>/<secretName>
* `mount` and `pathPrefix` come from the backend's `config` JSON; defaults are * `mount` and `pathPrefix` come from the backend's `config` JSON; defaults are
* `secret` and `mcpctl/`. * `secret` and `mcpctl/`.
*/ */
import { readFile } from 'node:fs/promises';
import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } from './types.js'; import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } from './types.js';
export interface OpenBaoConfig { export interface OpenBaoConfigBase {
url: string; url: string;
mount?: string; mount?: string;
pathPrefix?: string; pathPrefix?: string;
namespace?: string; namespace?: string;
}
export interface OpenBaoConfigToken extends OpenBaoConfigBase {
auth?: 'token';
tokenSecretRef: { name: string; key: string }; tokenSecretRef: { name: string; key: string };
} }
export interface OpenBaoConfigKubernetes extends OpenBaoConfigBase {
auth: 'kubernetes';
/** Vault role to login as (configured server-side at `auth/<authMount>/role/<role>`). */
role: string;
/** Auth method mount path. Defaults to `kubernetes`. */
authMount?: string;
/**
* Filesystem path to the projected ServiceAccount token. Defaults to
* `/var/run/secrets/kubernetes.io/serviceaccount/token` (the standard
* mount). Override only for tests or non-default projections.
*/
serviceAccountTokenPath?: string;
}
export type OpenBaoConfig = OpenBaoConfigToken | OpenBaoConfigKubernetes;
export interface OpenBaoDriverDeps { export interface OpenBaoDriverDeps {
/** Injected HTTP fetcher — mockable in tests. */ /** Injected HTTP fetcher — mockable in tests. */
fetch?: typeof globalThis.fetch; fetch?: typeof globalThis.fetch;
secretRefResolver: SecretRefResolver; /** Required only for `auth: 'token'`. */
secretRefResolver?: SecretRefResolver;
/** Override for the SA-token reader; tests use this to supply a fake JWT. */
readServiceAccountToken?: (path: string) => Promise<string>;
/** Clock for cache TTL — overridable in tests. */
now?: () => number;
} }
const SA_TOKEN_DEFAULT_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token';
const TOKEN_RENEW_GRACE_MS = 60_000;
export class OpenBaoDriver implements SecretBackendDriver { export class OpenBaoDriver implements SecretBackendDriver {
readonly kind = 'openbao'; readonly kind = 'openbao';
@@ -42,19 +78,48 @@ export class OpenBaoDriver implements SecretBackendDriver {
private readonly mount: string; private readonly mount: string;
private readonly pathPrefix: string; private readonly pathPrefix: string;
private readonly namespace: string | undefined; private readonly namespace: string | undefined;
private readonly tokenSecretRef: { name: string; key: string }; private readonly authStrategy: 'token' | 'kubernetes';
private readonly tokenSecretRef: { name: string; key: string } | undefined;
private readonly k8sRole: string | undefined;
private readonly k8sAuthMount: string;
private readonly k8sTokenPath: string;
private readonly fetchImpl: typeof globalThis.fetch; private readonly fetchImpl: typeof globalThis.fetch;
private readonly resolver: SecretRefResolver; private readonly resolver: SecretRefResolver | undefined;
private readonly readSaToken: (path: string) => Promise<string>;
private readonly nowFn: () => number;
// Cached vault token + when (epoch ms) it should be considered expired and refetched.
private cachedToken: string | undefined; private cachedToken: string | undefined;
private cachedTokenExpiresAt: number = Number.POSITIVE_INFINITY;
constructor(config: OpenBaoConfig, deps: OpenBaoDriverDeps) { constructor(config: OpenBaoConfig, deps: OpenBaoDriverDeps) {
this.url = config.url.replace(/\/+$/, ''); this.url = config.url.replace(/\/+$/, '');
this.mount = (config.mount ?? 'secret').replace(/^\/|\/$/g, ''); this.mount = (config.mount ?? 'secret').replace(/^\/|\/$/g, '');
this.pathPrefix = (config.pathPrefix ?? 'mcpctl').replace(/^\/|\/$/g, ''); this.pathPrefix = (config.pathPrefix ?? 'mcpctl').replace(/^\/|\/$/g, '');
if (config.namespace !== undefined) this.namespace = config.namespace; if (config.namespace !== undefined) this.namespace = config.namespace;
this.tokenSecretRef = config.tokenSecretRef;
this.authStrategy = config.auth ?? 'token';
if (this.authStrategy === 'kubernetes') {
const k = config as OpenBaoConfigKubernetes;
if (!k.role) throw new Error('openbao kubernetes auth: `role` is required');
this.k8sRole = k.role;
this.k8sAuthMount = (k.authMount ?? 'kubernetes').replace(/^\/|\/$/g, '');
this.k8sTokenPath = k.serviceAccountTokenPath ?? SA_TOKEN_DEFAULT_PATH;
} else {
const t = config as OpenBaoConfigToken;
if (!t.tokenSecretRef) throw new Error('openbao token auth: `tokenSecretRef` is required');
if (deps.secretRefResolver === undefined) {
throw new Error('openbao token auth: secretRefResolver dependency is required');
}
this.tokenSecretRef = t.tokenSecretRef;
this.k8sAuthMount = 'kubernetes';
this.k8sTokenPath = SA_TOKEN_DEFAULT_PATH;
}
this.fetchImpl = deps.fetch ?? globalThis.fetch; this.fetchImpl = deps.fetch ?? globalThis.fetch;
this.resolver = deps.secretRefResolver; if (deps.secretRefResolver !== undefined) this.resolver = deps.secretRefResolver;
this.readSaToken = deps.readServiceAccountToken ?? ((path) => readFile(path, 'utf-8').then((s) => s.trim()));
this.nowFn = deps.now ?? (() => Date.now());
} }
async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise<SecretData> { async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise<SecretData> {
@@ -113,10 +178,44 @@ export class OpenBaoDriver implements SecretBackendDriver {
} }
private async getToken(): Promise<string> { private async getToken(): Promise<string> {
if (this.cachedToken !== undefined) return this.cachedToken; if (this.cachedToken !== undefined && this.nowFn() < this.cachedTokenExpiresAt - TOKEN_RENEW_GRACE_MS) {
const token = await this.resolver.resolve(this.tokenSecretRef.name, this.tokenSecretRef.key); return this.cachedToken;
this.cachedToken = token; }
return token;
if (this.authStrategy === 'token') {
// Static token from a plaintext Secret. No TTL — cache for the driver's lifetime.
const token = await this.resolver!.resolve(this.tokenSecretRef!.name, this.tokenSecretRef!.key);
this.cachedToken = token;
this.cachedTokenExpiresAt = Number.POSITIVE_INFINITY;
return token;
}
// Kubernetes auth: read the projected SA JWT, exchange it for a Vault token.
const jwt = await this.readSaToken(this.k8sTokenPath);
const loginUrl = `${this.url}/v1/auth/${this.k8sAuthMount}/login`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace;
const res = await this.fetchImpl(loginUrl, {
method: 'POST',
headers,
body: JSON.stringify({ role: this.k8sRole, jwt }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`OpenBao kubernetes login (role=${this.k8sRole!}): HTTP ${String(res.status)} ${text}`);
}
const body = await res.json() as { auth?: { client_token?: string; lease_duration?: number } };
const clientToken = body.auth?.client_token;
if (clientToken === undefined || clientToken === '') {
throw new Error(`OpenBao kubernetes login: response missing auth.client_token`);
}
// lease_duration is seconds; 0 means token doesn't expire (rare for k8s auth).
const leaseSec = body.auth?.lease_duration ?? 0;
this.cachedToken = clientToken;
this.cachedTokenExpiresAt = leaseSec > 0
? this.nowFn() + leaseSec * 1000
: Number.POSITIVE_INFINITY;
return clientToken;
} }
private async request(method: string, path: string, body?: unknown): Promise<Response> { private async request(method: string, path: string, body?: unknown): Promise<Response> {
@@ -128,6 +227,21 @@ export class OpenBaoDriver implements SecretBackendDriver {
const init: RequestInit = { method, headers }; const init: RequestInit = { method, headers };
if (body !== undefined) init.body = JSON.stringify(body); if (body !== undefined) init.body = JSON.stringify(body);
return this.fetchImpl(`${this.url}${path}`, init); const res = await this.fetchImpl(`${this.url}${path}`, init);
// If the cached token expired between cache-check and request (k8s clock
// skew, server-side revocation, etc.), purge cache and retry once.
if (res.status === 403 && this.cachedToken !== undefined) {
this.cachedToken = undefined;
this.cachedTokenExpiresAt = 0;
const fresh = await this.getToken();
const retryHeaders: Record<string, string> = { 'X-Vault-Token': fresh };
if (this.namespace !== undefined) retryHeaders['X-Vault-Namespace'] = this.namespace;
if (body !== undefined) retryHeaders['Content-Type'] = 'application/json';
const retryInit: RequestInit = { method, headers: retryHeaders };
if (body !== undefined) retryInit.body = JSON.stringify(body);
return this.fetchImpl(`${this.url}${path}`, retryInit);
}
return res;
} }
} }

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/);
});
});

View File

@@ -129,4 +129,116 @@ describe('OpenBaoDriver', () => {
const headers = init.headers as Record<string, string>; const headers = init.headers as Record<string, string>;
expect(headers['X-Vault-Namespace']).toBe('myteam'); expect(headers['X-Vault-Namespace']).toBe('myteam');
}); });
describe('kubernetes auth', () => {
it('exchanges the SA JWT for a vault client token via /v1/auth/kubernetes/login', async () => {
const calls: Array<{ url: string; init: RequestInit }> = [];
const fetchFn = vi.fn(async (url: string | URL, init?: RequestInit) => {
const u = String(url);
calls.push({ url: u, init: init ?? {} });
if (u.endsWith('/v1/auth/kubernetes/login')) {
return new Response(JSON.stringify({
auth: { client_token: 'vault.client.token.xyz', lease_duration: 3600 },
}), { status: 200 });
}
return new Response(JSON.stringify({}), { status: 200 });
});
const driver = new OpenBaoDriver(
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' },
{
fetch: fetchFn as unknown as typeof fetch,
readServiceAccountToken: async () => 'eyJ.fake.sa.jwt',
},
);
await driver.write({ name: 'x', data: { k: 'v' } });
// Two calls: login + write
expect(calls).toHaveLength(2);
expect(calls[0]!.url).toBe('http://bao.example:8200/v1/auth/kubernetes/login');
expect(JSON.parse(calls[0]!.init.body as string)).toEqual({ role: 'mcpctl', jwt: 'eyJ.fake.sa.jwt' });
// Write uses the returned client token
const writeHeaders = calls[1]!.init.headers as Record<string, string>;
expect(writeHeaders['X-Vault-Token']).toBe('vault.client.token.xyz');
});
it('caches the vault token across requests and renews after lease expiry', async () => {
let nowMs = 1_000_000_000_000;
let loginCount = 0;
const fetchFn = vi.fn(async (url: string | URL) => {
const u = String(url);
if (u.endsWith('/v1/auth/kubernetes/login')) {
loginCount++;
// 600s lease leaves 540s of cached window after the 60s grace.
return new Response(JSON.stringify({
auth: { client_token: `tok-${String(loginCount)}`, lease_duration: 600 },
}), { status: 200 });
}
return new Response(JSON.stringify({}), { status: 200 });
});
const driver = new OpenBaoDriver(
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' },
{
fetch: fetchFn as unknown as typeof fetch,
readServiceAccountToken: async () => 'jwt',
now: () => nowMs,
},
);
await driver.write({ name: 'a', data: { k: 'v' } });
await driver.write({ name: 'b', data: { k: 'v' } });
expect(loginCount).toBe(1); // both writes share the cached token
// Advance past lease - grace window → driver re-logs in
nowMs += 600_000;
await driver.write({ name: 'c', data: { k: 'v' } });
expect(loginCount).toBe(2);
});
it('honours custom authMount path', async () => {
const calls: string[] = [];
const fetchFn = vi.fn(async (url: string | URL) => {
calls.push(String(url));
if (String(url).includes('/login')) {
return new Response(JSON.stringify({ auth: { client_token: 't', lease_duration: 3600 } }), { status: 200 });
}
return new Response(JSON.stringify({}), { status: 200 });
});
const driver = new OpenBaoDriver(
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl', authMount: 'kubernetes/cluster-a' },
{ fetch: fetchFn as unknown as typeof fetch, readServiceAccountToken: async () => 'jwt' },
);
await driver.write({ name: 'x', data: {} });
expect(calls[0]).toBe('http://bao.example:8200/v1/auth/kubernetes/cluster-a/login');
});
it('throws on login failure with a clear error', async () => {
const fetchFn = vi.fn(async () => new Response('permission denied', { status: 403 }));
const driver = new OpenBaoDriver(
{ url: 'http://bao.example:8200', auth: 'kubernetes', role: 'mcpctl' },
{ fetch: fetchFn as unknown as typeof fetch, readServiceAccountToken: async () => 'jwt' },
);
await expect(driver.read({ name: 'x', externalRef: '', data: {} }))
.rejects.toThrow(/kubernetes login.*role=mcpctl.*HTTP 403/);
});
it('rejects construction when role is missing', () => {
expect(() => new OpenBaoDriver(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ url: 'http://bao.example:8200', auth: 'kubernetes' } as any,
{ fetch: vi.fn() as unknown as typeof fetch, readServiceAccountToken: async () => 'jwt' },
)).toThrow(/role.*required/);
});
it('rejects token-auth construction when tokenSecretRef is missing', () => {
expect(() => new OpenBaoDriver(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ url: 'http://bao.example:8200' } as any,
{ fetch: vi.fn() as unknown as typeof fetch, secretRefResolver: resolver },
)).toThrow(/tokenSecretRef.*required/);
});
});
}); });

View File

@@ -121,10 +121,16 @@ describe('llm smoke', () => {
it('get llms shows the row with KEY column rendered as "secret://name/key"', () => { it('get llms shows the row with KEY column rendered as "secret://name/key"', () => {
if (!mcpdUp) return; if (!mcpdUp) return;
const result = run('get llms'); // Table output truncates the KEY column (≈34 chars), so the full
// "secret://<name>/<key>" string won't appear verbatim in the row. Assert
// against JSON output where the apiKeyRef round-trips as a structured
// object.
const result = run('get llms -o json');
expect(result.code).toBe(0); expect(result.code).toBe(0);
expect(result.stdout).toContain(LLM_NAME); const rows = JSON.parse(result.stdout) as Array<{ name: string; apiKeyRef?: { name: string; key: string } }>;
expect(result.stdout).toContain(`secret://${SECRET_NAME}/token`); const row = rows.find((r) => r.name === LLM_NAME);
expect(row, `row ${LLM_NAME} must be present`).toBeDefined();
expect(row!.apiKeyRef).toEqual({ name: SECRET_NAME, key: 'token' });
}); });
it('round-trips yaml output → apply -f', () => { it('round-trips yaml output → apply -f', () => {

View File

@@ -5,3 +5,4 @@ export * from './utils/index.js';
export * from './secrets/index.js'; export * from './secrets/index.js';
export * from './tokens/index.js'; export * from './tokens/index.js';
export * from './mcp-http/index.js'; export * from './mcp-http/index.js';
export * from './vault/index.js';

View File

@@ -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<string, string> {
const h: Record<string, string> = {};
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<string> {
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<VaultHealth> {
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<VaultHealth> & { 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<boolean> {
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<string, { type?: string; options?: { version?: string } }>;
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/<name> with the provided HCL. Idempotent. */
export async function writePolicy(
url: string,
adminToken: string,
name: string,
hcl: string,
deps: VaultDeps = {},
): Promise<void> {
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/<role>. Idempotent: upserts the role config. */
export async function ensureTokenRole(
url: string,
adminToken: string,
role: string,
cfg: TokenRoleConfig,
deps: VaultDeps = {},
): Promise<void> {
const fetchImpl = deps.fetch ?? globalThis.fetch;
const body: Record<string, unknown> = {
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/<role>. 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<MintedToken> {
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<void> {
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<void> {
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)}`);
}
}

View File

@@ -0,0 +1,2 @@
export * from './client.js';
export * from './policy.js';

View File

@@ -0,0 +1,35 @@
/**
* OpenBao / Vault policy template for mcpd's wizard-provisioned backend.
*
* The policy is deliberately narrow:
* - Read/write/list/delete under `<mount>/{data,metadata}/<pathPrefix>/*`
* - 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');
}

View File

@@ -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<typeof vi.fn> {
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/<name>', 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<string, unknown>;
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/);
});
});