feat(mcpd): pluggable SecretBackend + OpenBao driver + migrate #51
@@ -5,11 +5,11 @@ _mcpctl() {
|
|||||||
local cur prev words cword
|
local cur prev words cword
|
||||||
_init_completion || return
|
_init_completion || return
|
||||||
|
|
||||||
local commands="status login logout config get describe delete logs create edit apply patch backup approve console cache test"
|
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 project_commands="get describe delete logs create edit attach-server detach-server"
|
||||||
local global_opts="-v --version --daemon-url --direct -p --project -h --help"
|
local global_opts="-v --version --daemon-url --direct -p --project -h --help"
|
||||||
local resources="servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all"
|
local resources="servers instances secrets secretbackends 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 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
|
# Check if --project/-p was given
|
||||||
local has_project=false
|
local has_project=false
|
||||||
@@ -175,7 +175,7 @@ _mcpctl() {
|
|||||||
create)
|
create)
|
||||||
local create_sub=$(_mcpctl_get_subcmd $subcmd_pos)
|
local create_sub=$(_mcpctl_get_subcmd $subcmd_pos)
|
||||||
if [[ -z "$create_sub" ]]; then
|
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
|
else
|
||||||
case "$create_sub" in
|
case "$create_sub" in
|
||||||
server)
|
server)
|
||||||
@@ -184,6 +184,9 @@ _mcpctl() {
|
|||||||
secret)
|
secret)
|
||||||
COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur"))
|
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)
|
project)
|
||||||
COMPREPLY=($(compgen -W "-d --description --proxy-model --prompt --gated --no-gated --server --force -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-d --description --proxy-model --prompt --gated --no-gated --server --force -h --help" -- "$cur"))
|
||||||
;;
|
;;
|
||||||
@@ -329,6 +332,21 @@ _mcpctl() {
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
return ;;
|
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)
|
help)
|
||||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# Erase any stale completions from previous versions
|
# Erase any stale completions from previous versions
|
||||||
complete -c mcpctl -e
|
complete -c mcpctl -e
|
||||||
|
|
||||||
set -l commands status login logout config get describe delete logs create edit apply patch backup approve console cache test
|
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
|
set -l project_commands get describe delete logs create edit attach-server detach-server
|
||||||
|
|
||||||
# Disable file completions by default
|
# Disable file completions by default
|
||||||
@@ -31,10 +31,10 @@ function __mcpctl_has_project
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Resource type detection
|
# 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
|
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 tokens (commandline -opc)
|
||||||
set -l found_cmd false
|
set -l found_cmd false
|
||||||
for tok in $tokens
|
for tok in $tokens
|
||||||
@@ -59,6 +59,7 @@ function __mcpctl_resolve_resource
|
|||||||
case server srv servers; echo servers
|
case server srv servers; echo servers
|
||||||
case instance inst instances; echo instances
|
case instance inst instances; echo instances
|
||||||
case secret sec secrets; echo secrets
|
case secret sec secrets; echo secrets
|
||||||
|
case secretbackend sb secretbackends; echo secretbackends
|
||||||
case template tpl templates; echo templates
|
case template tpl templates; echo templates
|
||||||
case project proj projects; echo projects
|
case project proj projects; echo projects
|
||||||
case user users; echo users
|
case user users; echo users
|
||||||
@@ -74,7 +75,7 @@ function __mcpctl_resolve_resource
|
|||||||
end
|
end
|
||||||
|
|
||||||
function __mcpctl_get_resource_type
|
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 tokens (commandline -opc)
|
||||||
set -l found_cmd false
|
set -l found_cmd false
|
||||||
for tok in $tokens
|
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 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 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 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 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 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)'
|
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 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 cache -d 'Manage ProxyModel pipeline cache'
|
||||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a test -d 'Utilities for testing MCP endpoints and config'
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a test -d 'Utilities for testing MCP endpoints and config'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a migrate -d 'Move resources between backends (currently: secrets between SecretBackends)'
|
||||||
|
|
||||||
# Project-scoped commands (with --project)
|
# Project-scoped commands (with --project)
|
||||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (servers, projects, instances, all)'
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (servers, projects, instances, all)'
|
||||||
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 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 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 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 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 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)'
|
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'
|
complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity'
|
||||||
|
|
||||||
# create subcommands
|
# 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 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 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 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 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'
|
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 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'
|
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
|
# 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" -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
|
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" -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'
|
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
|
# status options
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x
|
complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x
|
||||||
|
|
||||||
|
|||||||
167
docs/secret-backends.md
Normal file
167
docs/secret-backends.md
Normal file
@@ -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 <name> # inspect config (credentials masked)
|
||||||
|
mcpctl create secretbackend <name> --type plaintext [--default] [--description ...]
|
||||||
|
mcpctl create secretbackend <name> --type openbao \
|
||||||
|
--url http://bao.example:8200 \
|
||||||
|
--token-secret bao-creds/token \
|
||||||
|
[--namespace <ns>] [--mount secret] [--path-prefix mcpctl] \
|
||||||
|
[--default]
|
||||||
|
mcpctl delete secretbackend <name> # 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 `<mount>/<pathPrefix>/<secretName>`. |
|
||||||
|
| `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.
|
||||||
@@ -184,7 +184,7 @@ async function extractTree(): Promise<CmdInfo> {
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
const CANONICAL_RESOURCES = [
|
const CANONICAL_RESOURCES = [
|
||||||
'servers', 'instances', 'secrets', 'templates', 'projects',
|
'servers', 'instances', 'secrets', 'secretbackends', 'templates', 'projects',
|
||||||
'users', 'groups', 'rbac', 'prompts', 'promptrequests',
|
'users', 'groups', 'rbac', 'prompts', 'promptrequests',
|
||||||
'serverattachments', 'proxymodels', 'all',
|
'serverattachments', 'proxymodels', 'all',
|
||||||
];
|
];
|
||||||
@@ -193,6 +193,7 @@ const ALIAS_ENTRIES: [string, string][] = [
|
|||||||
['server', 'servers'], ['srv', 'servers'],
|
['server', 'servers'], ['srv', 'servers'],
|
||||||
['instance', 'instances'], ['inst', 'instances'],
|
['instance', 'instances'], ['inst', 'instances'],
|
||||||
['secret', 'secrets'], ['sec', 'secrets'],
|
['secret', 'secrets'], ['sec', 'secrets'],
|
||||||
|
['secretbackend', 'secretbackends'], ['sb', 'secretbackends'],
|
||||||
['template', 'templates'], ['tpl', 'templates'],
|
['template', 'templates'], ['tpl', 'templates'],
|
||||||
['project', 'projects'], ['proj', 'projects'],
|
['project', 'projects'], ['proj', 'projects'],
|
||||||
['user', 'users'],
|
['user', 'users'],
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ const SecretSpecSchema = z.object({
|
|||||||
data: z.record(z.string()).default({}),
|
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({
|
const TemplateEnvEntrySchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -142,6 +150,7 @@ const McpTokenSpecSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ApplyConfigSchema = z.object({
|
const ApplyConfigSchema = z.object({
|
||||||
|
secretbackends: z.array(SecretBackendSpecSchema).default([]),
|
||||||
secrets: z.array(SecretSpecSchema).default([]),
|
secrets: z.array(SecretSpecSchema).default([]),
|
||||||
servers: z.array(ServerSpecSchema).default([]),
|
servers: z.array(ServerSpecSchema).default([]),
|
||||||
users: z.array(UserSpecSchema).default([]),
|
users: z.array(UserSpecSchema).default([]),
|
||||||
@@ -183,6 +192,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
|
|||||||
|
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
log('Dry run - would apply:');
|
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.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
|
||||||
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
|
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
|
||||||
if (config.users.length > 0) log(` ${config.users.length} user(s)`);
|
if (config.users.length > 0) log(` ${config.users.length} user(s)`);
|
||||||
@@ -229,6 +239,7 @@ const KIND_TO_RESOURCE: Record<string, string> = {
|
|||||||
promptrequest: 'promptrequests',
|
promptrequest: 'promptrequests',
|
||||||
serverattachment: 'serverattachments',
|
serverattachment: 'serverattachments',
|
||||||
mcptoken: 'mcptokens',
|
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<string, unknown> = {
|
||||||
|
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
|
// Apply secrets
|
||||||
for (const secret of config.secrets) {
|
for (const secret of config.secrets) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
const { client, log } = deps;
|
const { client, log } = deps;
|
||||||
|
|
||||||
const cmd = new Command('create')
|
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 ---
|
// --- create server ---
|
||||||
cmd.command('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('<name>', 'Backend name (lowercase, hyphens allowed)')
|
||||||
|
.requiredOption('--type <type>', 'Backend type (plaintext, openbao)')
|
||||||
|
.option('--description <text>', 'Description')
|
||||||
|
.option('--default', 'Promote this backend to default (atomically demotes the current one)')
|
||||||
|
.option('--url <url>', 'openbao: vault URL (e.g. http://bao.example:8200)')
|
||||||
|
.option('--namespace <ns>', 'openbao: X-Vault-Namespace header value')
|
||||||
|
.option('--mount <mount>', 'openbao: KV v2 mount point (default: secret)')
|
||||||
|
.option('--path-prefix <prefix>', 'openbao: path prefix under mount (default: mcpctl)')
|
||||||
|
.option('--token-secret <ref>', 'openbao: token secret reference in SECRET/KEY form (e.g. bao-creds/token)')
|
||||||
|
.option('--config <entry>', '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<string, unknown> = {};
|
||||||
|
|
||||||
|
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<string, unknown> = { 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<Array<{ id: string; name: string }>>('/api/v1/secretbackends')).find((b) => b.name === name);
|
||||||
|
if (!existing) throw err;
|
||||||
|
const updateBody: Record<string, unknown> = { 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 ---
|
// --- create project ---
|
||||||
cmd.command('project')
|
cmd.command('project')
|
||||||
.description('Create a project')
|
.description('Create a project')
|
||||||
|
|||||||
@@ -218,6 +218,37 @@ function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSecretBackendDetail(backend: Record<string, unknown>): 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<string, unknown> | 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, unknown>): string {
|
function formatTemplateDetail(template: Record<string, unknown>): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
lines.push(`=== Template: ${template.name} ===`);
|
lines.push(`=== Template: ${template.name} ===`);
|
||||||
@@ -806,6 +837,9 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
|||||||
case 'templates':
|
case 'templates':
|
||||||
deps.log(formatTemplateDetail(item));
|
deps.log(formatTemplateDetail(item));
|
||||||
break;
|
break;
|
||||||
|
case 'secretbackends':
|
||||||
|
deps.log(formatSecretBackendDetail(item));
|
||||||
|
break;
|
||||||
case 'projects': {
|
case 'projects': {
|
||||||
const projectPrompts = await deps.client
|
const projectPrompts = await deps.client
|
||||||
.get<Array<{ name: string; priority: number; linkTarget: string | null }>>(`/api/v1/prompts?projectId=${item.id as string}`)
|
.get<Array<{ name: string; priority: number; linkTarget: string | null }>>(`/api/v1/prompts?projectId=${item.id as string}`)
|
||||||
|
|||||||
@@ -119,6 +119,23 @@ const rbacColumns: Column<RbacRow>[] = [
|
|||||||
{ header: 'ID', key: 'id' },
|
{ header: 'ID', key: 'id' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
interface SecretBackendRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
description: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretBackendColumns: Column<SecretBackendRow>[] = [
|
||||||
|
{ 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 {
|
interface McpTokenRow {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -265,6 +282,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
|||||||
return proxymodelColumns as unknown as Column<Record<string, unknown>>[];
|
return proxymodelColumns as unknown as Column<Record<string, unknown>>[];
|
||||||
case 'mcptokens':
|
case 'mcptokens':
|
||||||
return mcpTokenColumns as unknown as Column<Record<string, unknown>>[];
|
return mcpTokenColumns as unknown as Column<Record<string, unknown>>[];
|
||||||
|
case 'secretbackends':
|
||||||
|
return secretBackendColumns as unknown as Column<Record<string, unknown>>[];
|
||||||
default:
|
default:
|
||||||
return [
|
return [
|
||||||
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
|
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
|
||||||
@@ -287,6 +306,7 @@ const RESOURCE_KIND: Record<string, string> = {
|
|||||||
promptrequests: 'promptrequest',
|
promptrequests: 'promptrequest',
|
||||||
serverattachments: 'serverattachment',
|
serverattachments: 'serverattachment',
|
||||||
mcptokens: 'mcptoken',
|
mcptokens: 'mcptoken',
|
||||||
|
secretbackends: 'secretbackend',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
80
src/cli/src/commands/migrate.ts
Normal file
80
src/cli/src/commands/migrate.ts
Normal file
@@ -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 <subcommand>` 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 <name>', 'Source SecretBackend name')
|
||||||
|
.requiredOption('--to <name>', 'Destination SecretBackend name')
|
||||||
|
.option('--names <csv>', '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<string, unknown> = { 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<DryRunResult>('/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<MigrateResult>('/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;
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ export const RESOURCE_ALIASES: Record<string, string> = {
|
|||||||
mcptokens: 'mcptokens',
|
mcptokens: 'mcptokens',
|
||||||
token: 'mcptokens',
|
token: 'mcptokens',
|
||||||
tokens: 'mcptokens',
|
tokens: 'mcptokens',
|
||||||
|
secretbackend: 'secretbackends',
|
||||||
|
secretbackends: 'secretbackends',
|
||||||
|
sb: 'secretbackends',
|
||||||
all: 'all',
|
all: 'all',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { createMcpCommand } from './commands/mcp.js';
|
|||||||
import { createPatchCommand } from './commands/patch.js';
|
import { createPatchCommand } from './commands/patch.js';
|
||||||
import { createConsoleCommand } from './commands/console/index.js';
|
import { createConsoleCommand } from './commands/console/index.js';
|
||||||
import { createCacheCommand } from './commands/cache.js';
|
import { createCacheCommand } from './commands/cache.js';
|
||||||
|
import { createMigrateCommand } from './commands/migrate.js';
|
||||||
import { ApiClient, ApiError } from './api-client.js';
|
import { ApiClient, ApiError } from './api-client.js';
|
||||||
import { loadConfig } from './config/index.js';
|
import { loadConfig } from './config/index.js';
|
||||||
import { loadCredentials } from './auth/index.js';
|
import { loadCredentials } from './auth/index.js';
|
||||||
@@ -249,6 +250,11 @@ export function createProgram(): Command {
|
|||||||
log: (...args) => console.log(...args),
|
log: (...args) => console.log(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
program.addCommand(createMigrateCommand({
|
||||||
|
client,
|
||||||
|
log: (...args) => console.log(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,17 +111,48 @@ model McpTemplate {
|
|||||||
@@index([name])
|
@@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 ──
|
// ── Secrets ──
|
||||||
|
|
||||||
model Secret {
|
model Secret {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
data Json @default("{}")
|
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)
|
version Int @default(1)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
backend SecretBackend @relation(fields: [backendId], references: [id])
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
|
@@index([backendId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Groups ──
|
// ── Groups ──
|
||||||
|
|||||||
53
src/mcpd/src/bootstrap/secret-backends.ts
Normal file
53
src/mcpd/src/bootstrap/secret-backends.ts
Normal file
@@ -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<void> {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,12 @@ import {
|
|||||||
AuditEventRepository,
|
AuditEventRepository,
|
||||||
McpTokenRepository,
|
McpTokenRepository,
|
||||||
} from './repositories/index.js';
|
} 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 { PromptRepository } from './repositories/prompt.repository.js';
|
||||||
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
||||||
import { bootstrapSystemProject } from './bootstrap/system-project.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 === 'backup') return { kind: 'operation', operation: 'backup' };
|
||||||
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
|
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
|
||||||
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
|
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
|
||||||
|
// /api/v1/secrets/migrate is a bulk cross-backend operation — treat as op, not a plain secret write.
|
||||||
|
if (url.startsWith('/api/v1/secrets/migrate')) return { kind: 'operation', operation: 'migrate-secrets' };
|
||||||
|
|
||||||
const resourceMap: Record<string, string | undefined> = {
|
const resourceMap: Record<string, string | undefined> = {
|
||||||
'servers': 'servers',
|
'servers': 'servers',
|
||||||
'instances': 'instances',
|
'instances': 'instances',
|
||||||
'secrets': 'secrets',
|
'secrets': 'secrets',
|
||||||
|
'secretbackends': 'secretbackends',
|
||||||
'projects': 'projects',
|
'projects': 'projects',
|
||||||
'templates': 'templates',
|
'templates': 'templates',
|
||||||
'users': 'users',
|
'users': 'users',
|
||||||
@@ -261,6 +270,7 @@ async function main(): Promise<void> {
|
|||||||
// Repositories
|
// Repositories
|
||||||
const serverRepo = new McpServerRepository(prisma);
|
const serverRepo = new McpServerRepository(prisma);
|
||||||
const secretRepo = new SecretRepository(prisma);
|
const secretRepo = new SecretRepository(prisma);
|
||||||
|
const secretBackendRepo = new SecretBackendRepository(prisma);
|
||||||
const instanceRepo = new McpInstanceRepository(prisma);
|
const instanceRepo = new McpInstanceRepository(prisma);
|
||||||
const projectRepo = new ProjectRepository(prisma);
|
const projectRepo = new ProjectRepository(prisma);
|
||||||
const auditLogRepo = new AuditLogRepository(prisma);
|
const auditLogRepo = new AuditLogRepository(prisma);
|
||||||
@@ -271,11 +281,16 @@ async function main(): Promise<void> {
|
|||||||
const groupRepo = new GroupRepository(prisma);
|
const groupRepo = new GroupRepository(prisma);
|
||||||
const mcpTokenRepo = new McpTokenRepository(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
|
// CUID detection for RBAC name resolution
|
||||||
const CUID_RE = /^c[^\s-]{8,}$/i;
|
const CUID_RE = /^c[^\s-]{8,}$/i;
|
||||||
const nameResolvers: Record<string, { findById(id: string): Promise<{ name: string } | null> }> = {
|
const nameResolvers: Record<string, { findById(id: string): Promise<{ name: string } | null> }> = {
|
||||||
servers: serverRepo,
|
servers: serverRepo,
|
||||||
secrets: secretRepo,
|
secrets: secretRepo,
|
||||||
|
secretbackends: secretBackendRepo,
|
||||||
projects: projectRepo,
|
projects: projectRepo,
|
||||||
groups: groupRepo,
|
groups: groupRepo,
|
||||||
mcptokens: mcpTokenRepo,
|
mcptokens: mcpTokenRepo,
|
||||||
@@ -291,9 +306,29 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
const serverService = new McpServerService(serverRepo);
|
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<string> => 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<string, string> }));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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);
|
serverService.setInstanceService(instanceService);
|
||||||
const secretService = new SecretService(secretRepo);
|
|
||||||
const projectService = new ProjectService(projectRepo, serverRepo);
|
const projectService = new ProjectService(projectRepo, serverRepo);
|
||||||
const auditLogService = new AuditLogService(auditLogRepo);
|
const auditLogService = new AuditLogService(auditLogRepo);
|
||||||
const auditEventService = new AuditEventService(auditEventRepo);
|
const auditEventService = new AuditEventService(auditEventRepo);
|
||||||
@@ -313,7 +348,7 @@ async function main(): Promise<void> {
|
|||||||
promptRuleRegistry.register(systemPromptVarsRule);
|
promptRuleRegistry.register(systemPromptVarsRule);
|
||||||
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry);
|
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry);
|
||||||
const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
|
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
|
// 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
|
// preHandler on /api/v1/mcp/proxy must know how to resolve both session
|
||||||
@@ -430,6 +465,8 @@ async function main(): Promise<void> {
|
|||||||
registerMcpServerRoutes(app, serverService, instanceService);
|
registerMcpServerRoutes(app, serverService, instanceService);
|
||||||
registerTemplateRoutes(app, templateService);
|
registerTemplateRoutes(app, templateService);
|
||||||
registerSecretRoutes(app, secretService);
|
registerSecretRoutes(app, secretService);
|
||||||
|
registerSecretBackendRoutes(app, secretBackendService);
|
||||||
|
registerSecretMigrateRoutes(app, secretMigrateService);
|
||||||
registerInstanceRoutes(app, instanceService);
|
registerInstanceRoutes(app, instanceService);
|
||||||
registerProjectRoutes(app, projectService);
|
registerProjectRoutes(app, projectService);
|
||||||
registerAuditLogRoutes(app, auditLogService);
|
registerAuditLogRoutes(app, auditLogService);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { McpServer, McpInstance, AuditLog, AuditEvent, McpToken, Secret, InstanceStatus } from '@prisma/client';
|
import type { McpServer, McpInstance, AuditLog, AuditEvent, McpToken, Secret, InstanceStatus } from '@prisma/client';
|
||||||
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
|
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 {
|
export interface IMcpServerRepository {
|
||||||
findAll(): Promise<McpServer[]>;
|
findAll(): Promise<McpServer[]>;
|
||||||
@@ -24,8 +24,9 @@ export interface ISecretRepository {
|
|||||||
findAll(): Promise<Secret[]>;
|
findAll(): Promise<Secret[]>;
|
||||||
findById(id: string): Promise<Secret | null>;
|
findById(id: string): Promise<Secret | null>;
|
||||||
findByName(name: string): Promise<Secret | null>;
|
findByName(name: string): Promise<Secret | null>;
|
||||||
create(data: CreateSecretInput): Promise<Secret>;
|
findByBackend(backendId: string): Promise<Secret[]>;
|
||||||
update(id: string, data: UpdateSecretInput): Promise<Secret>;
|
create(data: SecretRepoCreateInput): Promise<Secret>;
|
||||||
|
update(id: string, data: SecretRepoUpdateInput): Promise<Secret>;
|
||||||
delete(id: string): Promise<void>;
|
delete(id: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
103
src/mcpd/src/repositories/secret-backend.repository.ts
Normal file
103
src/mcpd/src/repositories/secret-backend.repository.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import type { PrismaClient, SecretBackend, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface CreateSecretBackendInput {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
isDefault?: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSecretBackendInput {
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
isDefault?: boolean;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISecretBackendRepository {
|
||||||
|
findAll(): Promise<SecretBackend[]>;
|
||||||
|
findById(id: string): Promise<SecretBackend | null>;
|
||||||
|
findByName(name: string): Promise<SecretBackend | null>;
|
||||||
|
findDefault(): Promise<SecretBackend | null>;
|
||||||
|
create(data: CreateSecretBackendInput): Promise<SecretBackend>;
|
||||||
|
update(id: string, data: UpdateSecretBackendInput): Promise<SecretBackend>;
|
||||||
|
/**
|
||||||
|
* Atomically clear `isDefault` on every row except the one named, then set
|
||||||
|
* the given row as default. Used by `setDefault`.
|
||||||
|
*/
|
||||||
|
setAsDefault(id: string): Promise<SecretBackend>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
/** Count secrets that still reference this backend — used to guard delete. */
|
||||||
|
countReferencingSecrets(backendId: string): Promise<number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SecretBackendRepository implements ISecretBackendRepository {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async findAll(): Promise<SecretBackend[]> {
|
||||||
|
return this.prisma.secretBackend.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<SecretBackend | null> {
|
||||||
|
return this.prisma.secretBackend.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByName(name: string): Promise<SecretBackend | null> {
|
||||||
|
return this.prisma.secretBackend.findUnique({ where: { name } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDefault(): Promise<SecretBackend | null> {
|
||||||
|
return this.prisma.secretBackend.findFirst({ where: { isDefault: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: CreateSecretBackendInput): Promise<SecretBackend> {
|
||||||
|
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<SecretBackend> {
|
||||||
|
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<SecretBackend> {
|
||||||
|
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<void> {
|
||||||
|
await this.prisma.secretBackend.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async countReferencingSecrets(backendId: string): Promise<number> {
|
||||||
|
return this.prisma.secret.count({ where: { backendId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { ISecretRepository } from './interfaces.js';
|
||||||
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
|
|
||||||
|
export interface SecretRepoCreateInput {
|
||||||
|
name: string;
|
||||||
|
backendId: string;
|
||||||
|
data?: Record<string, string>;
|
||||||
|
externalRef?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretRepoUpdateInput {
|
||||||
|
data?: Record<string, string>;
|
||||||
|
externalRef?: string;
|
||||||
|
backendId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class SecretRepository implements ISecretRepository {
|
export class SecretRepository implements ISecretRepository {
|
||||||
constructor(private readonly prisma: PrismaClient) {}
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
@@ -17,20 +29,29 @@ export class SecretRepository implements ISecretRepository {
|
|||||||
return this.prisma.secret.findUnique({ where: { name } });
|
return this.prisma.secret.findUnique({ where: { name } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: CreateSecretInput): Promise<Secret> {
|
async findByBackend(backendId: string): Promise<Secret[]> {
|
||||||
|
return this.prisma.secret.findMany({ where: { backendId }, orderBy: { name: 'asc' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: SecretRepoCreateInput): Promise<Secret> {
|
||||||
return this.prisma.secret.create({
|
return this.prisma.secret.create({
|
||||||
data: {
|
data: {
|
||||||
name: data.name,
|
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<Secret> {
|
async update(id: string, data: SecretRepoUpdateInput): Promise<Secret> {
|
||||||
return this.prisma.secret.update({
|
const updateData: Prisma.SecretUpdateInput = {};
|
||||||
where: { id },
|
if (data.data !== undefined) updateData.data = data.data as Prisma.InputJsonValue;
|
||||||
data: { data: data.data },
|
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<void> {
|
async delete(id: string): Promise<void> {
|
||||||
|
|||||||
89
src/mcpd/src/routes/secret-backends.ts
Normal file
89
src/mcpd/src/routes/secret-backends.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<T extends { config: unknown }>(row: T): T {
|
||||||
|
const config = (row.config ?? {}) as Record<string, unknown>;
|
||||||
|
const cleaned: Record<string, unknown> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
41
src/mcpd/src/routes/secret-migrate.ts
Normal file
41
src/mcpd/src/routes/secret-migrate.ts
Normal file
@@ -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<SecretMigrateService['dryRun']>[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<SecretMigrateService['migrate']>[0] = { from, to };
|
||||||
|
if (names !== undefined) options.names = names;
|
||||||
|
if (keepSource !== undefined) options.keepSource = keepSource;
|
||||||
|
return service.migrate(options);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type { IRbacDefinitionRepository } from '../../repositories/rbac-definiti
|
|||||||
import type { IPromptRepository } from '../../repositories/prompt.repository.js';
|
import type { IPromptRepository } from '../../repositories/prompt.repository.js';
|
||||||
import type { ITemplateRepository } from '../../repositories/template.repository.js';
|
import type { ITemplateRepository } from '../../repositories/template.repository.js';
|
||||||
import type { RbacRoleBinding } from '../../validation/rbac-definition.schema.js';
|
import type { RbacRoleBinding } from '../../validation/rbac-definition.schema.js';
|
||||||
|
import type { SecretService } from '../secret.service.js';
|
||||||
import { decrypt } from './crypto.js';
|
import { decrypt } from './crypto.js';
|
||||||
import type { BackupBundle } from './backup-service.js';
|
import type { BackupBundle } from './backup-service.js';
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export class RestoreService {
|
|||||||
private serverRepo: IMcpServerRepository,
|
private serverRepo: IMcpServerRepository,
|
||||||
private projectRepo: IProjectRepository,
|
private projectRepo: IProjectRepository,
|
||||||
private secretRepo: ISecretRepository,
|
private secretRepo: ISecretRepository,
|
||||||
|
private secretService: SecretService,
|
||||||
private userRepo?: IUserRepository,
|
private userRepo?: IUserRepository,
|
||||||
private groupRepo?: IGroupRepository,
|
private groupRepo?: IGroupRepository,
|
||||||
private rbacRepo?: IRbacDefinitionRepository,
|
private rbacRepo?: IRbacDefinitionRepository,
|
||||||
@@ -125,16 +127,13 @@ export class RestoreService {
|
|||||||
result.secretsSkipped++;
|
result.secretsSkipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// overwrite
|
// overwrite — route through SecretService so backend dispatch applies.
|
||||||
await this.secretRepo.update(existing.id, { data: secret.data });
|
await this.secretService.update(existing.id, { data: secret.data });
|
||||||
result.secretsCreated++;
|
result.secretsCreated++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.secretRepo.create({
|
await this.secretService.create({ name: secret.name, data: secret.data });
|
||||||
name: secret.name,
|
|
||||||
data: secret.data,
|
|
||||||
});
|
|
||||||
result.secretsCreated++;
|
result.secretsCreated++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`);
|
result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
import type { McpServer } from '@prisma/client';
|
import type { McpServer } from '@prisma/client';
|
||||||
import type { ISecretRepository } from '../repositories/interfaces.js';
|
|
||||||
import type { ServerEnvEntry } from '../validation/mcp-server.schema.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<string>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a server's env entries into a flat key-value map.
|
* Resolve a server's env entries into a flat key-value map.
|
||||||
* - Inline `value` entries are used directly.
|
* - 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.
|
* Throws if a referenced secret or key is missing.
|
||||||
*/
|
*/
|
||||||
export async function resolveServerEnv(
|
export async function resolveServerEnv(
|
||||||
server: McpServer,
|
server: McpServer,
|
||||||
secretRepo: ISecretRepository,
|
resolver: SecretResolver,
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, string>> {
|
||||||
const entries = server.env as ServerEnvEntry[];
|
const entries = server.env as ServerEnvEntry[];
|
||||||
if (!entries || entries.length === 0) return {};
|
if (!entries || entries.length === 0) return {};
|
||||||
|
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
const secretCache = new Map<string, Record<string, string>>();
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.value !== undefined) {
|
if (entry.value !== undefined) {
|
||||||
result[entry.name] = entry.value;
|
result[entry.name] = entry.value;
|
||||||
} else if (entry.valueFrom?.secretRef) {
|
} else if (entry.valueFrom?.secretRef) {
|
||||||
const { name: secretName, key } = entry.valueFrom.secretRef;
|
const { name: secretName, key } = entry.valueFrom.secretRef;
|
||||||
|
try {
|
||||||
if (!secretCache.has(secretName)) {
|
result[entry.name] = await resolver.resolve(secretName, key);
|
||||||
const secret = await secretRepo.findByName(secretName);
|
} catch (err) {
|
||||||
if (!secret) {
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
throw new Error(`Secret '${secretName}' not found (referenced by server '${server.name}' env '${entry.name}')`);
|
throw new Error(
|
||||||
|
`Cannot resolve secret for server '${server.name}' env '${entry.name}': ${msg}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
secretCache.set(secretName, secret.data as Record<string, string>);
|
|
||||||
}
|
|
||||||
|
|
||||||
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]!;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { McpInstance } from '@prisma/client';
|
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 type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js';
|
||||||
import { NotFoundError } from './mcp-server.service.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. */
|
/** Runner images for package-based MCP servers, keyed by runtime name. */
|
||||||
const RUNNER_IMAGES: Record<string, string> = {
|
const RUNNER_IMAGES: Record<string, string> = {
|
||||||
@@ -26,7 +26,7 @@ export class InstanceService {
|
|||||||
private instanceRepo: IMcpInstanceRepository,
|
private instanceRepo: IMcpInstanceRepository,
|
||||||
private serverRepo: IMcpServerRepository,
|
private serverRepo: IMcpServerRepository,
|
||||||
private orchestrator: McpOrchestrator,
|
private orchestrator: McpOrchestrator,
|
||||||
private secretRepo?: ISecretRepository,
|
private secretResolver?: SecretResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(serverId?: string): Promise<McpInstance[]> {
|
async list(serverId?: string): Promise<McpInstance[]> {
|
||||||
@@ -284,9 +284,9 @@ export class InstanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve env vars from inline values and secret refs
|
// Resolve env vars from inline values and secret refs
|
||||||
if (this.secretRepo) {
|
if (this.secretResolver) {
|
||||||
try {
|
try {
|
||||||
const resolvedEnv = await resolveServerEnv(server, this.secretRepo);
|
const resolvedEnv = await resolveServerEnv(server, this.secretResolver);
|
||||||
if (Object.keys(resolvedEnv).length > 0) {
|
if (Object.keys(resolvedEnv).length > 0) {
|
||||||
spec.env = resolvedEnv;
|
spec.env = resolvedEnv;
|
||||||
}
|
}
|
||||||
|
|||||||
88
src/mcpd/src/services/secret-backend.service.ts
Normal file
88
src/mcpd/src/services/secret-backend.service.ts
Normal file
@@ -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<string, SecretBackendDriver>(); // keyed by backend id
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly repo: ISecretBackendRepository,
|
||||||
|
private readonly driverDeps: DriverFactoryDeps,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(): Promise<SecretBackend[]> {
|
||||||
|
return this.repo.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<SecretBackend> {
|
||||||
|
const row = await this.repo.findById(id);
|
||||||
|
if (row === null) throw new NotFoundError(`SecretBackend not found: ${id}`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByName(name: string): Promise<SecretBackend> {
|
||||||
|
const row = await this.repo.findByName(name);
|
||||||
|
if (row === null) throw new NotFoundError(`SecretBackend not found: ${name}`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDefault(): Promise<SecretBackend> {
|
||||||
|
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<string, unknown>;
|
||||||
|
isDefault?: boolean;
|
||||||
|
description?: string;
|
||||||
|
}): Promise<SecretBackend> {
|
||||||
|
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<string, unknown>; isDefault?: boolean; description?: string }): Promise<SecretBackend> {
|
||||||
|
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<SecretBackend> {
|
||||||
|
await this.getById(id);
|
||||||
|
return this.repo.setAsDefault(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/mcpd/src/services/secret-backends/factory.ts
Normal file
43
src/mcpd/src/services/secret-backends/factory.ts
Normal file
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/mcpd/src/services/secret-backends/openbao.ts
Normal file
133
src/mcpd/src/services/secret-backends/openbao.ts
Normal file
@@ -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 <url>/v1/<mount>/data/<path> -- write
|
||||||
|
* GET <url>/v1/<mount>/data/<path> -- read latest
|
||||||
|
* DELETE <url>/v1/<mount>/metadata/<path> -- full delete (all versions)
|
||||||
|
* LIST <url>/v1/<mount>/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>/<pathPrefix>/<secretName>
|
||||||
|
* `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<SecretData> {
|
||||||
|
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<void> {
|
||||||
|
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<Array<{ name: string; externalRef: ExternalRef }>> {
|
||||||
|
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<string> {
|
||||||
|
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<Response> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
const headers: Record<string, string> = { '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/mcpd/src/services/secret-backends/plaintext.ts
Normal file
44
src/mcpd/src/services/secret-backends/plaintext.ts
Normal file
@@ -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<Array<{ name: string; data: SecretData }>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlaintextDriver implements SecretBackendDriver {
|
||||||
|
readonly kind = 'plaintext';
|
||||||
|
|
||||||
|
constructor(private readonly deps: PlaintextDriverDeps) {}
|
||||||
|
|
||||||
|
async read(input: { name: string; externalRef: ExternalRef; data: SecretData }): Promise<SecretData> {
|
||||||
|
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<void> {
|
||||||
|
// The row deletion itself is the secret service's job; nothing remote to clean up here.
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<Array<{ name: string; externalRef: ExternalRef }>> {
|
||||||
|
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)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/mcpd/src/services/secret-backends/types.ts
Normal file
68
src/mcpd/src/services/secret-backends/types.ts
Normal file
@@ -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<string, string>;
|
||||||
|
|
||||||
|
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<SecretData>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
|
|
||||||
|
/** List everything the backend knows about. Used for migration + drift detection. */
|
||||||
|
list(): Promise<Array<{ name: string; externalRef: ExternalRef }>>;
|
||||||
|
|
||||||
|
/** 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string>;
|
||||||
|
}
|
||||||
113
src/mcpd/src/services/secret-migrate.service.ts
Normal file
113
src/mcpd/src/services/secret-migrate.service.ts
Normal file
@@ -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<MigrateResult> {
|
||||||
|
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<string, string>,
|
||||||
|
});
|
||||||
|
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<Array<Secret>> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 { Secret } from '@prisma/client';
|
||||||
import type { ISecretRepository } from '../repositories/interfaces.js';
|
import type { ISecretRepository } from '../repositories/interfaces.js';
|
||||||
|
import type { SecretBackendService } from './secret-backend.service.js';
|
||||||
import { CreateSecretSchema, UpdateSecretSchema } from '../validation/secret.schema.js';
|
import { CreateSecretSchema, UpdateSecretSchema } from '../validation/secret.schema.js';
|
||||||
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||||
|
import type { SecretRefResolver } from './secret-backends/types.js';
|
||||||
|
|
||||||
export class SecretService {
|
export class SecretService implements SecretRefResolver {
|
||||||
constructor(private readonly repo: ISecretRepository) {}
|
constructor(
|
||||||
|
private readonly repo: ISecretRepository,
|
||||||
|
private readonly backends: SecretBackendService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async list(): Promise<Secret[]> {
|
async list(): Promise<Secret[]> {
|
||||||
return this.repo.findAll();
|
return this.repo.findAll();
|
||||||
@@ -26,47 +39,79 @@ export class SecretService {
|
|||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return the secret's actual data by dispatching through its backend driver. */
|
||||||
|
async resolveData(secret: Secret): Promise<Record<string, string>> {
|
||||||
|
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<string, string>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience: resolve {secretName, key} → string. Implements SecretRefResolver. */
|
||||||
|
async resolve(secretName: string, key: string): Promise<string> {
|
||||||
|
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<Secret> {
|
async create(input: unknown): Promise<Secret> {
|
||||||
const data = CreateSecretSchema.parse(input);
|
const data = CreateSecretSchema.parse(input);
|
||||||
|
|
||||||
const existing = await this.repo.findByName(data.name);
|
const existing = await this.repo.findByName(data.name);
|
||||||
if (existing !== null) {
|
if (existing !== null) {
|
||||||
throw new ConflictError(`Secret already exists: ${data.name}`);
|
throw new ConflictError(`Secret already exists: ${data.name}`);
|
||||||
}
|
}
|
||||||
|
const backend = await this.backends.getDefault();
|
||||||
return this.repo.create(data);
|
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<Secret> {
|
async update(id: string, input: unknown): Promise<Secret> {
|
||||||
const data = UpdateSecretSchema.parse(input);
|
const data = UpdateSecretSchema.parse(input);
|
||||||
|
const existing = await this.getById(id);
|
||||||
// Verify exists
|
const backend = await this.backends.getById(existing.backendId);
|
||||||
await this.getById(id);
|
const driver = this.backends.driverFor(backend);
|
||||||
|
const written = await driver.write({ name: existing.name, data: data.data });
|
||||||
return this.repo.update(id, data);
|
return this.repo.update(id, {
|
||||||
|
data: written.storedData,
|
||||||
|
externalRef: written.externalRef,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
async delete(id: string): Promise<void> {
|
||||||
// Verify exists
|
const existing = await this.getById(id);
|
||||||
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);
|
await this.repo.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Backup/restore helpers ──
|
// ── Backup/restore helpers (preserved) ──
|
||||||
|
|
||||||
async upsertByName(data: Record<string, unknown>): Promise<Secret> {
|
async upsertByName(data: Record<string, unknown>): Promise<Secret> {
|
||||||
const name = data['name'] as string;
|
const name = data['name'] as string;
|
||||||
const existing = await this.repo.findByName(name);
|
const existing = await this.repo.findByName(name);
|
||||||
if (existing !== null) {
|
if (existing !== null) {
|
||||||
const { name: _, ...updateFields } = data;
|
return this.update(existing.id, data);
|
||||||
return this.repo.update(existing.id, updateFields as Parameters<ISecretRepository['update']>[1]);
|
|
||||||
}
|
}
|
||||||
return this.repo.create(data as Parameters<ISecretRepository['create']>[0]);
|
return this.create(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteByName(name: string): Promise<void> {
|
async deleteByName(name: string): Promise<void> {
|
||||||
const existing = await this.repo.findByName(name);
|
const existing = await this.repo.findByName(name);
|
||||||
if (existing === null) return;
|
if (existing === null) return;
|
||||||
await this.repo.delete(existing.id);
|
await this.delete(existing.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
|
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. */
|
/** Singular→plural map for resource names. */
|
||||||
const RESOURCE_ALIASES: Record<string, string> = {
|
const RESOURCE_ALIASES: Record<string, string> = {
|
||||||
@@ -15,6 +15,7 @@ const RESOURCE_ALIASES: Record<string, string> = {
|
|||||||
prompt: 'prompts',
|
prompt: 'prompts',
|
||||||
promptrequest: 'promptrequests',
|
promptrequest: 'promptrequests',
|
||||||
mcptoken: 'mcptokens',
|
mcptoken: 'mcptokens',
|
||||||
|
secretbackend: 'secretbackends',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Normalize a resource name to its canonical plural form. */
|
/** Normalize a resource name to its canonical plural form. */
|
||||||
|
|||||||
@@ -9,6 +9,25 @@ import type { IProjectRepository } from '../src/repositories/project.repository.
|
|||||||
import type { IUserRepository } from '../src/repositories/user.repository.js';
|
import type { IUserRepository } from '../src/repositories/user.repository.js';
|
||||||
import type { IGroupRepository } from '../src/repositories/group.repository.js';
|
import type { IGroupRepository } from '../src/repositories/group.repository.js';
|
||||||
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.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<string, string> };
|
||||||
|
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<string, string> };
|
||||||
|
return repo.update(id, { data: data.data });
|
||||||
|
}),
|
||||||
|
} as unknown as SecretService;
|
||||||
|
}
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
const mockServers = [
|
const mockServers = [
|
||||||
@@ -295,7 +314,7 @@ describe('RestoreService', () => {
|
|||||||
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||||
(groupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
(groupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||||
(rbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
(rbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||||
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacRepo);
|
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, mockSecretService(secretRepo), userRepo, groupRepo, rbacRepo);
|
||||||
});
|
});
|
||||||
|
|
||||||
const validBundle = {
|
const validBundle = {
|
||||||
@@ -576,7 +595,7 @@ describe('Backup Routes', () => {
|
|||||||
(rGroupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
(rGroupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||||
const rRbacRepo = mockRbacRepo();
|
const rRbacRepo = mockRbacRepo();
|
||||||
(rRbacRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
(rRbacRepo.findByName as ReturnType<typeof vi.fn>).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() {
|
async function buildApp() {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { resolveServerEnv } from '../src/services/env-resolver.js';
|
import { resolveServerEnv, type SecretResolver } from '../src/services/env-resolver.js';
|
||||||
import type { ISecretRepository } from '../src/repositories/interfaces.js';
|
|
||||||
import type { McpServer } from '@prisma/client';
|
import type { McpServer } from '@prisma/client';
|
||||||
|
|
||||||
function makeServer(env: unknown[]): McpServer {
|
function makeServer(env: unknown[]): McpServer {
|
||||||
@@ -23,18 +22,16 @@ function makeServer(env: unknown[]): McpServer {
|
|||||||
} as McpServer;
|
} as McpServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockSecretRepo(secrets: Record<string, Record<string, string>>): ISecretRepository {
|
/** A SecretResolver backed by a {secretName: {key: value}} map. */
|
||||||
|
function mockResolver(secrets: Record<string, Record<string, string>>): SecretResolver {
|
||||||
return {
|
return {
|
||||||
findAll: vi.fn(async () => []),
|
resolve: vi.fn(async (name: string, key: string): Promise<string> => {
|
||||||
findById: vi.fn(async () => null),
|
|
||||||
findByName: vi.fn(async (name: string) => {
|
|
||||||
const data = secrets[name];
|
const data = secrets[name];
|
||||||
if (!data) return null;
|
if (!data) throw new Error(`Secret '${name}' not found`);
|
||||||
return { id: `sec-${name}`, name, data, version: 1, createdAt: new Date(), updatedAt: new Date() };
|
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: 'FOO', value: 'bar' },
|
||||||
{ name: 'BAZ', value: 'qux' },
|
{ name: 'BAZ', value: 'qux' },
|
||||||
]);
|
]);
|
||||||
const repo = mockSecretRepo({});
|
const result = await resolveServerEnv(server, mockResolver({}));
|
||||||
const result = await resolveServerEnv(server, repo);
|
|
||||||
expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' });
|
expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,10 +49,8 @@ describe('resolveServerEnv', () => {
|
|||||||
const server = makeServer([
|
const server = makeServer([
|
||||||
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'ha-creds', key: 'HOMEASSISTANT_TOKEN' } } },
|
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'ha-creds', key: 'HOMEASSISTANT_TOKEN' } } },
|
||||||
]);
|
]);
|
||||||
const repo = mockSecretRepo({
|
const resolver = mockResolver({ 'ha-creds': { HOMEASSISTANT_TOKEN: 'secret-token-123' } });
|
||||||
'ha-creds': { HOMEASSISTANT_TOKEN: 'secret-token-123' },
|
const result = await resolveServerEnv(server, resolver);
|
||||||
});
|
|
||||||
const result = await resolveServerEnv(server, repo);
|
|
||||||
expect(result).toEqual({ TOKEN: 'secret-token-123' });
|
expect(result).toEqual({ TOKEN: 'secret-token-123' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,48 +59,42 @@ describe('resolveServerEnv', () => {
|
|||||||
{ name: 'URL', value: 'https://ha.local' },
|
{ name: 'URL', value: 'https://ha.local' },
|
||||||
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'TOKEN' } } },
|
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'TOKEN' } } },
|
||||||
]);
|
]);
|
||||||
const repo = mockSecretRepo({
|
const resolver = mockResolver({ creds: { TOKEN: 'my-token' } });
|
||||||
creds: { TOKEN: 'my-token' },
|
const result = await resolveServerEnv(server, resolver);
|
||||||
});
|
|
||||||
const result = await resolveServerEnv(server, repo);
|
|
||||||
expect(result).toEqual({ URL: 'https://ha.local', TOKEN: 'my-token' });
|
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([
|
const server = makeServer([
|
||||||
{ name: 'A', valueFrom: { secretRef: { name: 'shared', key: 'KEY_A' } } },
|
{ name: 'A', valueFrom: { secretRef: { name: 'shared', key: 'KEY_A' } } },
|
||||||
{ name: 'B', valueFrom: { secretRef: { name: 'shared', key: 'KEY_B' } } },
|
{ name: 'B', valueFrom: { secretRef: { name: 'shared', key: 'KEY_B' } } },
|
||||||
]);
|
]);
|
||||||
const repo = mockSecretRepo({
|
const resolver = mockResolver({ shared: { KEY_A: 'val-a', KEY_B: 'val-b' } });
|
||||||
shared: { KEY_A: 'val-a', KEY_B: 'val-b' },
|
const result = await resolveServerEnv(server, resolver);
|
||||||
});
|
|
||||||
const result = await resolveServerEnv(server, repo);
|
|
||||||
expect(result).toEqual({ A: 'val-a', B: 'val-b' });
|
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 () => {
|
it('throws when secret not found', async () => {
|
||||||
const server = makeServer([
|
const server = makeServer([
|
||||||
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'missing', key: 'TOKEN' } } },
|
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'missing', key: 'TOKEN' } } },
|
||||||
]);
|
]);
|
||||||
const repo = mockSecretRepo({});
|
await expect(resolveServerEnv(server, mockResolver({}))).rejects.toThrow(/Secret 'missing' not found/);
|
||||||
await expect(resolveServerEnv(server, repo)).rejects.toThrow("Secret 'missing' not found");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when secret key not found', async () => {
|
it('throws when secret key not found', async () => {
|
||||||
const server = makeServer([
|
const server = makeServer([
|
||||||
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'NONEXISTENT' } } },
|
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'NONEXISTENT' } } },
|
||||||
]);
|
]);
|
||||||
const repo = mockSecretRepo({
|
const resolver = mockResolver({ creds: { OTHER_KEY: 'val' } });
|
||||||
creds: { OTHER_KEY: 'val' },
|
await expect(resolveServerEnv(server, resolver)).rejects.toThrow(/Key 'NONEXISTENT' not found/);
|
||||||
});
|
|
||||||
await expect(resolveServerEnv(server, repo)).rejects.toThrow("Key 'NONEXISTENT' not found in secret 'creds'");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty map for empty env', async () => {
|
it('returns empty map for empty env', async () => {
|
||||||
const server = makeServer([]);
|
const server = makeServer([]);
|
||||||
const repo = mockSecretRepo({});
|
const result = await resolveServerEnv(server, mockResolver({}));
|
||||||
const result = await resolveServerEnv(server, repo);
|
|
||||||
expect(result).toEqual({});
|
expect(result).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
132
src/mcpd/tests/secret-backends.test.ts
Normal file
132
src/mcpd/tests/secret-backends.test.ts
Normal file
@@ -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<typeof vi.fn> {
|
||||||
|
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/<path> 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<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
expect(headers['X-Vault-Namespace']).toBe('myteam');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,43 +3,68 @@ import Fastify from 'fastify';
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { registerSecretRoutes } from '../src/routes/secrets.js';
|
import { registerSecretRoutes } from '../src/routes/secrets.js';
|
||||||
import { SecretService } from '../src/services/secret.service.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 { errorHandler } from '../src/middleware/error-handler.js';
|
||||||
import type { ISecretRepository } from '../src/repositories/interfaces.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;
|
let app: FastifyInstance;
|
||||||
|
|
||||||
function mockRepo(): ISecretRepository {
|
const PLAINTEXT_BACKEND: SecretBackend = {
|
||||||
let lastCreated: Record<string, unknown> | null = null;
|
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<string, string>; externalRef: string; backendId: string }> = {}) {
|
||||||
return {
|
return {
|
||||||
findAll: vi.fn(async () => [
|
id: overrides.id ?? 'sec-1',
|
||||||
{ id: '1', name: 'ha-creds', data: { TOKEN: 'abc' }, version: 1, createdAt: new Date(), updatedAt: new Date() },
|
name: overrides.name ?? 'ha-creds',
|
||||||
]),
|
backendId: overrides.backendId ?? PLAINTEXT_BACKEND.id,
|
||||||
findById: vi.fn(async (id: string) => {
|
data: overrides.data ?? { TOKEN: 'abc' },
|
||||||
if (lastCreated && (lastCreated as { id: string }).id === id) return lastCreated as never;
|
externalRef: overrides.externalRef ?? '',
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
findByName: vi.fn(async () => null),
|
|
||||||
create: vi.fn(async (data) => {
|
|
||||||
const secret = {
|
|
||||||
id: 'new-id',
|
|
||||||
name: data.name,
|
|
||||||
data: data.data ?? {},
|
|
||||||
version: 1,
|
version: 1,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockRepo(): ISecretRepository {
|
||||||
|
let lastCreated: ReturnType<typeof makeSecret> | null = null;
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => [makeSecret()]),
|
||||||
|
findById: vi.fn(async (id: string) => {
|
||||||
|
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 = makeSecret({
|
||||||
|
id: 'new-id',
|
||||||
|
name: data.name,
|
||||||
|
data: data.data ?? {},
|
||||||
|
externalRef: data.externalRef ?? '',
|
||||||
|
backendId: data.backendId,
|
||||||
|
});
|
||||||
lastCreated = secret;
|
lastCreated = secret;
|
||||||
return secret;
|
return secret;
|
||||||
}),
|
}),
|
||||||
update: vi.fn(async (id, data) => {
|
update: vi.fn(async (id, data) => {
|
||||||
const secret = {
|
const secret = makeSecret({
|
||||||
id,
|
id,
|
||||||
name: 'ha-creds',
|
name: lastCreated?.name ?? 'ha-creds',
|
||||||
data: data.data,
|
data: data.data,
|
||||||
version: 2,
|
externalRef: data.externalRef,
|
||||||
createdAt: new Date(),
|
backendId: data.backendId ?? PLAINTEXT_BACKEND.id,
|
||||||
updatedAt: new Date(),
|
});
|
||||||
};
|
|
||||||
lastCreated = secret;
|
lastCreated = secret;
|
||||||
return 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 () => {
|
afterEach(async () => {
|
||||||
if (app) await app.close();
|
if (app) await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
function createApp(repo: ISecretRepository) {
|
async function createApp(repo: ISecretRepository) {
|
||||||
app = Fastify({ logger: false });
|
app = Fastify({ logger: false });
|
||||||
app.setErrorHandler(errorHandler);
|
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);
|
registerSecretRoutes(app, service);
|
||||||
return app.ready();
|
return app.ready();
|
||||||
}
|
}
|
||||||
@@ -129,7 +172,7 @@ describe('Secret Routes', () => {
|
|||||||
describe('PUT /api/v1/secrets/:id', () => {
|
describe('PUT /api/v1/secrets/:id', () => {
|
||||||
it('updates a secret', async () => {
|
it('updates a secret', async () => {
|
||||||
const repo = mockRepo();
|
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);
|
await createApp(repo);
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -154,7 +197,7 @@ describe('Secret Routes', () => {
|
|||||||
describe('DELETE /api/v1/secrets/:id', () => {
|
describe('DELETE /api/v1/secrets/:id', () => {
|
||||||
it('deletes a secret and returns 204', async () => {
|
it('deletes a secret and returns 204', async () => {
|
||||||
const repo = mockRepo();
|
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);
|
await createApp(repo);
|
||||||
const res = await app.inject({ method: 'DELETE', url: '/api/v1/secrets/1' });
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/secrets/1' });
|
||||||
expect(res.statusCode).toBe(204);
|
expect(res.statusCode).toBe(204);
|
||||||
|
|||||||
Reference in New Issue
Block a user