Merge pull request 'feat(mcpd): pluggable SecretBackend + OpenBao driver + migrate' (#51) from feat/secretbackend into main
Some checks failed
Some checks failed
This commit was merged in pull request #51.
This commit is contained in:
@@ -5,11 +5,11 @@ _mcpctl() {
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
local commands="status login logout config get describe delete logs create edit apply patch backup approve console cache test"
|
||||
local commands="status login logout config get describe delete logs create edit apply patch backup approve console cache test migrate"
|
||||
local project_commands="get describe delete logs create edit attach-server detach-server"
|
||||
local global_opts="-v --version --daemon-url --direct -p --project -h --help"
|
||||
local resources="servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all"
|
||||
local resource_aliases="servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm"
|
||||
local resources="servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all"
|
||||
local resource_aliases="servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm"
|
||||
|
||||
# Check if --project/-p was given
|
||||
local has_project=false
|
||||
@@ -175,7 +175,7 @@ _mcpctl() {
|
||||
create)
|
||||
local create_sub=$(_mcpctl_get_subcmd $subcmd_pos)
|
||||
if [[ -z "$create_sub" ]]; then
|
||||
COMPREPLY=($(compgen -W "server secret project user group rbac mcptoken prompt serverattachment promptrequest help" -- "$cur"))
|
||||
COMPREPLY=($(compgen -W "server secret secretbackend project user group rbac mcptoken prompt serverattachment promptrequest help" -- "$cur"))
|
||||
else
|
||||
case "$create_sub" in
|
||||
server)
|
||||
@@ -184,6 +184,9 @@ _mcpctl() {
|
||||
secret)
|
||||
COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur"))
|
||||
;;
|
||||
secretbackend)
|
||||
COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --token-secret --config --force -h --help" -- "$cur"))
|
||||
;;
|
||||
project)
|
||||
COMPREPLY=($(compgen -W "-d --description --proxy-model --prompt --gated --no-gated --server --force -h --help" -- "$cur"))
|
||||
;;
|
||||
@@ -329,6 +332,21 @@ _mcpctl() {
|
||||
esac
|
||||
fi
|
||||
return ;;
|
||||
migrate)
|
||||
local migrate_sub=$(_mcpctl_get_subcmd $subcmd_pos)
|
||||
if [[ -z "$migrate_sub" ]]; then
|
||||
COMPREPLY=($(compgen -W "secrets help" -- "$cur"))
|
||||
else
|
||||
case "$migrate_sub" in
|
||||
secrets)
|
||||
COMPREPLY=($(compgen -W "--from --to --names --keep-source --dry-run -h --help" -- "$cur"))
|
||||
;;
|
||||
*)
|
||||
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
return ;;
|
||||
help)
|
||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||
return ;;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# Erase any stale completions from previous versions
|
||||
complete -c mcpctl -e
|
||||
|
||||
set -l commands status login logout config get describe delete logs create edit apply patch backup approve console cache test
|
||||
set -l commands status login logout config get describe delete logs create edit apply patch backup approve console cache test migrate
|
||||
set -l project_commands get describe delete logs create edit attach-server detach-server
|
||||
|
||||
# Disable file completions by default
|
||||
@@ -31,10 +31,10 @@ function __mcpctl_has_project
|
||||
end
|
||||
|
||||
# Resource type detection
|
||||
set -l resources servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all
|
||||
set -l resources servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all
|
||||
|
||||
function __mcpctl_needs_resource_type
|
||||
set -l resource_aliases servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm
|
||||
set -l resource_aliases servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm
|
||||
set -l tokens (commandline -opc)
|
||||
set -l found_cmd false
|
||||
for tok in $tokens
|
||||
@@ -59,6 +59,7 @@ function __mcpctl_resolve_resource
|
||||
case server srv servers; echo servers
|
||||
case instance inst instances; echo instances
|
||||
case secret sec secrets; echo secrets
|
||||
case secretbackend sb secretbackends; echo secretbackends
|
||||
case template tpl templates; echo templates
|
||||
case project proj projects; echo projects
|
||||
case user users; echo users
|
||||
@@ -74,7 +75,7 @@ function __mcpctl_resolve_resource
|
||||
end
|
||||
|
||||
function __mcpctl_get_resource_type
|
||||
set -l resource_aliases servers instances secrets templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm
|
||||
set -l resource_aliases servers instances secrets secretbackends templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm
|
||||
set -l tokens (commandline -opc)
|
||||
set -l found_cmd false
|
||||
for tok in $tokens
|
||||
@@ -223,7 +224,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a describe -d 'Show detailed information about a resource'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac)'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get logs from an MCP server instance'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, project, user, group, rbac, serverattachment, prompt)'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, project, user, group, rbac, serverattachment, prompt)'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource in your default editor (server, project)'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply declarative configuration from a YAML or JSON file'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a patch -d 'Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)'
|
||||
@@ -232,13 +233,14 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a test -d 'Utilities for testing MCP endpoints and config'
|
||||
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a migrate -d 'Move resources between backends (currently: secrets between SecretBackends)'
|
||||
|
||||
# Project-scoped commands (with --project)
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (servers, projects, instances, all)'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a describe -d 'Show detailed information about a resource'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac)'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get logs from an MCP server instance'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, project, user, group, rbac, serverattachment, prompt)'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, project, user, group, rbac, serverattachment, prompt)'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to a project (requires --project)'
|
||||
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from a project (requires --project)'
|
||||
@@ -281,9 +283,10 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity'
|
||||
|
||||
# create subcommands
|
||||
set -l create_cmds server secret project user group rbac mcptoken prompt serverattachment promptrequest
|
||||
set -l create_cmds server secret secretbackend project user group rbac mcptoken prompt serverattachment promptrequest
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create an MCP server definition'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secretbackend -d 'Create a secret backend (plaintext, openbao)'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a project -d 'Create a project'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a user -d 'Create a user'
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a group -d 'Create a group'
|
||||
@@ -313,6 +316,18 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create server" -l force -d 'Update
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secret" -l data -d 'Secret data KEY=value (repeat for multiple)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secret" -l force -d 'Update if already exists'
|
||||
|
||||
# create secretbackend options
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l type -d 'Backend type (plaintext, openbao)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l description -d 'Description' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l default -d 'Promote this backend to default (atomically demotes the current one)'
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l url -d 'openbao: vault URL (e.g. http://bao.example:8200)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l namespace -d 'openbao: X-Vault-Namespace header value' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l mount -d 'openbao: KV v2 mount point (default: secret)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l path-prefix -d 'openbao: path prefix under mount (default: mcpctl)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l token-secret -d 'openbao: token secret reference in SECRET/KEY form (e.g. bao-creds/token)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l config -d 'Extra config as key=value (repeat for multiple)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create secretbackend" -l force -d 'Update if already exists'
|
||||
|
||||
# create project options
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -s d -l description -d 'Project description' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active create project" -l proxy-model -d 'Plugin name (default, content-pipeline, gate, none)' -x
|
||||
@@ -391,6 +406,17 @@ complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -l timeout -d 'Per-reque
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -s o -l output -d 'Output format: text or json' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active test mcp" -l no-health -d 'Skip the /healthz preflight check'
|
||||
|
||||
# migrate subcommands
|
||||
set -l migrate_cmds secrets
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from migrate; and not __fish_seen_subcommand_from $migrate_cmds" -a secrets -d 'Migrate secrets from one SecretBackend to another'
|
||||
|
||||
# migrate secrets options
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active migrate secrets" -l from -d 'Source SecretBackend name' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active migrate secrets" -l to -d 'Destination SecretBackend name' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active migrate secrets" -l names -d 'Comma-separated secret names (default: all)' -x
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active migrate secrets" -l keep-source -d 'Leave the source copy intact (default: delete from source after write+commit)'
|
||||
complete -c mcpctl -n "__mcpctl_subcmd_active migrate secrets" -l dry-run -d 'Show which secrets would be migrated without touching them'
|
||||
|
||||
# status options
|
||||
complete -c mcpctl -n "__fish_seen_subcommand_from status" -s o -l output -d 'output format (table, json, yaml)' -x
|
||||
|
||||
|
||||
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 = [
|
||||
'servers', 'instances', 'secrets', 'templates', 'projects',
|
||||
'servers', 'instances', 'secrets', 'secretbackends', 'templates', 'projects',
|
||||
'users', 'groups', 'rbac', 'prompts', 'promptrequests',
|
||||
'serverattachments', 'proxymodels', 'all',
|
||||
];
|
||||
@@ -193,6 +193,7 @@ const ALIAS_ENTRIES: [string, string][] = [
|
||||
['server', 'servers'], ['srv', 'servers'],
|
||||
['instance', 'instances'], ['inst', 'instances'],
|
||||
['secret', 'secrets'], ['sec', 'secrets'],
|
||||
['secretbackend', 'secretbackends'], ['sb', 'secretbackends'],
|
||||
['template', 'templates'], ['tpl', 'templates'],
|
||||
['project', 'projects'], ['proj', 'projects'],
|
||||
['user', 'users'],
|
||||
|
||||
@@ -41,6 +41,14 @@ const SecretSpecSchema = z.object({
|
||||
data: z.record(z.string()).default({}),
|
||||
});
|
||||
|
||||
const SecretBackendSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
description: z.string().default(''),
|
||||
isDefault: z.boolean().optional(),
|
||||
config: z.record(z.unknown()).default({}),
|
||||
});
|
||||
|
||||
const TemplateEnvEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
@@ -142,6 +150,7 @@ const McpTokenSpecSchema = z.object({
|
||||
});
|
||||
|
||||
const ApplyConfigSchema = z.object({
|
||||
secretbackends: z.array(SecretBackendSpecSchema).default([]),
|
||||
secrets: z.array(SecretSpecSchema).default([]),
|
||||
servers: z.array(ServerSpecSchema).default([]),
|
||||
users: z.array(UserSpecSchema).default([]),
|
||||
@@ -183,6 +192,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
|
||||
|
||||
if (opts.dryRun) {
|
||||
log('Dry run - would apply:');
|
||||
if (config.secretbackends.length > 0) log(` ${config.secretbackends.length} secretbackend(s)`);
|
||||
if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
|
||||
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
|
||||
if (config.users.length > 0) log(` ${config.users.length} user(s)`);
|
||||
@@ -229,6 +239,7 @@ const KIND_TO_RESOURCE: Record<string, string> = {
|
||||
promptrequest: 'promptrequests',
|
||||
serverattachment: 'serverattachments',
|
||||
mcptoken: 'mcptokens',
|
||||
secretbackend: 'secretbackends',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -324,6 +335,30 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
}
|
||||
}
|
||||
|
||||
// Apply secret backends first — secrets reference them.
|
||||
// When multiple backends claim isDefault: true, the server's atomic swap will
|
||||
// leave whichever was applied last as the effective default.
|
||||
for (const sb of config.secretbackends) {
|
||||
try {
|
||||
const existing = await cachedFindByName('secretbackends', sb.name);
|
||||
if (existing) {
|
||||
const updateBody: Record<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
|
||||
for (const secret of config.secrets) {
|
||||
try {
|
||||
|
||||
@@ -88,7 +88,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('create')
|
||||
.description('Create a resource (server, secret, project, user, group, rbac, serverattachment, prompt)');
|
||||
.description('Create a resource (server, secret, secretbackend, project, user, group, rbac, serverattachment, prompt)');
|
||||
|
||||
// --- create server ---
|
||||
cmd.command('server')
|
||||
@@ -252,6 +252,70 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
}
|
||||
});
|
||||
|
||||
// --- create secretbackend ---
|
||||
cmd.command('secretbackend')
|
||||
.alias('sb')
|
||||
.description('Create a secret backend (plaintext, openbao)')
|
||||
.argument('<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 ---
|
||||
cmd.command('project')
|
||||
.description('Create a project')
|
||||
|
||||
@@ -218,6 +218,37 @@ function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean
|
||||
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 {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Template: ${template.name} ===`);
|
||||
@@ -806,6 +837,9 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
case 'templates':
|
||||
deps.log(formatTemplateDetail(item));
|
||||
break;
|
||||
case 'secretbackends':
|
||||
deps.log(formatSecretBackendDetail(item));
|
||||
break;
|
||||
case 'projects': {
|
||||
const projectPrompts = await deps.client
|
||||
.get<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' },
|
||||
];
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -265,6 +282,8 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
||||
return proxymodelColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'mcptokens':
|
||||
return mcpTokenColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'secretbackends':
|
||||
return secretBackendColumns as unknown as Column<Record<string, unknown>>[];
|
||||
default:
|
||||
return [
|
||||
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
|
||||
@@ -287,6 +306,7 @@ const RESOURCE_KIND: Record<string, string> = {
|
||||
promptrequests: 'promptrequest',
|
||||
serverattachments: 'serverattachment',
|
||||
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',
|
||||
token: 'mcptokens',
|
||||
tokens: 'mcptokens',
|
||||
secretbackend: 'secretbackends',
|
||||
secretbackends: 'secretbackends',
|
||||
sb: 'secretbackends',
|
||||
all: 'all',
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createMcpCommand } from './commands/mcp.js';
|
||||
import { createPatchCommand } from './commands/patch.js';
|
||||
import { createConsoleCommand } from './commands/console/index.js';
|
||||
import { createCacheCommand } from './commands/cache.js';
|
||||
import { createMigrateCommand } from './commands/migrate.js';
|
||||
import { ApiClient, ApiError } from './api-client.js';
|
||||
import { loadConfig } from './config/index.js';
|
||||
import { loadCredentials } from './auth/index.js';
|
||||
@@ -249,6 +250,11 @@ export function createProgram(): Command {
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
program.addCommand(createMigrateCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
}));
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,17 +111,48 @@ model McpTemplate {
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
// ── Secret Backends ──
|
||||
//
|
||||
// Pluggable storage for Secret.data. Default is `plaintext` (data stored in
|
||||
// Secret.data JSON). Other drivers (e.g. `openbao`) store only a reference in
|
||||
// Secret.externalRef and fetch actual values from the external system at read
|
||||
// time. A `plaintext` row is seeded on first startup so the system always has
|
||||
// a viable backend; additional backends are user-managed via
|
||||
// `mcpctl create secretbackend`.
|
||||
|
||||
model SecretBackend {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
type String // plaintext | openbao | (future: vault, aws-sm, ...)
|
||||
config Json @default("{}") // type-specific: url, mount, namespace, tokenSecretRef
|
||||
isDefault Boolean @default(false) // exactly one row has isDefault=true
|
||||
description String @default("")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
secrets Secret[]
|
||||
|
||||
@@index([name])
|
||||
@@index([isDefault])
|
||||
}
|
||||
|
||||
// ── Secrets ──
|
||||
|
||||
model Secret {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
data Json @default("{}")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
backendId String // FK to SecretBackend — dispatches read/write
|
||||
data Json @default("{}") // populated by plaintext backend only
|
||||
externalRef String @default("") // populated by non-plaintext backends (e.g. "mount/path#v3")
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
backend SecretBackend @relation(fields: [backendId], references: [id])
|
||||
|
||||
@@index([name])
|
||||
@@index([backendId])
|
||||
}
|
||||
|
||||
// ── Groups ──
|
||||
|
||||
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,
|
||||
McpTokenRepository,
|
||||
} from './repositories/index.js';
|
||||
import { SecretBackendRepository } from './repositories/secret-backend.repository.js';
|
||||
import { SecretBackendService } from './services/secret-backend.service.js';
|
||||
import { SecretMigrateService } from './services/secret-migrate.service.js';
|
||||
import { bootstrapSecretBackends } from './bootstrap/secret-backends.js';
|
||||
import { registerSecretBackendRoutes } from './routes/secret-backends.js';
|
||||
import { registerSecretMigrateRoutes } from './routes/secret-migrate.js';
|
||||
import { PromptRepository } from './repositories/prompt.repository.js';
|
||||
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
|
||||
import { bootstrapSystemProject } from './bootstrap/system-project.js';
|
||||
@@ -93,11 +99,14 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
||||
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
|
||||
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
|
||||
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
|
||||
// /api/v1/secrets/migrate is a bulk cross-backend operation — treat as op, not a plain secret write.
|
||||
if (url.startsWith('/api/v1/secrets/migrate')) return { kind: 'operation', operation: 'migrate-secrets' };
|
||||
|
||||
const resourceMap: Record<string, string | undefined> = {
|
||||
'servers': 'servers',
|
||||
'instances': 'instances',
|
||||
'secrets': 'secrets',
|
||||
'secretbackends': 'secretbackends',
|
||||
'projects': 'projects',
|
||||
'templates': 'templates',
|
||||
'users': 'users',
|
||||
@@ -261,6 +270,7 @@ async function main(): Promise<void> {
|
||||
// Repositories
|
||||
const serverRepo = new McpServerRepository(prisma);
|
||||
const secretRepo = new SecretRepository(prisma);
|
||||
const secretBackendRepo = new SecretBackendRepository(prisma);
|
||||
const instanceRepo = new McpInstanceRepository(prisma);
|
||||
const projectRepo = new ProjectRepository(prisma);
|
||||
const auditLogRepo = new AuditLogRepository(prisma);
|
||||
@@ -271,11 +281,16 @@ async function main(): Promise<void> {
|
||||
const groupRepo = new GroupRepository(prisma);
|
||||
const mcpTokenRepo = new McpTokenRepository(prisma);
|
||||
|
||||
// SecretBackend bootstrap: ensure a `plaintext` default row exists and any
|
||||
// pre-existing `Secret` rows are pointed at it. Idempotent per run.
|
||||
await bootstrapSecretBackends(prisma);
|
||||
|
||||
// CUID detection for RBAC name resolution
|
||||
const CUID_RE = /^c[^\s-]{8,}$/i;
|
||||
const nameResolvers: Record<string, { findById(id: string): Promise<{ name: string } | null> }> = {
|
||||
servers: serverRepo,
|
||||
secrets: secretRepo,
|
||||
secretbackends: secretBackendRepo,
|
||||
projects: projectRepo,
|
||||
groups: groupRepo,
|
||||
mcptokens: mcpTokenRepo,
|
||||
@@ -291,9 +306,29 @@ async function main(): Promise<void> {
|
||||
|
||||
// Services
|
||||
const serverService = new McpServerService(serverRepo);
|
||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
||||
// SecretBackend service — needs a lazy bridge to the yet-to-be-constructed
|
||||
// SecretService because the OpenBao driver's auth token lives in a plaintext
|
||||
// Secret. The bridge defers the resolve until after `secretService` is
|
||||
// assigned, breaking the circular dependency at construction time.
|
||||
const secretResolverBridge = {
|
||||
resolve: async (name: string, key: string): Promise<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);
|
||||
const secretService = new SecretService(secretRepo);
|
||||
const projectService = new ProjectService(projectRepo, serverRepo);
|
||||
const auditLogService = new AuditLogService(auditLogRepo);
|
||||
const auditEventService = new AuditEventService(auditEventRepo);
|
||||
@@ -313,7 +348,7 @@ async function main(): Promise<void> {
|
||||
promptRuleRegistry.register(systemPromptVarsRule);
|
||||
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry);
|
||||
const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
|
||||
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
|
||||
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, secretService, userRepo, groupRepo, rbacDefinitionRepo, promptRepo, templateRepo);
|
||||
|
||||
// Shared auth dependencies. Both the global auth hook and the per-route
|
||||
// preHandler on /api/v1/mcp/proxy must know how to resolve both session
|
||||
@@ -430,6 +465,8 @@ async function main(): Promise<void> {
|
||||
registerMcpServerRoutes(app, serverService, instanceService);
|
||||
registerTemplateRoutes(app, templateService);
|
||||
registerSecretRoutes(app, secretService);
|
||||
registerSecretBackendRoutes(app, secretBackendService);
|
||||
registerSecretMigrateRoutes(app, secretMigrateService);
|
||||
registerInstanceRoutes(app, instanceService);
|
||||
registerProjectRoutes(app, projectService);
|
||||
registerAuditLogRoutes(app, auditLogService);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { McpServer, McpInstance, AuditLog, AuditEvent, McpToken, Secret, InstanceStatus } from '@prisma/client';
|
||||
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
|
||||
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
|
||||
import type { SecretRepoCreateInput, SecretRepoUpdateInput } from './secret.repository.js';
|
||||
|
||||
export interface IMcpServerRepository {
|
||||
findAll(): Promise<McpServer[]>;
|
||||
@@ -24,8 +24,9 @@ export interface ISecretRepository {
|
||||
findAll(): Promise<Secret[]>;
|
||||
findById(id: string): Promise<Secret | null>;
|
||||
findByName(name: string): Promise<Secret | null>;
|
||||
create(data: CreateSecretInput): Promise<Secret>;
|
||||
update(id: string, data: UpdateSecretInput): Promise<Secret>;
|
||||
findByBackend(backendId: string): Promise<Secret[]>;
|
||||
create(data: SecretRepoCreateInput): Promise<Secret>;
|
||||
update(id: string, data: SecretRepoUpdateInput): Promise<Secret>;
|
||||
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 { 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 {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
@@ -17,20 +29,29 @@ export class SecretRepository implements ISecretRepository {
|
||||
return this.prisma.secret.findUnique({ where: { name } });
|
||||
}
|
||||
|
||||
async create(data: CreateSecretInput): Promise<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({
|
||||
data: {
|
||||
name: data.name,
|
||||
data: data.data,
|
||||
backendId: data.backendId,
|
||||
data: (data.data ?? {}) as Prisma.InputJsonValue,
|
||||
externalRef: data.externalRef ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateSecretInput): Promise<Secret> {
|
||||
return this.prisma.secret.update({
|
||||
where: { id },
|
||||
data: { data: data.data },
|
||||
});
|
||||
async update(id: string, data: SecretRepoUpdateInput): Promise<Secret> {
|
||||
const updateData: Prisma.SecretUpdateInput = {};
|
||||
if (data.data !== undefined) updateData.data = data.data as Prisma.InputJsonValue;
|
||||
if (data.externalRef !== undefined) updateData.externalRef = data.externalRef;
|
||||
if (data.backendId !== undefined) {
|
||||
updateData.backend = { connect: { id: data.backendId } };
|
||||
}
|
||||
return this.prisma.secret.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<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 { ITemplateRepository } from '../../repositories/template.repository.js';
|
||||
import type { RbacRoleBinding } from '../../validation/rbac-definition.schema.js';
|
||||
import type { SecretService } from '../secret.service.js';
|
||||
import { decrypt } from './crypto.js';
|
||||
import type { BackupBundle } from './backup-service.js';
|
||||
|
||||
@@ -41,6 +42,7 @@ export class RestoreService {
|
||||
private serverRepo: IMcpServerRepository,
|
||||
private projectRepo: IProjectRepository,
|
||||
private secretRepo: ISecretRepository,
|
||||
private secretService: SecretService,
|
||||
private userRepo?: IUserRepository,
|
||||
private groupRepo?: IGroupRepository,
|
||||
private rbacRepo?: IRbacDefinitionRepository,
|
||||
@@ -125,16 +127,13 @@ export class RestoreService {
|
||||
result.secretsSkipped++;
|
||||
continue;
|
||||
}
|
||||
// overwrite
|
||||
await this.secretRepo.update(existing.id, { data: secret.data });
|
||||
// overwrite — route through SecretService so backend dispatch applies.
|
||||
await this.secretService.update(existing.id, { data: secret.data });
|
||||
result.secretsCreated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.secretRepo.create({
|
||||
name: secret.name,
|
||||
data: secret.data,
|
||||
});
|
||||
await this.secretService.create({ name: secret.name, data: secret.data });
|
||||
result.secretsCreated++;
|
||||
} catch (err) {
|
||||
result.errors.push(`Failed to restore secret "${secret.name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
import type { McpServer } from '@prisma/client';
|
||||
import type { ISecretRepository } from '../repositories/interfaces.js';
|
||||
import type { ServerEnvEntry } from '../validation/mcp-server.schema.js';
|
||||
|
||||
/**
|
||||
* Minimal dependency surface for the env resolver: anything that can turn a
|
||||
* (secretName, key) pair into a string. Matches `SecretService.resolve()` so
|
||||
* resolution now flows through the configured SecretBackend driver instead
|
||||
* of reading `Secret.data` directly.
|
||||
*/
|
||||
export interface SecretResolver {
|
||||
resolve(secretName: string, key: string): Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a server's env entries into a flat key-value map.
|
||||
* - Inline `value` entries are used directly.
|
||||
* - `valueFrom.secretRef` entries are looked up from the secret repository.
|
||||
* - `valueFrom.secretRef` entries are looked up through the resolver.
|
||||
* Throws if a referenced secret or key is missing.
|
||||
*/
|
||||
export async function resolveServerEnv(
|
||||
server: McpServer,
|
||||
secretRepo: ISecretRepository,
|
||||
resolver: SecretResolver,
|
||||
): Promise<Record<string, string>> {
|
||||
const entries = server.env as ServerEnvEntry[];
|
||||
if (!entries || entries.length === 0) return {};
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
const secretCache = new Map<string, Record<string, string>>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.value !== undefined) {
|
||||
result[entry.name] = entry.value;
|
||||
} else if (entry.valueFrom?.secretRef) {
|
||||
const { name: secretName, key } = entry.valueFrom.secretRef;
|
||||
|
||||
if (!secretCache.has(secretName)) {
|
||||
const secret = await secretRepo.findByName(secretName);
|
||||
if (!secret) {
|
||||
throw new Error(`Secret '${secretName}' not found (referenced by server '${server.name}' env '${entry.name}')`);
|
||||
}
|
||||
secretCache.set(secretName, secret.data as Record<string, string>);
|
||||
try {
|
||||
result[entry.name] = await resolver.resolve(secretName, key);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(
|
||||
`Cannot resolve secret for server '${server.name}' env '${entry.name}': ${msg}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = secretCache.get(secretName)!;
|
||||
if (!(key in data)) {
|
||||
throw new Error(`Key '${key}' not found in secret '${secretName}' (referenced by server '${server.name}' env '${entry.name}')`);
|
||||
}
|
||||
result[entry.name] = data[key]!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { McpInstance } from '@prisma/client';
|
||||
import type { IMcpInstanceRepository, IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.js';
|
||||
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
|
||||
import type { McpOrchestrator, ContainerSpec, ContainerInfo } from './orchestrator.js';
|
||||
import { NotFoundError } from './mcp-server.service.js';
|
||||
import { resolveServerEnv } from './env-resolver.js';
|
||||
import { resolveServerEnv, type SecretResolver } from './env-resolver.js';
|
||||
|
||||
/** Runner images for package-based MCP servers, keyed by runtime name. */
|
||||
const RUNNER_IMAGES: Record<string, string> = {
|
||||
@@ -26,7 +26,7 @@ export class InstanceService {
|
||||
private instanceRepo: IMcpInstanceRepository,
|
||||
private serverRepo: IMcpServerRepository,
|
||||
private orchestrator: McpOrchestrator,
|
||||
private secretRepo?: ISecretRepository,
|
||||
private secretResolver?: SecretResolver,
|
||||
) {}
|
||||
|
||||
async list(serverId?: string): Promise<McpInstance[]> {
|
||||
@@ -284,9 +284,9 @@ export class InstanceService {
|
||||
}
|
||||
|
||||
// Resolve env vars from inline values and secret refs
|
||||
if (this.secretRepo) {
|
||||
if (this.secretResolver) {
|
||||
try {
|
||||
const resolvedEnv = await resolveServerEnv(server, this.secretRepo);
|
||||
const resolvedEnv = await resolveServerEnv(server, this.secretResolver);
|
||||
if (Object.keys(resolvedEnv).length > 0) {
|
||||
spec.env = resolvedEnv;
|
||||
}
|
||||
|
||||
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 { ISecretRepository } from '../repositories/interfaces.js';
|
||||
import type { SecretBackendService } from './secret-backend.service.js';
|
||||
import { CreateSecretSchema, UpdateSecretSchema } from '../validation/secret.schema.js';
|
||||
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||
import type { SecretRefResolver } from './secret-backends/types.js';
|
||||
|
||||
export class SecretService {
|
||||
constructor(private readonly repo: ISecretRepository) {}
|
||||
export class SecretService implements SecretRefResolver {
|
||||
constructor(
|
||||
private readonly repo: ISecretRepository,
|
||||
private readonly backends: SecretBackendService,
|
||||
) {}
|
||||
|
||||
async list(): Promise<Secret[]> {
|
||||
return this.repo.findAll();
|
||||
@@ -26,47 +39,79 @@ export class SecretService {
|
||||
return secret;
|
||||
}
|
||||
|
||||
/** Return the secret's actual data by dispatching through its backend driver. */
|
||||
async resolveData(secret: Secret): Promise<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> {
|
||||
const data = CreateSecretSchema.parse(input);
|
||||
|
||||
const existing = await this.repo.findByName(data.name);
|
||||
if (existing !== null) {
|
||||
throw new ConflictError(`Secret already exists: ${data.name}`);
|
||||
}
|
||||
|
||||
return this.repo.create(data);
|
||||
const backend = await this.backends.getDefault();
|
||||
const driver = this.backends.driverFor(backend);
|
||||
const written = await driver.write({ name: data.name, data: data.data });
|
||||
return this.repo.create({
|
||||
name: data.name,
|
||||
backendId: backend.id,
|
||||
data: written.storedData,
|
||||
externalRef: written.externalRef,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, input: unknown): Promise<Secret> {
|
||||
const data = UpdateSecretSchema.parse(input);
|
||||
|
||||
// Verify exists
|
||||
await this.getById(id);
|
||||
|
||||
return this.repo.update(id, data);
|
||||
const existing = await this.getById(id);
|
||||
const backend = await this.backends.getById(existing.backendId);
|
||||
const driver = this.backends.driverFor(backend);
|
||||
const written = await driver.write({ name: existing.name, data: data.data });
|
||||
return this.repo.update(id, {
|
||||
data: written.storedData,
|
||||
externalRef: written.externalRef,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
// Verify exists
|
||||
await this.getById(id);
|
||||
const existing = await this.getById(id);
|
||||
const backend = await this.backends.getById(existing.backendId);
|
||||
const driver = this.backends.driverFor(backend);
|
||||
await driver.delete({ name: existing.name, externalRef: existing.externalRef });
|
||||
await this.repo.delete(id);
|
||||
}
|
||||
|
||||
// ── Backup/restore helpers ──
|
||||
// ── Backup/restore helpers (preserved) ──
|
||||
|
||||
async upsertByName(data: Record<string, unknown>): Promise<Secret> {
|
||||
const name = data['name'] as string;
|
||||
const existing = await this.repo.findByName(name);
|
||||
if (existing !== null) {
|
||||
const { name: _, ...updateFields } = data;
|
||||
return this.repo.update(existing.id, updateFields as Parameters<ISecretRepository['update']>[1]);
|
||||
return this.update(existing.id, data);
|
||||
}
|
||||
return this.repo.create(data as Parameters<ISecretRepository['create']>[0]);
|
||||
return this.create(data);
|
||||
}
|
||||
|
||||
async deleteByName(name: string): Promise<void> {
|
||||
const existing = await this.repo.findByName(name);
|
||||
if (existing === null) return;
|
||||
await this.repo.delete(existing.id);
|
||||
await this.delete(existing.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
|
||||
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens'] as const;
|
||||
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'secretbackends', 'projects', 'templates', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'mcptokens'] as const;
|
||||
|
||||
/** Singular→plural map for resource names. */
|
||||
const RESOURCE_ALIASES: Record<string, string> = {
|
||||
@@ -15,6 +15,7 @@ const RESOURCE_ALIASES: Record<string, string> = {
|
||||
prompt: 'prompts',
|
||||
promptrequest: 'promptrequests',
|
||||
mcptoken: 'mcptokens',
|
||||
secretbackend: 'secretbackends',
|
||||
};
|
||||
|
||||
/** 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 { IGroupRepository } from '../src/repositories/group.repository.js';
|
||||
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
|
||||
import type { SecretService } from '../src/services/secret.service.js';
|
||||
|
||||
/**
|
||||
* Minimal SecretService shim over a mock repo — just the `.create()` / `.update()`
|
||||
* methods that RestoreService calls. We don't need the backend-dispatch path
|
||||
* here since the restore happy-path tests don't exercise remote backends.
|
||||
*/
|
||||
function mockSecretService(repo: ISecretRepository): SecretService {
|
||||
return {
|
||||
create: vi.fn(async (input: unknown) => {
|
||||
const data = input as { name: string; data: Record<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
|
||||
const mockServers = [
|
||||
@@ -295,7 +314,7 @@ describe('RestoreService', () => {
|
||||
(userRepo.findByEmail 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);
|
||||
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacRepo);
|
||||
restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, mockSecretService(secretRepo), userRepo, groupRepo, rbacRepo);
|
||||
});
|
||||
|
||||
const validBundle = {
|
||||
@@ -576,7 +595,7 @@ describe('Backup Routes', () => {
|
||||
(rGroupRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
const rRbacRepo = mockRbacRepo();
|
||||
(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() {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { resolveServerEnv } from '../src/services/env-resolver.js';
|
||||
import type { ISecretRepository } from '../src/repositories/interfaces.js';
|
||||
import { resolveServerEnv, type SecretResolver } from '../src/services/env-resolver.js';
|
||||
import type { McpServer } from '@prisma/client';
|
||||
|
||||
function makeServer(env: unknown[]): McpServer {
|
||||
@@ -23,18 +22,16 @@ function makeServer(env: unknown[]): McpServer {
|
||||
} as McpServer;
|
||||
}
|
||||
|
||||
function mockSecretRepo(secrets: Record<string, Record<string, string>>): ISecretRepository {
|
||||
/** A SecretResolver backed by a {secretName: {key: value}} map. */
|
||||
function mockResolver(secrets: Record<string, Record<string, string>>): SecretResolver {
|
||||
return {
|
||||
findAll: vi.fn(async () => []),
|
||||
findById: vi.fn(async () => null),
|
||||
findByName: vi.fn(async (name: string) => {
|
||||
resolve: vi.fn(async (name: string, key: string): Promise<string> => {
|
||||
const data = secrets[name];
|
||||
if (!data) return null;
|
||||
return { id: `sec-${name}`, name, data, version: 1, createdAt: new Date(), updatedAt: new Date() };
|
||||
if (!data) throw new Error(`Secret '${name}' not found`);
|
||||
const value = data[key];
|
||||
if (value === undefined) throw new Error(`Key '${key}' not found in secret '${name}'`);
|
||||
return value;
|
||||
}),
|
||||
create: vi.fn(async () => ({} as never)),
|
||||
update: vi.fn(async () => ({} as never)),
|
||||
delete: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,8 +41,7 @@ describe('resolveServerEnv', () => {
|
||||
{ name: 'FOO', value: 'bar' },
|
||||
{ name: 'BAZ', value: 'qux' },
|
||||
]);
|
||||
const repo = mockSecretRepo({});
|
||||
const result = await resolveServerEnv(server, repo);
|
||||
const result = await resolveServerEnv(server, mockResolver({}));
|
||||
expect(result).toEqual({ FOO: 'bar', BAZ: 'qux' });
|
||||
});
|
||||
|
||||
@@ -53,10 +49,8 @@ describe('resolveServerEnv', () => {
|
||||
const server = makeServer([
|
||||
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'ha-creds', key: 'HOMEASSISTANT_TOKEN' } } },
|
||||
]);
|
||||
const repo = mockSecretRepo({
|
||||
'ha-creds': { HOMEASSISTANT_TOKEN: 'secret-token-123' },
|
||||
});
|
||||
const result = await resolveServerEnv(server, repo);
|
||||
const resolver = mockResolver({ 'ha-creds': { HOMEASSISTANT_TOKEN: 'secret-token-123' } });
|
||||
const result = await resolveServerEnv(server, resolver);
|
||||
expect(result).toEqual({ TOKEN: 'secret-token-123' });
|
||||
});
|
||||
|
||||
@@ -65,48 +59,42 @@ describe('resolveServerEnv', () => {
|
||||
{ name: 'URL', value: 'https://ha.local' },
|
||||
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'TOKEN' } } },
|
||||
]);
|
||||
const repo = mockSecretRepo({
|
||||
creds: { TOKEN: 'my-token' },
|
||||
});
|
||||
const result = await resolveServerEnv(server, repo);
|
||||
const resolver = mockResolver({ creds: { TOKEN: 'my-token' } });
|
||||
const result = await resolveServerEnv(server, resolver);
|
||||
expect(result).toEqual({ URL: 'https://ha.local', TOKEN: 'my-token' });
|
||||
});
|
||||
|
||||
it('caches secret lookups', async () => {
|
||||
it('calls the resolver once per distinct ref', async () => {
|
||||
const server = makeServer([
|
||||
{ name: 'A', valueFrom: { secretRef: { name: 'shared', key: 'KEY_A' } } },
|
||||
{ name: 'B', valueFrom: { secretRef: { name: 'shared', key: 'KEY_B' } } },
|
||||
]);
|
||||
const repo = mockSecretRepo({
|
||||
shared: { KEY_A: 'val-a', KEY_B: 'val-b' },
|
||||
});
|
||||
const result = await resolveServerEnv(server, repo);
|
||||
const resolver = mockResolver({ shared: { KEY_A: 'val-a', KEY_B: 'val-b' } });
|
||||
const result = await resolveServerEnv(server, resolver);
|
||||
expect(result).toEqual({ A: 'val-a', B: 'val-b' });
|
||||
expect(repo.findByName).toHaveBeenCalledTimes(1);
|
||||
// Resolver is called per-entry now — caching moved to the SecretService layer,
|
||||
// which is where downstream drivers can be hit at most once per (name, key) pair.
|
||||
expect(resolver.resolve).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws when secret not found', async () => {
|
||||
const server = makeServer([
|
||||
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'missing', key: 'TOKEN' } } },
|
||||
]);
|
||||
const repo = mockSecretRepo({});
|
||||
await expect(resolveServerEnv(server, repo)).rejects.toThrow("Secret 'missing' not found");
|
||||
await expect(resolveServerEnv(server, mockResolver({}))).rejects.toThrow(/Secret 'missing' not found/);
|
||||
});
|
||||
|
||||
it('throws when secret key not found', async () => {
|
||||
const server = makeServer([
|
||||
{ name: 'TOKEN', valueFrom: { secretRef: { name: 'creds', key: 'NONEXISTENT' } } },
|
||||
]);
|
||||
const repo = mockSecretRepo({
|
||||
creds: { OTHER_KEY: 'val' },
|
||||
});
|
||||
await expect(resolveServerEnv(server, repo)).rejects.toThrow("Key 'NONEXISTENT' not found in secret 'creds'");
|
||||
const resolver = mockResolver({ creds: { OTHER_KEY: 'val' } });
|
||||
await expect(resolveServerEnv(server, resolver)).rejects.toThrow(/Key 'NONEXISTENT' not found/);
|
||||
});
|
||||
|
||||
it('returns empty map for empty env', async () => {
|
||||
const server = makeServer([]);
|
||||
const repo = mockSecretRepo({});
|
||||
const result = await resolveServerEnv(server, repo);
|
||||
const result = await resolveServerEnv(server, mockResolver({}));
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
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 { registerSecretRoutes } from '../src/routes/secrets.js';
|
||||
import { SecretService } from '../src/services/secret.service.js';
|
||||
import { SecretBackendService } from '../src/services/secret-backend.service.js';
|
||||
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||
import type { ISecretRepository } from '../src/repositories/interfaces.js';
|
||||
import type { ISecretBackendRepository } from '../src/repositories/secret-backend.repository.js';
|
||||
import type { SecretBackend } from '@prisma/client';
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
function mockRepo(): ISecretRepository {
|
||||
let lastCreated: Record<string, unknown> | null = null;
|
||||
const PLAINTEXT_BACKEND: SecretBackend = {
|
||||
id: 'backend-plaintext',
|
||||
name: 'default',
|
||||
type: 'plaintext',
|
||||
config: {},
|
||||
isDefault: true,
|
||||
description: '',
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function makeSecret(overrides: Partial<{ id: string; name: string; data: Record<string, string>; externalRef: string; backendId: string }> = {}) {
|
||||
return {
|
||||
findAll: vi.fn(async () => [
|
||||
{ id: '1', name: 'ha-creds', data: { TOKEN: 'abc' }, version: 1, createdAt: new Date(), updatedAt: new Date() },
|
||||
]),
|
||||
id: overrides.id ?? 'sec-1',
|
||||
name: overrides.name ?? 'ha-creds',
|
||||
backendId: overrides.backendId ?? PLAINTEXT_BACKEND.id,
|
||||
data: overrides.data ?? { TOKEN: 'abc' },
|
||||
externalRef: overrides.externalRef ?? '',
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function mockRepo(): ISecretRepository {
|
||||
let lastCreated: ReturnType<typeof makeSecret> | null = null;
|
||||
return {
|
||||
findAll: vi.fn(async () => [makeSecret()]),
|
||||
findById: vi.fn(async (id: string) => {
|
||||
if (lastCreated && (lastCreated as { id: string }).id === id) return lastCreated as never;
|
||||
if (lastCreated && lastCreated.id === id) return lastCreated;
|
||||
return null;
|
||||
}),
|
||||
findByName: vi.fn(async () => null),
|
||||
findByBackend: vi.fn(async () => []),
|
||||
create: vi.fn(async (data) => {
|
||||
const secret = {
|
||||
const secret = makeSecret({
|
||||
id: 'new-id',
|
||||
name: data.name,
|
||||
data: data.data ?? {},
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
externalRef: data.externalRef ?? '',
|
||||
backendId: data.backendId,
|
||||
});
|
||||
lastCreated = secret;
|
||||
return secret;
|
||||
}),
|
||||
update: vi.fn(async (id, data) => {
|
||||
const secret = {
|
||||
const secret = makeSecret({
|
||||
id,
|
||||
name: 'ha-creds',
|
||||
name: lastCreated?.name ?? 'ha-creds',
|
||||
data: data.data,
|
||||
version: 2,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
externalRef: data.externalRef,
|
||||
backendId: data.backendId ?? PLAINTEXT_BACKEND.id,
|
||||
});
|
||||
lastCreated = secret;
|
||||
return secret;
|
||||
}),
|
||||
@@ -47,14 +72,32 @@ function mockRepo(): ISecretRepository {
|
||||
};
|
||||
}
|
||||
|
||||
function mockBackendRepo(): ISecretBackendRepository {
|
||||
return {
|
||||
findAll: vi.fn(async () => [PLAINTEXT_BACKEND]),
|
||||
findById: vi.fn(async (id) => (id === PLAINTEXT_BACKEND.id ? PLAINTEXT_BACKEND : null)),
|
||||
findByName: vi.fn(async (name) => (name === PLAINTEXT_BACKEND.name ? PLAINTEXT_BACKEND : null)),
|
||||
findDefault: vi.fn(async () => PLAINTEXT_BACKEND),
|
||||
create: vi.fn(async () => PLAINTEXT_BACKEND),
|
||||
update: vi.fn(async () => PLAINTEXT_BACKEND),
|
||||
setAsDefault: vi.fn(async () => PLAINTEXT_BACKEND),
|
||||
delete: vi.fn(async () => {}),
|
||||
countReferencingSecrets: vi.fn(async () => 0),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
function createApp(repo: ISecretRepository) {
|
||||
async function createApp(repo: ISecretRepository) {
|
||||
app = Fastify({ logger: false });
|
||||
app.setErrorHandler(errorHandler);
|
||||
const service = new SecretService(repo);
|
||||
const backends = new SecretBackendService(mockBackendRepo(), {
|
||||
plaintext: { listAllPlaintext: async () => [] },
|
||||
secretRefResolver: { resolve: async () => '' },
|
||||
});
|
||||
const service = new SecretService(repo, backends);
|
||||
registerSecretRoutes(app, service);
|
||||
return app.ready();
|
||||
}
|
||||
@@ -129,7 +172,7 @@ describe('Secret Routes', () => {
|
||||
describe('PUT /api/v1/secrets/:id', () => {
|
||||
it('updates a secret', async () => {
|
||||
const repo = mockRepo();
|
||||
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds' } as never);
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeSecret({ id: '1' }) as never);
|
||||
await createApp(repo);
|
||||
const res = await app.inject({
|
||||
method: 'PUT',
|
||||
@@ -154,7 +197,7 @@ describe('Secret Routes', () => {
|
||||
describe('DELETE /api/v1/secrets/:id', () => {
|
||||
it('deletes a secret and returns 204', async () => {
|
||||
const repo = mockRepo();
|
||||
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'ha-creds' } as never);
|
||||
vi.mocked(repo.findById).mockResolvedValue(makeSecret({ id: '1' }) as never);
|
||||
await createApp(repo);
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/v1/secrets/1' });
|
||||
expect(res.statusCode).toBe(204);
|
||||
|
||||
Reference in New Issue
Block a user