diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 5214ef6..e5739fd 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,11 +5,11 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply patch backup approve console cache test" + local commands="status login logout config get describe delete logs create edit apply patch backup approve console cache test migrate" local project_commands="get describe delete logs create edit attach-server detach-server" local global_opts="-v --version --daemon-url --direct -p --project -h --help" - local resources="servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all" - local resource_aliases="servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm" + local resources="servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all" + local resource_aliases="servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm" # Check if --project/-p was given local has_project=false @@ -175,7 +175,7 @@ _mcpctl() { create) local create_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$create_sub" ]]; then - COMPREPLY=($(compgen -W "server secret project user group rbac mcptoken prompt serverattachment promptrequest help" -- "$cur")) + COMPREPLY=($(compgen -W "server secret secretbackend project user group rbac mcptoken prompt serverattachment promptrequest help" -- "$cur")) else case "$create_sub" in server) @@ -184,6 +184,9 @@ _mcpctl() { secret) COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur")) ;; + secretbackend) + COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --token-secret --config --force -h --help" -- "$cur")) + ;; project) COMPREPLY=($(compgen -W "-d --description --proxy-model --prompt --gated --no-gated --server --force -h --help" -- "$cur")) ;; @@ -329,6 +332,21 @@ _mcpctl() { esac fi return ;; + migrate) + local migrate_sub=$(_mcpctl_get_subcmd $subcmd_pos) + if [[ -z "$migrate_sub" ]]; then + COMPREPLY=($(compgen -W "secrets help" -- "$cur")) + else + case "$migrate_sub" in + secrets) + COMPREPLY=($(compgen -W "--from --to --names --keep-source --dry-run -h --help" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + esac + fi + return ;; help) COMPREPLY=($(compgen -W "$commands" -- "$cur")) return ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index f45a46d..37573b3 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply patch backup approve console cache test +set -l commands status login logout config get describe delete logs create edit apply patch backup approve console cache test migrate set -l project_commands get describe delete logs create edit attach-server detach-server # Disable file completions by default @@ -31,10 +31,10 @@ function __mcpctl_has_project end # Resource type detection -set -l resources servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all +set -l resources servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all function __mcpctl_needs_resource_type - set -l resource_aliases servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm + set -l resource_aliases servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -59,6 +59,7 @@ function __mcpctl_resolve_resource case server srv servers; echo servers case instance inst instances; echo instances case secret sec secrets; echo secrets + case secretbackend sb secretbackends; echo secretbackends case template tpl templates; echo templates case project proj projects; echo projects case user users; echo users @@ -74,7 +75,7 @@ function __mcpctl_resolve_resource end function __mcpctl_get_resource_type - set -l resource_aliases servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm + set -l resource_aliases servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -223,7 +224,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 describe -d 'Show detailed information about a resource' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get logs from an MCP server instance' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, project, user, group, rbac, serverattachment, prompt)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, project, user, group, rbac, serverattachment, prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply declarative configuration from a YAML or JSON file' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a patch -d 'Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)' @@ -232,13 +233,14 @@ 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 console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a test -d 'Utilities for testing MCP endpoints and config' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a migrate -d 'Move resources between backends (currently: secrets between SecretBackends)' # 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 describe -d 'Show detailed information about a resource' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get logs from an MCP server instance' -complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, project, user, group, rbac, serverattachment, prompt)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, project, user, group, rbac, serverattachment, prompt)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to a project (requires --project)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from a project (requires --project)' @@ -281,9 +283,10 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' # create subcommands -set -l create_cmds server secret project user group rbac mcptoken prompt serverattachment promptrequest +set -l create_cmds server secret secretbackend project user group rbac mcptoken prompt serverattachment promptrequest complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create an MCP server definition' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secretbackend -d 'Create a secret backend (plaintext, openbao)' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a project -d 'Create a project' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a user -d 'Create a user' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a group -d 'Create a group' @@ -313,6 +316,18 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l force -d 'Update complete -c mcpctl -n "__mcpctl_subcmd_active create secret" -l data -d 'Secret data KEY=value (repeat for multiple)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create secret" -l force -d 'Update if already exists' +# create secretbackend options +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l type -d 'Backend type (plaintext, openbao)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l description -d 'Description' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l default -d 'Promote this backend to default (atomically demotes the current one)' +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l url -d 'openbao: vault URL (e.g. http://bao.example:8200)' -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 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 config -d 'Extra config as key=value (repeat for multiple)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l force -d 'Update if already exists' + # create project options complete -c mcpctl -n "__mcpctl_subcmd_active create project" -s d -l description -d 'Project description' -x complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l proxy-model -d 'Plugin name (default, content-pipeline, gate, none)' -x @@ -391,6 +406,17 @@ complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -l timeout -d 'Per-reque complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -s o -l output -d 'Output format: text or json' -x complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -l no-health -d 'Skip the /healthz preflight check' +# migrate subcommands +set -l migrate_cmds secrets +complete -c mcpctl -n "__fish_seen_subcommand_from migrate; and not __fish_seen_subcommand_from $migrate_cmds" -a secrets -d 'Migrate secrets from one SecretBackend to another' + +# migrate secrets options +complete -c mcpctl -n "__mcpctl_subcmd_active migrate secrets" -l from -d 'Source SecretBackend name' -x +complete -c mcpctl -n "__mcpctl_subcmd_active migrate secrets" -l to -d 'Destination SecretBackend name' -x +complete -c mcpctl -n "__mcpctl_subcmd_active migrate secrets" -l names -d 'Comma-separated secret names (default: all)' -x +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' + # status options complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x diff --git a/docs/secret-backends.md b/docs/secret-backends.md new file mode 100644 index 0000000..d53d358 --- /dev/null +++ b/docs/secret-backends.md @@ -0,0 +1,167 @@ +# Secret backends + +`mcpctl` stores the raw data for `Secret` resources in a pluggable **backend**. +The default is `plaintext` — the secret payload lives in Postgres as plain JSON +— which is fine for laptop development but a poor fit for shared clusters. For +production, point at an external KV store and delete secrets from the DB after +migration. + +This guide covers the model, the shipped drivers, and how to migrate without +downtime. + +## Model + +- A `SecretBackend` resource is a single named driver instance (e.g. a pointer + at one OpenBao deployment). +- Every `Secret` row carries a `backendId` FK — the backend that owns its data. +- Exactly one `SecretBackend` has `isDefault: true`. New secrets created through + the API/CLI land on that backend. +- The `plaintext` backend is seeded at startup and named `default`. It cannot + be deleted — there needs to always be one row where the driver's own + credentials can bootstrap from (see below). + +## CLI + +```bash +mcpctl get secretbackends # list backends +mcpctl describe secretbackend # inspect config (credentials masked) +mcpctl create secretbackend --type plaintext [--default] [--description ...] +mcpctl create secretbackend --type openbao \ + --url http://bao.example:8200 \ + --token-secret bao-creds/token \ + [--namespace ] [--mount secret] [--path-prefix mcpctl] \ + [--default] +mcpctl delete secretbackend # blocked if any secret still points at it + +mcpctl migrate secrets --from default --to bao +mcpctl migrate secrets --from default --to bao --names a,b --keep-source +mcpctl migrate secrets --from default --to bao --dry-run +``` + +Anything you can do with `create secretbackend` also works via `apply -f`: + +```yaml +kind: secretbackend +name: bao +type: openbao +description: "shared cluster OpenBao" +isDefault: true +config: + url: http://bao.svc.cluster.local:8200 + tokenSecretRef: { name: bao-creds, key: token } + namespace: platform +``` + +## Drivers + +### plaintext + +Trivial. `Secret.data` holds the JSON, `externalRef` is empty. + +- Storage: Postgres column. +- Bootstrap: seeded as `default` at startup. +- Cost: zero setup, zero encryption at rest, full access for any DB reader. + +Use for development, CI, or single-tenant self-hosts where the DB itself is +treated as sensitive. + +### openbao + +Talks HTTP to an [OpenBao](https://openbao.org) (MPL 2.0 Vault fork) KV v2 +mount. Also compatible with HashiCorp Vault KV v2 — the wire protocol is the +same. + +| Config key | Required? | Description | +|------------------|-----------|-------------| +| `url` | yes | Base URL, e.g. `http://bao.svc.cluster.local:8200`. | +| `tokenSecretRef` | yes | `{ name, key }` pointing at a `Secret` on the **plaintext** backend that holds the bootstrap token. | +| `mount` | no | KV v2 mount name. Default `secret`. | +| `pathPrefix` | no | Path prefix under the mount. Default `mcpctl`. Secrets land at `//`. | +| `namespace` | no | `X-Vault-Namespace` header for OpenBao/Vault Enterprise namespaces. | + +The driver only stores a reference in `Secret.externalRef` (`mount/path`). The +`Secret.data` column is left empty for openbao-backed rows — you can safely +drop DB-level access to secrets after migration. + +#### Required OpenBao policy + +Minimum token policy for a backend that lives at `secret/mcpctl/`: + +```hcl +path "secret/data/mcpctl/*" { + capabilities = ["create", "read", "update"] +} + +path "secret/metadata/mcpctl/*" { + capabilities = ["list", "delete"] +} + +path "secret/metadata/mcpctl/" { + capabilities = ["list"] +} +``` + +Grant `delete` on `metadata/...` only if you need mcpctl to fully remove +secrets — OpenBao soft-deletes until the metadata is gone. + +#### Chicken-and-egg: where does the OpenBao token live? + +mcpd reads the OpenBao token from a `Secret` on the **plaintext** backend. +That's the whole point of keeping plaintext around — it's the trust root: + +1. Operator creates a plaintext `Secret` holding the bootstrap token. +2. Operator creates the `openbao` backend, pointing at that secret via + `tokenSecretRef`. +3. Operator runs `mcpctl migrate secrets --from default --to bao` to move all + other secrets off plaintext. +4. After migration, the only sensitive row left on plaintext is the OpenBao + token itself. DB access is now equivalent to OpenBao token access (a single + key), not equivalent to all API keys in the system. + +Follow-up work (not shipped yet) replaces static token auth with Kubernetes +ServiceAccount auth so no bootstrap token is needed at all. + +## Migration — `mcpctl migrate secrets` + +Atomicity is **per secret**, not per batch. Remote writes can't roll back, so we +don't pretend. For each secret the service: + +1. Reads the plaintext from the source driver. +2. Writes it to the destination driver. +3. Updates the `Secret` row: flips `backendId`, sets new `externalRef`, clears + `data`. +4. Deletes from source (skipped with `--keep-source`). + +If the command is interrupted between step 2 and 3, the destination has an +orphan entry but the source still owns the row. Re-running is idempotent — the +service skips secrets that are already on the destination and picks up the +rest. + +```bash +# Dry-run first: see what would move. +mcpctl migrate secrets --from default --to bao --dry-run + +# Migrate everything. +mcpctl migrate secrets --from default --to bao + +# Migrate a subset only. +mcpctl migrate secrets --from default --to bao --names api-keys,oauth-client + +# Leave the source copy in place (useful for A/B validation). +mcpctl migrate secrets --from default --to bao --keep-source +``` + +The command prints a per-secret summary (migrated / skipped / failed) and exits +non-zero if any secret failed. Ctrl-C during the run is safe — restart when you +want, no duplicate writes. + +## RBAC + +- `resource: secretbackends` — gated like any other resource (`view`, + `create`, `edit`, `delete`). +- `role: run, action: migrate-secrets` — required to call + `POST /api/v1/secrets/migrate`. + +Describe output masks config values whose keys look like credentials +(`token`, `secret`, `password`, `key`), so `mcpctl describe secretbackend` is +safe to paste into tickets. diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index 60614af..7a86a8b 100644 --- a/scripts/generate-completions.ts +++ b/scripts/generate-completions.ts @@ -184,7 +184,7 @@ async function extractTree(): Promise { // ============================================================ const CANONICAL_RESOURCES = [ - 'servers', 'instances', 'secrets', 'templates', 'projects', + 'servers', 'instances', 'secrets', 'secretbackends', 'templates', 'projects', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'serverattachments', 'proxymodels', 'all', ]; @@ -193,6 +193,7 @@ const ALIAS_ENTRIES: [string, string][] = [ ['server', 'servers'], ['srv', 'servers'], ['instance', 'instances'], ['inst', 'instances'], ['secret', 'secrets'], ['sec', 'secrets'], + ['secretbackend', 'secretbackends'], ['sb', 'secretbackends'], ['template', 'templates'], ['tpl', 'templates'], ['project', 'projects'], ['proj', 'projects'], ['user', 'users'], diff --git a/src/cli/src/commands/apply.ts b/src/cli/src/commands/apply.ts index 9c5e0d7..64c0f10 100644 --- a/src/cli/src/commands/apply.ts +++ b/src/cli/src/commands/apply.ts @@ -41,6 +41,14 @@ const SecretSpecSchema = z.object({ data: z.record(z.string()).default({}), }); +const SecretBackendSpecSchema = z.object({ + name: z.string().min(1), + type: z.string().min(1), + description: z.string().default(''), + isDefault: z.boolean().optional(), + config: z.record(z.unknown()).default({}), +}); + const TemplateEnvEntrySchema = z.object({ name: z.string().min(1), description: z.string().optional(), @@ -142,6 +150,7 @@ const McpTokenSpecSchema = z.object({ }); const ApplyConfigSchema = z.object({ + secretbackends: z.array(SecretBackendSpecSchema).default([]), secrets: z.array(SecretSpecSchema).default([]), servers: z.array(ServerSpecSchema).default([]), users: z.array(UserSpecSchema).default([]), @@ -183,6 +192,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command { if (opts.dryRun) { log('Dry run - would apply:'); + if (config.secretbackends.length > 0) log(` ${config.secretbackends.length} secretbackend(s)`); if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`); if (config.servers.length > 0) log(` ${config.servers.length} server(s)`); if (config.users.length > 0) log(` ${config.users.length} user(s)`); @@ -229,6 +239,7 @@ const KIND_TO_RESOURCE: Record = { promptrequest: 'promptrequests', serverattachment: 'serverattachments', mcptoken: 'mcptokens', + secretbackend: 'secretbackends', }; /** @@ -324,6 +335,30 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args } } + // Apply secret backends first — secrets reference them. + // When multiple backends claim isDefault: true, the server's atomic swap will + // leave whichever was applied last as the effective default. + for (const sb of config.secretbackends) { + try { + const existing = await cachedFindByName('secretbackends', sb.name); + if (existing) { + const updateBody: Record = { + config: sb.config, + description: sb.description, + }; + if (sb.isDefault !== undefined) updateBody.isDefault = sb.isDefault; + await withRetry(() => client.put(`/api/v1/secretbackends/${existing.id}`, updateBody)); + log(`Updated secretbackend: ${sb.name}`); + } else { + await withRetry(() => client.post('/api/v1/secretbackends', sb)); + invalidateCache('secretbackends'); + log(`Created secretbackend: ${sb.name}`); + } + } catch (err) { + log(`Error applying secretbackend '${sb.name}': ${err instanceof Error ? err.message : err}`); + } + } + // Apply secrets for (const secret of config.secrets) { try { diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 42be2cd..5603f7e 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -88,7 +88,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { const { client, log } = deps; const cmd = new Command('create') - .description('Create a resource (server, secret, project, user, group, rbac, serverattachment, prompt)'); + .description('Create a resource (server, secret, secretbackend, project, user, group, rbac, serverattachment, prompt)'); // --- create server --- cmd.command('server') @@ -252,6 +252,70 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { } }); + // --- create secretbackend --- + cmd.command('secretbackend') + .alias('sb') + .description('Create a secret backend (plaintext, openbao)') + .argument('', 'Backend name (lowercase, hyphens allowed)') + .requiredOption('--type ', 'Backend type (plaintext, openbao)') + .option('--description ', 'Description') + .option('--default', 'Promote this backend to default (atomically demotes the current one)') + .option('--url ', 'openbao: vault URL (e.g. http://bao.example:8200)') + .option('--namespace ', 'openbao: X-Vault-Namespace header value') + .option('--mount ', 'openbao: KV v2 mount point (default: secret)') + .option('--path-prefix ', 'openbao: path prefix under mount (default: mcpctl)') + .option('--token-secret ', 'openbao: token secret reference in SECRET/KEY form (e.g. bao-creds/token)') + .option('--config ', 'Extra config as key=value (repeat for multiple)', collect, []) + .option('--force', 'Update if already exists') + .action(async (name: string, opts) => { + const type = opts.type as string; + const config: Record = {}; + + if (type === 'openbao') { + 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 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.url = opts.url; + config.tokenSecretRef = { + name: (opts.tokenSecret as string).slice(0, slashIdx), + key: (opts.tokenSecret as string).slice(slashIdx + 1), + }; + if (opts.namespace) config.namespace = opts.namespace; + if (opts.mount) config.mount = opts.mount; + if (opts.pathPrefix) config.pathPrefix = opts.pathPrefix; + } + + // Extra config key=value pairs (overwrite/extend above) + for (const entry of opts.config as string[]) { + const eqIdx = entry.indexOf('='); + if (eqIdx === -1) throw new Error(`Invalid --config '${entry}'. Expected key=value`); + config[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1); + } + + const body: Record = { name, type, config }; + if (opts.description !== undefined) body.description = opts.description; + if (opts.default) body.isDefault = true; + + try { + const row = await client.post<{ id: string; name: string }>('/api/v1/secretbackends', body); + log(`secretbackend '${row.name}' created (id: ${row.id})`); + if (opts.default) log(` promoted to default backend`); + } catch (err) { + if (err instanceof ApiError && err.status === 409 && opts.force) { + const existing = (await client.get>('/api/v1/secretbackends')).find((b) => b.name === name); + if (!existing) throw err; + const updateBody: Record = { config }; + if (opts.description !== undefined) updateBody.description = opts.description; + if (opts.default) updateBody.isDefault = true; + await client.put(`/api/v1/secretbackends/${existing.id}`, updateBody); + log(`secretbackend '${name}' updated (id: ${existing.id})`); + } else { + throw err; + } + } + }); + // --- create project --- cmd.command('project') .description('Create a project') diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index a3f2b3c..e434bb8 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -218,6 +218,37 @@ function formatSecretDetail(secret: Record, showValues: boolean return lines.join('\n'); } +function formatSecretBackendDetail(backend: Record): string { + const lines: string[] = []; + lines.push(`=== SecretBackend: ${backend.name} ===`); + lines.push(`${pad('Name:')}${backend.name}`); + lines.push(`${pad('Type:')}${backend.type}`); + lines.push(`${pad('Default:')}${backend.isDefault ? 'yes' : 'no'}`); + if (backend.description) lines.push(`${pad('Description:')}${backend.description}`); + + const config = backend.config as Record | undefined; + if (config && Object.keys(config).length > 0) { + lines.push(''); + lines.push('Config:'); + const keyW = Math.max(6, ...Object.keys(config).map((k) => k.length)) + 2; + for (const [key, value] of Object.entries(config)) { + let display: string; + if (value === null || value === undefined) display = '-'; + else if (typeof value === 'object') display = JSON.stringify(value); + else display = String(value); + lines.push(` ${key.padEnd(keyW)}${display}`); + } + } + + lines.push(''); + lines.push('Metadata:'); + lines.push(` ${pad('ID:', 12)}${backend.id}`); + if (backend.createdAt) lines.push(` ${pad('Created:', 12)}${backend.createdAt}`); + if (backend.updatedAt) lines.push(` ${pad('Updated:', 12)}${backend.updatedAt}`); + + return lines.join('\n'); +} + function formatTemplateDetail(template: Record): string { const lines: string[] = []; lines.push(`=== Template: ${template.name} ===`); @@ -806,6 +837,9 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { case 'templates': deps.log(formatTemplateDetail(item)); break; + case 'secretbackends': + deps.log(formatSecretBackendDetail(item)); + break; case 'projects': { const projectPrompts = await deps.client .get>(`/api/v1/prompts?projectId=${item.id as string}`) diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 1777db5..ac1d786 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -119,6 +119,23 @@ const rbacColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +interface SecretBackendRow { + id: string; + name: string; + type: string; + isDefault: boolean; + description: string; + config?: Record; +} + +const secretBackendColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'TYPE', key: 'type', width: 14 }, + { header: 'DEFAULT', key: (r) => r.isDefault ? '*' : '', width: 8 }, + { header: 'DESCRIPTION', key: (r) => r.description || '-', width: 30 }, + { header: 'ID', key: 'id' }, +]; + interface McpTokenRow { id: string; name: string; @@ -265,6 +282,8 @@ function getColumnsForResource(resource: string): Column return proxymodelColumns as unknown as Column>[]; case 'mcptokens': return mcpTokenColumns as unknown as Column>[]; + case 'secretbackends': + return secretBackendColumns as unknown as Column>[]; default: return [ { header: 'ID', key: 'id' as keyof Record }, @@ -287,6 +306,7 @@ const RESOURCE_KIND: Record = { promptrequests: 'promptrequest', serverattachments: 'serverattachment', mcptokens: 'mcptoken', + secretbackends: 'secretbackend', }; /** diff --git a/src/cli/src/commands/migrate.ts b/src/cli/src/commands/migrate.ts new file mode 100644 index 0000000..e1e43f4 --- /dev/null +++ b/src/cli/src/commands/migrate.ts @@ -0,0 +1,80 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +export interface MigrateCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +interface MigrateResult { + migrated: Array<{ name: string }>; + skipped: Array<{ name: string; reason: string }>; + failed: Array<{ name: string; error: string }>; +} + +interface DryRunResult { + dryRun: true; + candidates: Array<{ id: string; name: string }>; +} + +/** + * Top-level `mcpctl migrate ` verb. + * + * Today only `secrets` is implemented (SecretBackend → SecretBackend move), + * but the command is structured so new migrations can slot in. + * + * Per-secret atomicity is handled server-side — if this command is interrupted + * mid-run, re-running is idempotent (skips secrets already on the destination). + */ +export function createMigrateCommand(deps: MigrateCommandDeps): Command { + const { client, log } = deps; + + const cmd = new Command('migrate') + .description('Move resources between backends (currently: secrets between SecretBackends)'); + + cmd.command('secrets') + .description('Migrate secrets from one SecretBackend to another') + .requiredOption('--from ', 'Source SecretBackend name') + .requiredOption('--to ', 'Destination SecretBackend name') + .option('--names ', 'Comma-separated secret names (default: all)') + .option('--keep-source', 'Leave the source copy intact (default: delete from source after write+commit)') + .option('--dry-run', 'Show which secrets would be migrated without touching them') + .action(async (opts) => { + const body: Record = { from: opts.from, to: opts.to }; + if (opts.names) body.names = (opts.names as string).split(',').map((s) => s.trim()).filter(Boolean); + if (opts.keepSource) body.keepSource = true; + if (opts.dryRun) body.dryRun = true; + + if (opts.dryRun) { + const res = await client.post('/api/v1/secrets/migrate', body); + if (res.candidates.length === 0) { + log(`No secrets to migrate from '${opts.from as string}' to '${opts.to as string}'.`); + return; + } + log(`Dry run — ${String(res.candidates.length)} secret(s) would be migrated from '${opts.from as string}' → '${opts.to as string}':`); + for (const c of res.candidates) log(` - ${c.name}`); + return; + } + + const res = await client.post('/api/v1/secrets/migrate', body); + + if (res.migrated.length > 0) { + log(`Migrated ${String(res.migrated.length)} secret(s) from '${opts.from as string}' → '${opts.to as string}':`); + for (const m of res.migrated) log(` ✓ ${m.name}`); + } + if (res.skipped.length > 0) { + log(`Skipped ${String(res.skipped.length)}:`); + for (const s of res.skipped) log(` - ${s.name}: ${s.reason}`); + } + if (res.failed.length > 0) { + log(`Failed ${String(res.failed.length)}:`); + for (const f of res.failed) log(` ✗ ${f.name}: ${f.error}`); + process.exitCode = 1; + } + if (res.migrated.length === 0 && res.skipped.length === 0 && res.failed.length === 0) { + log(`No secrets to migrate from '${opts.from as string}' to '${opts.to as string}'.`); + } + }); + + return cmd; +} diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index abc5584..cd5abab 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -31,6 +31,9 @@ export const RESOURCE_ALIASES: Record = { mcptokens: 'mcptokens', token: 'mcptokens', tokens: 'mcptokens', + secretbackend: 'secretbackends', + secretbackends: 'secretbackends', + sb: 'secretbackends', all: 'all', }; diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index ca395b8..2eac1e0 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -18,6 +18,7 @@ import { createMcpCommand } from './commands/mcp.js'; import { createPatchCommand } from './commands/patch.js'; import { createConsoleCommand } from './commands/console/index.js'; import { createCacheCommand } from './commands/cache.js'; +import { createMigrateCommand } from './commands/migrate.js'; import { ApiClient, ApiError } from './api-client.js'; import { loadConfig } from './config/index.js'; import { loadCredentials } from './auth/index.js'; @@ -249,6 +250,11 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + program.addCommand(createMigrateCommand({ + client, + log: (...args) => console.log(...args), + })); + return program; } diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 2186eb8..e39bf02 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -111,17 +111,48 @@ model McpTemplate { @@index([name]) } +// ── Secret Backends ── +// +// Pluggable storage for Secret.data. Default is `plaintext` (data stored in +// Secret.data JSON). Other drivers (e.g. `openbao`) store only a reference in +// Secret.externalRef and fetch actual values from the external system at read +// time. A `plaintext` row is seeded on first startup so the system always has +// a viable backend; additional backends are user-managed via +// `mcpctl create secretbackend`. + +model SecretBackend { + id String @id @default(cuid()) + name String @unique + type String // plaintext | openbao | (future: vault, aws-sm, ...) + config Json @default("{}") // type-specific: url, mount, namespace, tokenSecretRef + isDefault Boolean @default(false) // exactly one row has isDefault=true + description String @default("") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + secrets Secret[] + + @@index([name]) + @@index([isDefault]) +} + // ── Secrets ── model Secret { - id String @id @default(cuid()) - name String @unique - data Json @default("{}") - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String @unique + backendId String // FK to SecretBackend — dispatches read/write + data Json @default("{}") // populated by plaintext backend only + externalRef String @default("") // populated by non-plaintext backends (e.g. "mount/path#v3") + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + backend SecretBackend @relation(fields: [backendId], references: [id]) @@index([name]) + @@index([backendId]) } // ── Groups ── diff --git a/src/mcpd/src/bootstrap/secret-backends.ts b/src/mcpd/src/bootstrap/secret-backends.ts new file mode 100644 index 0000000..eb1ac2d --- /dev/null +++ b/src/mcpd/src/bootstrap/secret-backends.ts @@ -0,0 +1,53 @@ +/** + * Bootstrap the `plaintext` SecretBackend + backfill existing Secret rows. + * + * Runs on every mcpd startup. Idempotent: + * - if no SecretBackend exists, create `default` (type `plaintext`, isDefault=true) + * - if any Secret has no backendId (fresh after schema migration), point it at `default` + * - if no backend is currently flagged default, promote `default` + * + * Safe to run repeatedly; never destroys configuration. + */ +import type { PrismaClient } from '@prisma/client'; + +/** Well-known name for the always-present plaintext backend. */ +export const DEFAULT_PLAINTEXT_BACKEND_NAME = 'default'; + +export async function bootstrapSecretBackends(prisma: PrismaClient): Promise { + let plaintext = await prisma.secretBackend.findUnique({ + where: { name: DEFAULT_PLAINTEXT_BACKEND_NAME }, + }); + + if (plaintext === null) { + plaintext = await prisma.secretBackend.create({ + data: { + name: DEFAULT_PLAINTEXT_BACKEND_NAME, + type: 'plaintext', + isDefault: true, + description: 'Default in-database plaintext backend. Seeded on first startup.', + }, + }); + } + + const currentDefault = await prisma.secretBackend.findFirst({ where: { isDefault: true } }); + if (currentDefault === null) { + await prisma.secretBackend.update({ + where: { id: plaintext.id }, + data: { isDefault: true }, + }); + } + + // Backfill any secrets left with an empty backendId after the schema migration. + // `findMany({ where: { backendId: '' } })` catches rows that existed before + // the column was added and had a default-empty value assigned. + const orphans = await prisma.secret.findMany({ + where: { backendId: '' }, + select: { id: true }, + }); + if (orphans.length > 0) { + await prisma.secret.updateMany({ + where: { id: { in: orphans.map((o) => o.id) } }, + data: { backendId: plaintext.id }, + }); + } +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 71545fa..bf4d79f 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -20,6 +20,12 @@ import { AuditEventRepository, McpTokenRepository, } from './repositories/index.js'; +import { SecretBackendRepository } from './repositories/secret-backend.repository.js'; +import { SecretBackendService } from './services/secret-backend.service.js'; +import { SecretMigrateService } from './services/secret-migrate.service.js'; +import { bootstrapSecretBackends } from './bootstrap/secret-backends.js'; +import { registerSecretBackendRoutes } from './routes/secret-backends.js'; +import { registerSecretMigrateRoutes } from './routes/secret-migrate.js'; import { PromptRepository } from './repositories/prompt.repository.js'; import { PromptRequestRepository } from './repositories/prompt-request.repository.js'; import { bootstrapSystemProject } from './bootstrap/system-project.js'; @@ -93,11 +99,14 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { if (segment === 'backup') return { kind: 'operation', operation: 'backup' }; if (segment === 'restore') return { kind: 'operation', operation: 'restore' }; if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' }; + // /api/v1/secrets/migrate is a bulk cross-backend operation — treat as op, not a plain secret write. + if (url.startsWith('/api/v1/secrets/migrate')) return { kind: 'operation', operation: 'migrate-secrets' }; const resourceMap: Record = { 'servers': 'servers', 'instances': 'instances', 'secrets': 'secrets', + 'secretbackends': 'secretbackends', 'projects': 'projects', 'templates': 'templates', 'users': 'users', @@ -261,6 +270,7 @@ async function main(): Promise { // Repositories const serverRepo = new McpServerRepository(prisma); const secretRepo = new SecretRepository(prisma); + const secretBackendRepo = new SecretBackendRepository(prisma); const instanceRepo = new McpInstanceRepository(prisma); const projectRepo = new ProjectRepository(prisma); const auditLogRepo = new AuditLogRepository(prisma); @@ -271,11 +281,16 @@ async function main(): Promise { const groupRepo = new GroupRepository(prisma); const mcpTokenRepo = new McpTokenRepository(prisma); + // SecretBackend bootstrap: ensure a `plaintext` default row exists and any + // pre-existing `Secret` rows are pointed at it. Idempotent per run. + await bootstrapSecretBackends(prisma); + // CUID detection for RBAC name resolution const CUID_RE = /^c[^\s-]{8,}$/i; const nameResolvers: Record }> = { servers: serverRepo, secrets: secretRepo, + secretbackends: secretBackendRepo, projects: projectRepo, groups: groupRepo, mcptokens: mcpTokenRepo, @@ -291,9 +306,29 @@ async function main(): Promise { // Services const serverService = new McpServerService(serverRepo); - const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo); + // SecretBackend service — needs a lazy bridge to the yet-to-be-constructed + // SecretService because the OpenBao driver's auth token lives in a plaintext + // Secret. The bridge defers the resolve until after `secretService` is + // assigned, breaking the circular dependency at construction time. + const secretResolverBridge = { + resolve: async (name: string, key: string): Promise => secretService.resolve(name, key), + }; + const secretBackendService = new SecretBackendService(secretBackendRepo, { + plaintext: { + listAllPlaintext: async () => { + const rows = await prisma.secret.findMany({ + where: { backend: { type: 'plaintext' } }, + select: { name: true, data: true }, + }); + return rows.map((r) => ({ name: r.name, data: r.data as Record })); + }, + }, + secretRefResolver: secretResolverBridge, + }); + const secretService = new SecretService(secretRepo, secretBackendService); + const secretMigrateService = new SecretMigrateService(secretRepo, secretBackendService); + const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretService); serverService.setInstanceService(instanceService); - const secretService = new SecretService(secretRepo); const projectService = new ProjectService(projectRepo, serverRepo); const auditLogService = new AuditLogService(auditLogRepo); const auditEventService = new AuditEventService(auditEventRepo); @@ -313,7 +348,7 @@ async function main(): Promise { promptRuleRegistry.register(systemPromptVarsRule); const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry); const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo); - const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo); + const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, secretService, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo); // Shared auth dependencies. Both the global auth hook and the per-route // preHandler on /api/v1/mcp/proxy must know how to resolve both session @@ -430,6 +465,8 @@ async function main(): Promise { registerMcpServerRoutes(app, serverService, instanceService); registerTemplateRoutes(app, templateService); registerSecretRoutes(app, secretService); + registerSecretBackendRoutes(app, secretBackendService); + registerSecretMigrateRoutes(app, secretMigrateService); registerInstanceRoutes(app, instanceService); registerProjectRoutes(app, projectService); registerAuditLogRoutes(app, auditLogService); diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts index 950c339..919e3ed 100644 --- a/src/mcpd/src/repositories/interfaces.ts +++ b/src/mcpd/src/repositories/interfaces.ts @@ -1,6 +1,6 @@ import type { McpServer, McpInstance, AuditLog, AuditEvent, McpToken, Secret, InstanceStatus } from '@prisma/client'; import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js'; -import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js'; +import type { SecretRepoCreateInput, SecretRepoUpdateInput } from './secret.repository.js'; export interface IMcpServerRepository { findAll(): Promise; @@ -24,8 +24,9 @@ export interface ISecretRepository { findAll(): Promise; findById(id: string): Promise; findByName(name: string): Promise; - create(data: CreateSecretInput): Promise; - update(id: string, data: UpdateSecretInput): Promise; + findByBackend(backendId: string): Promise; + create(data: SecretRepoCreateInput): Promise; + update(id: string, data: SecretRepoUpdateInput): Promise; delete(id: string): Promise; } diff --git a/src/mcpd/src/repositories/secret-backend.repository.ts b/src/mcpd/src/repositories/secret-backend.repository.ts new file mode 100644 index 0000000..e6d04bb --- /dev/null +++ b/src/mcpd/src/repositories/secret-backend.repository.ts @@ -0,0 +1,103 @@ +import type { PrismaClient, SecretBackend, Prisma } from '@prisma/client'; + +export interface CreateSecretBackendInput { + name: string; + type: string; + config?: Record; + isDefault?: boolean; + description?: string; +} + +export interface UpdateSecretBackendInput { + config?: Record; + isDefault?: boolean; + description?: string; +} + +export interface ISecretBackendRepository { + findAll(): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + findDefault(): Promise; + create(data: CreateSecretBackendInput): Promise; + update(id: string, data: UpdateSecretBackendInput): Promise; + /** + * Atomically clear `isDefault` on every row except the one named, then set + * the given row as default. Used by `setDefault`. + */ + setAsDefault(id: string): Promise; + delete(id: string): Promise; + /** Count secrets that still reference this backend — used to guard delete. */ + countReferencingSecrets(backendId: string): Promise; +} + +export class SecretBackendRepository implements ISecretBackendRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(): Promise { + return this.prisma.secretBackend.findMany({ orderBy: { name: 'asc' } }); + } + + async findById(id: string): Promise { + return this.prisma.secretBackend.findUnique({ where: { id } }); + } + + async findByName(name: string): Promise { + return this.prisma.secretBackend.findUnique({ where: { name } }); + } + + async findDefault(): Promise { + return this.prisma.secretBackend.findFirst({ where: { isDefault: true } }); + } + + async create(data: CreateSecretBackendInput): Promise { + return this.prisma.$transaction(async (tx) => { + if (data.isDefault === true) { + await tx.secretBackend.updateMany({ where: { isDefault: true }, data: { isDefault: false } }); + } + return tx.secretBackend.create({ + data: { + name: data.name, + type: data.type, + config: (data.config ?? {}) as Prisma.InputJsonValue, + isDefault: data.isDefault ?? false, + description: data.description ?? '', + }, + }); + }); + } + + async update(id: string, data: UpdateSecretBackendInput): Promise { + return this.prisma.$transaction(async (tx) => { + if (data.isDefault === true) { + await tx.secretBackend.updateMany({ + where: { isDefault: true, NOT: { id } }, + data: { isDefault: false }, + }); + } + const updateData: Prisma.SecretBackendUpdateInput = {}; + if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue; + if (data.isDefault !== undefined) updateData.isDefault = data.isDefault; + if (data.description !== undefined) updateData.description = data.description; + return tx.secretBackend.update({ where: { id }, data: updateData }); + }); + } + + async setAsDefault(id: string): Promise { + return this.prisma.$transaction(async (tx) => { + await tx.secretBackend.updateMany({ + where: { isDefault: true, NOT: { id } }, + data: { isDefault: false }, + }); + return tx.secretBackend.update({ where: { id }, data: { isDefault: true } }); + }); + } + + async delete(id: string): Promise { + await this.prisma.secretBackend.delete({ where: { id } }); + } + + async countReferencingSecrets(backendId: string): Promise { + return this.prisma.secret.count({ where: { backendId } }); + } +} diff --git a/src/mcpd/src/repositories/secret.repository.ts b/src/mcpd/src/repositories/secret.repository.ts index 05bb162..72434ce 100644 --- a/src/mcpd/src/repositories/secret.repository.ts +++ b/src/mcpd/src/repositories/secret.repository.ts @@ -1,6 +1,18 @@ -import { type PrismaClient, type Secret } from '@prisma/client'; +import { type PrismaClient, type Secret, type Prisma } from '@prisma/client'; import type { ISecretRepository } from './interfaces.js'; -import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js'; + +export interface SecretRepoCreateInput { + name: string; + backendId: string; + data?: Record; + externalRef?: string; +} + +export interface SecretRepoUpdateInput { + data?: Record; + externalRef?: string; + backendId?: string; +} export class SecretRepository implements ISecretRepository { constructor(private readonly prisma: PrismaClient) {} @@ -17,20 +29,29 @@ export class SecretRepository implements ISecretRepository { return this.prisma.secret.findUnique({ where: { name } }); } - async create(data: CreateSecretInput): Promise { + async findByBackend(backendId: string): Promise { + return this.prisma.secret.findMany({ where: { backendId }, orderBy: { name: 'asc' } }); + } + + async create(data: SecretRepoCreateInput): Promise { return this.prisma.secret.create({ data: { name: data.name, - data: data.data, + backendId: data.backendId, + data: (data.data ?? {}) as Prisma.InputJsonValue, + externalRef: data.externalRef ?? '', }, }); } - async update(id: string, data: UpdateSecretInput): Promise { - return this.prisma.secret.update({ - where: { id }, - data: { data: data.data }, - }); + async update(id: string, data: SecretRepoUpdateInput): Promise { + const updateData: Prisma.SecretUpdateInput = {}; + if (data.data !== undefined) updateData.data = data.data as Prisma.InputJsonValue; + if (data.externalRef !== undefined) updateData.externalRef = data.externalRef; + if (data.backendId !== undefined) { + updateData.backend = { connect: { id: data.backendId } }; + } + return this.prisma.secret.update({ where: { id }, data: updateData }); } async delete(id: string): Promise { diff --git a/src/mcpd/src/routes/secret-backends.ts b/src/mcpd/src/routes/secret-backends.ts new file mode 100644 index 0000000..4ea3664 --- /dev/null +++ b/src/mcpd/src/routes/secret-backends.ts @@ -0,0 +1,89 @@ +import type { FastifyInstance } from 'fastify'; +import type { SecretBackendService } from '../services/secret-backend.service.js'; +import { SecretBackendInUseError } from '../services/secret-backend.service.js'; +import { NotFoundError, ConflictError } from '../services/mcp-server.service.js'; + +export function registerSecretBackendRoutes( + app: FastifyInstance, + service: SecretBackendService, +): void { + app.get('/api/v1/secretbackends', async () => { + const rows = await service.list(); + return rows.map(redactConfig); + }); + + app.get<{ Params: { id: string } }>('/api/v1/secretbackends/:id', async (request) => { + const row = await service.getById(request.params.id); + return redactConfig(row); + }); + + app.post('/api/v1/secretbackends', async (request, reply) => { + try { + const row = await service.create(request.body as { + name: string; + type: string; + config?: Record; + isDefault?: boolean; + description?: string; + }); + reply.code(201); + return redactConfig(row); + } catch (err) { + if (err instanceof ConflictError) { + reply.code(409); + return { error: err.message }; + } + throw err; + } + }); + + app.put<{ Params: { id: string } }>('/api/v1/secretbackends/:id', async (request) => { + const row = await service.update(request.params.id, request.body as { + config?: Record; + isDefault?: boolean; + description?: string; + }); + return redactConfig(row); + }); + + app.post<{ Params: { id: string } }>('/api/v1/secretbackends/:id/default', async (request) => { + const row = await service.setDefault(request.params.id); + return redactConfig(row); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/secretbackends/:id', async (request, reply) => { + try { + await service.delete(request.params.id); + reply.code(204); + return null; + } catch (err) { + if (err instanceof SecretBackendInUseError) { + reply.code(409); + return { error: err.message }; + } + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }); +} + +/** + * Strip any value from `config` whose key looks like a credential, and replace + * tokenSecretRef with a short description. Prevents accidental exposure via + * GET responses. + */ +function redactConfig(row: T): T { + const config = (row.config ?? {}) as Record; + const cleaned: Record = {}; + for (const [k, v] of Object.entries(config)) { + if (/token|secret|password|key/i.test(k) && typeof v === 'string') { + cleaned[k] = '***'; + } else { + cleaned[k] = v; + } + } + return { ...row, config: cleaned }; +} diff --git a/src/mcpd/src/routes/secret-migrate.ts b/src/mcpd/src/routes/secret-migrate.ts new file mode 100644 index 0000000..6b68c48 --- /dev/null +++ b/src/mcpd/src/routes/secret-migrate.ts @@ -0,0 +1,41 @@ +import type { FastifyInstance } from 'fastify'; +import type { SecretMigrateService } from '../services/secret-migrate.service.js'; + +export function registerSecretMigrateRoutes( + app: FastifyInstance, + service: SecretMigrateService, +): void { + /** + * POST /api/v1/secrets/migrate + * body: { from: string, to: string, names?: string[], keepSource?: boolean, dryRun?: boolean } + * RBAC: operation `migrate-secrets` (role:run). + */ + app.post<{ + Body: { + from: string; + to: string; + names?: string[]; + keepSource?: boolean; + dryRun?: boolean; + }; + }>('/api/v1/secrets/migrate', async (request, reply) => { + const { from, to, names, keepSource, dryRun } = request.body; + if (!from || !to) { + reply.code(400); + return { error: 'from and to are required' }; + } + + if (dryRun === true) { + const options: Parameters[0] = { from, to }; + if (names !== undefined) options.names = names; + if (keepSource !== undefined) options.keepSource = keepSource; + const secrets = await service.dryRun(options); + return { dryRun: true, candidates: secrets.map((s) => ({ id: s.id, name: s.name })) }; + } + + const options: Parameters[0] = { from, to }; + if (names !== undefined) options.names = names; + if (keepSource !== undefined) options.keepSource = keepSource; + return service.migrate(options); + }); +} diff --git a/src/mcpd/src/services/backup/restore-service.ts b/src/mcpd/src/services/backup/restore-service.ts index 6771948..feed096 100644 --- a/src/mcpd/src/services/backup/restore-service.ts +++ b/src/mcpd/src/services/backup/restore-service.ts @@ -6,6 +6,7 @@ import type { IRbacDefinitionRepository } from '../../repositories/rbac-definiti import type { IPromptRepository } from '../../repositories/prompt.repository.js'; import type { ITemplateRepository } from '../../repositories/template.repository.js'; import type { RbacRoleBinding } from '../../validation/rbac-definition.schema.js'; +import type { SecretService } from '../secret.service.js'; import { decrypt } from './crypto.js'; import type { BackupBundle } from './backup-service.js'; @@ -41,6 +42,7 @@ export class RestoreService { private serverRepo: IMcpServerRepository, private projectRepo: IProjectRepository, private secretRepo: ISecretRepository, + private secretService: SecretService, private userRepo?: IUserRepository, private groupRepo?: IGroupRepository, private rbacRepo?: IRbacDefinitionRepository, @@ -125,16 +127,13 @@ export class RestoreService { result.secretsSkipped++; continue; } - // overwrite - await this.secretRepo.update(existing.id, { data: secret.data }); + // overwrite — route through SecretService so backend dispatch applies. + await this.secretService.update(existing.id, { data: secret.data }); result.secretsCreated++; continue; } - await this.secretRepo.create({ - name: secret.name, - data: secret.data, - }); + await this.secretService.create({ name: secret.name, data: secret.data }); result.secretsCreated++; } catch (err) { result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`); diff --git a/src/mcpd/src/services/env-resolver.ts b/src/mcpd/src/services/env-resolver.ts index efc8bcd..c964079 100644 --- a/src/mcpd/src/services/env-resolver.ts +++ b/src/mcpd/src/services/env-resolver.ts @@ -1,42 +1,44 @@ import type { McpServer } from '@prisma/client'; -import type { ISecretRepository } from '../repositories/interfaces.js'; import type { ServerEnvEntry } from '../validation/mcp-server.schema.js'; +/** + * Minimal dependency surface for the env resolver: anything that can turn a + * (secretName, key) pair into a string. Matches `SecretService.resolve()` so + * resolution now flows through the configured SecretBackend driver instead + * of reading `Secret.data` directly. + */ +export interface SecretResolver { + resolve(secretName: string, key: string): Promise; +} + /** * Resolve a server's env entries into a flat key-value map. * - Inline `value` entries are used directly. - * - `valueFrom.secretRef` entries are looked up from the secret repository. + * - `valueFrom.secretRef` entries are looked up through the resolver. * Throws if a referenced secret or key is missing. */ export async function resolveServerEnv( server: McpServer, - secretRepo: ISecretRepository, + resolver: SecretResolver, ): Promise> { const entries = server.env as ServerEnvEntry[]; if (!entries || entries.length === 0) return {}; const result: Record = {}; - const secretCache = new Map>(); for (const entry of entries) { if (entry.value !== undefined) { result[entry.name] = entry.value; } else if (entry.valueFrom?.secretRef) { const { name: secretName, key } = entry.valueFrom.secretRef; - - if (!secretCache.has(secretName)) { - const secret = await secretRepo.findByName(secretName); - if (!secret) { - throw new Error(`Secret '${secretName}' not found (referenced by server '${server.name}' env '${entry.name}')`); - } - secretCache.set(secretName, secret.data as Record); + try { + result[entry.name] = await resolver.resolve(secretName, key); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Cannot resolve secret for server '${server.name}' env '${entry.name}': ${msg}`, + ); } - - const data = secretCache.get(secretName)!; - if (!(key in data)) { - throw new Error(`Key '${key}' not found in secret '${secretName}' (referenced by server '${server.name}' env '${entry.name}')`); - } - result[entry.name] = data[key]!; } } diff --git a/src/mcpd/src/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts index 6e72bcc..ed0b1f9 100644 --- a/src/mcpd/src/services/instance.service.ts +++ b/src/mcpd/src/services/instance.service.ts @@ -1,8 +1,8 @@ import type { McpInstance } from '@prisma/client'; -import type { IMcpInstanceRepository, IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.js'; +import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js'; import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js'; import { NotFoundError } from './mcp-server.service.js'; -import { resolveServerEnv } from './env-resolver.js'; +import { resolveServerEnv, type SecretResolver } from './env-resolver.js'; /** Runner images for package-based MCP servers, keyed by runtime name. */ const RUNNER_IMAGES: Record = { @@ -26,7 +26,7 @@ export class InstanceService { private instanceRepo: IMcpInstanceRepository, private serverRepo: IMcpServerRepository, private orchestrator: McpOrchestrator, - private secretRepo?: ISecretRepository, + private secretResolver?: SecretResolver, ) {} async list(serverId?: string): Promise { @@ -284,9 +284,9 @@ export class InstanceService { } // Resolve env vars from inline values and secret refs - if (this.secretRepo) { + if (this.secretResolver) { try { - const resolvedEnv = await resolveServerEnv(server, this.secretRepo); + const resolvedEnv = await resolveServerEnv(server, this.secretResolver); if (Object.keys(resolvedEnv).length > 0) { spec.env = resolvedEnv; } diff --git a/src/mcpd/src/services/secret-backend.service.ts b/src/mcpd/src/services/secret-backend.service.ts new file mode 100644 index 0000000..44e049d --- /dev/null +++ b/src/mcpd/src/services/secret-backend.service.ts @@ -0,0 +1,88 @@ +import type { SecretBackend } from '@prisma/client'; +import type { ISecretBackendRepository } from '../repositories/secret-backend.repository.js'; +import type { SecretBackendDriver } from './secret-backends/types.js'; +import { createDriver, type DriverFactoryDeps } from './secret-backends/factory.js'; +import { NotFoundError, ConflictError } from './mcp-server.service.js'; + +export class SecretBackendInUseError extends Error { + constructor(backendName: string, count: number) { + super(`SecretBackend '${backendName}' is still referenced by ${String(count)} secret(s); migrate them first`); + this.name = 'SecretBackendInUseError'; + } +} + +export class SecretBackendService { + private driverCache = new Map(); // keyed by backend id + + constructor( + private readonly repo: ISecretBackendRepository, + private readonly driverDeps: DriverFactoryDeps, + ) {} + + async list(): Promise { + return this.repo.findAll(); + } + + async getById(id: string): Promise { + const row = await this.repo.findById(id); + if (row === null) throw new NotFoundError(`SecretBackend not found: ${id}`); + return row; + } + + async getByName(name: string): Promise { + const row = await this.repo.findByName(name); + if (row === null) throw new NotFoundError(`SecretBackend not found: ${name}`); + return row; + } + + async getDefault(): Promise { + const row = await this.repo.findDefault(); + if (row === null) { + throw new Error('No default SecretBackend configured. This shouldn\'t happen — the plaintext row should have been seeded on startup.'); + } + return row; + } + + async create(input: { + name: string; + type: string; + config?: Record; + isDefault?: boolean; + description?: string; + }): Promise { + if (!input.name || !input.type) throw new Error('name and type are required'); + const existing = await this.repo.findByName(input.name); + if (existing !== null) throw new ConflictError(`SecretBackend already exists: ${input.name}`); + return this.repo.create(input); + } + + async update(id: string, input: { config?: Record; isDefault?: boolean; description?: string }): Promise { + await this.getById(id); + const row = await this.repo.update(id, input); + this.driverCache.delete(id); // config may have changed; rebuild lazily + return row; + } + + async setDefault(id: string): Promise { + await this.getById(id); + return this.repo.setAsDefault(id); + } + + async delete(id: string): Promise { + const row = await this.getById(id); + const count = await this.repo.countReferencingSecrets(id); + if (count > 0) throw new SecretBackendInUseError(row.name, count); + if (row.isDefault) throw new Error(`Cannot delete the default SecretBackend '${row.name}'; promote another one first`); + await this.repo.delete(id); + this.driverCache.delete(id); + } + + /** Get the driver for a given backend id, creating + caching on first call. */ + driverFor(backend: SecretBackend): SecretBackendDriver { + const cached = this.driverCache.get(backend.id); + if (cached) return cached; + const driver = createDriver(backend, this.driverDeps); + this.driverCache.set(backend.id, driver); + return driver; + } +} diff --git a/src/mcpd/src/services/secret-backends/factory.ts b/src/mcpd/src/services/secret-backends/factory.ts new file mode 100644 index 0000000..e4c89b1 --- /dev/null +++ b/src/mcpd/src/services/secret-backends/factory.ts @@ -0,0 +1,43 @@ +/** + * Build a `SecretBackendDriver` from a `SecretBackend` row. + * + * Lives separate from the service because it's the only place aware of every + * driver type — adding a new backend means adding one case here and one + * driver file. Everything else (service, routes, CLI) is type-agnostic. + */ +import type { SecretBackend } from '@prisma/client'; +import type { SecretBackendDriver, SecretRefResolver } from './types.js'; +import { PlaintextDriver, type PlaintextDriverDeps } from './plaintext.js'; +import { OpenBaoDriver, type OpenBaoConfig } from './openbao.js'; + +export interface DriverFactoryDeps { + plaintext: PlaintextDriverDeps; + /** Resolves `{secretName, key}` against the plaintext backend — used by remote drivers' auth. */ + secretRefResolver: SecretRefResolver; + /** Overridable for tests. */ + fetch?: typeof globalThis.fetch; +} + +export function createDriver(row: SecretBackend, deps: DriverFactoryDeps): SecretBackendDriver { + switch (row.type) { + case 'plaintext': + return new PlaintextDriver(deps.plaintext); + + case 'openbao': { + const cfg = row.config as unknown as OpenBaoConfig; + if (!cfg.url || !cfg.tokenSecretRef?.name || !cfg.tokenSecretRef?.key) { + throw new Error( + `SecretBackend '${row.name}' (openbao): config must provide url + tokenSecretRef {name, key}`, + ); + } + const driverDeps: { fetch?: typeof globalThis.fetch; secretRefResolver: SecretRefResolver } = { + secretRefResolver: deps.secretRefResolver, + }; + if (deps.fetch !== undefined) driverDeps.fetch = deps.fetch; + return new OpenBaoDriver(cfg, driverDeps); + } + + default: + throw new Error(`Unknown SecretBackend type: ${row.type}`); + } +} diff --git a/src/mcpd/src/services/secret-backends/openbao.ts b/src/mcpd/src/services/secret-backends/openbao.ts new file mode 100644 index 0000000..895394b --- /dev/null +++ b/src/mcpd/src/services/secret-backends/openbao.ts @@ -0,0 +1,133 @@ +/** + * OpenBao (MPL 2.0 fork of HashiCorp Vault) driver for the KV v2 secrets engine. + * + * Uses the plain HTTP API — no third-party client — so we don't pick up a + * Vault SDK licensing headache. Endpoints touched: + * + * POST /v1//data/ -- write + * GET /v1//data/ -- read latest + * DELETE /v1//metadata/ -- full delete (all versions) + * LIST /v1//metadata/ -- for migration + * + * Auth: static token for v1. The token is stored in a `Secret` on the + * plaintext backend (see `config.tokenSecretRef = { name, key }`); the driver + * resolves it on construction via the injected `SecretRefResolver`. Follow-up + * work (not here) adds Kubernetes ServiceAccount auth. + * + * Path layout inside OpenBao: + * // + * `mount` and `pathPrefix` come from the backend's `config` JSON; defaults are + * `secret` and `mcpctl/`. + */ +import type { SecretBackendDriver, SecretData, ExternalRef, SecretRefResolver } from './types.js'; + +export interface OpenBaoConfig { + url: string; + mount?: string; + pathPrefix?: string; + namespace?: string; + tokenSecretRef: { name: string; key: string }; +} + +export interface OpenBaoDriverDeps { + /** Injected HTTP fetcher — mockable in tests. */ + fetch?: typeof globalThis.fetch; + secretRefResolver: SecretRefResolver; +} + +export class OpenBaoDriver implements SecretBackendDriver { + readonly kind = 'openbao'; + + private readonly url: string; + private readonly mount: string; + private readonly pathPrefix: string; + private readonly namespace: string | undefined; + private readonly tokenSecretRef: { name: string; key: string }; + private readonly fetchImpl: typeof globalThis.fetch; + private readonly resolver: SecretRefResolver; + private cachedToken: string | undefined; + + constructor(config: OpenBaoConfig, deps: OpenBaoDriverDeps) { + this.url = config.url.replace(/\/+$/, ''); + this.mount = (config.mount ?? 'secret').replace(/^\/|\/$/g, ''); + this.pathPrefix = (config.pathPrefix ?? 'mcpctl').replace(/^\/|\/$/g, ''); + if (config.namespace !== undefined) this.namespace = config.namespace; + this.tokenSecretRef = config.tokenSecretRef; + this.fetchImpl = deps.fetch ?? globalThis.fetch; + this.resolver = deps.secretRefResolver; + } + + async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise { + const path = this.pathFor(input.name); + const res = await this.request('GET', `/v1/${this.mount}/data/${path}`); + if (res.status === 404) { + throw new Error(`OpenBao: secret '${input.name}' not found at ${path}`); + } + if (!res.ok) throw new Error(`OpenBao read ${path}: HTTP ${res.status}`); + const body = await res.json() as { data?: { data?: SecretData } }; + return body.data?.data ?? {}; + } + + async write(input: { name: string; data: SecretData }): Promise<{ externalRef: ExternalRef; storedData: SecretData }> { + const path = this.pathFor(input.name); + const res = await this.request('POST', `/v1/${this.mount}/data/${path}`, { data: input.data }); + if (!res.ok) throw new Error(`OpenBao write ${path}: HTTP ${res.status}`); + return { externalRef: `${this.mount}/${path}`, storedData: {} }; + } + + async delete(input: { name: string; externalRef: ExternalRef }): Promise { + const path = this.pathFor(input.name); + const res = await this.request('DELETE', `/v1/${this.mount}/metadata/${path}`); + if (!res.ok && res.status !== 404) { + throw new Error(`OpenBao delete ${path}: HTTP ${res.status}`); + } + } + + async list(): Promise> { + const listPath = this.pathPrefix === '' ? '' : `${this.pathPrefix}/`; + const res = await this.request('LIST', `/v1/${this.mount}/metadata/${listPath}`); + if (res.status === 404) return []; + if (!res.ok) throw new Error(`OpenBao list: HTTP ${res.status}`); + const body = await res.json() as { data?: { keys?: string[] } }; + const keys = body.data?.keys ?? []; + return keys + .filter((k) => !k.endsWith('/')) + .map((k) => ({ + name: k, + externalRef: `${this.mount}/${this.pathPrefix === '' ? '' : `${this.pathPrefix}/`}${k}`, + })); + } + + async healthCheck(): Promise<{ ok: boolean; detail?: string }> { + try { + const res = await this.request('GET', '/v1/sys/health'); + return { ok: res.ok, detail: `HTTP ${res.status}` }; + } catch (err) { + return { ok: false, detail: err instanceof Error ? err.message : String(err) }; + } + } + + private pathFor(name: string): string { + const safe = encodeURIComponent(name); + return this.pathPrefix === '' ? safe : `${this.pathPrefix}/${safe}`; + } + + private async getToken(): Promise { + if (this.cachedToken !== undefined) return this.cachedToken; + const token = await this.resolver.resolve(this.tokenSecretRef.name, this.tokenSecretRef.key); + this.cachedToken = token; + return token; + } + + private async request(method: string, path: string, body?: unknown): Promise { + const token = await this.getToken(); + const headers: Record = { 'X-Vault-Token': token }; + if (this.namespace !== undefined) headers['X-Vault-Namespace'] = this.namespace; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + + const init: RequestInit = { method, headers }; + if (body !== undefined) init.body = JSON.stringify(body); + + return this.fetchImpl(`${this.url}${path}`, init); + } +} diff --git a/src/mcpd/src/services/secret-backends/plaintext.ts b/src/mcpd/src/services/secret-backends/plaintext.ts new file mode 100644 index 0000000..97e5fd4 --- /dev/null +++ b/src/mcpd/src/services/secret-backends/plaintext.ts @@ -0,0 +1,44 @@ +/** + * Plaintext backend driver — stores Secret.data directly in the DB column. + * + * This is the bootstrap/default backend. It always exists (seeded on startup) + * so the system can hold its own backends' auth credentials (e.g. OpenBao + * token) somewhere before the real backend is configured. + * + * The driver is deliberately almost a no-op: the service writes to and reads + * from `Secret.data` directly. We still route through the driver interface so + * the service layer can stay uniform. + */ +import type { SecretBackendDriver, SecretData, ExternalRef } from './types.js'; + +export interface PlaintextDriverDeps { + /** Queries `prisma.secret.findMany(...)` for the `list` method (migration path). */ + listAllPlaintext: () => Promise>; +} + +export class PlaintextDriver implements SecretBackendDriver { + readonly kind = 'plaintext'; + + constructor(private readonly deps: PlaintextDriverDeps) {} + + async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise { + return input.data; + } + + async write(input: { name: string; data: SecretData }): Promise<{ externalRef: ExternalRef; storedData: SecretData }> { + return { externalRef: '', storedData: input.data }; + } + + async delete(_input: { name: string; externalRef: ExternalRef }): Promise { + // The row deletion itself is the secret service's job; nothing remote to clean up here. + } + + async list(): Promise> { + const rows = await this.deps.listAllPlaintext(); + return rows.map((r) => ({ name: r.name, externalRef: '' })); + } + + async healthCheck(): Promise<{ ok: boolean; detail?: string }> { + return { ok: true, detail: 'plaintext backend (DB)' }; + } +} diff --git a/src/mcpd/src/services/secret-backends/types.ts b/src/mcpd/src/services/secret-backends/types.ts new file mode 100644 index 0000000..bab41d5 --- /dev/null +++ b/src/mcpd/src/services/secret-backends/types.ts @@ -0,0 +1,68 @@ +/** + * SecretBackend driver interface. + * + * The plaintext backend stores `data` in the DB column directly. + * Remote backends (openbao, vault, cloud KV) store an opaque `externalRef` + * and fetch the actual data on demand. + * + * Drivers are stateless factories keyed on a `SecretBackend` config row. + * Secret management (CRUD, naming) stays in the service layer; drivers + * handle only the storage I/O. + */ + +/** + * Opaque reference written by a driver on `write` and read back on `read`. + * + * For the plaintext driver this is unused — the data itself lives in + * `Secret.data`. For openbao it's a string like `secret/data/mcpctl/mysecret` + * that tells the driver where to fetch on next `read`. + */ +export type ExternalRef = string; + +/** The shape of secret data — a flat map of key → value. */ +export type SecretData = Record; + +export interface SecretBackendDriver { + /** Human-readable identifier, included in errors. */ + readonly kind: string; + + /** + * Read the stored secret. For plaintext this is a no-op — the data is + * already in the Secret row and passed in here for symmetry. For remote + * backends this makes the network call. + */ + read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise; + + /** + * Store a new secret (or a new version of an existing one). Returns the + * reference (or an empty string for plaintext) + the `data` object that + * should be persisted on the Secret row (empty for remote backends). + */ + write(input: { name: string; data: SecretData }): Promise<{ externalRef: ExternalRef; storedData: SecretData }>; + + /** Remove the secret from the backend. Idempotent — missing is OK. */ + delete(input: { name: string; externalRef: ExternalRef }): Promise; + + /** List everything the backend knows about. Used for migration + drift detection. */ + list(): Promise>; + + /** Optional: health probe. Used by `mcpctl describe secretbackend`. */ + healthCheck?(): Promise<{ ok: boolean; detail?: string }>; +} + +/** Stored config for a SecretBackend row; dispatched on `type`. */ +export interface BackendRow { + id: string; + name: string; + type: string; + config: Record; +} + +/** + * Dependency passed to the openbao driver so it can resolve its own auth + * token (stored in the plaintext backend — chicken-and-egg bootstrap). + * Implemented by the SecretService so we don't have a circular import. + */ +export interface SecretRefResolver { + resolve(secretName: string, key: string): Promise; +} diff --git a/src/mcpd/src/services/secret-migrate.service.ts b/src/mcpd/src/services/secret-migrate.service.ts new file mode 100644 index 0000000..80c27fb --- /dev/null +++ b/src/mcpd/src/services/secret-migrate.service.ts @@ -0,0 +1,113 @@ +/** + * Move secrets from one SecretBackend to another. + * + * Per-secret atomicity: for each secret we + * 1. resolve the data via the source driver, + * 2. write it to the destination driver, + * 3. update the Secret row (flip backendId + set new externalRef, clear data), + * 4. optionally delete from source. + * + * If the process dies between 2 and 3, the destination has an orphan entry + * but the row still points at the source — restart is idempotent (skips rows + * already on destination). We never run a batch-wide transaction because each + * remote driver write is a real HTTP call that can't roll back. + */ +import type { Secret } from '@prisma/client'; +import type { ISecretRepository } from '../repositories/interfaces.js'; +import type { SecretBackendService } from './secret-backend.service.js'; + +export interface MigrateOptions { + /** Source backend name. */ + from: string; + /** Destination backend name. */ + to: string; + /** If provided, only migrate secrets with these names. Otherwise migrate all. */ + names?: string[]; + /** Leave the source copy intact after migration. Default false. */ + keepSource?: boolean; +} + +export interface MigrateResult { + migrated: Array<{ name: string }>; + skipped: Array<{ name: string; reason: string }>; + failed: Array<{ name: string; error: string }>; +} + +export class SecretMigrateService { + constructor( + private readonly secretRepo: ISecretRepository, + private readonly backends: SecretBackendService, + ) {} + + async migrate(opts: MigrateOptions): Promise { + const source = await this.backends.getByName(opts.from); + const dest = await this.backends.getByName(opts.to); + if (source.id === dest.id) { + return { migrated: [], skipped: [], failed: [{ name: '*', error: 'source and destination are the same backend' }] }; + } + + const sourceDriver = this.backends.driverFor(source); + const destDriver = this.backends.driverFor(dest); + + let secrets = await this.secretRepo.findByBackend(source.id); + if (opts.names && opts.names.length > 0) { + const wanted = new Set(opts.names); + secrets = secrets.filter((s) => wanted.has(s.name)); + } + + const result: MigrateResult = { migrated: [], skipped: [], failed: [] }; + for (const secret of secrets) { + try { + // Skip if somehow already on destination (re-run safety). + if (secret.backendId === dest.id) { + result.skipped.push({ name: secret.name, reason: 'already on destination' }); + continue; + } + + const data = await sourceDriver.read({ + name: secret.name, + externalRef: secret.externalRef, + data: secret.data as Record, + }); + const written = await destDriver.write({ name: secret.name, data }); + + await this.secretRepo.update(secret.id, { + backendId: dest.id, + data: written.storedData, + externalRef: written.externalRef, + }); + + if (opts.keepSource !== true) { + await sourceDriver.delete({ name: secret.name, externalRef: secret.externalRef }) + .catch((err: unknown) => { + // Destination is intact; best-effort source cleanup. Log + continue. + const msg = err instanceof Error ? err.message : String(err); + result.skipped.push({ name: secret.name, reason: `migrated OK; source cleanup failed: ${msg}` }); + }); + } + + result.migrated.push({ name: secret.name }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + result.failed.push({ name: secret.name, error: msg }); + } + } + + return result; + } + + /** Track which secrets would be touched by a migrate run, without performing it. */ + async dryRun(opts: MigrateOptions): Promise> { + const source = await this.backends.getByName(opts.from); + let secrets = await this.secretRepo.findByBackend(source.id); + if (opts.names && opts.names.length > 0) { + const wanted = new Set(opts.names); + secrets = secrets.filter((s) => wanted.has(s.name)); + } + return secrets; + } +} + +export interface SecretMigrateRouteDeps { + migrateService: SecretMigrateService; +} diff --git a/src/mcpd/src/services/secret.service.ts b/src/mcpd/src/services/secret.service.ts index 581ed22..0603cd9 100644 --- a/src/mcpd/src/services/secret.service.ts +++ b/src/mcpd/src/services/secret.service.ts @@ -1,10 +1,23 @@ +/** + * SecretService — CRUD over `Secret` rows. + * + * Dispatches storage I/O through the `SecretBackendService`: on create/update + * the default backend's driver writes, and the resulting {externalRef, + * storedData} is persisted on the row. On read (`resolveData`) the row's + * `backendId` selects the driver, which fetches the actual data. + */ import type { Secret } from '@prisma/client'; import type { ISecretRepository } from '../repositories/interfaces.js'; +import type { SecretBackendService } from './secret-backend.service.js'; import { CreateSecretSchema, UpdateSecretSchema } from '../validation/secret.schema.js'; import { NotFoundError, ConflictError } from './mcp-server.service.js'; +import type { SecretRefResolver } from './secret-backends/types.js'; -export class SecretService { - constructor(private readonly repo: ISecretRepository) {} +export class SecretService implements SecretRefResolver { + constructor( + private readonly repo: ISecretRepository, + private readonly backends: SecretBackendService, + ) {} async list(): Promise { return this.repo.findAll(); @@ -26,47 +39,79 @@ export class SecretService { return secret; } + /** Return the secret's actual data by dispatching through its backend driver. */ + async resolveData(secret: Secret): Promise> { + const backend = await this.backends.getById(secret.backendId); + const driver = this.backends.driverFor(backend); + return driver.read({ + name: secret.name, + externalRef: secret.externalRef, + data: secret.data as Record, + }); + } + + /** Convenience: resolve {secretName, key} → string. Implements SecretRefResolver. */ + async resolve(secretName: string, key: string): Promise { + const secret = await this.getByName(secretName); + const data = await this.resolveData(secret); + const value = data[key]; + if (value === undefined) { + throw new NotFoundError(`Secret '${secretName}' has no key '${key}'`); + } + return value; + } + async create(input: unknown): Promise { const data = CreateSecretSchema.parse(input); - const existing = await this.repo.findByName(data.name); if (existing !== null) { throw new ConflictError(`Secret already exists: ${data.name}`); } - - return this.repo.create(data); + const backend = await this.backends.getDefault(); + const driver = this.backends.driverFor(backend); + const written = await driver.write({ name: data.name, data: data.data }); + return this.repo.create({ + name: data.name, + backendId: backend.id, + data: written.storedData, + externalRef: written.externalRef, + }); } async update(id: string, input: unknown): Promise { const data = UpdateSecretSchema.parse(input); - - // Verify exists - await this.getById(id); - - return this.repo.update(id, data); + const existing = await this.getById(id); + const backend = await this.backends.getById(existing.backendId); + const driver = this.backends.driverFor(backend); + const written = await driver.write({ name: existing.name, data: data.data }); + return this.repo.update(id, { + data: written.storedData, + externalRef: written.externalRef, + }); } async delete(id: string): Promise { - // Verify exists - await this.getById(id); + const existing = await this.getById(id); + const backend = await this.backends.getById(existing.backendId); + const driver = this.backends.driverFor(backend); + await driver.delete({ name: existing.name, externalRef: existing.externalRef }); await this.repo.delete(id); } - // ── Backup/restore helpers ── + // ── Backup/restore helpers (preserved) ── async upsertByName(data: Record): Promise { const name = data['name'] as string; const existing = await this.repo.findByName(name); if (existing !== null) { - const { name: _, ...updateFields } = data; - return this.repo.update(existing.id, updateFields as Parameters[1]); + return this.update(existing.id, data); } - return this.repo.create(data as Parameters[0]); + return this.create(data); } async deleteByName(name: string): Promise { const existing = await this.repo.findByName(name); if (existing === null) return; - await this.repo.delete(existing.id); + await this.delete(existing.id); } } diff --git a/src/mcpd/src/validation/rbac-definition.schema.ts b/src/mcpd/src/validation/rbac-definition.schema.ts index 6594394..ebe8c36 100644 --- a/src/mcpd/src/validation/rbac-definition.schema.ts +++ b/src/mcpd/src/validation/rbac-definition.schema.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const; -export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens'] as const; +export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens'] as const; /** Singular→plural map for resource names. */ const RESOURCE_ALIASES: Record = { @@ -15,6 +15,7 @@ const RESOURCE_ALIASES: Record = { prompt: 'prompts', promptrequest: 'promptrequests', mcptoken: 'mcptokens', + secretbackend: 'secretbackends', }; /** Normalize a resource name to its canonical plural form. */ diff --git a/src/mcpd/tests/backup.test.ts b/src/mcpd/tests/backup.test.ts index 3ea1553..75e0f59 100644 --- a/src/mcpd/tests/backup.test.ts +++ b/src/mcpd/tests/backup.test.ts @@ -9,6 +9,25 @@ import type { IProjectRepository } from '../src/repositories/project.repository. import type { IUserRepository } from '../src/repositories/user.repository.js'; import type { IGroupRepository } from '../src/repositories/group.repository.js'; import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js'; +import type { SecretService } from '../src/services/secret.service.js'; + +/** + * Minimal SecretService shim over a mock repo — just the `.create()` / `.update()` + * methods that RestoreService calls. We don't need the backend-dispatch path + * here since the restore happy-path tests don't exercise remote backends. + */ +function mockSecretService(repo: ISecretRepository): SecretService { + return { + create: vi.fn(async (input: unknown) => { + const data = input as { name: string; data: Record }; + return repo.create({ name: data.name, backendId: 'backend-plaintext', data: data.data, externalRef: '' }); + }), + update: vi.fn(async (id: string, input: unknown) => { + const data = input as { data: Record }; + return repo.update(id, { data: data.data }); + }), + } as unknown as SecretService; +} // Mock data const mockServers = [ @@ -295,7 +314,7 @@ describe('RestoreService', () => { (userRepo.findByEmail as ReturnType).mockResolvedValue(null); (groupRepo.findByName as ReturnType).mockResolvedValue(null); (rbacRepo.findByName as ReturnType).mockResolvedValue(null); - restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacRepo); + restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, mockSecretService(secretRepo), userRepo, groupRepo, rbacRepo); }); const validBundle = { @@ -576,7 +595,7 @@ describe('Backup Routes', () => { (rGroupRepo.findByName as ReturnType).mockResolvedValue(null); const rRbacRepo = mockRbacRepo(); (rRbacRepo.findByName as ReturnType).mockResolvedValue(null); - restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo, rUserRepo, rGroupRepo, rRbacRepo); + restoreService = new RestoreService(rSRepo, rPrRepo, rSecRepo, mockSecretService(rSecRepo), rUserRepo, rGroupRepo, rRbacRepo); }); async function buildApp() { diff --git a/src/mcpd/tests/env-resolver.test.ts b/src/mcpd/tests/env-resolver.test.ts index a7736f7..04f08b5 100644 --- a/src/mcpd/tests/env-resolver.test.ts +++ b/src/mcpd/tests/env-resolver.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { resolveServerEnv } from '../src/services/env-resolver.js'; -import type { ISecretRepository } from '../src/repositories/interfaces.js'; +import { resolveServerEnv, type SecretResolver } from '../src/services/env-resolver.js'; import type { McpServer } from '@prisma/client'; function makeServer(env: unknown[]): McpServer { @@ -23,18 +22,16 @@ function makeServer(env: unknown[]): McpServer { } as McpServer; } -function mockSecretRepo(secrets: Record>): ISecretRepository { +/** A SecretResolver backed by a {secretName: {key: value}} map. */ +function mockResolver(secrets: Record>): SecretResolver { return { - findAll: vi.fn(async () => []), - findById: vi.fn(async () => null), - findByName: vi.fn(async (name: string) => { + resolve: vi.fn(async (name: string, key: string): Promise => { const data = secrets[name]; - if (!data) return null; - return { id: `sec-${name}`, name, data, version: 1, createdAt: new Date(), updatedAt: new Date() }; + if (!data) throw new Error(`Secret '${name}' not found`); + const value = data[key]; + if (value === undefined) throw new Error(`Key '${key}' not found in secret '${name}'`); + return value; }), - create: vi.fn(async () => ({} as never)), - update: vi.fn(async () => ({} as never)), - delete: vi.fn(async () => {}), }; } @@ -44,8 +41,7 @@ describe('resolveServerEnv', () => { { name: 'FOO', value: 'bar' }, { name: 'BAZ', value: 'qux' }, ]); - const repo = mockSecretRepo({}); - const result = await resolveServerEnv(server, repo); + const result = await resolveServerEnv(server, mockResolver({})); expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' }); }); @@ -53,10 +49,8 @@ describe('resolveServerEnv', () => { const server = makeServer([ { name: 'TOKEN', valueFrom: { secretRef: { name: 'ha-creds', key: 'HOMEASSISTANT_TOKEN' } } }, ]); - const repo = mockSecretRepo({ - 'ha-creds': { HOMEASSISTANT_TOKEN: 'secret-token-123' }, - }); - const result = await resolveServerEnv(server, repo); + const resolver = mockResolver({ 'ha-creds': { HOMEASSISTANT_TOKEN: 'secret-token-123' } }); + const result = await resolveServerEnv(server, resolver); expect(result).toEqual({ TOKEN: 'secret-token-123' }); }); @@ -65,48 +59,42 @@ describe('resolveServerEnv', () => { { name: 'URL', value: 'https://ha.local' }, { name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'TOKEN' } } }, ]); - const repo = mockSecretRepo({ - creds: { TOKEN: 'my-token' }, - }); - const result = await resolveServerEnv(server, repo); + const resolver = mockResolver({ creds: { TOKEN: 'my-token' } }); + const result = await resolveServerEnv(server, resolver); expect(result).toEqual({ URL: 'https://ha.local', TOKEN: 'my-token' }); }); - it('caches secret lookups', async () => { + it('calls the resolver once per distinct ref', async () => { const server = makeServer([ { name: 'A', valueFrom: { secretRef: { name: 'shared', key: 'KEY_A' } } }, { name: 'B', valueFrom: { secretRef: { name: 'shared', key: 'KEY_B' } } }, ]); - const repo = mockSecretRepo({ - shared: { KEY_A: 'val-a', KEY_B: 'val-b' }, - }); - const result = await resolveServerEnv(server, repo); + const resolver = mockResolver({ shared: { KEY_A: 'val-a', KEY_B: 'val-b' } }); + const result = await resolveServerEnv(server, resolver); expect(result).toEqual({ A: 'val-a', B: 'val-b' }); - expect(repo.findByName).toHaveBeenCalledTimes(1); + // Resolver is called per-entry now — caching moved to the SecretService layer, + // which is where downstream drivers can be hit at most once per (name, key) pair. + expect(resolver.resolve).toHaveBeenCalledTimes(2); }); it('throws when secret not found', async () => { const server = makeServer([ { name: 'TOKEN', valueFrom: { secretRef: { name: 'missing', key: 'TOKEN' } } }, ]); - const repo = mockSecretRepo({}); - await expect(resolveServerEnv(server, repo)).rejects.toThrow("Secret 'missing' not found"); + await expect(resolveServerEnv(server, mockResolver({}))).rejects.toThrow(/Secret 'missing' not found/); }); it('throws when secret key not found', async () => { const server = makeServer([ { name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'NONEXISTENT' } } }, ]); - const repo = mockSecretRepo({ - creds: { OTHER_KEY: 'val' }, - }); - await expect(resolveServerEnv(server, repo)).rejects.toThrow("Key 'NONEXISTENT' not found in secret 'creds'"); + const resolver = mockResolver({ creds: { OTHER_KEY: 'val' } }); + await expect(resolveServerEnv(server, resolver)).rejects.toThrow(/Key 'NONEXISTENT' not found/); }); it('returns empty map for empty env', async () => { const server = makeServer([]); - const repo = mockSecretRepo({}); - const result = await resolveServerEnv(server, repo); + const result = await resolveServerEnv(server, mockResolver({})); expect(result).toEqual({}); }); }); diff --git a/src/mcpd/tests/secret-backends.test.ts b/src/mcpd/tests/secret-backends.test.ts new file mode 100644 index 0000000..6e0da9e --- /dev/null +++ b/src/mcpd/tests/secret-backends.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PlaintextDriver } from '../src/services/secret-backends/plaintext.js'; +import { OpenBaoDriver } from '../src/services/secret-backends/openbao.js'; + +describe('PlaintextDriver', () => { + const driver = new PlaintextDriver({ listAllPlaintext: async () => [{ name: 'a', data: { k: 'v' } }] }); + + it('read returns the data passed in', async () => { + const result = await driver.read({ name: 's', externalRef: '', data: { token: 'abc' } }); + expect(result).toEqual({ token: 'abc' }); + }); + + it('write returns storedData = input, externalRef = empty', async () => { + const result = await driver.write({ name: 's', data: { k: 'v' } }); + expect(result).toEqual({ externalRef: '', storedData: { k: 'v' } }); + }); + + it('list delegates to the injected dep', async () => { + const list = await driver.list(); + expect(list).toEqual([{ name: 'a', externalRef: '' }]); + }); + + it('delete is a no-op', async () => { + await expect(driver.delete({ name: 's', externalRef: '' })).resolves.toBeUndefined(); + }); +}); + +describe('OpenBaoDriver', () => { + function makeFetch(responses: Array<{ url: RegExp; status: number; body?: unknown }>): ReturnType { + return vi.fn(async (url: string | URL, _init?: RequestInit) => { + const urlStr = String(url); + const match = responses.find((r) => r.url.test(urlStr)); + if (!match) throw new Error(`unexpected fetch: ${urlStr}`); + return new Response(match.body ? JSON.stringify(match.body) : '', { status: match.status }); + }); + } + + const resolver = { resolve: vi.fn(async () => 'test-vault-token') }; + + it('write sends POST to .../data/ with {data: ...}', async () => { + const fetchFn = makeFetch([{ url: /\/v1\/secret\/data\/mcpctl\/mytoken$/, status: 200 }]); + const driver = new OpenBaoDriver( + { url: 'http://bao.example:8200', tokenSecretRef: { name: 'bao', key: 'token' } }, + { fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver }, + ); + const result = await driver.write({ name: 'mytoken', data: { api_key: 'secret-xyz' } }); + expect(result.externalRef).toBe('secret/mcpctl/mytoken'); + expect(result.storedData).toEqual({}); + expect(fetchFn).toHaveBeenCalledTimes(1); + const [, init] = fetchFn.mock.calls[0] as [unknown, RequestInit]; + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ data: { api_key: 'secret-xyz' } }); + const headers = init.headers as Record; + expect(headers['X-Vault-Token']).toBe('test-vault-token'); + }); + + it('read returns body.data.data', async () => { + const fetchFn = makeFetch([{ + url: /\/v1\/secret\/data\/mcpctl\/mytoken$/, + status: 200, + body: { data: { data: { api_key: 'secret-xyz' } } }, + }]); + const driver = new OpenBaoDriver( + { url: 'http://bao.example:8200', tokenSecretRef: { name: 'bao', key: 'token' } }, + { fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver }, + ); + const result = await driver.read({ name: 'mytoken', externalRef: 'secret/mcpctl/mytoken', data: {} }); + expect(result).toEqual({ api_key: 'secret-xyz' }); + }); + + it('read throws when the path 404s', async () => { + const fetchFn = makeFetch([{ url: /\/data\//, status: 404 }]); + const driver = new OpenBaoDriver( + { url: 'http://bao.example:8200', tokenSecretRef: { name: 'bao', key: 'token' } }, + { fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver }, + ); + await expect(driver.read({ name: 'missing', externalRef: '', data: {} })).rejects.toThrow(/not found/); + }); + + it('delete swallows 404', async () => { + const fetchFn = makeFetch([{ url: /\/metadata\//, status: 404 }]); + const driver = new OpenBaoDriver( + { url: 'http://bao.example:8200', tokenSecretRef: { name: 'bao', key: 'token' } }, + { fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver }, + ); + await expect(driver.delete({ name: 'gone', externalRef: '' })).resolves.toBeUndefined(); + }); + + it('list returns names from the metadata LIST call', async () => { + const fetchFn = makeFetch([{ + url: /\/v1\/secret\/metadata\/mcpctl\/$/, + status: 200, + body: { data: { keys: ['token1', 'token2', 'sub-folder/'] } }, + }]); + const driver = new OpenBaoDriver( + { url: 'http://bao.example:8200', tokenSecretRef: { name: 'bao', key: 'token' } }, + { fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver }, + ); + const result = await driver.list(); + // Sub-folders (trailing slash) are excluded; only leaf keys are returned. + expect(result).toEqual([ + { name: 'token1', externalRef: 'secret/mcpctl/token1' }, + { name: 'token2', externalRef: 'secret/mcpctl/token2' }, + ]); + }); + + it('caches the vault token after first resolve', async () => { + const fetchFn = makeFetch([ + { url: /\/v1\/secret\/data\/mcpctl\//, status: 200, body: { data: { data: { k: 'v' } } } }, + ]); + const singleResolver = { resolve: vi.fn(async () => 'test-vault-token') }; + const driver = new OpenBaoDriver( + { url: 'http://bao.example:8200', tokenSecretRef: { name: 'bao', key: 'token' } }, + { fetch: fetchFn as unknown as typeof fetch, secretRefResolver: singleResolver }, + ); + await driver.read({ name: 'a', externalRef: '', data: {} }); + await driver.read({ name: 'a', externalRef: '', data: {} }); + expect(singleResolver.resolve).toHaveBeenCalledTimes(1); + }); + + it('propagates X-Vault-Namespace when configured', async () => { + const fetchFn = makeFetch([{ url: /\/v1\/secret\/data\/mcpctl\//, status: 200 }]); + const driver = new OpenBaoDriver( + { url: 'http://bao.example:8200', namespace: 'myteam', tokenSecretRef: { name: 'bao', key: 'token' } }, + { fetch: fetchFn as unknown as typeof fetch, secretRefResolver: resolver }, + ); + await driver.write({ name: 'x', data: { k: 'v' } }); + const [, init] = fetchFn.mock.calls[0] as [unknown, RequestInit]; + const headers = init.headers as Record; + expect(headers['X-Vault-Namespace']).toBe('myteam'); + }); +}); diff --git a/src/mcpd/tests/secret-routes.test.ts b/src/mcpd/tests/secret-routes.test.ts index dca335f..4116ced 100644 --- a/src/mcpd/tests/secret-routes.test.ts +++ b/src/mcpd/tests/secret-routes.test.ts @@ -3,43 +3,68 @@ import Fastify from 'fastify'; import type { FastifyInstance } from 'fastify'; import { registerSecretRoutes } from '../src/routes/secrets.js'; import { SecretService } from '../src/services/secret.service.js'; +import { SecretBackendService } from '../src/services/secret-backend.service.js'; import { errorHandler } from '../src/middleware/error-handler.js'; import type { ISecretRepository } from '../src/repositories/interfaces.js'; +import type { ISecretBackendRepository } from '../src/repositories/secret-backend.repository.js'; +import type { SecretBackend } from '@prisma/client'; let app: FastifyInstance; -function mockRepo(): ISecretRepository { - let lastCreated: Record | null = null; +const PLAINTEXT_BACKEND: SecretBackend = { + id: 'backend-plaintext', + name: 'default', + type: 'plaintext', + config: {}, + isDefault: true, + description: '', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), +}; + +function makeSecret(overrides: Partial<{ id: string; name: string; data: Record; externalRef: string; backendId: string }> = {}) { return { - findAll: vi.fn(async () => [ - { id: '1', name: 'ha-creds', data: { TOKEN: 'abc' }, version: 1, createdAt: new Date(), updatedAt: new Date() }, - ]), + id: overrides.id ?? 'sec-1', + name: overrides.name ?? 'ha-creds', + backendId: overrides.backendId ?? PLAINTEXT_BACKEND.id, + data: overrides.data ?? { TOKEN: 'abc' }, + externalRef: overrides.externalRef ?? '', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function mockRepo(): ISecretRepository { + let lastCreated: ReturnType | null = null; + return { + findAll: vi.fn(async () => [makeSecret()]), findById: vi.fn(async (id: string) => { - if (lastCreated && (lastCreated as { id: string }).id === id) return lastCreated as never; + if (lastCreated && lastCreated.id === id) return lastCreated; return null; }), findByName: vi.fn(async () => null), + findByBackend: vi.fn(async () => []), create: vi.fn(async (data) => { - const secret = { + const secret = makeSecret({ id: 'new-id', name: data.name, data: data.data ?? {}, - version: 1, - createdAt: new Date(), - updatedAt: new Date(), - }; + externalRef: data.externalRef ?? '', + backendId: data.backendId, + }); lastCreated = secret; return secret; }), update: vi.fn(async (id, data) => { - const secret = { + const secret = makeSecret({ id, - name: 'ha-creds', + name: lastCreated?.name ?? 'ha-creds', data: data.data, - version: 2, - createdAt: new Date(), - updatedAt: new Date(), - }; + externalRef: data.externalRef, + backendId: data.backendId ?? PLAINTEXT_BACKEND.id, + }); lastCreated = secret; return secret; }), @@ -47,14 +72,32 @@ function mockRepo(): ISecretRepository { }; } +function mockBackendRepo(): ISecretBackendRepository { + return { + findAll: vi.fn(async () => [PLAINTEXT_BACKEND]), + findById: vi.fn(async (id) => (id === PLAINTEXT_BACKEND.id ? PLAINTEXT_BACKEND : null)), + findByName: vi.fn(async (name) => (name === PLAINTEXT_BACKEND.name ? PLAINTEXT_BACKEND : null)), + findDefault: vi.fn(async () => PLAINTEXT_BACKEND), + create: vi.fn(async () => PLAINTEXT_BACKEND), + update: vi.fn(async () => PLAINTEXT_BACKEND), + setAsDefault: vi.fn(async () => PLAINTEXT_BACKEND), + delete: vi.fn(async () => {}), + countReferencingSecrets: vi.fn(async () => 0), + }; +} + afterEach(async () => { if (app) await app.close(); }); -function createApp(repo: ISecretRepository) { +async function createApp(repo: ISecretRepository) { app = Fastify({ logger: false }); app.setErrorHandler(errorHandler); - const service = new SecretService(repo); + const backends = new SecretBackendService(mockBackendRepo(), { + plaintext: { listAllPlaintext: async () => [] }, + secretRefResolver: { resolve: async () => '' }, + }); + const service = new SecretService(repo, backends); registerSecretRoutes(app, service); return app.ready(); } @@ -129,7 +172,7 @@ describe('Secret Routes', () => { describe('PUT /api/v1/secrets/:id', () => { it('updates a secret', async () => { const repo = mockRepo(); - vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds' } as never); + vi.mocked(repo.findById).mockResolvedValue(makeSecret({ id: '1' }) as never); await createApp(repo); const res = await app.inject({ method: 'PUT', @@ -154,7 +197,7 @@ describe('Secret Routes', () => { describe('DELETE /api/v1/secrets/:id', () => { it('deletes a secret and returns 204', async () => { const repo = mockRepo(); - vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds' } as never); + vi.mocked(repo.findById).mockResolvedValue(makeSecret({ id: '1' }) as never); await createApp(repo); const res = await app.inject({ method: 'DELETE', url: '/api/v1/secrets/1' }); expect(res.statusCode).toBe(204);