Compare commits
22 Commits
fix/update
...
fix/comple
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65c340a03c | ||
| 677d34b868 | |||
|
|
c5b8cb60b7 | ||
| 9a5deffb8f | |||
|
|
ec7ada5383 | ||
| b81d3be2d5 | |||
|
|
e2c54bfc5c | ||
| 7b7854b007 | |||
|
|
f23dd99662 | ||
| 43af85cb58 | |||
|
|
6d2e3c2eb3 | ||
| ce21db3853 | |||
|
|
767725023e | ||
| 2bd1b55fe8 | |||
|
|
0f2a93f2f0 | ||
| ce81d9d616 | |||
|
|
c6cc39c6f7 | ||
| de074d9a90 | |||
|
|
783cf15179 | ||
| 5844d6c73f | |||
|
|
604bd76d60 | ||
| da14bb8c23 |
@@ -3,12 +3,81 @@ _mcpctl() {
|
|||||||
_init_completion || return
|
_init_completion || return
|
||||||
|
|
||||||
local commands="status login logout config get describe delete logs create edit apply backup restore help"
|
local commands="status login logout config get describe delete logs create edit apply backup restore help"
|
||||||
local global_opts="-v --version --daemon-url --direct -h --help"
|
local project_commands="attach-server detach-server get describe delete logs create edit help"
|
||||||
|
local global_opts="-v --version --daemon-url --direct --project -h --help"
|
||||||
local resources="servers instances secrets templates projects users groups rbac"
|
local resources="servers instances secrets templates projects users groups rbac"
|
||||||
|
|
||||||
case "${words[1]}" in
|
# Check if --project was given
|
||||||
|
local has_project=false
|
||||||
|
local i
|
||||||
|
for ((i=1; i < cword; i++)); do
|
||||||
|
if [[ "${words[i]}" == "--project" ]]; then
|
||||||
|
has_project=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Find the first subcommand (skip --project and its argument, skip flags)
|
||||||
|
local subcmd=""
|
||||||
|
local subcmd_pos=0
|
||||||
|
for ((i=1; i < cword; i++)); do
|
||||||
|
if [[ "${words[i]}" == "--project" || "${words[i]}" == "--daemon-url" ]]; then
|
||||||
|
((i++)) # skip the argument
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "${words[i]}" != -* ]]; then
|
||||||
|
subcmd="${words[i]}"
|
||||||
|
subcmd_pos=$i
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Find the resource type after get/describe/delete/edit
|
||||||
|
local resource_type=""
|
||||||
|
if [[ -n "$subcmd_pos" ]] && [[ $subcmd_pos -gt 0 ]]; then
|
||||||
|
for ((i=subcmd_pos+1; i < cword; i++)); do
|
||||||
|
if [[ "${words[i]}" != -* ]] && [[ " $resources " == *" ${words[i]} "* ]]; then
|
||||||
|
resource_type="${words[i]}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If completing the --project value
|
||||||
|
if [[ "$prev" == "--project" ]]; then
|
||||||
|
local names
|
||||||
|
names=$(mcpctl get projects -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
|
||||||
|
COMPREPLY=($(compgen -W "$names" -- "$cur"))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fetch resource names dynamically (jq extracts only top-level names)
|
||||||
|
_mcpctl_resource_names() {
|
||||||
|
local rt="$1"
|
||||||
|
if [[ -n "$rt" ]]; then
|
||||||
|
# Instances don't have a name field — use server.name instead
|
||||||
|
if [[ "$rt" == "instances" ]]; then
|
||||||
|
mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null
|
||||||
|
else
|
||||||
|
mcpctl get "$rt" -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the --project value from the command line
|
||||||
|
_mcpctl_get_project_value() {
|
||||||
|
local i
|
||||||
|
for ((i=1; i < cword; i++)); do
|
||||||
|
if [[ "${words[i]}" == "--project" ]] && (( i+1 < cword )); then
|
||||||
|
echo "${words[i+1]}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$subcmd" in
|
||||||
config)
|
config)
|
||||||
if [[ $cword -eq 2 ]]; then
|
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
||||||
COMPREPLY=($(compgen -W "view set path reset claude-generate impersonate help" -- "$cur"))
|
COMPREPLY=($(compgen -W "view set path reset claude-generate impersonate help" -- "$cur"))
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
@@ -20,35 +89,29 @@ _mcpctl() {
|
|||||||
return ;;
|
return ;;
|
||||||
logout)
|
logout)
|
||||||
return ;;
|
return ;;
|
||||||
get)
|
get|describe|delete)
|
||||||
if [[ $cword -eq 2 ]]; then
|
if [[ -z "$resource_type" ]]; then
|
||||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
||||||
else
|
else
|
||||||
COMPREPLY=($(compgen -W "-o --output -h --help" -- "$cur"))
|
local names
|
||||||
fi
|
names=$(_mcpctl_resource_names "$resource_type")
|
||||||
return ;;
|
COMPREPLY=($(compgen -W "$names -o --output -h --help" -- "$cur"))
|
||||||
describe)
|
|
||||||
if [[ $cword -eq 2 ]]; then
|
|
||||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
|
||||||
else
|
|
||||||
COMPREPLY=($(compgen -W "-o --output --show-values -h --help" -- "$cur"))
|
|
||||||
fi
|
|
||||||
return ;;
|
|
||||||
delete)
|
|
||||||
if [[ $cword -eq 2 ]]; then
|
|
||||||
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
|
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
edit)
|
edit)
|
||||||
if [[ $cword -eq 2 ]]; then
|
if [[ -z "$resource_type" ]]; then
|
||||||
COMPREPLY=($(compgen -W "servers projects" -- "$cur"))
|
COMPREPLY=($(compgen -W "servers projects" -- "$cur"))
|
||||||
|
else
|
||||||
|
local names
|
||||||
|
names=$(_mcpctl_resource_names "$resource_type")
|
||||||
|
COMPREPLY=($(compgen -W "$names -h --help" -- "$cur"))
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
logs)
|
logs)
|
||||||
COMPREPLY=($(compgen -W "--tail --since -f --follow -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "--tail --since -f --follow -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
create)
|
create)
|
||||||
if [[ $cword -eq 2 ]]; then
|
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
|
||||||
COMPREPLY=($(compgen -W "server secret project user group rbac help" -- "$cur"))
|
COMPREPLY=($(compgen -W "server secret project user group rbac help" -- "$cur"))
|
||||||
fi
|
fi
|
||||||
return ;;
|
return ;;
|
||||||
@@ -61,13 +124,42 @@ _mcpctl() {
|
|||||||
restore)
|
restore)
|
||||||
COMPREPLY=($(compgen -W "-i --input -p --password -c --conflict -h --help" -- "$cur"))
|
COMPREPLY=($(compgen -W "-i --input -p --password -c --conflict -h --help" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
|
attach-server)
|
||||||
|
# Only complete if no server arg given yet (first arg after subcmd)
|
||||||
|
if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi
|
||||||
|
local proj names all_servers proj_servers
|
||||||
|
proj=$(_mcpctl_get_project_value)
|
||||||
|
if [[ -n "$proj" ]]; then
|
||||||
|
all_servers=$(mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
|
||||||
|
proj_servers=$(mcpctl --project "$proj" get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
|
||||||
|
names=$(comm -23 <(echo "$all_servers" | sort) <(echo "$proj_servers" | sort))
|
||||||
|
else
|
||||||
|
names=$(_mcpctl_resource_names "servers")
|
||||||
|
fi
|
||||||
|
COMPREPLY=($(compgen -W "$names" -- "$cur"))
|
||||||
|
return ;;
|
||||||
|
detach-server)
|
||||||
|
# Only complete if no server arg given yet (first arg after subcmd)
|
||||||
|
if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi
|
||||||
|
local proj names
|
||||||
|
proj=$(_mcpctl_get_project_value)
|
||||||
|
if [[ -n "$proj" ]]; then
|
||||||
|
names=$(mcpctl --project "$proj" get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
|
||||||
|
fi
|
||||||
|
COMPREPLY=($(compgen -W "$names" -- "$cur"))
|
||||||
|
return ;;
|
||||||
help)
|
help)
|
||||||
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
|
||||||
return ;;
|
return ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
if [[ $cword -eq 1 ]]; then
|
# No subcommand yet — offer commands based on context
|
||||||
COMPREPLY=($(compgen -W "$commands $global_opts" -- "$cur"))
|
if [[ -z "$subcmd" ]]; then
|
||||||
|
if $has_project; then
|
||||||
|
COMPREPLY=($(compgen -W "$project_commands $global_opts" -- "$cur"))
|
||||||
|
else
|
||||||
|
COMPREPLY=($(compgen -W "$commands $global_opts" -- "$cur"))
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# mcpctl fish completions
|
# mcpctl fish completions
|
||||||
|
|
||||||
|
# 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 backup restore help
|
set -l commands status login logout config get describe delete logs create edit apply backup restore help
|
||||||
|
set -l project_commands attach-server detach-server get describe delete logs create edit help
|
||||||
|
|
||||||
# Disable file completions by default
|
# Disable file completions by default
|
||||||
complete -c mcpctl -f
|
complete -c mcpctl -f
|
||||||
@@ -9,30 +13,179 @@ complete -c mcpctl -f
|
|||||||
complete -c mcpctl -s v -l version -d 'Show version'
|
complete -c mcpctl -s v -l version -d 'Show version'
|
||||||
complete -c mcpctl -l daemon-url -d 'mcplocal daemon URL' -x
|
complete -c mcpctl -l daemon-url -d 'mcplocal daemon URL' -x
|
||||||
complete -c mcpctl -l direct -d 'Bypass mcplocal, connect directly to mcpd'
|
complete -c mcpctl -l direct -d 'Bypass mcplocal, connect directly to mcpd'
|
||||||
|
complete -c mcpctl -l project -d 'Target project context' -x
|
||||||
complete -c mcpctl -s h -l help -d 'Show help'
|
complete -c mcpctl -s h -l help -d 'Show help'
|
||||||
|
|
||||||
# Top-level commands
|
# Helper: check if --project was given
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a status -d 'Show status and connectivity'
|
function __mcpctl_has_project
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd'
|
set -l tokens (commandline -opc)
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a logout -d 'Log out'
|
for i in (seq (count $tokens))
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a config -d 'Manage configuration'
|
if test "$tokens[$i]" = "--project"
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a get -d 'List resources'
|
return 0
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a describe -d 'Show resource details'
|
end
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource'
|
end
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a logs -d 'Get instance logs'
|
return 1
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource'
|
end
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource'
|
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
|
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
|
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
|
|
||||||
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
|
|
||||||
|
|
||||||
# Resource types for get/describe/delete/edit
|
# Helper: check if a resource type has been selected after get/describe/delete/edit
|
||||||
set -l resources servers instances secrets templates projects users groups rbac
|
set -l resources servers instances secrets templates projects users groups rbac
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete" -a "$resources" -d 'Resource type'
|
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from edit" -a 'servers projects' -d 'Resource type'
|
|
||||||
|
|
||||||
# get/describe/delete options
|
function __mcpctl_needs_resource_type
|
||||||
|
set -l tokens (commandline -opc)
|
||||||
|
set -l found_cmd false
|
||||||
|
for tok in $tokens
|
||||||
|
if $found_cmd
|
||||||
|
# Check if next token after get/describe/delete/edit is a resource type
|
||||||
|
if contains -- $tok servers instances secrets templates projects users groups rbac
|
||||||
|
return 1 # resource type already present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if contains -- $tok get describe delete edit
|
||||||
|
set found_cmd true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if $found_cmd
|
||||||
|
return 0 # command found but no resource type yet
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function __mcpctl_get_resource_type
|
||||||
|
set -l tokens (commandline -opc)
|
||||||
|
set -l found_cmd false
|
||||||
|
for tok in $tokens
|
||||||
|
if $found_cmd
|
||||||
|
if contains -- $tok servers instances secrets templates projects users groups rbac
|
||||||
|
echo $tok
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if contains -- $tok get describe delete edit
|
||||||
|
set found_cmd true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fetch resource names dynamically from the API (jq extracts only top-level names)
|
||||||
|
function __mcpctl_resource_names
|
||||||
|
set -l resource (__mcpctl_get_resource_type)
|
||||||
|
if test -z "$resource"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
# Instances don't have a name field — use server.name instead
|
||||||
|
if test "$resource" = "instances"
|
||||||
|
mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null
|
||||||
|
else
|
||||||
|
mcpctl get $resource -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fetch project names for --project value
|
||||||
|
function __mcpctl_project_names
|
||||||
|
mcpctl get projects -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper: get the --project value from the command line
|
||||||
|
function __mcpctl_get_project_value
|
||||||
|
set -l tokens (commandline -opc)
|
||||||
|
for i in (seq (count $tokens))
|
||||||
|
if test "$tokens[$i]" = "--project"; and test $i -lt (count $tokens)
|
||||||
|
echo $tokens[(math $i + 1)]
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Servers currently attached to the project (for detach-server)
|
||||||
|
function __mcpctl_project_servers
|
||||||
|
set -l proj (__mcpctl_get_project_value)
|
||||||
|
if test -z "$proj"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
mcpctl --project $proj get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
|
||||||
|
end
|
||||||
|
|
||||||
|
# Servers NOT attached to the project (for attach-server)
|
||||||
|
function __mcpctl_available_servers
|
||||||
|
set -l proj (__mcpctl_get_project_value)
|
||||||
|
if test -z "$proj"
|
||||||
|
# No project — show all servers
|
||||||
|
mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
|
||||||
|
return
|
||||||
|
end
|
||||||
|
set -l all (mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
|
||||||
|
set -l attached (mcpctl --project $proj get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
|
||||||
|
for s in $all
|
||||||
|
if not contains -- $s $attached
|
||||||
|
echo $s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# --project value completion
|
||||||
|
complete -c mcpctl -l project -xa '(__mcpctl_project_names)'
|
||||||
|
|
||||||
|
# Top-level commands (without --project)
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a status -d 'Show status and connectivity'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logout -d 'Log out'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage configuration'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a get -d 'List resources'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a describe -d 'Show resource details'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get instance logs'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
|
||||||
|
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
|
||||||
|
|
||||||
|
# Project-scoped commands (with --project)
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to the project'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from the project'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (scoped to project)'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a describe -d 'Show resource details'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get instance logs'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource'
|
||||||
|
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a help -d 'Show help'
|
||||||
|
|
||||||
|
# Resource types — only when resource type not yet selected
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete; and __mcpctl_needs_resource_type" -a "$resources" -d 'Resource type'
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers projects' -d 'Resource type'
|
||||||
|
|
||||||
|
# Resource names — after resource type is selected
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name'
|
||||||
|
|
||||||
|
# Helper: check if attach-server/detach-server already has a server argument
|
||||||
|
function __mcpctl_needs_server_arg
|
||||||
|
set -l tokens (commandline -opc)
|
||||||
|
set -l found_cmd false
|
||||||
|
for tok in $tokens
|
||||||
|
if $found_cmd
|
||||||
|
if not string match -q -- '-*' $tok
|
||||||
|
return 1 # server arg already present
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if contains -- $tok attach-server detach-server
|
||||||
|
set found_cmd true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if $found_cmd
|
||||||
|
return 0 # command found but no server arg yet
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
# attach-server: show servers NOT in the project (only if no server arg yet)
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from attach-server; and __mcpctl_needs_server_arg" -a '(__mcpctl_available_servers)' -d 'Server'
|
||||||
|
|
||||||
|
# detach-server: show servers IN the project (only if no server arg yet)
|
||||||
|
complete -c mcpctl -n "__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg" -a '(__mcpctl_project_servers)' -d 'Server'
|
||||||
|
|
||||||
|
# get/describe options
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from get" -s o -l output -d 'Output format' -xa 'table json yaml'
|
complete -c mcpctl -n "__fish_seen_subcommand_from get" -s o -l output -d 'Output format' -xa 'table json yaml'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -s o -l output -d 'Output format' -xa 'detail json yaml'
|
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -s o -l output -d 'Output format' -xa 'detail json yaml'
|
||||||
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d 'Show secret values'
|
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d 'Show secret values'
|
||||||
|
|||||||
35
fulldeploy.sh
Executable file
35
fulldeploy.sh
Executable file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Full deployment: Docker image → Portainer stack → RPM build/publish/install
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# Load .env
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a; source .env; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " mcpctl Full Deploy"
|
||||||
|
echo "========================================"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> Step 1/3: Build & push mcpd Docker image"
|
||||||
|
echo ""
|
||||||
|
bash scripts/build-mcpd.sh "$@"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> Step 2/3: Deploy stack to production"
|
||||||
|
echo ""
|
||||||
|
bash deploy.sh
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo ">>> Step 3/3: Build, publish & install RPM"
|
||||||
|
echo ""
|
||||||
|
bash scripts/release.sh
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo " Full deploy complete!"
|
||||||
|
echo "========================================"
|
||||||
@@ -5,6 +5,8 @@ release: "1"
|
|||||||
maintainer: michal
|
maintainer: michal
|
||||||
description: kubectl-like CLI for managing MCP servers
|
description: kubectl-like CLI for managing MCP servers
|
||||||
license: MIT
|
license: MIT
|
||||||
|
depends:
|
||||||
|
- jq
|
||||||
contents:
|
contents:
|
||||||
- src: ./dist/mcpctl
|
- src: ./dist/mcpctl
|
||||||
dst: /usr/bin/mcpctl
|
dst: /usr/bin/mcpctl
|
||||||
|
|||||||
55
pr.sh
Executable file
55
pr.sh
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage: bash pr.sh "PR title" "PR body"
|
||||||
|
# Loads GITEA_TOKEN from .env automatically
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Load .env if GITEA_TOKEN not already exported
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ] && [ -f .env ]; then
|
||||||
|
set -a
|
||||||
|
source .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
GITEA_URL="${GITEA_URL:-http://10.0.0.194:3012}"
|
||||||
|
REPO="${GITEA_OWNER:-michal}/mcpctl"
|
||||||
|
|
||||||
|
TITLE="${1:?Usage: pr.sh <title> [body]}"
|
||||||
|
BODY="${2:-}"
|
||||||
|
BASE="${3:-main}"
|
||||||
|
HEAD=$(git rev-parse --abbrev-ref HEAD)
|
||||||
|
|
||||||
|
if [ "$HEAD" = "$BASE" ]; then
|
||||||
|
echo "Error: already on $BASE, switch to a feature branch first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||||
|
echo "Error: GITEA_TOKEN not set and .env not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Push if needed
|
||||||
|
if ! git rev-parse --verify "origin/$HEAD" &>/dev/null; then
|
||||||
|
git push -u origin "$HEAD"
|
||||||
|
else
|
||||||
|
git push
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create PR
|
||||||
|
RESPONSE=$(curl -s -X POST "$GITEA_URL/api/v1/repos/$REPO/pulls" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg t "$TITLE" --arg b "$BODY" --arg h "$HEAD" --arg base "$BASE" \
|
||||||
|
'{title: $t, body: $b, head: $h, base: $base}')")
|
||||||
|
|
||||||
|
PR_NUM=$(echo "$RESPONSE" | jq -r '.number // empty')
|
||||||
|
PR_URL=$(echo "$RESPONSE" | jq -r '.html_url // empty')
|
||||||
|
|
||||||
|
if [ -z "$PR_NUM" ]; then
|
||||||
|
echo "Error creating PR:" >&2
|
||||||
|
echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PR #$PR_NUM: https://mysources.co.uk/$REPO/pulls/$PR_NUM"
|
||||||
@@ -87,7 +87,7 @@ const RESOURCE_ALIASES: Record<string, string> = {
|
|||||||
|
|
||||||
const RbacRoleBindingSchema = z.union([
|
const RbacRoleBindingSchema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
role: z.enum(['edit', 'view', 'create', 'delete', 'run']),
|
role: z.enum(['edit', 'view', 'create', 'delete', 'run', 'expose']),
|
||||||
resource: z.string().min(1).transform((r) => RESOURCE_ALIASES[r] ?? r),
|
resource: z.string().min(1).transform((r) => RESOURCE_ALIASES[r] ?? r),
|
||||||
name: z.string().min(1).optional(),
|
name: z.string().min(1).optional(),
|
||||||
}),
|
}),
|
||||||
@@ -110,7 +110,6 @@ const ProjectSpecSchema = z.object({
|
|||||||
llmProvider: z.string().optional(),
|
llmProvider: z.string().optional(),
|
||||||
llmModel: z.string().optional(),
|
llmModel: z.string().optional(),
|
||||||
servers: z.array(z.string()).default([]),
|
servers: z.array(z.string()).default([]),
|
||||||
members: z.array(z.string().email()).default([]),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ApplyConfigSchema = z.object({
|
const ApplyConfigSchema = z.object({
|
||||||
@@ -246,7 +245,7 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply projects (send full spec including servers/members)
|
// Apply projects (send full spec including servers)
|
||||||
for (const project of config.projects) {
|
for (const project of config.projects) {
|
||||||
try {
|
try {
|
||||||
const existing = await findByName(client, 'projects', project.name);
|
const existing = await findByName(client, 'projects', project.name);
|
||||||
|
|||||||
@@ -196,10 +196,9 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
.argument('<name>', 'Project name')
|
.argument('<name>', 'Project name')
|
||||||
.option('-d, --description <text>', 'Project description', '')
|
.option('-d, --description <text>', 'Project description', '')
|
||||||
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
|
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
|
||||||
.option('--llm-provider <name>', 'LLM provider name')
|
.option('--proxy-mode-llm-provider <name>', 'LLM provider name (for filtered proxy mode)')
|
||||||
.option('--llm-model <name>', 'LLM model name')
|
.option('--proxy-mode-llm-model <name>', 'LLM model name (for filtered proxy mode)')
|
||||||
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
|
||||||
.option('--member <email>', 'Member email (repeat for multiple)', collect, [])
|
|
||||||
.option('--force', 'Update if already exists')
|
.option('--force', 'Update if already exists')
|
||||||
.action(async (name: string, opts) => {
|
.action(async (name: string, opts) => {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
@@ -207,10 +206,9 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
|||||||
description: opts.description,
|
description: opts.description,
|
||||||
proxyMode: opts.proxyMode ?? 'direct',
|
proxyMode: opts.proxyMode ?? 'direct',
|
||||||
};
|
};
|
||||||
if (opts.llmProvider) body.llmProvider = opts.llmProvider;
|
if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider;
|
||||||
if (opts.llmModel) body.llmModel = opts.llmModel;
|
if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel;
|
||||||
if (opts.server.length > 0) body.servers = opts.server;
|
if (opts.server.length > 0) body.servers = opts.server;
|
||||||
if (opts.member.length > 0) body.members = opts.member;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body);
|
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body);
|
||||||
|
|||||||
@@ -162,17 +162,6 @@ function formatProjectDetail(project: Record<string, unknown>): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Members section (no role — all permissions are in RBAC)
|
|
||||||
const members = project.members as Array<{ user: { email: string } }> | undefined;
|
|
||||||
if (members && members.length > 0) {
|
|
||||||
lines.push('');
|
|
||||||
lines.push('Members:');
|
|
||||||
lines.push(' EMAIL');
|
|
||||||
for (const m of members) {
|
|
||||||
lines.push(` ${m.user.email}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('Metadata:');
|
lines.push('Metadata:');
|
||||||
lines.push(` ${pad('ID:', 12)}${project.id}`);
|
lines.push(` ${pad('ID:', 12)}${project.id}`);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ interface ProjectRow {
|
|||||||
proxyMode: string;
|
proxyMode: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
servers?: Array<{ server: { name: string } }>;
|
servers?: Array<{ server: { name: string } }>;
|
||||||
members?: Array<{ user: { email: string }; role: string }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SecretRow {
|
interface SecretRow {
|
||||||
@@ -85,7 +84,6 @@ const projectColumns: Column<ProjectRow>[] = [
|
|||||||
{ header: 'NAME', key: 'name' },
|
{ header: 'NAME', key: 'name' },
|
||||||
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
|
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
|
||||||
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
|
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
|
||||||
{ header: 'MEMBERS', key: (r) => r.members ? String(r.members.length) : '0', width: 8 },
|
|
||||||
{ header: 'DESCRIPTION', key: 'description', width: 30 },
|
{ header: 'DESCRIPTION', key: 'description', width: 30 },
|
||||||
{ header: 'ID', key: 'id' },
|
{ header: 'ID', key: 'id' },
|
||||||
];
|
];
|
||||||
|
|||||||
47
src/cli/src/commands/project-ops.ts
Normal file
47
src/cli/src/commands/project-ops.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import type { ApiClient } from '../api-client.js';
|
||||||
|
import { resolveNameOrId } from './shared.js';
|
||||||
|
|
||||||
|
export interface ProjectOpsDeps {
|
||||||
|
client: ApiClient;
|
||||||
|
log: (...args: string[]) => void;
|
||||||
|
getProject: () => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireProject(deps: ProjectOpsDeps): string {
|
||||||
|
const project = deps.getProject();
|
||||||
|
if (!project) {
|
||||||
|
deps.log('Error: --project <name> is required for this command.');
|
||||||
|
process.exitCode = 1;
|
||||||
|
throw new Error('--project required');
|
||||||
|
}
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAttachServerCommand(deps: ProjectOpsDeps): Command {
|
||||||
|
const { client, log } = deps;
|
||||||
|
|
||||||
|
return new Command('attach-server')
|
||||||
|
.description('Attach a server to a project (requires --project)')
|
||||||
|
.argument('<server-name>', 'Server name to attach')
|
||||||
|
.action(async (serverName: string) => {
|
||||||
|
const projectName = requireProject(deps);
|
||||||
|
const projectId = await resolveNameOrId(client, 'projects', projectName);
|
||||||
|
await client.post(`/api/v1/projects/${projectId}/servers`, { server: serverName });
|
||||||
|
log(`server '${serverName}' attached to project '${projectName}'`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDetachServerCommand(deps: ProjectOpsDeps): Command {
|
||||||
|
const { client, log } = deps;
|
||||||
|
|
||||||
|
return new Command('detach-server')
|
||||||
|
.description('Detach a server from a project (requires --project)')
|
||||||
|
.argument('<server-name>', 'Server name to detach')
|
||||||
|
.action(async (serverName: string) => {
|
||||||
|
const projectName = requireProject(deps);
|
||||||
|
const projectId = await resolveNameOrId(client, 'projects', projectName);
|
||||||
|
await client.delete(`/api/v1/projects/${projectId}/servers/${serverName}`);
|
||||||
|
log(`server '${serverName}' detached from project '${projectName}'`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { createCreateCommand } from './commands/create.js';
|
|||||||
import { createEditCommand } from './commands/edit.js';
|
import { createEditCommand } from './commands/edit.js';
|
||||||
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
||||||
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
|
||||||
|
import { createAttachServerCommand, createDetachServerCommand } from './commands/project-ops.js';
|
||||||
import { ApiClient, ApiError } from './api-client.js';
|
import { ApiClient, ApiError } from './api-client.js';
|
||||||
import { loadConfig } from './config/index.js';
|
import { loadConfig } from './config/index.js';
|
||||||
import { loadCredentials } from './auth/index.js';
|
import { loadCredentials } from './auth/index.js';
|
||||||
@@ -24,7 +25,8 @@ export function createProgram(): Command {
|
|||||||
.version(APP_VERSION, '-v, --version')
|
.version(APP_VERSION, '-v, --version')
|
||||||
.enablePositionalOptions()
|
.enablePositionalOptions()
|
||||||
.option('--daemon-url <url>', 'mcplocal daemon URL')
|
.option('--daemon-url <url>', 'mcplocal daemon URL')
|
||||||
.option('--direct', 'bypass mcplocal and connect directly to mcpd');
|
.option('--direct', 'bypass mcplocal and connect directly to mcpd')
|
||||||
|
.option('--project <name>', 'Target project for project commands');
|
||||||
|
|
||||||
program.addCommand(createStatusCommand());
|
program.addCommand(createStatusCommand());
|
||||||
program.addCommand(createLoginCommand());
|
program.addCommand(createLoginCommand());
|
||||||
@@ -52,6 +54,21 @@ export function createProgram(): Command {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
|
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
|
||||||
|
const projectName = program.opts().project as string | undefined;
|
||||||
|
|
||||||
|
// --project scoping for servers and instances
|
||||||
|
if (projectName && !nameOrId && (resource === 'servers' || resource === 'instances')) {
|
||||||
|
const projectId = await resolveNameOrId(client, 'projects', projectName);
|
||||||
|
if (resource === 'servers') {
|
||||||
|
return client.get<unknown[]>(`/api/v1/projects/${projectId}/servers`);
|
||||||
|
}
|
||||||
|
// instances: fetch project servers, then filter instances by serverId
|
||||||
|
const projectServers = await client.get<Array<{ id: string }>>(`/api/v1/projects/${projectId}/servers`);
|
||||||
|
const serverIds = new Set(projectServers.map((s) => s.id));
|
||||||
|
const allInstances = await client.get<Array<{ serverId: string }>>(`/api/v1/instances`);
|
||||||
|
return allInstances.filter((inst) => serverIds.has(inst.serverId));
|
||||||
|
}
|
||||||
|
|
||||||
if (nameOrId) {
|
if (nameOrId) {
|
||||||
// Glob pattern — use query param filtering
|
// Glob pattern — use query param filtering
|
||||||
if (nameOrId.includes('*')) {
|
if (nameOrId.includes('*')) {
|
||||||
@@ -126,6 +143,14 @@ export function createProgram(): Command {
|
|||||||
log: (...args) => console.log(...args),
|
log: (...args) => console.log(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const projectOpsDeps = {
|
||||||
|
client,
|
||||||
|
log: (...args: string[]) => console.log(...args),
|
||||||
|
getProject: () => program.opts().project as string | undefined,
|
||||||
|
};
|
||||||
|
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
|
||||||
|
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ rbacBindings:
|
|||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies projects with servers and members', async () => {
|
it('applies projects with servers', async () => {
|
||||||
const configPath = join(tmpDir, 'config.yaml');
|
const configPath = join(tmpDir, 'config.yaml');
|
||||||
writeFileSync(configPath, `
|
writeFileSync(configPath, `
|
||||||
projects:
|
projects:
|
||||||
@@ -338,9 +338,6 @@ projects:
|
|||||||
servers:
|
servers:
|
||||||
- my-grafana
|
- my-grafana
|
||||||
- my-ha
|
- my-ha
|
||||||
members:
|
|
||||||
- alice@test.com
|
|
||||||
- bob@test.com
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cmd = createApplyCommand({ client, log });
|
const cmd = createApplyCommand({ client, log });
|
||||||
@@ -352,7 +349,6 @@ projects:
|
|||||||
llmProvider: 'gemini-cli',
|
llmProvider: 'gemini-cli',
|
||||||
llmModel: 'gemini-2.0-flash',
|
llmModel: 'gemini-2.0-flash',
|
||||||
servers: ['my-grafana', 'my-ha'],
|
servers: ['my-grafana', 'my-ha'],
|
||||||
members: ['alice@test.com', 'bob@test.com'],
|
|
||||||
}));
|
}));
|
||||||
expect(output.join('\n')).toContain('Created project: smart-home');
|
expect(output.join('\n')).toContain('Created project: smart-home');
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ describe('get command', () => {
|
|||||||
proxyMode: 'filtered',
|
proxyMode: 'filtered',
|
||||||
ownerId: 'usr-1',
|
ownerId: 'usr-1',
|
||||||
servers: [{ server: { name: 'grafana' } }],
|
servers: [{ server: { name: 'grafana' } }],
|
||||||
members: [{ user: { email: 'a@b.com' }, role: 'admin' }, { user: { email: 'c@d.com' }, role: 'member' }],
|
|
||||||
}]);
|
}]);
|
||||||
const cmd = createGetCommand(deps);
|
const cmd = createGetCommand(deps);
|
||||||
await cmd.parseAsync(['node', 'test', 'projects']);
|
await cmd.parseAsync(['node', 'test', 'projects']);
|
||||||
@@ -189,11 +188,9 @@ describe('get command', () => {
|
|||||||
const text = deps.output.join('\n');
|
const text = deps.output.join('\n');
|
||||||
expect(text).toContain('MODE');
|
expect(text).toContain('MODE');
|
||||||
expect(text).toContain('SERVERS');
|
expect(text).toContain('SERVERS');
|
||||||
expect(text).toContain('MEMBERS');
|
|
||||||
expect(text).toContain('smart-home');
|
expect(text).toContain('smart-home');
|
||||||
expect(text).toContain('filtered');
|
expect(text).toContain('filtered');
|
||||||
expect(text).toContain('1');
|
expect(text).toContain('1');
|
||||||
expect(text).toContain('2');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays mixed resource and operation bindings', async () => {
|
it('displays mixed resource and operation bindings', async () => {
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ describe('project with new fields', () => {
|
|||||||
'project', 'smart-home',
|
'project', 'smart-home',
|
||||||
'-d', 'Smart home project',
|
'-d', 'Smart home project',
|
||||||
'--proxy-mode', 'filtered',
|
'--proxy-mode', 'filtered',
|
||||||
'--llm-provider', 'gemini-cli',
|
'--proxy-mode-llm-provider', 'gemini-cli',
|
||||||
'--llm-model', 'gemini-2.0-flash',
|
'--proxy-mode-llm-model', 'gemini-2.0-flash',
|
||||||
'--server', 'my-grafana',
|
'--server', 'my-grafana',
|
||||||
'--server', 'my-ha',
|
'--server', 'my-ha',
|
||||||
], { from: 'user' });
|
], { from: 'user' });
|
||||||
@@ -46,20 +46,6 @@ describe('project with new fields', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates project with members', async () => {
|
|
||||||
const cmd = createCreateCommand({ client, log });
|
|
||||||
await cmd.parseAsync([
|
|
||||||
'project', 'team-project',
|
|
||||||
'--member', 'alice@test.com',
|
|
||||||
'--member', 'bob@test.com',
|
|
||||||
], { from: 'user' });
|
|
||||||
|
|
||||||
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
|
|
||||||
name: 'team-project',
|
|
||||||
members: ['alice@test.com', 'bob@test.com'],
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults proxy mode to direct', async () => {
|
it('defaults proxy mode to direct', async () => {
|
||||||
const cmd = createCreateCommand({ client, log });
|
const cmd = createCreateCommand({ client, log });
|
||||||
await cmd.parseAsync(['project', 'basic'], { from: 'user' });
|
await cmd.parseAsync(['project', 'basic'], { from: 'user' });
|
||||||
@@ -71,7 +57,7 @@ describe('project with new fields', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('get projects shows new columns', () => {
|
describe('get projects shows new columns', () => {
|
||||||
it('shows MODE, SERVERS, MEMBERS columns', async () => {
|
it('shows MODE and SERVERS columns', async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
output: [] as string[],
|
output: [] as string[],
|
||||||
fetchResource: vi.fn(async () => [{
|
fetchResource: vi.fn(async () => [{
|
||||||
@@ -81,7 +67,6 @@ describe('project with new fields', () => {
|
|||||||
proxyMode: 'filtered',
|
proxyMode: 'filtered',
|
||||||
ownerId: 'user-1',
|
ownerId: 'user-1',
|
||||||
servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }],
|
servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }],
|
||||||
members: [{ user: { email: 'alice@test.com' } }],
|
|
||||||
}]),
|
}]),
|
||||||
log: (...args: string[]) => deps.output.push(args.join(' ')),
|
log: (...args: string[]) => deps.output.push(args.join(' ')),
|
||||||
};
|
};
|
||||||
@@ -91,13 +76,12 @@ describe('project with new fields', () => {
|
|||||||
const text = deps.output.join('\n');
|
const text = deps.output.join('\n');
|
||||||
expect(text).toContain('MODE');
|
expect(text).toContain('MODE');
|
||||||
expect(text).toContain('SERVERS');
|
expect(text).toContain('SERVERS');
|
||||||
expect(text).toContain('MEMBERS');
|
|
||||||
expect(text).toContain('smart-home');
|
expect(text).toContain('smart-home');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('describe project shows full detail', () => {
|
describe('describe project shows full detail', () => {
|
||||||
it('shows servers and members', async () => {
|
it('shows servers and proxy config', async () => {
|
||||||
const deps = {
|
const deps = {
|
||||||
output: [] as string[],
|
output: [] as string[],
|
||||||
client: mockClient(),
|
client: mockClient(),
|
||||||
@@ -113,10 +97,6 @@ describe('project with new fields', () => {
|
|||||||
{ server: { name: 'my-grafana' } },
|
{ server: { name: 'my-grafana' } },
|
||||||
{ server: { name: 'my-ha' } },
|
{ server: { name: 'my-ha' } },
|
||||||
],
|
],
|
||||||
members: [
|
|
||||||
{ user: { email: 'alice@test.com' } },
|
|
||||||
{ user: { email: 'bob@test.com' } },
|
|
||||||
],
|
|
||||||
createdAt: '2025-01-01',
|
createdAt: '2025-01-01',
|
||||||
updatedAt: '2025-01-01',
|
updatedAt: '2025-01-01',
|
||||||
})),
|
})),
|
||||||
@@ -131,8 +111,6 @@ describe('project with new fields', () => {
|
|||||||
expect(text).toContain('gemini-cli');
|
expect(text).toContain('gemini-cli');
|
||||||
expect(text).toContain('my-grafana');
|
expect(text).toContain('my-grafana');
|
||||||
expect(text).toContain('my-ha');
|
expect(text).toContain('my-ha');
|
||||||
expect(text).toContain('alice@test.com');
|
|
||||||
expect(text).toContain('bob@test.com');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
176
src/cli/tests/completions.test.ts
Normal file
176
src/cli/tests/completions.test.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
|
||||||
|
const fishFile = readFileSync(join(root, 'completions', 'mcpctl.fish'), 'utf-8');
|
||||||
|
const bashFile = readFileSync(join(root, 'completions', 'mcpctl.bash'), 'utf-8');
|
||||||
|
|
||||||
|
describe('fish completions', () => {
|
||||||
|
it('erases stale completions at the top', () => {
|
||||||
|
const lines = fishFile.split('\n');
|
||||||
|
const firstComplete = lines.findIndex((l) => l.startsWith('complete '));
|
||||||
|
expect(lines[firstComplete]).toContain('-e');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer resource types without __mcpctl_needs_resource_type guard', () => {
|
||||||
|
const resourceTypes = ['servers', 'instances', 'secrets', 'templates', 'projects', 'users', 'groups', 'rbac'];
|
||||||
|
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete '));
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Find lines that offer resource types as positional args
|
||||||
|
const offersResourceType = resourceTypes.some((r) => {
|
||||||
|
// Match `-a "...servers..."` or `-a 'servers projects'`
|
||||||
|
const aMatch = line.match(/-a\s+['"]([^'"]+)['"]/);
|
||||||
|
if (!aMatch) return false;
|
||||||
|
return aMatch[1].split(/\s+/).includes(r);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!offersResourceType) continue;
|
||||||
|
|
||||||
|
// Skip the help completions line and the -e line
|
||||||
|
if (line.includes('__fish_seen_subcommand_from help')) continue;
|
||||||
|
// Skip project-scoped command offerings (those offer commands, not resource types)
|
||||||
|
if (line.includes('attach-server') || line.includes('detach-server')) continue;
|
||||||
|
// Skip lines that offer commands (not resource types)
|
||||||
|
if (line.includes("-d 'Show") || line.includes("-d 'Manage") || line.includes("-d 'Authenticate") ||
|
||||||
|
line.includes("-d 'Log out'") || line.includes("-d 'Get instance") || line.includes("-d 'Create a resource'") ||
|
||||||
|
line.includes("-d 'Edit a resource'") || line.includes("-d 'Apply") || line.includes("-d 'Backup") ||
|
||||||
|
line.includes("-d 'Restore") || line.includes("-d 'List resources") || line.includes("-d 'Delete a resource'")) continue;
|
||||||
|
|
||||||
|
// Lines offering resource types MUST have __mcpctl_needs_resource_type in their condition
|
||||||
|
expect(line, `Resource type completion missing guard: ${line}`).toContain('__mcpctl_needs_resource_type');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resource name completions require resource type to be selected', () => {
|
||||||
|
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('__mcpctl_resource_names'));
|
||||||
|
expect(lines.length).toBeGreaterThan(0);
|
||||||
|
for (const line of lines) {
|
||||||
|
expect(line).toContain('not __mcpctl_needs_resource_type');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines --project option', () => {
|
||||||
|
expect(fishFile).toContain("complete -c mcpctl -l project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attach-server command only shows with --project', () => {
|
||||||
|
// Only check lines that OFFER attach-server as a command (via -a attach-server), not argument completions
|
||||||
|
const lines = fishFile.split('\n').filter((l) =>
|
||||||
|
l.startsWith('complete') && l.includes("-a attach-server"));
|
||||||
|
expect(lines.length).toBeGreaterThan(0);
|
||||||
|
for (const line of lines) {
|
||||||
|
expect(line).toContain('__mcpctl_has_project');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detach-server command only shows with --project', () => {
|
||||||
|
const lines = fishFile.split('\n').filter((l) =>
|
||||||
|
l.startsWith('complete') && l.includes("-a detach-server"));
|
||||||
|
expect(lines.length).toBeGreaterThan(0);
|
||||||
|
for (const line of lines) {
|
||||||
|
expect(line).toContain('__mcpctl_has_project');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resource name functions use jq .[][].name to unwrap wrapped JSON and avoid nested matches', () => {
|
||||||
|
// API returns { "resources": [...] } not [...], so .[].name fails silently.
|
||||||
|
// Must use .[][].name to unwrap the outer object then iterate the array.
|
||||||
|
// Also must not use string match regex which matches nested name fields.
|
||||||
|
const resourceNamesFn = fishFile.match(/function __mcpctl_resource_names[\s\S]*?^end/m)?.[0] ?? '';
|
||||||
|
const projectNamesFn = fishFile.match(/function __mcpctl_project_names[\s\S]*?^end/m)?.[0] ?? '';
|
||||||
|
|
||||||
|
expect(resourceNamesFn, '__mcpctl_resource_names must use jq .[][].name').toContain("jq -r '.[][].name'");
|
||||||
|
expect(resourceNamesFn, '__mcpctl_resource_names must not use string match on name').not.toMatch(/string match.*"name"/);
|
||||||
|
|
||||||
|
expect(projectNamesFn, '__mcpctl_project_names must use jq .[][].name').toContain("jq -r '.[][].name'");
|
||||||
|
expect(projectNamesFn, '__mcpctl_project_names must not use string match on name').not.toMatch(/string match.*"name"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('instances use server.name instead of name', () => {
|
||||||
|
const resourceNamesFn = fishFile.match(/function __mcpctl_resource_names[\s\S]*?^end/m)?.[0] ?? '';
|
||||||
|
expect(resourceNamesFn, 'must handle instances via server.name').toContain('.server.name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attach-server completes with available (unattached) servers and guards against repeat', () => {
|
||||||
|
const attachLine = fishFile.split('\n').find((l) =>
|
||||||
|
l.startsWith('complete') && l.includes('__fish_seen_subcommand_from attach-server'));
|
||||||
|
expect(attachLine, 'attach-server argument completion must exist').toBeDefined();
|
||||||
|
expect(attachLine, 'attach-server must use __mcpctl_available_servers').toContain('__mcpctl_available_servers');
|
||||||
|
expect(attachLine, 'attach-server must guard with __mcpctl_needs_server_arg').toContain('__mcpctl_needs_server_arg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detach-server completes with project servers and guards against repeat', () => {
|
||||||
|
const detachLine = fishFile.split('\n').find((l) =>
|
||||||
|
l.startsWith('complete') && l.includes('__fish_seen_subcommand_from detach-server'));
|
||||||
|
expect(detachLine, 'detach-server argument completion must exist').toBeDefined();
|
||||||
|
expect(detachLine, 'detach-server must use __mcpctl_project_servers').toContain('__mcpctl_project_servers');
|
||||||
|
expect(detachLine, 'detach-server must guard with __mcpctl_needs_server_arg').toContain('__mcpctl_needs_server_arg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-project commands do not show with --project', () => {
|
||||||
|
const nonProjectCmds = ['status', 'login', 'logout', 'config', 'apply', 'backup', 'restore'];
|
||||||
|
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('-a '));
|
||||||
|
|
||||||
|
for (const cmd of nonProjectCmds) {
|
||||||
|
const cmdLines = lines.filter((l) => {
|
||||||
|
const aMatch = l.match(/-a\s+(\S+)/);
|
||||||
|
return aMatch && aMatch[1].replace(/['"]/g, '') === cmd;
|
||||||
|
});
|
||||||
|
for (const line of cmdLines) {
|
||||||
|
expect(line, `${cmd} should require 'not __mcpctl_has_project'`).toContain('not __mcpctl_has_project');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bash completions', () => {
|
||||||
|
it('separates project commands from regular commands', () => {
|
||||||
|
expect(bashFile).toContain('project_commands=');
|
||||||
|
expect(bashFile).toContain('attach-server detach-server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks has_project before offering project commands', () => {
|
||||||
|
expect(bashFile).toContain('if $has_project');
|
||||||
|
expect(bashFile).toContain('$project_commands');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches resource names dynamically after resource type', () => {
|
||||||
|
expect(bashFile).toContain('_mcpctl_resource_names');
|
||||||
|
// get/describe/delete should use resource_names when resource_type is set
|
||||||
|
expect(bashFile).toMatch(/get\|describe\|delete\)[\s\S]*?_mcpctl_resource_names/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('attach-server filters out already-attached servers and guards against repeat', () => {
|
||||||
|
const attachBlock = bashFile.match(/attach-server\)[\s\S]*?return ;;/)?.[0] ?? '';
|
||||||
|
expect(attachBlock, 'attach-server must use _mcpctl_get_project_value').toContain('_mcpctl_get_project_value');
|
||||||
|
expect(attachBlock, 'attach-server must query project servers to exclude').toContain('--project');
|
||||||
|
expect(attachBlock, 'attach-server must check position to prevent repeat').toContain('cword - subcmd_pos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detach-server shows only project servers and guards against repeat', () => {
|
||||||
|
const detachBlock = bashFile.match(/detach-server\)[\s\S]*?return ;;/)?.[0] ?? '';
|
||||||
|
expect(detachBlock, 'detach-server must use _mcpctl_get_project_value').toContain('_mcpctl_get_project_value');
|
||||||
|
expect(detachBlock, 'detach-server must query project servers').toContain('--project');
|
||||||
|
expect(detachBlock, 'detach-server must check position to prevent repeat').toContain('cword - subcmd_pos');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('instances use server.name instead of name', () => {
|
||||||
|
const fnMatch = bashFile.match(/_mcpctl_resource_names\(\)[\s\S]*?\n\s*\}/)?.[0] ?? '';
|
||||||
|
expect(fnMatch, 'must handle instances via .server.name').toContain('.server.name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines --project option', () => {
|
||||||
|
expect(bashFile).toContain('--project');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resource name function uses jq .[][].name to unwrap wrapped JSON and avoid nested matches', () => {
|
||||||
|
const fnMatch = bashFile.match(/_mcpctl_resource_names\(\)[\s\S]*?\n\s*\}/)?.[0] ?? '';
|
||||||
|
expect(fnMatch, '_mcpctl_resource_names must use jq .[][].name').toContain("jq -r '.[][].name'");
|
||||||
|
expect(fnMatch, '_mcpctl_resource_names must not use grep on name').not.toMatch(/grep.*"name"/);
|
||||||
|
// Guard against .[].name (single bracket) which fails on wrapped JSON
|
||||||
|
expect(fnMatch, '_mcpctl_resource_names must not use .[].name (needs .[][].name)').not.toMatch(/jq.*'\.\[\]\.name'/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "ProjectMember" DROP CONSTRAINT IF EXISTS "ProjectMember_projectId_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "ProjectMember" DROP CONSTRAINT IF EXISTS "ProjectMember_userId_fkey";
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE IF EXISTS "ProjectMember";
|
||||||
@@ -24,7 +24,6 @@ model User {
|
|||||||
sessions Session[]
|
sessions Session[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
ownedProjects Project[]
|
ownedProjects Project[]
|
||||||
projectMemberships ProjectMember[]
|
|
||||||
groupMemberships GroupMember[]
|
groupMemberships GroupMember[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@ -181,7 +180,6 @@ model Project {
|
|||||||
|
|
||||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||||
servers ProjectServer[]
|
servers ProjectServer[]
|
||||||
members ProjectMember[]
|
|
||||||
|
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@ -199,18 +197,6 @@ model ProjectServer {
|
|||||||
@@unique([projectId, serverId])
|
@@unique([projectId, serverId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProjectMember {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
projectId String
|
|
||||||
userId String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([projectId, userId])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── MCP Instances (running containers) ──
|
// ── MCP Instances (running containers) ──
|
||||||
|
|
||||||
model McpInstance {
|
model McpInstance {
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
|||||||
const resource = resourceMap[segment];
|
const resource = resourceMap[segment];
|
||||||
if (resource === undefined) return { kind: 'skip' };
|
if (resource === undefined) return { kind: 'skip' };
|
||||||
|
|
||||||
|
// Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission
|
||||||
|
const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/);
|
||||||
|
if (mcpConfigMatch?.[1]) {
|
||||||
|
return { kind: 'resource', resource: 'projects', action: 'expose', resourceName: mcpConfigMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special case: /api/v1/projects/:id/servers — attach/detach requires 'edit'
|
||||||
|
const projectServersMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/servers/);
|
||||||
|
if (projectServersMatch?.[1] && method !== 'GET') {
|
||||||
|
return { kind: 'resource', resource: 'projects', action: 'edit', resourceName: projectServersMatch[1] };
|
||||||
|
}
|
||||||
|
|
||||||
// Map HTTP method to action
|
// Map HTTP method to action
|
||||||
let action: RbacAction;
|
let action: RbacAction;
|
||||||
switch (method) {
|
switch (method) {
|
||||||
@@ -203,6 +215,15 @@ async function main(): Promise<void> {
|
|||||||
const userRepo = new UserRepository(prisma);
|
const userRepo = new UserRepository(prisma);
|
||||||
const groupRepo = new GroupRepository(prisma);
|
const groupRepo = new GroupRepository(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,
|
||||||
|
projects: projectRepo,
|
||||||
|
groups: groupRepo,
|
||||||
|
};
|
||||||
|
|
||||||
// Migrate legacy 'admin' role → granular roles
|
// Migrate legacy 'admin' role → granular roles
|
||||||
await migrateAdminRole(rbacDefinitionRepo);
|
await migrateAdminRole(rbacDefinitionRepo);
|
||||||
|
|
||||||
@@ -214,7 +235,7 @@ async function main(): Promise<void> {
|
|||||||
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
|
||||||
serverService.setInstanceService(instanceService);
|
serverService.setInstanceService(instanceService);
|
||||||
const secretService = new SecretService(secretRepo);
|
const secretService = new SecretService(secretRepo);
|
||||||
const projectService = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo);
|
const projectService = new ProjectService(projectRepo, serverRepo, secretRepo);
|
||||||
const auditLogService = new AuditLogService(auditLogRepo);
|
const auditLogService = new AuditLogService(auditLogRepo);
|
||||||
const metricsCollector = new MetricsCollector();
|
const metricsCollector = new MetricsCollector();
|
||||||
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
|
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
|
||||||
@@ -277,7 +298,19 @@ async function main(): Promise<void> {
|
|||||||
if (check.kind === 'operation') {
|
if (check.kind === 'operation') {
|
||||||
allowed = await rbacService.canRunOperation(request.userId, check.operation);
|
allowed = await rbacService.canRunOperation(request.userId, check.operation);
|
||||||
} else {
|
} else {
|
||||||
|
// Resolve CUID → human name for name-scoped RBAC bindings
|
||||||
|
if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
|
||||||
|
const resolver = nameResolvers[check.resource];
|
||||||
|
if (resolver) {
|
||||||
|
const entity = await resolver.findById(check.resourceName);
|
||||||
|
if (entity) check.resourceName = entity.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName);
|
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName);
|
||||||
|
// Compute scope for list filtering (used by preSerialization hook)
|
||||||
|
if (allowed && check.resourceName === undefined) {
|
||||||
|
request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
reply.code(403).send({ error: 'Forbidden' });
|
reply.code(403).send({ error: 'Forbidden' });
|
||||||
@@ -303,6 +336,17 @@ async function main(): Promise<void> {
|
|||||||
registerUserRoutes(app, userService);
|
registerUserRoutes(app, userService);
|
||||||
registerGroupRoutes(app, groupService);
|
registerGroupRoutes(app, groupService);
|
||||||
|
|
||||||
|
// ── RBAC list filtering hook ──
|
||||||
|
// Filters array responses to only include resources the user is allowed to see.
|
||||||
|
app.addHook('preSerialization', async (request, _reply, payload) => {
|
||||||
|
if (!request.rbacScope || request.rbacScope.wildcard) return payload;
|
||||||
|
if (!Array.isArray(payload)) return payload;
|
||||||
|
return (payload as Array<Record<string, unknown>>).filter((item) => {
|
||||||
|
const name = item['name'];
|
||||||
|
return typeof name === 'string' && request.rbacScope!.names.has(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Start
|
// Start
|
||||||
await app.listen({ port: config.port, host: config.host });
|
await app.listen({ port: config.port, host: config.host });
|
||||||
app.log.info(`mcpd listening on ${config.host}:${config.port}`);
|
app.log.info(`mcpd listening on ${config.host}:${config.port}`);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface AuthDeps {
|
|||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
rbacScope?: { wildcard: boolean; names: Set<string> };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ import type { PrismaClient, Project } from '@prisma/client';
|
|||||||
|
|
||||||
export interface ProjectWithRelations extends Project {
|
export interface ProjectWithRelations extends Project {
|
||||||
servers: Array<{ id: string; server: { id: string; name: string } }>;
|
servers: Array<{ id: string; server: { id: string; name: string } }>;
|
||||||
members: Array<{ id: string; user: { id: string; email: string; name: string | null } }>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROJECT_INCLUDE = {
|
const PROJECT_INCLUDE = {
|
||||||
servers: { include: { server: { select: { id: true, name: true } } } },
|
servers: { include: { server: { select: { id: true, name: true } } } },
|
||||||
members: { include: { user: { select: { id: true, email: true, name: true } } } },
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export interface IProjectRepository {
|
export interface IProjectRepository {
|
||||||
@@ -18,7 +16,8 @@ export interface IProjectRepository {
|
|||||||
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
|
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
|
||||||
delete(id: string): Promise<void>;
|
delete(id: string): Promise<void>;
|
||||||
setServers(projectId: string, serverIds: string[]): Promise<void>;
|
setServers(projectId: string, serverIds: string[]): Promise<void>;
|
||||||
setMembers(projectId: string, userIds: string[]): Promise<void>;
|
addServer(projectId: string, serverId: string): Promise<void>;
|
||||||
|
removeServer(projectId: string, serverId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProjectRepository implements IProjectRepository {
|
export class ProjectRepository implements IProjectRepository {
|
||||||
@@ -76,14 +75,17 @@ export class ProjectRepository implements IProjectRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setMembers(projectId: string, userIds: string[]): Promise<void> {
|
async addServer(projectId: string, serverId: string): Promise<void> {
|
||||||
await this.prisma.$transaction(async (tx) => {
|
await this.prisma.projectServer.upsert({
|
||||||
await tx.projectMember.deleteMany({ where: { projectId } });
|
where: { projectId_serverId: { projectId, serverId } },
|
||||||
if (userIds.length > 0) {
|
create: { projectId, serverId },
|
||||||
await tx.projectMember.createMany({
|
update: {},
|
||||||
data: userIds.map((userId) => ({ projectId, userId })),
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
async removeServer(projectId: string, serverId: string): Promise<void> {
|
||||||
|
await this.prisma.projectServer.deleteMany({
|
||||||
|
where: { projectId, serverId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import type { ProjectService } from '../services/project.service.js';
|
import type { ProjectService } from '../services/project.service.js';
|
||||||
|
|
||||||
export function registerProjectRoutes(app: FastifyInstance, service: ProjectService): void {
|
export function registerProjectRoutes(app: FastifyInstance, service: ProjectService): void {
|
||||||
app.get('/api/v1/projects', async (request) => {
|
app.get('/api/v1/projects', async () => {
|
||||||
// If authenticated, filter by owner; otherwise list all
|
// RBAC preSerialization hook handles access filtering
|
||||||
return service.list(request.userId);
|
return service.list();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
|
app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
|
||||||
@@ -34,6 +34,21 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
|
|||||||
return service.generateMcpConfig(request.params.id);
|
return service.generateMcpConfig(request.params.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Attach a server to a project
|
||||||
|
app.post<{ Params: { id: string }; Body: { server: string } }>('/api/v1/projects/:id/servers', async (request) => {
|
||||||
|
const body = request.body as { server?: string };
|
||||||
|
if (!body.server) {
|
||||||
|
throw Object.assign(new Error('Missing "server" in request body'), { statusCode: 400 });
|
||||||
|
}
|
||||||
|
return service.addServer(request.params.id, body.server);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detach a server from a project
|
||||||
|
app.delete<{ Params: { id: string; serverName: string } }>('/api/v1/projects/:id/servers/:serverName', async (request, reply) => {
|
||||||
|
await service.removeServer(request.params.id, request.params.serverName);
|
||||||
|
reply.code(204);
|
||||||
|
});
|
||||||
|
|
||||||
// List servers in a project (for mcplocal discovery)
|
// List servers in a project (for mcplocal discovery)
|
||||||
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/servers', async (request) => {
|
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/servers', async (request) => {
|
||||||
const project = await service.resolveAndGet(request.params.id);
|
const project = await service.resolveAndGet(request.params.id);
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export interface BackupProject {
|
|||||||
llmProvider?: string | null;
|
llmProvider?: string | null;
|
||||||
llmModel?: string | null;
|
llmModel?: string | null;
|
||||||
serverNames?: string[];
|
serverNames?: string[];
|
||||||
members?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupUser {
|
export interface BackupUser {
|
||||||
@@ -120,7 +119,6 @@ export class BackupService {
|
|||||||
llmProvider: proj.llmProvider,
|
llmProvider: proj.llmProvider,
|
||||||
llmModel: proj.llmModel,
|
llmModel: proj.llmModel,
|
||||||
serverNames: proj.servers.map((ps) => ps.server.name),
|
serverNames: proj.servers.map((ps) => ps.server.name),
|
||||||
members: proj.members.map((pm) => pm.user.email),
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -260,15 +260,11 @@ export class RestoreService {
|
|||||||
if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel;
|
if (project.llmModel !== undefined) updateData['llmModel'] = project.llmModel;
|
||||||
await this.projectRepo.update(existing.id, updateData);
|
await this.projectRepo.update(existing.id, updateData);
|
||||||
|
|
||||||
// Re-link servers and members
|
// Re-link servers
|
||||||
if (project.serverNames && project.serverNames.length > 0) {
|
if (project.serverNames && project.serverNames.length > 0) {
|
||||||
const serverIds = await this.resolveServerNames(project.serverNames);
|
const serverIds = await this.resolveServerNames(project.serverNames);
|
||||||
await this.projectRepo.setServers(existing.id, serverIds);
|
await this.projectRepo.setServers(existing.id, serverIds);
|
||||||
}
|
}
|
||||||
if (project.members && project.members.length > 0 && this.userRepo) {
|
|
||||||
const memberData = await this.resolveProjectMembers(project.members);
|
|
||||||
await this.projectRepo.setMembers(existing.id, memberData);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.projectsCreated++;
|
result.projectsCreated++;
|
||||||
continue;
|
continue;
|
||||||
@@ -289,11 +285,6 @@ export class RestoreService {
|
|||||||
const serverIds = await this.resolveServerNames(project.serverNames);
|
const serverIds = await this.resolveServerNames(project.serverNames);
|
||||||
await this.projectRepo.setServers(created.id, serverIds);
|
await this.projectRepo.setServers(created.id, serverIds);
|
||||||
}
|
}
|
||||||
// Link members
|
|
||||||
if (project.members && project.members.length > 0 && this.userRepo) {
|
|
||||||
const memberData = await this.resolveProjectMembers(project.members);
|
|
||||||
await this.projectRepo.setMembers(created.id, memberData);
|
|
||||||
}
|
|
||||||
|
|
||||||
result.projectsCreated++;
|
result.projectsCreated++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -359,15 +350,4 @@ export class RestoreService {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve project member emails to user IDs. */
|
|
||||||
private async resolveProjectMembers(
|
|
||||||
members: string[],
|
|
||||||
): Promise<string[]> {
|
|
||||||
const resolved: string[] = [];
|
|
||||||
for (const email of members) {
|
|
||||||
const user = await this.userRepo!.findByEmail(email);
|
|
||||||
if (user) resolved.push(user.id);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,6 @@ export { HealthProbeRunner } from './health-probe.service.js';
|
|||||||
export type { HealthCheckSpec, ProbeResult } from './health-probe.service.js';
|
export type { HealthCheckSpec, ProbeResult } from './health-probe.service.js';
|
||||||
export { RbacDefinitionService } from './rbac-definition.service.js';
|
export { RbacDefinitionService } from './rbac-definition.service.js';
|
||||||
export { RbacService } from './rbac.service.js';
|
export { RbacService } from './rbac.service.js';
|
||||||
export type { RbacAction, Permission } from './rbac.service.js';
|
export type { RbacAction, Permission, AllowedScope } from './rbac.service.js';
|
||||||
export { UserService } from './user.service.js';
|
export { UserService } from './user.service.js';
|
||||||
export { GroupService } from './group.service.js';
|
export { GroupService } from './group.service.js';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { McpServer } from '@prisma/client';
|
import type { McpServer } from '@prisma/client';
|
||||||
import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
|
import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js';
|
||||||
import type { IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.js';
|
import type { IMcpServerRepository, ISecretRepository } from '../repositories/interfaces.js';
|
||||||
import type { IUserRepository } from '../repositories/user.repository.js';
|
|
||||||
import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
|
import { CreateProjectSchema, UpdateProjectSchema } from '../validation/project.schema.js';
|
||||||
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
import { NotFoundError, ConflictError } from './mcp-server.service.js';
|
||||||
import { resolveServerEnv } from './env-resolver.js';
|
import { resolveServerEnv } from './env-resolver.js';
|
||||||
@@ -13,7 +12,6 @@ export class ProjectService {
|
|||||||
private readonly projectRepo: IProjectRepository,
|
private readonly projectRepo: IProjectRepository,
|
||||||
private readonly serverRepo: IMcpServerRepository,
|
private readonly serverRepo: IMcpServerRepository,
|
||||||
private readonly secretRepo: ISecretRepository,
|
private readonly secretRepo: ISecretRepository,
|
||||||
private readonly userRepo: IUserRepository,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(ownerId?: string): Promise<ProjectWithRelations[]> {
|
async list(ownerId?: string): Promise<ProjectWithRelations[]> {
|
||||||
@@ -52,9 +50,6 @@ export class ProjectService {
|
|||||||
// Resolve server names to IDs
|
// Resolve server names to IDs
|
||||||
const serverIds = await this.resolveServerNames(data.servers);
|
const serverIds = await this.resolveServerNames(data.servers);
|
||||||
|
|
||||||
// Resolve member emails to user IDs
|
|
||||||
const resolvedMembers = await this.resolveMemberEmails(data.members);
|
|
||||||
|
|
||||||
const project = await this.projectRepo.create({
|
const project = await this.projectRepo.create({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
@@ -64,13 +59,10 @@ export class ProjectService {
|
|||||||
...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}),
|
...(data.llmModel !== undefined ? { llmModel: data.llmModel } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Link servers and members
|
// Link servers
|
||||||
if (serverIds.length > 0) {
|
if (serverIds.length > 0) {
|
||||||
await this.projectRepo.setServers(project.id, serverIds);
|
await this.projectRepo.setServers(project.id, serverIds);
|
||||||
}
|
}
|
||||||
if (resolvedMembers.length > 0) {
|
|
||||||
await this.projectRepo.setMembers(project.id, resolvedMembers);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-fetch to include relations
|
// Re-fetch to include relations
|
||||||
return this.getById(project.id);
|
return this.getById(project.id);
|
||||||
@@ -98,12 +90,6 @@ export class ProjectService {
|
|||||||
await this.projectRepo.setServers(project.id, serverIds);
|
await this.projectRepo.setServers(project.id, serverIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update members if provided
|
|
||||||
if (data.members !== undefined) {
|
|
||||||
const resolvedMembers = await this.resolveMemberEmails(data.members);
|
|
||||||
await this.projectRepo.setMembers(project.id, resolvedMembers);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-fetch to include updated relations
|
// Re-fetch to include updated relations
|
||||||
return this.getById(project.id);
|
return this.getById(project.id);
|
||||||
}
|
}
|
||||||
@@ -141,6 +127,22 @@ export class ProjectService {
|
|||||||
return generateMcpConfig(serverEntries);
|
return generateMcpConfig(serverEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addServer(idOrName: string, serverName: string): Promise<ProjectWithRelations> {
|
||||||
|
const project = await this.resolveAndGet(idOrName);
|
||||||
|
const server = await this.serverRepo.findByName(serverName);
|
||||||
|
if (server === null) throw new NotFoundError(`Server not found: ${serverName}`);
|
||||||
|
await this.projectRepo.addServer(project.id, server.id);
|
||||||
|
return this.getById(project.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeServer(idOrName: string, serverName: string): Promise<ProjectWithRelations> {
|
||||||
|
const project = await this.resolveAndGet(idOrName);
|
||||||
|
const server = await this.serverRepo.findByName(serverName);
|
||||||
|
if (server === null) throw new NotFoundError(`Server not found: ${serverName}`);
|
||||||
|
await this.projectRepo.removeServer(project.id, server.id);
|
||||||
|
return this.getById(project.id);
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveServerNames(names: string[]): Promise<string[]> {
|
private async resolveServerNames(names: string[]): Promise<string[]> {
|
||||||
return Promise.all(names.map(async (name) => {
|
return Promise.all(names.map(async (name) => {
|
||||||
const server = await this.serverRepo.findByName(name);
|
const server = await this.serverRepo.findByName(name);
|
||||||
@@ -148,12 +150,4 @@ export class ProjectService {
|
|||||||
return server.id;
|
return server.id;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveMemberEmails(emails: string[]): Promise<string[]> {
|
|
||||||
return Promise.all(emails.map(async (email) => {
|
|
||||||
const user = await this.userRepo.findByEmail(email);
|
|
||||||
if (user === null) throw new NotFoundError(`User not found: ${email}`);
|
|
||||||
return user.id;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type RbacRoleBinding,
|
type RbacRoleBinding,
|
||||||
} from '../validation/rbac-definition.schema.js';
|
} from '../validation/rbac-definition.schema.js';
|
||||||
|
|
||||||
export type RbacAction = 'view' | 'create' | 'delete' | 'edit' | 'run';
|
export type RbacAction = 'view' | 'create' | 'delete' | 'edit' | 'run' | 'expose';
|
||||||
|
|
||||||
export interface ResourcePermission {
|
export interface ResourcePermission {
|
||||||
role: string;
|
role: string;
|
||||||
@@ -23,13 +23,19 @@ export interface OperationPermission {
|
|||||||
|
|
||||||
export type Permission = ResourcePermission | OperationPermission;
|
export type Permission = ResourcePermission | OperationPermission;
|
||||||
|
|
||||||
|
export interface AllowedScope {
|
||||||
|
wildcard: boolean;
|
||||||
|
names: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Maps roles to the set of actions they grant. */
|
/** Maps roles to the set of actions they grant. */
|
||||||
const ROLE_ACTIONS: Record<string, readonly RbacAction[]> = {
|
const ROLE_ACTIONS: Record<string, readonly RbacAction[]> = {
|
||||||
edit: ['view', 'create', 'delete', 'edit'],
|
edit: ['view', 'create', 'delete', 'edit', 'expose'],
|
||||||
view: ['view'],
|
view: ['view'],
|
||||||
create: ['create'],
|
create: ['create'],
|
||||||
delete: ['delete'],
|
delete: ['delete'],
|
||||||
run: ['run'],
|
run: ['run'],
|
||||||
|
expose: ['expose', 'view'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RbacService {
|
export class RbacService {
|
||||||
@@ -79,6 +85,31 @@ export class RbacService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the set of resource names a user may access for a given action+resource.
|
||||||
|
* Returns wildcard:true if any matching binding is unscoped (no name constraint).
|
||||||
|
* Returns wildcard:false with a set of allowed names if all bindings are name-scoped.
|
||||||
|
*/
|
||||||
|
async getAllowedScope(userId: string, action: RbacAction, resource: string): Promise<AllowedScope> {
|
||||||
|
const permissions = await this.getPermissions(userId);
|
||||||
|
const normalized = normalizeResource(resource);
|
||||||
|
const names = new Set<string>();
|
||||||
|
|
||||||
|
for (const perm of permissions) {
|
||||||
|
if (!('resource' in perm)) continue;
|
||||||
|
const actions = ROLE_ACTIONS[perm.role];
|
||||||
|
if (actions === undefined) continue;
|
||||||
|
if (!actions.includes(action)) continue;
|
||||||
|
const permResource = normalizeResource(perm.resource);
|
||||||
|
if (permResource !== '*' && permResource !== normalized) continue;
|
||||||
|
// Unscoped binding → wildcard access to this resource
|
||||||
|
if (perm.name === undefined) return { wildcard: true, names: new Set() };
|
||||||
|
names.add(perm.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { wildcard: false, names };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all permissions for a user across all matching RbacDefinitions.
|
* Collect all permissions for a user across all matching RbacDefinitions.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export const CreateProjectSchema = z.object({
|
|||||||
llmProvider: z.string().max(100).optional(),
|
llmProvider: z.string().max(100).optional(),
|
||||||
llmModel: z.string().max(100).optional(),
|
llmModel: z.string().max(100).optional(),
|
||||||
servers: z.array(z.string().min(1)).default([]),
|
servers: z.array(z.string().min(1)).default([]),
|
||||||
members: z.array(z.string().email()).default([]),
|
|
||||||
}).refine(
|
}).refine(
|
||||||
(d) => d.proxyMode !== 'filtered' || d.llmProvider,
|
(d) => d.proxyMode !== 'filtered' || d.llmProvider,
|
||||||
{ message: 'llmProvider is required when proxyMode is "filtered"' },
|
{ message: 'llmProvider is required when proxyMode is "filtered"' },
|
||||||
@@ -19,7 +18,6 @@ export const UpdateProjectSchema = z.object({
|
|||||||
llmProvider: z.string().max(100).nullable().optional(),
|
llmProvider: z.string().max(100).nullable().optional(),
|
||||||
llmModel: z.string().max(100).nullable().optional(),
|
llmModel: z.string().max(100).nullable().optional(),
|
||||||
servers: z.array(z.string().min(1)).optional(),
|
servers: z.array(z.string().min(1)).optional(),
|
||||||
members: z.array(z.string().email()).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run'] as const;
|
export const RBAC_ROLES = ['edit', 'view', 'create', 'delete', 'run', 'expose'] as const;
|
||||||
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac'] as const;
|
export const RBAC_RESOURCES = ['*', 'servers', 'instances', 'secrets', 'projects', 'templates', 'users', 'groups', 'rbac'] as const;
|
||||||
|
|
||||||
/** Singular→plural map for resource names. */
|
/** Singular→plural map for resource names. */
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ const mockProjects = [
|
|||||||
id: 'proj1', name: 'my-project', description: 'Test project', proxyMode: 'direct', llmProvider: null, llmModel: null,
|
id: 'proj1', name: 'my-project', description: 'Test project', proxyMode: 'direct', llmProvider: null, llmModel: null,
|
||||||
ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
ownerId: 'user1', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
servers: [{ id: 'ps1', server: { id: 's1', name: 'github' } }],
|
servers: [{ id: 'ps1', server: { id: 's1', name: 'github' } }],
|
||||||
members: [{ id: 'pm1', user: { id: 'u1', email: 'alice@test.com', name: 'Alice' } }],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -91,11 +90,12 @@ function mockProjectRepo(): IProjectRepository {
|
|||||||
findAll: vi.fn(async () => [...mockProjects]),
|
findAll: vi.fn(async () => [...mockProjects]),
|
||||||
findById: vi.fn(async (id: string) => mockProjects.find((p) => p.id === id) ?? null),
|
findById: vi.fn(async (id: string) => mockProjects.find((p) => p.id === id) ?? null),
|
||||||
findByName: vi.fn(async () => null),
|
findByName: vi.fn(async () => null),
|
||||||
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, servers: [], members: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
|
create: vi.fn(async (data) => ({ id: 'new-proj', ...data, servers: [], version: 1, createdAt: new Date(), updatedAt: new Date() } as typeof mockProjects[0])),
|
||||||
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
|
update: vi.fn(async (id, data) => ({ ...mockProjects.find((p) => p.id === id)!, ...data })),
|
||||||
delete: vi.fn(async () => {}),
|
delete: vi.fn(async () => {}),
|
||||||
setServers: vi.fn(async () => {}),
|
setServers: vi.fn(async () => {}),
|
||||||
setMembers: vi.fn(async () => {}),
|
addServer: vi.fn(async () => {}),
|
||||||
|
removeServer: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,12 +214,11 @@ describe('BackupService', () => {
|
|||||||
expect(bundle.rbacBindings![0]!.subjects).toEqual([{ kind: 'User', name: 'alice@test.com' }]);
|
expect(bundle.rbacBindings![0]!.subjects).toEqual([{ kind: 'User', name: 'alice@test.com' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes enriched projects with server names and members', async () => {
|
it('includes enriched projects with server names', async () => {
|
||||||
const bundle = await backupService.createBackup();
|
const bundle = await backupService.createBackup();
|
||||||
const proj = bundle.projects[0]!;
|
const proj = bundle.projects[0]!;
|
||||||
expect(proj.proxyMode).toBe('direct');
|
expect(proj.proxyMode).toBe('direct');
|
||||||
expect(proj.serverNames).toEqual(['github']);
|
expect(proj.serverNames).toEqual(['github']);
|
||||||
expect(proj.members).toEqual(['alice@test.com']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters resources', async () => {
|
it('filters resources', async () => {
|
||||||
@@ -406,7 +405,7 @@ describe('RestoreService', () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('restores enriched projects with server and member linking', async () => {
|
it('restores enriched projects with server linking', async () => {
|
||||||
// Simulate servers exist (restored in prior step)
|
// Simulate servers exist (restored in prior step)
|
||||||
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
(serverRepo.findByName as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||||
// After server restore, we can find them
|
// After server restore, we can find them
|
||||||
@@ -419,14 +418,6 @@ describe('RestoreService', () => {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate users exist for member resolution
|
|
||||||
let userCallCount = 0;
|
|
||||||
(userRepo.findByEmail as ReturnType<typeof vi.fn>).mockImplementation(async (email: string) => {
|
|
||||||
userCallCount++;
|
|
||||||
if (userCallCount > 2 && email === 'alice@test.com') return { id: 'restored-u1', email };
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await restoreService.restore(fullBundle);
|
const result = await restoreService.restore(fullBundle);
|
||||||
|
|
||||||
expect(result.projectsCreated).toBe(1);
|
expect(result.projectsCreated).toBe(1);
|
||||||
@@ -437,7 +428,6 @@ describe('RestoreService', () => {
|
|||||||
llmModel: 'gpt-4',
|
llmModel: 'gpt-4',
|
||||||
}));
|
}));
|
||||||
expect(projectRepo.setServers).toHaveBeenCalled();
|
expect(projectRepo.setServers).toHaveBeenCalled();
|
||||||
expect(projectRepo.setMembers).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('restores old bundle without users/groups/rbac', async () => {
|
it('restores old bundle without users/groups/rbac', async () => {
|
||||||
@@ -551,7 +541,7 @@ describe('RestoreService', () => {
|
|||||||
(serverRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('server'); return { id: 'srv' }; });
|
(serverRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('server'); return { id: 'srv' }; });
|
||||||
(userRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('user'); return { id: 'usr' }; });
|
(userRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('user'); return { id: 'usr' }; });
|
||||||
(groupRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('group'); return { id: 'grp' }; });
|
(groupRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('group'); return { id: 'grp' }; });
|
||||||
(projectRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('project'); return { id: 'proj', servers: [], members: [] }; });
|
(projectRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('project'); return { id: 'proj', servers: [] }; });
|
||||||
(rbacRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('rbac'); return { id: 'rbac' }; });
|
(rbacRepo.create as ReturnType<typeof vi.fn>).mockImplementation(async () => { callOrder.push('rbac'); return { id: 'rbac' }; });
|
||||||
|
|
||||||
await restoreService.restore(fullBundle);
|
await restoreService.restore(fullBundle);
|
||||||
|
|||||||
283
src/mcpd/tests/project-routes.test.ts
Normal file
283
src/mcpd/tests/project-routes.test.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { registerProjectRoutes } from '../src/routes/projects.js';
|
||||||
|
import { ProjectService } from '../src/services/project.service.js';
|
||||||
|
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||||
|
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
||||||
|
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
||||||
|
return {
|
||||||
|
id: 'proj-1',
|
||||||
|
name: 'test-project',
|
||||||
|
description: '',
|
||||||
|
ownerId: 'user-1',
|
||||||
|
proxyMode: 'direct',
|
||||||
|
llmProvider: null,
|
||||||
|
llmModel: null,
|
||||||
|
version: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
servers: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockProjectRepo(): IProjectRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => []),
|
||||||
|
findById: vi.fn(async () => null),
|
||||||
|
findByName: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async (data) => makeProject({
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
ownerId: data.ownerId,
|
||||||
|
proxyMode: data.proxyMode,
|
||||||
|
})),
|
||||||
|
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
setServers: vi.fn(async () => {}),
|
||||||
|
addServer: vi.fn(async () => {}),
|
||||||
|
removeServer: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockServerRepo(): IMcpServerRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => []),
|
||||||
|
findById: vi.fn(async () => null),
|
||||||
|
findByName: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async () => ({} as never)),
|
||||||
|
update: vi.fn(async () => ({} as never)),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockSecretRepo(): ISecretRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => []),
|
||||||
|
findById: vi.fn(async () => null),
|
||||||
|
findByName: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async () => ({} as never)),
|
||||||
|
update: vi.fn(async () => ({} as never)),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
function createApp(projectRepo: IProjectRepository, serverRepo?: IMcpServerRepository) {
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
app.setErrorHandler(errorHandler);
|
||||||
|
const service = new ProjectService(projectRepo, serverRepo ?? mockServerRepo(), mockSecretRepo());
|
||||||
|
registerProjectRoutes(app, service);
|
||||||
|
return app.ready();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Project Routes', () => {
|
||||||
|
describe('GET /api/v1/projects', () => {
|
||||||
|
it('returns project list', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findAll).mockResolvedValue([
|
||||||
|
makeProject({ id: 'p1', name: 'alpha', ownerId: 'user-1' }),
|
||||||
|
makeProject({ id: 'p2', name: 'beta', ownerId: 'user-2' }),
|
||||||
|
]);
|
||||||
|
await createApp(repo);
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json<Array<{ name: string }>>();
|
||||||
|
expect(body).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists all projects without ownerId filtering', async () => {
|
||||||
|
// This is the bug fix: the route must call list() without ownerId
|
||||||
|
// so that RBAC (preSerialization) handles access filtering, not the DB query.
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findAll).mockResolvedValue([makeProject()]);
|
||||||
|
await createApp(repo);
|
||||||
|
|
||||||
|
await app.inject({ method: 'GET', url: '/api/v1/projects' });
|
||||||
|
// findAll must be called with NO arguments (undefined ownerId)
|
||||||
|
expect(repo.findAll).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v1/projects/:id', () => {
|
||||||
|
it('returns 404 when not found', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/missing' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns project when found by ID', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1', name: 'my-proj' }));
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/p1' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<{ name: string }>().name).toBe('my-proj');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves by name when ID not found', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findByName).mockResolvedValue(makeProject({ name: 'my-proj' }));
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/v1/projects/my-proj' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<{ name: string }>().name).toBe('my-proj');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/projects', () => {
|
||||||
|
it('creates a project and returns 201', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ name: 'new-proj' }));
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects',
|
||||||
|
payload: { name: 'new-proj' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 for invalid input', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects',
|
||||||
|
payload: { name: '' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 when name already exists', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findByName).mockResolvedValue(makeProject());
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects',
|
||||||
|
payload: { name: 'taken' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/v1/projects/:id', () => {
|
||||||
|
it('updates a project', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/projects/p1',
|
||||||
|
payload: { description: 'Updated' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when not found', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/v1/projects/missing',
|
||||||
|
payload: { description: 'x' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/v1/projects/:id', () => {
|
||||||
|
it('deletes a project and returns 204', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1' });
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when not found', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
await createApp(repo);
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/missing' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v1/projects/:id/servers (attach)', () => {
|
||||||
|
it('attaches a server to a project', async () => {
|
||||||
|
const projectRepo = mockProjectRepo();
|
||||||
|
const serverRepo = mockServerRepo();
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
vi.mocked(serverRepo.findByName).mockResolvedValue({ id: 'srv-1', name: 'my-ha' } as never);
|
||||||
|
await createApp(projectRepo, serverRepo);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects/p1/servers',
|
||||||
|
payload: { server: 'my-ha' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(projectRepo.addServer).toHaveBeenCalledWith('p1', 'srv-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 400 when server field is missing', async () => {
|
||||||
|
const repo = mockProjectRepo();
|
||||||
|
vi.mocked(repo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
await createApp(repo);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects/p1/servers',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when server not found', async () => {
|
||||||
|
const projectRepo = mockProjectRepo();
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
await createApp(projectRepo);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/projects/p1/servers',
|
||||||
|
payload: { server: 'nonexistent' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /api/v1/projects/:id/servers/:serverName (detach)', () => {
|
||||||
|
it('detaches a server from a project', async () => {
|
||||||
|
const projectRepo = mockProjectRepo();
|
||||||
|
const serverRepo = mockServerRepo();
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
vi.mocked(serverRepo.findByName).mockResolvedValue({ id: 'srv-1', name: 'my-ha' } as never);
|
||||||
|
await createApp(projectRepo, serverRepo);
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1/servers/my-ha' });
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
expect(projectRepo.removeServer).toHaveBeenCalledWith('p1', 'srv-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 when server not found', async () => {
|
||||||
|
const projectRepo = mockProjectRepo();
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'p1' }));
|
||||||
|
await createApp(projectRepo);
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/v1/projects/p1/servers/nonexistent' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,6 @@ import { ProjectService } from '../src/services/project.service.js';
|
|||||||
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
|
||||||
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
import type { IProjectRepository, ProjectWithRelations } from '../src/repositories/project.repository.js';
|
||||||
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
|
import type { IMcpServerRepository, ISecretRepository } from '../src/repositories/interfaces.js';
|
||||||
import type { IUserRepository } from '../src/repositories/user.repository.js';
|
|
||||||
import type { McpServer } from '@prisma/client';
|
import type { McpServer } from '@prisma/client';
|
||||||
|
|
||||||
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWithRelations {
|
||||||
@@ -19,7 +18,6 @@ function makeProject(overrides: Partial<ProjectWithRelations> = {}): ProjectWith
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
servers: [],
|
servers: [],
|
||||||
members: [],
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -64,7 +62,8 @@ function mockProjectRepo(): IProjectRepository {
|
|||||||
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<ProjectWithRelations> })),
|
||||||
delete: vi.fn(async () => {}),
|
delete: vi.fn(async () => {}),
|
||||||
setServers: vi.fn(async () => {}),
|
setServers: vi.fn(async () => {}),
|
||||||
setMembers: vi.fn(async () => {}),
|
addServer: vi.fn(async () => {}),
|
||||||
|
removeServer: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,33 +89,17 @@ function mockSecretRepo(): ISecretRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockUserRepo(): IUserRepository {
|
|
||||||
return {
|
|
||||||
findAll: vi.fn(async () => []),
|
|
||||||
findById: vi.fn(async () => null),
|
|
||||||
findByEmail: vi.fn(async () => null),
|
|
||||||
create: vi.fn(async () => ({
|
|
||||||
id: 'u-1', email: 'test@example.com', name: null, role: 'user',
|
|
||||||
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
|
|
||||||
})),
|
|
||||||
delete: vi.fn(async () => {}),
|
|
||||||
count: vi.fn(async () => 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ProjectService', () => {
|
describe('ProjectService', () => {
|
||||||
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
let projectRepo: ReturnType<typeof mockProjectRepo>;
|
||||||
let serverRepo: ReturnType<typeof mockServerRepo>;
|
let serverRepo: ReturnType<typeof mockServerRepo>;
|
||||||
let secretRepo: ReturnType<typeof mockSecretRepo>;
|
let secretRepo: ReturnType<typeof mockSecretRepo>;
|
||||||
let userRepo: ReturnType<typeof mockUserRepo>;
|
|
||||||
let service: ProjectService;
|
let service: ProjectService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
projectRepo = mockProjectRepo();
|
projectRepo = mockProjectRepo();
|
||||||
serverRepo = mockServerRepo();
|
serverRepo = mockServerRepo();
|
||||||
secretRepo = mockSecretRepo();
|
secretRepo = mockSecretRepo();
|
||||||
userRepo = mockUserRepo();
|
service = new ProjectService(projectRepo, serverRepo, secretRepo);
|
||||||
service = new ProjectService(projectRepo, serverRepo, secretRepo, userRepo);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create', () => {
|
describe('create', () => {
|
||||||
@@ -164,32 +147,6 @@ describe('ProjectService', () => {
|
|||||||
expect(result.servers).toHaveLength(2);
|
expect(result.servers).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates project with members (resolves emails)', async () => {
|
|
||||||
vi.mocked(userRepo.findByEmail).mockImplementation(async (email) => {
|
|
||||||
if (email === 'alice@test.com') {
|
|
||||||
return { id: 'u-alice', email: 'alice@test.com', name: 'Alice', role: 'user', provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date() };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = makeProject({ id: 'proj-new' });
|
|
||||||
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
|
||||||
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({
|
|
||||||
id: 'proj-new',
|
|
||||||
members: [
|
|
||||||
{ id: 'pm-1', user: { id: 'u-alice', email: 'alice@test.com', name: 'Alice' } },
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = await service.create({
|
|
||||||
name: 'my-project',
|
|
||||||
members: ['alice@test.com'],
|
|
||||||
}, 'user-1');
|
|
||||||
|
|
||||||
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-new', ['u-alice']);
|
|
||||||
expect(result.members).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates project with proxyMode and llmProvider', async () => {
|
it('creates project with proxyMode and llmProvider', async () => {
|
||||||
const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' });
|
const created = makeProject({ id: 'proj-filtered', proxyMode: 'filtered', llmProvider: 'openai' });
|
||||||
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
vi.mocked(projectRepo.create).mockResolvedValue(created);
|
||||||
@@ -219,16 +176,6 @@ describe('ProjectService', () => {
|
|||||||
).rejects.toThrow(NotFoundError);
|
).rejects.toThrow(NotFoundError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws NotFoundError when member email resolution fails', async () => {
|
|
||||||
vi.mocked(userRepo.findByEmail).mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.create({
|
|
||||||
name: 'my-project',
|
|
||||||
members: ['nobody@test.com'],
|
|
||||||
}, 'user-1'),
|
|
||||||
).rejects.toThrow(NotFoundError);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getById', () => {
|
describe('getById', () => {
|
||||||
@@ -277,19 +224,6 @@ describe('ProjectService', () => {
|
|||||||
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
|
expect(projectRepo.setServers).toHaveBeenCalledWith('proj-1', ['srv-new']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates members (full replacement)', async () => {
|
|
||||||
const existing = makeProject({ id: 'proj-1' });
|
|
||||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
|
||||||
|
|
||||||
vi.mocked(userRepo.findByEmail).mockResolvedValue({
|
|
||||||
id: 'u-bob', email: 'bob@test.com', name: 'Bob', role: 'user',
|
|
||||||
provider: null, externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.update('proj-1', { members: ['bob@test.com'] });
|
|
||||||
expect(projectRepo.setMembers).toHaveBeenCalledWith('proj-1', ['u-bob']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates proxyMode', async () => {
|
it('updates proxyMode', async () => {
|
||||||
const existing = makeProject({ id: 'proj-1' });
|
const existing = makeProject({ id: 'proj-1' });
|
||||||
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
vi.mocked(projectRepo.findById).mockResolvedValue(existing);
|
||||||
@@ -314,6 +248,52 @@ describe('ProjectService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('addServer', () => {
|
||||||
|
it('attaches a server by name', async () => {
|
||||||
|
const project = makeProject({ id: 'proj-1' });
|
||||||
|
const srv = makeServer({ id: 'srv-1', name: 'my-ha' });
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
||||||
|
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
|
||||||
|
|
||||||
|
await service.addServer('proj-1', 'my-ha');
|
||||||
|
expect(projectRepo.addServer).toHaveBeenCalledWith('proj-1', 'srv-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when project not found', async () => {
|
||||||
|
await expect(service.addServer('missing', 'my-ha')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when server not found', async () => {
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' }));
|
||||||
|
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.addServer('proj-1', 'nonexistent')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeServer', () => {
|
||||||
|
it('detaches a server by name', async () => {
|
||||||
|
const project = makeProject({ id: 'proj-1' });
|
||||||
|
const srv = makeServer({ id: 'srv-1', name: 'my-ha' });
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(project);
|
||||||
|
vi.mocked(serverRepo.findByName).mockResolvedValue(srv);
|
||||||
|
|
||||||
|
await service.removeServer('proj-1', 'my-ha');
|
||||||
|
expect(projectRepo.removeServer).toHaveBeenCalledWith('proj-1', 'srv-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when project not found', async () => {
|
||||||
|
await expect(service.removeServer('missing', 'my-ha')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws NotFoundError when server not found', async () => {
|
||||||
|
vi.mocked(projectRepo.findById).mockResolvedValue(makeProject({ id: 'proj-1' }));
|
||||||
|
vi.mocked(serverRepo.findByName).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.removeServer('proj-1', 'nonexistent')).rejects.toThrow(NotFoundError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('generateMcpConfig', () => {
|
describe('generateMcpConfig', () => {
|
||||||
it('generates direct mode config with STDIO servers', async () => {
|
it('generates direct mode config with STDIO servers', async () => {
|
||||||
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });
|
const srv = makeServer({ id: 'srv-1', name: 'github', packageName: '@mcp/github', transport: 'STDIO' });
|
||||||
|
|||||||
444
src/mcpd/tests/rbac-name-scope-integration.test.ts
Normal file
444
src/mcpd/tests/rbac-name-scope-integration.test.ts
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests reproducing RBAC name-scoped access bugs.
|
||||||
|
*
|
||||||
|
* Bug 1: `mcpctl get servers` shows ALL servers despite user only having
|
||||||
|
* view:servers+name:my-home-assistant
|
||||||
|
* Bug 2: `mcpctl get server my-home-assistant -o yaml` returns 403 because
|
||||||
|
* CLI resolves name→CUID, and RBAC compares CUID against binding name
|
||||||
|
*
|
||||||
|
* These tests spin up a full Fastify app with auth + RBAC hooks + server routes,
|
||||||
|
* exactly like main.ts, to catch regressions at the HTTP level.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
|
||||||
|
import { McpServerService } from '../src/services/mcp-server.service.js';
|
||||||
|
import { InstanceService } from '../src/services/instance.service.js';
|
||||||
|
import { RbacService } from '../src/services/rbac.service.js';
|
||||||
|
import { errorHandler } from '../src/middleware/error-handler.js';
|
||||||
|
import type { IMcpServerRepository, IMcpInstanceRepository } from '../src/repositories/interfaces.js';
|
||||||
|
import type { IRbacDefinitionRepository } from '../src/repositories/rbac-definition.repository.js';
|
||||||
|
import type { McpOrchestrator } from '../src/services/orchestrator.js';
|
||||||
|
import type { McpServer, RbacDefinition, PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
// ── Test data ──
|
||||||
|
|
||||||
|
const SERVERS: McpServer[] = [
|
||||||
|
{ id: 'clxyz000000001', name: 'my-home-assistant', description: 'HA server', transport: 'STDIO', packageName: null, dockerImage: null, repositoryUrl: null, externalUrl: null, command: null, containerPort: null, replicas: 1, env: [], healthCheck: null, version: 1, createdAt: new Date(), updatedAt: new Date() },
|
||||||
|
{ id: 'clxyz000000002', name: 'slack-server', description: 'Slack MCP', transport: 'STDIO', packageName: null, dockerImage: null, repositoryUrl: null, externalUrl: null, command: null, containerPort: null, replicas: 1, env: [], healthCheck: null, version: 1, createdAt: new Date(), updatedAt: new Date() },
|
||||||
|
{ id: 'clxyz000000003', name: 'github-server', description: 'GitHub MCP', transport: 'STDIO', packageName: null, dockerImage: null, repositoryUrl: null, externalUrl: null, command: null, containerPort: null, replicas: 1, env: [], healthCheck: null, version: 1, createdAt: new Date(), updatedAt: new Date() },
|
||||||
|
];
|
||||||
|
|
||||||
|
// User tokens → userId mapping
|
||||||
|
const SESSIONS: Record<string, { userId: string }> = {
|
||||||
|
'scoped-token': { userId: 'user-scoped' },
|
||||||
|
'admin-token': { userId: 'user-admin' },
|
||||||
|
'multi-scoped-token': { userId: 'user-multi' },
|
||||||
|
'secrets-only-token': { userId: 'user-secrets' },
|
||||||
|
'edit-scoped-token': { userId: 'user-edit-scoped' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// User email mapping
|
||||||
|
const USERS: Record<string, { email: string }> = {
|
||||||
|
'user-scoped': { email: 'scoped@example.com' },
|
||||||
|
'user-admin': { email: 'admin@example.com' },
|
||||||
|
'user-multi': { email: 'multi@example.com' },
|
||||||
|
'user-secrets': { email: 'secrets@example.com' },
|
||||||
|
'user-edit-scoped': { email: 'editscoped@example.com' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// RBAC definitions
|
||||||
|
const RBAC_DEFS: RbacDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'rbac-scoped', name: 'scoped-view', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
subjects: [{ kind: 'User', name: 'scoped@example.com' }],
|
||||||
|
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-home-assistant' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rbac-admin', name: 'admin-all', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
subjects: [{ kind: 'User', name: 'admin@example.com' }],
|
||||||
|
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rbac-multi', name: 'multi-scoped', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
subjects: [{ kind: 'User', name: 'multi@example.com' }],
|
||||||
|
roleBindings: [
|
||||||
|
{ role: 'view', resource: 'servers', name: 'my-home-assistant' },
|
||||||
|
{ role: 'view', resource: 'servers', name: 'slack-server' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rbac-secrets', name: 'secrets-only', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
subjects: [{ kind: 'User', name: 'secrets@example.com' }],
|
||||||
|
roleBindings: [{ role: 'view', resource: 'secrets' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rbac-edit-scoped', name: 'edit-scoped', version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
subjects: [{ kind: 'User', name: 'editscoped@example.com' }],
|
||||||
|
roleBindings: [{ role: 'edit', resource: 'servers', name: 'my-home-assistant' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Mock factories ──
|
||||||
|
|
||||||
|
function mockServerRepo(): IMcpServerRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => [...SERVERS]),
|
||||||
|
findById: vi.fn(async (id: string) => SERVERS.find((s) => s.id === id) ?? null),
|
||||||
|
findByName: vi.fn(async (name: string) => SERVERS.find((s) => s.name === name) ?? null),
|
||||||
|
create: vi.fn(async () => SERVERS[0]!),
|
||||||
|
update: vi.fn(async () => SERVERS[0]!),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockRbacRepo(): IRbacDefinitionRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => [...RBAC_DEFS]),
|
||||||
|
findById: vi.fn(async () => null),
|
||||||
|
findByName: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async () => RBAC_DEFS[0]!),
|
||||||
|
update: vi.fn(async () => RBAC_DEFS[0]!),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockPrisma(): PrismaClient {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||||
|
const u = USERS[where.id];
|
||||||
|
return u ? { email: u.email } : null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
groupMember: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
},
|
||||||
|
} as unknown as PrismaClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubInstanceRepo(): IMcpInstanceRepository {
|
||||||
|
return {
|
||||||
|
findAll: vi.fn(async () => []),
|
||||||
|
findById: vi.fn(async () => null),
|
||||||
|
findByContainerId: vi.fn(async () => null),
|
||||||
|
create: vi.fn(async (data) => ({
|
||||||
|
id: 'inst-stub', serverId: data.serverId, containerId: null,
|
||||||
|
status: data.status ?? 'STOPPED', port: null, metadata: {},
|
||||||
|
healthStatus: null, lastHealthCheck: null, events: [],
|
||||||
|
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
}) as never),
|
||||||
|
updateStatus: vi.fn(async () => ({}) as never),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubOrchestrator(): McpOrchestrator {
|
||||||
|
return {
|
||||||
|
ping: vi.fn(async () => true),
|
||||||
|
pullImage: vi.fn(async () => {}),
|
||||||
|
createContainer: vi.fn(async () => ({ containerId: 'ctr', name: 'stub', state: 'running' as const, port: 3000, createdAt: new Date() })),
|
||||||
|
stopContainer: vi.fn(async () => {}),
|
||||||
|
removeContainer: vi.fn(async () => {}),
|
||||||
|
inspectContainer: vi.fn(async () => ({ containerId: 'ctr', name: 'stub', state: 'running' as const, createdAt: new Date() })),
|
||||||
|
getContainerLogs: vi.fn(async () => ({ stdout: '', stderr: '' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App setup (replicates main.ts hooks) ──
|
||||||
|
|
||||||
|
import { normalizeResource } from '../src/validation/rbac-definition.schema.js';
|
||||||
|
import type { RbacAction } from '../src/services/rbac.service.js';
|
||||||
|
|
||||||
|
type PermissionCheck =
|
||||||
|
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
|
||||||
|
| { kind: 'operation'; operation: string }
|
||||||
|
| { kind: 'skip' };
|
||||||
|
|
||||||
|
function mapUrlToPermission(method: string, url: string): PermissionCheck {
|
||||||
|
const match = url.match(/^\/api\/v1\/([a-z-]+)/);
|
||||||
|
if (!match) return { kind: 'skip' };
|
||||||
|
const segment = match[1] as string;
|
||||||
|
|
||||||
|
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' };
|
||||||
|
|
||||||
|
const resourceMap: Record<string, string | undefined> = {
|
||||||
|
servers: 'servers', instances: 'instances', secrets: 'secrets',
|
||||||
|
projects: 'projects', templates: 'templates', users: 'users',
|
||||||
|
groups: 'groups', rbac: 'rbac', 'audit-logs': 'rbac', mcp: 'servers',
|
||||||
|
};
|
||||||
|
|
||||||
|
const resource = resourceMap[segment];
|
||||||
|
if (resource === undefined) return { kind: 'skip' };
|
||||||
|
|
||||||
|
let action: RbacAction;
|
||||||
|
switch (method) {
|
||||||
|
case 'GET': case 'HEAD': action = 'view'; break;
|
||||||
|
case 'POST': action = 'create'; break;
|
||||||
|
case 'DELETE': action = 'delete'; break;
|
||||||
|
default: action = 'edit'; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/);
|
||||||
|
const resourceName = nameMatch?.[1];
|
||||||
|
const check: PermissionCheck = { kind: 'resource', resource, action };
|
||||||
|
if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName;
|
||||||
|
return check;
|
||||||
|
}
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createTestApp() {
|
||||||
|
const serverRepo = mockServerRepo();
|
||||||
|
const rbacRepo = mockRbacRepo();
|
||||||
|
const prisma = mockPrisma();
|
||||||
|
const rbacService = new RbacService(rbacRepo, prisma);
|
||||||
|
|
||||||
|
const CUID_RE = /^c[^\s-]{8,}$/i;
|
||||||
|
const nameResolvers: Record<string, { findById(id: string): Promise<{ name: string } | null> }> = {
|
||||||
|
servers: serverRepo,
|
||||||
|
};
|
||||||
|
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
app.setErrorHandler(errorHandler);
|
||||||
|
|
||||||
|
// Auth hook (mock)
|
||||||
|
app.addHook('preHandler', async (request, reply) => {
|
||||||
|
const url = request.url;
|
||||||
|
if (url.startsWith('/api/v1/auth/') || url === '/healthz') return;
|
||||||
|
if (!url.startsWith('/api/v1/')) return;
|
||||||
|
|
||||||
|
const header = request.headers.authorization;
|
||||||
|
if (!header?.startsWith('Bearer ')) {
|
||||||
|
reply.code(401).send({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = header.slice(7);
|
||||||
|
const session = SESSIONS[token];
|
||||||
|
if (!session) {
|
||||||
|
reply.code(401).send({ error: 'Invalid token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request.userId = session.userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// RBAC hook (replicates main.ts)
|
||||||
|
app.addHook('preHandler', async (request, reply) => {
|
||||||
|
if (reply.sent) return;
|
||||||
|
const url = request.url;
|
||||||
|
if (url.startsWith('/api/v1/auth/') || url === '/healthz') return;
|
||||||
|
if (!url.startsWith('/api/v1/')) return;
|
||||||
|
if (request.userId === undefined) return;
|
||||||
|
|
||||||
|
const check = mapUrlToPermission(request.method, url);
|
||||||
|
if (check.kind === 'skip') return;
|
||||||
|
|
||||||
|
let allowed: boolean;
|
||||||
|
if (check.kind === 'operation') {
|
||||||
|
allowed = await rbacService.canRunOperation(request.userId, check.operation);
|
||||||
|
} else {
|
||||||
|
// CUID→name resolution
|
||||||
|
if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
|
||||||
|
const resolver = nameResolvers[check.resource];
|
||||||
|
if (resolver) {
|
||||||
|
const entity = await resolver.findById(check.resourceName);
|
||||||
|
if (entity) check.resourceName = entity.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName);
|
||||||
|
// Compute scope for list filtering
|
||||||
|
if (allowed && check.resourceName === undefined) {
|
||||||
|
request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!allowed) {
|
||||||
|
reply.code(403).send({ error: 'Forbidden' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
const serverService = new McpServerService(serverRepo);
|
||||||
|
const instanceService = new InstanceService(stubInstanceRepo(), serverRepo, stubOrchestrator());
|
||||||
|
serverService.setInstanceService(instanceService);
|
||||||
|
registerMcpServerRoutes(app, serverService, instanceService);
|
||||||
|
|
||||||
|
// preSerialization hook (list filtering)
|
||||||
|
app.addHook('preSerialization', async (request, _reply, payload) => {
|
||||||
|
if (!request.rbacScope || request.rbacScope.wildcard) return payload;
|
||||||
|
if (!Array.isArray(payload)) return payload;
|
||||||
|
return (payload as Array<Record<string, unknown>>).filter((item) => {
|
||||||
|
const name = item['name'];
|
||||||
|
return typeof name === 'string' && request.rbacScope!.names.has(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──
|
||||||
|
|
||||||
|
describe('RBAC name-scoped integration (reproduces mcpctl bugs)', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await createTestApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bug 1: mcpctl get servers (list filtering)', () => {
|
||||||
|
it('name-scoped user sees ONLY their permitted server', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
headers: { authorization: 'Bearer scoped-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const servers = res.json<Array<{ name: string }>>();
|
||||||
|
expect(servers).toHaveLength(1);
|
||||||
|
expect(servers[0]!.name).toBe('my-home-assistant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard user sees ALL servers', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
headers: { authorization: 'Bearer admin-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const servers = res.json<Array<{ name: string }>>();
|
||||||
|
expect(servers).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user with multiple name-scoped bindings sees only those servers', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
headers: { authorization: 'Bearer multi-scoped-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const servers = res.json<Array<{ name: string }>>();
|
||||||
|
expect(servers).toHaveLength(2);
|
||||||
|
const names = servers.map((s) => s.name);
|
||||||
|
expect(names).toContain('my-home-assistant');
|
||||||
|
expect(names).toContain('slack-server');
|
||||||
|
expect(names).not.toContain('github-server');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('user with no server permissions gets 403', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
headers: { authorization: 'Bearer secrets-only-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Bug 2: mcpctl get server NAME (CUID resolution)', () => {
|
||||||
|
it('allows access when URL contains CUID matching a name-scoped binding', async () => {
|
||||||
|
// CLI resolves my-home-assistant → clxyz000000001
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/servers/clxyz000000001',
|
||||||
|
headers: { authorization: 'Bearer scoped-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<{ name: string }>().name).toBe('my-home-assistant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies access when CUID resolves to server NOT in binding', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/servers/clxyz000000002',
|
||||||
|
headers: { authorization: 'Bearer scoped-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes RBAC when URL has human-readable name (route 404 is expected)', async () => {
|
||||||
|
// Human name in URL: RBAC passes (matches binding directly),
|
||||||
|
// but the route only does findById, so it 404s.
|
||||||
|
// CLI always resolves name→CUID first, so this doesn't happen in practice.
|
||||||
|
// The important thing: it does NOT return 403.
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/servers/my-home-assistant',
|
||||||
|
headers: { authorization: 'Bearer scoped-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404); // Not 403!
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles nonexistent CUID gracefully (403)', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/servers/cnonexistent12345678',
|
||||||
|
headers: { authorization: 'Bearer scoped-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wildcard user can access any server by CUID', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/servers/clxyz000000002',
|
||||||
|
headers: { authorization: 'Bearer admin-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<{ name: string }>().name).toBe('slack-server');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('name-scoped write operations', () => {
|
||||||
|
it('name-scoped edit user can DELETE their named server by CUID', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/servers/clxyz000000001',
|
||||||
|
headers: { authorization: 'Bearer edit-scoped-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('name-scoped edit user CANNOT delete other servers', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/servers/clxyz000000002',
|
||||||
|
headers: { authorization: 'Bearer edit-scoped-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('name-scoped view user CANNOT delete their named server', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/api/v1/servers/clxyz000000001',
|
||||||
|
headers: { authorization: 'Bearer scoped-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('preSerialization edge cases', () => {
|
||||||
|
it('single-object responses pass through unmodified', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/servers/clxyz000000001',
|
||||||
|
headers: { authorization: 'Bearer scoped-token' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json<{ name: string }>().name).toBe('my-home-assistant');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unauthenticated requests get 401', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/v1/servers',
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -681,6 +681,199 @@ describe('RbacService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getAllowedScope', () => {
|
||||||
|
describe('unscoped binding → wildcard', () => {
|
||||||
|
it('returns wildcard:true for matching resource', async () => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||||
|
roleBindings: [{ role: 'view', resource: 'servers' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||||
|
expect(scope.wildcard).toBe(true);
|
||||||
|
expect(scope.names.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns wildcard:true with wildcard resource binding', async () => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||||
|
roleBindings: [{ role: 'edit', resource: '*' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||||
|
expect(scope.wildcard).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('name-scoped binding → restricted', () => {
|
||||||
|
let service: RbacService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||||
|
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
service = new RbacService(repo, prisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns names containing the scoped name', async () => {
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||||
|
expect(scope.wildcard).toBe(false);
|
||||||
|
expect(scope.names).toEqual(new Set(['my-ha']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty names for wrong resource', async () => {
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'view', 'secrets');
|
||||||
|
expect(scope.wildcard).toBe(false);
|
||||||
|
expect(scope.names.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty names for wrong action', async () => {
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'edit', 'servers');
|
||||||
|
expect(scope.wildcard).toBe(false);
|
||||||
|
expect(scope.names.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multiple name-scoped bindings → union of names', () => {
|
||||||
|
it('collects names from multiple bindings', async () => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
id: 'def-1',
|
||||||
|
name: 'rbac-a',
|
||||||
|
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||||
|
roleBindings: [{ role: 'view', resource: 'servers', name: 'server-a' }],
|
||||||
|
}),
|
||||||
|
makeDef({
|
||||||
|
id: 'def-2',
|
||||||
|
name: 'rbac-b',
|
||||||
|
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||||
|
roleBindings: [{ role: 'view', resource: 'servers', name: 'server-b' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||||
|
expect(scope.wildcard).toBe(false);
|
||||||
|
expect(scope.names).toEqual(new Set(['server-a', 'server-b']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mixed scoped + unscoped → wildcard wins', () => {
|
||||||
|
it('returns wildcard:true when any binding is unscoped', async () => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||||
|
roleBindings: [
|
||||||
|
{ role: 'view', resource: 'servers', name: 'my-ha' },
|
||||||
|
{ role: 'view', resource: 'servers' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||||
|
expect(scope.wildcard).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('no matching permissions → empty', () => {
|
||||||
|
it('returns wildcard:false with empty names', async () => {
|
||||||
|
const repo = mockRepo([]);
|
||||||
|
const prisma = mockPrisma();
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
|
||||||
|
const scope = await service.getAllowedScope('unknown', 'view', 'servers');
|
||||||
|
expect(scope.wildcard).toBe(false);
|
||||||
|
expect(scope.names.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edit role grants view scope', () => {
|
||||||
|
let service: RbacService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||||
|
roleBindings: [{ role: 'edit', resource: 'servers', name: 'my-ha' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
service = new RbacService(repo, prisma);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns names for view action', async () => {
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||||
|
expect(scope.wildcard).toBe(false);
|
||||||
|
expect(scope.names).toEqual(new Set(['my-ha']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns names for create action', async () => {
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'create', 'servers');
|
||||||
|
expect(scope.wildcard).toBe(false);
|
||||||
|
expect(scope.names).toEqual(new Set(['my-ha']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns names for delete action', async () => {
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'delete', 'servers');
|
||||||
|
expect(scope.wildcard).toBe(false);
|
||||||
|
expect(scope.names).toEqual(new Set(['my-ha']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operation bindings are ignored', () => {
|
||||||
|
it('returns empty names when only operation bindings exist', async () => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
subjects: [{ kind: 'User', name: 'alice@example.com' }],
|
||||||
|
roleBindings: [{ role: 'run', action: 'logs' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'view', 'servers');
|
||||||
|
expect(scope.wildcard).toBe(false);
|
||||||
|
expect(scope.names.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('unknown/legacy roles are denied', () => {
|
describe('unknown/legacy roles are denied', () => {
|
||||||
let service: RbacService;
|
let service: RbacService;
|
||||||
|
|
||||||
@@ -728,4 +921,92 @@ describe('RbacService', () => {
|
|||||||
expect(await svc.canAccess('user-1', 'edit', 'servers')).toBe(false);
|
expect(await svc.canAccess('user-1', 'edit', 'servers')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('expose role', () => {
|
||||||
|
it('grants expose access with expose role binding', async () => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
roleBindings: [{ role: 'expose', resource: 'projects' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
expect(await service.canAccess('user-1', 'expose', 'projects')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grants expose access with edit role binding (edit includes expose)', async () => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
roleBindings: [{ role: 'edit', resource: 'projects' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
expect(await service.canAccess('user-1', 'expose', 'projects')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies expose access with view role binding', async () => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
roleBindings: [{ role: 'view', resource: 'projects' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
expect(await service.canAccess('user-1', 'expose', 'projects')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expose role also grants view access', async () => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
roleBindings: [{ role: 'expose', resource: 'projects' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
expect(await service.canAccess('user-1', 'view', 'projects')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expose role with name-scoped binding', async () => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
roleBindings: [{ role: 'expose', resource: 'projects', name: 'my-project' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
expect(await service.canAccess('user-1', 'expose', 'projects', 'my-project')).toBe(true);
|
||||||
|
expect(await service.canAccess('user-1', 'expose', 'projects', 'other-project')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAllowedScope with expose role grants view scope', async () => {
|
||||||
|
const repo = mockRepo([
|
||||||
|
makeDef({
|
||||||
|
roleBindings: [{ role: 'expose', resource: 'projects' }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const prisma = mockPrisma({
|
||||||
|
user: { findUnique: vi.fn(async () => ({ email: 'alice@example.com' })) },
|
||||||
|
groupMember: { findMany: vi.fn(async () => []) },
|
||||||
|
});
|
||||||
|
const service = new RbacService(repo, prisma);
|
||||||
|
const scope = await service.getAllowedScope('user-1', 'view', 'projects');
|
||||||
|
expect(scope.wildcard).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
82
tests.sh
Executable file
82
tests.sh
Executable file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PATH="$HOME/.npm-global/bin:$PATH"
|
||||||
|
|
||||||
|
SHORT=false
|
||||||
|
FILTER=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--short|-s) SHORT=true; shift ;;
|
||||||
|
--filter|-f) FILTER="$2"; shift 2 ;;
|
||||||
|
*) echo "Usage: tests.sh [--short|-s] [--filter|-f <package>]"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
strip_ansi() {
|
||||||
|
sed $'s/\033\[[0-9;]*m//g'
|
||||||
|
}
|
||||||
|
|
||||||
|
run_tests() {
|
||||||
|
local pkg="$1"
|
||||||
|
local label="$2"
|
||||||
|
|
||||||
|
if $SHORT; then
|
||||||
|
local tmpfile
|
||||||
|
tmpfile=$(mktemp)
|
||||||
|
trap "rm -f $tmpfile" RETURN
|
||||||
|
|
||||||
|
local exit_code=0
|
||||||
|
pnpm --filter "$pkg" test:run >"$tmpfile" 2>&1 || exit_code=$?
|
||||||
|
|
||||||
|
# Parse from cleaned output
|
||||||
|
local clean
|
||||||
|
clean=$(strip_ansi < "$tmpfile")
|
||||||
|
|
||||||
|
local tests_line files_line duration_line
|
||||||
|
tests_line=$(echo "$clean" | grep -oP 'Tests\s+\K.*' | tail -1 | xargs)
|
||||||
|
files_line=$(echo "$clean" | grep -oP 'Test Files\s+\K.*' | tail -1 | xargs)
|
||||||
|
duration_line=$(echo "$clean" | grep -oP 'Duration\s+\K[0-9.]+s' | tail -1)
|
||||||
|
|
||||||
|
if [[ $exit_code -eq 0 ]]; then
|
||||||
|
printf " \033[32mPASS\033[0m %-6s %s | %s | %s\n" "$label" "$files_line" "$tests_line" "$duration_line"
|
||||||
|
else
|
||||||
|
printf " \033[31mFAIL\033[0m %-6s %s | %s | %s\n" "$label" "$files_line" "$tests_line" "$duration_line"
|
||||||
|
echo "$clean" | grep -E 'FAIL |AssertionError|expected .* to' | head -10 | sed 's/^/ /'
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$tmpfile"
|
||||||
|
return $exit_code
|
||||||
|
else
|
||||||
|
echo "=== $label ==="
|
||||||
|
pnpm --filter "$pkg" test:run
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
if $SHORT; then
|
||||||
|
echo "Running tests..."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
|
||||||
|
if [[ -z "$FILTER" || "$FILTER" == "mcpd" ]]; then
|
||||||
|
run_tests mcpd "mcpd" || failed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$FILTER" || "$FILTER" == "cli" ]]; then
|
||||||
|
run_tests cli "cli" || failed=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $SHORT; then
|
||||||
|
echo ""
|
||||||
|
if [[ $failed -eq 0 ]]; then
|
||||||
|
echo "All tests passed."
|
||||||
|
else
|
||||||
|
echo "Some tests FAILED."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $failed
|
||||||
Reference in New Issue
Block a user