mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 08:00:45 +00:00
fix(mcp): apply token scopes consistently across mcp endpoint tools (#10162)
* fix(mcp): apply token scopes consistently across mcp endpoint tools Endpoint-tool authorization is now shared between single- and multi-workspace modes, honors the token's script/flow path patterns for every path-taking tool, and the JWT minted for proxied endpoint calls carries scopes derived from the caller's own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(mcp): review nits: restore create_http_request doc, reword comments Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -518,6 +518,42 @@ pub fn build_request_body(
|
||||
}
|
||||
}
|
||||
|
||||
/// Scopes to embed in the JWT minted for a proxied MCP endpoint request. The MCP
|
||||
/// runner already authorized *which* endpoint may be called; this bounds *what
|
||||
/// the resulting internal request can do*.
|
||||
///
|
||||
/// - Unscoped caller (cookie / full-privilege token): unscoped JWT.
|
||||
/// - Scope-restricted caller whose own scopes already authorize the route: keep
|
||||
/// those scopes verbatim, so the target handler's per-path `check_scopes` still
|
||||
/// enforces the caller's path caps (e.g. a `variables:read:u/admin/safe/*`
|
||||
/// token can't read `u/admin/secret` via the getVariable proxy).
|
||||
/// - Otherwise the caller has no route scope for this domain (the common
|
||||
/// `mcp:`-only token): mint a least-privilege scope for exactly this route,
|
||||
/// failing closed if the route can't be resolved.
|
||||
fn jwt_scopes_for_proxied_route(
|
||||
caller_scopes: Option<&[String]>,
|
||||
method: &str,
|
||||
route_path: &str,
|
||||
) -> BackendResult<Option<Vec<String>>> {
|
||||
let caller_restricted =
|
||||
caller_scopes.is_some_and(|s| s.iter().any(|x| !x.starts_with("if_jobs:filter_tags:")));
|
||||
if !caller_restricted {
|
||||
return Ok(None);
|
||||
}
|
||||
if windmill_api_auth::scopes::check_scopes_for_route(caller_scopes, route_path, method).is_ok()
|
||||
{
|
||||
return Ok(caller_scopes.map(|s| s.to_vec()));
|
||||
}
|
||||
let scope =
|
||||
windmill_api_auth::scopes::scope_for_route(method, route_path).ok_or_else(|| {
|
||||
ErrorData::internal_error(
|
||||
"Could not derive route scope for proxied MCP endpoint".to_string(),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
Ok(Some(vec![scope]))
|
||||
}
|
||||
|
||||
/// Create HTTP request with authentication
|
||||
pub async fn create_http_request(
|
||||
method: &str,
|
||||
@@ -541,31 +577,12 @@ pub async fn create_http_request(
|
||||
}
|
||||
};
|
||||
|
||||
// Bound the minted JWT to exactly this proxied route so a scope-restricted
|
||||
// MCP token can't be widened into a full-privilege blank check. The
|
||||
// endpoint-name gate (in the MCP runner) already authorized *which* endpoint
|
||||
// may be called; this constrains what the resulting request can do. Unscoped
|
||||
// callers (cookie / full-privilege tokens) keep an unscoped JWT to preserve
|
||||
// existing behavior. A scope-restricted caller whose route can't be resolved
|
||||
// fails closed.
|
||||
let caller_restricted = api_authed
|
||||
.scopes
|
||||
.as_deref()
|
||||
.is_some_and(|s| s.iter().any(|x| !x.starts_with("if_jobs:filter_tags:")));
|
||||
let scopes = if caller_restricted {
|
||||
let parsed = reqwest::Url::parse(url)
|
||||
.map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?;
|
||||
let scope =
|
||||
windmill_api_auth::scopes::scope_for_route(method, parsed.path()).ok_or_else(|| {
|
||||
ErrorData::internal_error(
|
||||
"Could not derive route scope for proxied MCP endpoint".to_string(),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
Some(vec![scope])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Scope the minted JWT to the proxied route so a scope-restricted MCP token
|
||||
// can't be widened into a full-privilege blank check. See
|
||||
// `jwt_scopes_for_proxied_route`.
|
||||
let parsed = reqwest::Url::parse(url)
|
||||
.map_err(|e| ErrorData::internal_error(format!("Invalid proxied URL: {}", e), None))?;
|
||||
let scopes = jwt_scopes_for_proxied_route(api_authed.scopes.as_deref(), method, parsed.path())?;
|
||||
|
||||
// Add authorization header
|
||||
let authed = Authed::from(api_authed.clone());
|
||||
@@ -620,6 +637,59 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn scopes(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_jwt_unscoped_caller_keeps_none() {
|
||||
// No scopes, or filter-tags-only, is treated as unscoped -> unscoped JWT.
|
||||
assert_eq!(
|
||||
jwt_scopes_for_proxied_route(None, "GET", "/api/w/ws/variables/get/u/a/b").unwrap(),
|
||||
None
|
||||
);
|
||||
let ft = scopes(&["if_jobs:filter_tags:foo"]);
|
||||
assert_eq!(
|
||||
jwt_scopes_for_proxied_route(Some(&ft), "GET", "/api/w/ws/variables/get/u/a/b")
|
||||
.unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_jwt_bare_mcp_token_falls_back_to_route_scope() {
|
||||
// A token whose only authority is its mcp: scope has no variables route
|
||||
// scope, so the JWT gets a least-privilege route scope for this request.
|
||||
let s = scopes(&["mcp:endpoints:getVariable"]);
|
||||
assert_eq!(
|
||||
jwt_scopes_for_proxied_route(Some(&s), "GET", "/api/w/ws/variables/get/u/admin/secret")
|
||||
.unwrap(),
|
||||
Some(scopes(&["variables:read"]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_jwt_mixed_token_passes_through_caller_route_scope() {
|
||||
// The caller's route scope is preserved so the target handler's per-path
|
||||
// check_scopes enforces the cap; the coarse route match here is path-blind.
|
||||
let s = scopes(&["mcp:endpoints:getVariable", "variables:read:u/admin/safe/*"]);
|
||||
assert_eq!(
|
||||
jwt_scopes_for_proxied_route(Some(&s), "GET", "/api/w/ws/variables/get/u/admin/secret")
|
||||
.unwrap(),
|
||||
Some(s.clone())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_jwt_run_script_bare_mcp_falls_back_to_jobs_run_scripts() {
|
||||
let s = scopes(&["mcp:scripts:f/team/*", "mcp:endpoints:*"]);
|
||||
assert_eq!(
|
||||
jwt_scopes_for_proxied_route(Some(&s), "POST", "/api/w/ws/jobs/run/p/f/team/deploy")
|
||||
.unwrap(),
|
||||
Some(scopes(&["jobs:run:scripts"]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_request_body_passthrough_forwards_script_args_minus_path() {
|
||||
// runScriptByPath-shaped body: additionalProperties, no declared props.
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::common::transform::{
|
||||
use crate::common::types::{McpToken, MultiWorkspaceMcp, ResourceInfo, ToolableItem, WorkspaceId};
|
||||
use crate::server::backend::{McpAuth, McpBackend, PathFilter};
|
||||
use crate::server::endpoints::{
|
||||
endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, list_workspaces_tool,
|
||||
endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, list_workspaces_tool, EndpointTool,
|
||||
};
|
||||
use crate::server::tools::create_tool_from_item;
|
||||
use rmcp::handler::server::ServerHandler;
|
||||
@@ -126,20 +126,176 @@ impl<B: McpBackend> Runner<B> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The run-by-path endpoint tools execute an arbitrary script/flow named by a
|
||||
/// `path` argument. In multi-workspace mode they are the only way to run
|
||||
/// scripts/flows, so their authorization must honor the `mcp:scripts:` /
|
||||
/// `mcp:flows:` path scopes (not the generic endpoint scope) — otherwise a
|
||||
/// granular token could run items outside its allowed paths. Returns the scope
|
||||
/// resource type ("script"/"flow") for these endpoints, `None` otherwise.
|
||||
fn run_by_path_scope_kind(endpoint_name: &str) -> Option<&'static str> {
|
||||
/// How an endpoint tool interacts with the token's `mcp:scripts:` / `mcp:flows:`
|
||||
/// path scopes.
|
||||
enum EndpointPathPolicy {
|
||||
/// Executes the script/flow named by the `path` argument. Gated by the
|
||||
/// script/flow scope alone (an endpoint scope is not enough to run things):
|
||||
/// in multi-workspace mode these are the only way to run scripts/flows, so a
|
||||
/// granular token must not run items outside its allowed paths.
|
||||
RunByPath(&'static str),
|
||||
/// Reads/writes the script/flow named by the listed path arguments. The
|
||||
/// endpoint scope grants the capability; when the token also carries path
|
||||
/// patterns for `kind`, every listed argument must match them.
|
||||
PathArgs { kind: &'static str, fields: &'static [&'static str] },
|
||||
/// Affects scripts without taking a checkable path (delete-by-hash) or
|
||||
/// executes arbitrary code (preview). Unavailable to path-confined tokens —
|
||||
/// allowing these would bypass the path patterns entirely.
|
||||
Unconfinable(&'static str),
|
||||
}
|
||||
|
||||
fn endpoint_path_policy(endpoint_name: &str) -> Option<EndpointPathPolicy> {
|
||||
use EndpointPathPolicy::*;
|
||||
match endpoint_name {
|
||||
"runScriptByPath" => Some("script"),
|
||||
"runFlowByPath" => Some("flow"),
|
||||
"runScriptByPath" => Some(RunByPath("script")),
|
||||
"runFlowByPath" => Some(RunByPath("flow")),
|
||||
"getScriptByPath" | "deleteScriptByPath" | "createScript" => {
|
||||
Some(PathArgs { kind: "script", fields: &["path"] })
|
||||
}
|
||||
"getFlowByPath" | "deleteFlowByPath" | "createFlow" => {
|
||||
Some(PathArgs { kind: "flow", fields: &["path"] })
|
||||
}
|
||||
// updateFlow addresses the flow via the URL path and can move it to the
|
||||
// path given in the body — both must stay within scope.
|
||||
"updateFlow" => Some(PathArgs { kind: "flow", fields: &["path__path", "path__body"] }),
|
||||
"deleteScriptByHash" | "runScriptPreviewAndWaitResult" => Some(Unconfinable("script")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the token restricts `kind` ("script"/"flow") to specific paths. A
|
||||
/// `*` pattern grants every path (see `is_resource_allowed`), so it does not
|
||||
/// count as confinement.
|
||||
fn path_confined(scope_config: &crate::common::scope::McpScopeConfig, kind: &str) -> bool {
|
||||
if scope_config.all {
|
||||
return false;
|
||||
}
|
||||
let patterns = match kind {
|
||||
"script" => &scope_config.scripts,
|
||||
"flow" => &scope_config.flows,
|
||||
_ => return false,
|
||||
};
|
||||
!patterns.is_empty() && !patterns.iter().any(|p| p == "*")
|
||||
}
|
||||
|
||||
fn require_path_arg<'a>(
|
||||
endpoint_tool: &EndpointTool,
|
||||
args: &'a Value,
|
||||
field: &str,
|
||||
) -> Result<&'a str, ErrorData> {
|
||||
args.get(field)
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ErrorData::invalid_params(
|
||||
format!(
|
||||
"Missing required '{}' argument for tool '{}'.",
|
||||
field, endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an endpoint tool is in scope for *listing*. Run-by-path endpoints are
|
||||
/// gated by the script/flow scope (`has_any` — the token can run at least one
|
||||
/// path of that kind); unconfinable endpoints are hidden from path-confined
|
||||
/// tokens (their calls would always be denied); every other endpoint by the
|
||||
/// endpoint-name scope.
|
||||
fn endpoint_tool_in_scope(
|
||||
scope_config: &crate::common::scope::McpScopeConfig,
|
||||
endpoint_tool: &EndpointTool,
|
||||
) -> bool {
|
||||
let endpoint_allowed =
|
||||
!scope_config.granular || scope_config.is_allowed("endpoint", &endpoint_tool.name);
|
||||
match endpoint_path_policy(&endpoint_tool.name) {
|
||||
Some(EndpointPathPolicy::RunByPath(kind)) => scope_config.has_any(kind),
|
||||
Some(EndpointPathPolicy::Unconfinable(kind)) => {
|
||||
endpoint_allowed && !path_confined(scope_config, kind)
|
||||
}
|
||||
_ => endpoint_allowed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorize an endpoint-tool *call* against the token's MCP scopes and
|
||||
/// read-only flag. Shared by single- and multi-workspace modes so both enforce
|
||||
/// the same rules — otherwise a granular token could run items outside its
|
||||
/// allowed paths through the single-workspace path. Run-by-path endpoints
|
||||
/// (runScriptByPath/runFlowByPath) are gated by the script/flow scope for the
|
||||
/// requested `path` (the endpoint-name scope alone is insufficient); other
|
||||
/// endpoints by the endpoint-name scope, with script/flow path arguments
|
||||
/// additionally confined to the token's path patterns when it has any; and
|
||||
/// non-GET endpoints are refused for read-only tokens.
|
||||
fn authorize_endpoint_call(
|
||||
scope_config: &crate::common::scope::McpScopeConfig,
|
||||
endpoint_tool: &EndpointTool,
|
||||
args: &Value,
|
||||
read_only: bool,
|
||||
) -> Result<(), ErrorData> {
|
||||
match endpoint_path_policy(&endpoint_tool.name) {
|
||||
Some(EndpointPathPolicy::RunByPath(kind)) => {
|
||||
let path = require_path_arg(endpoint_tool, args, "path")?;
|
||||
// No `granular` gate: is_allowed already encodes every mode — true for
|
||||
// mcp:all, pattern-matched for granular scopes, and false for
|
||||
// mcp:favorites (a favorites token can't run an arbitrary path).
|
||||
if !scope_config.is_allowed(kind, path) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("Access denied: {} '{}' not in token scope", kind, path),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
policy => {
|
||||
if scope_config.granular && !scope_config.is_allowed("endpoint", &endpoint_tool.name) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Access denied: endpoint '{}' not in token scope",
|
||||
endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
match policy {
|
||||
Some(EndpointPathPolicy::PathArgs { kind, fields })
|
||||
if path_confined(scope_config, kind) =>
|
||||
{
|
||||
for field in fields {
|
||||
let path = require_path_arg(endpoint_tool, args, field)?;
|
||||
if !scope_config.is_allowed(kind, path) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("Access denied: {} '{}' not in token scope", kind, path),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(EndpointPathPolicy::Unconfinable(kind))
|
||||
if path_confined(scope_config, kind) =>
|
||||
{
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Access denied: endpoint '{}' is not available to a token restricted to specific {} paths",
|
||||
endpoint_tool.name, kind
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if read_only && !crate::server::is_endpoint_read_only(endpoint_tool) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Access denied: endpoint '{}' is not read-only and this token is restricted to read-only operations",
|
||||
endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_matching_path<T: ToolableItem>(candidates: Vec<T>, request_name: &str) -> Option<String> {
|
||||
candidates
|
||||
.into_iter()
|
||||
@@ -373,10 +529,13 @@ impl<B: McpBackend> Runner<B> {
|
||||
}
|
||||
}
|
||||
|
||||
// Add endpoint tools from the generated MCP tools, filtered by scope
|
||||
// Add endpoint tools from the generated MCP tools, filtered by scope.
|
||||
// Uses the same run-by-path-aware gate as multi-workspace mode so a
|
||||
// granular token only sees runScriptByPath / runFlowByPath when it can
|
||||
// actually run scripts / flows.
|
||||
let endpoint_tools = self.backend.all_endpoint_tools();
|
||||
for endpoint_tool in endpoint_tools {
|
||||
if scope_config.granular && !scope_config.is_allowed("endpoint", &endpoint_tool.name) {
|
||||
if !endpoint_tool_in_scope(scope_config, &endpoint_tool) {
|
||||
continue;
|
||||
}
|
||||
if read_only && !crate::server::is_endpoint_read_only(&endpoint_tool) {
|
||||
@@ -403,27 +562,9 @@ impl<B: McpBackend> Runner<B> {
|
||||
let endpoint_tools = self.backend.all_endpoint_tools();
|
||||
for endpoint_tool in &endpoint_tools {
|
||||
if endpoint_tool.name.as_ref() == name.as_ref() {
|
||||
// Validate endpoint scope
|
||||
if scope_config.granular
|
||||
&& !scope_config.is_allowed("endpoint", &endpoint_tool.name)
|
||||
{
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Access denied: endpoint '{}' not in token scope",
|
||||
endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if read_only && !crate::server::is_endpoint_read_only(endpoint_tool) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Access denied: endpoint '{}' is not read-only and this token is restricted to read-only operations",
|
||||
endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
// Authorize against the token's MCP scopes and read-only flag,
|
||||
// including the run-by-path path check (shared with multi mode).
|
||||
authorize_endpoint_call(scope_config, endpoint_tool, &args, read_only)?;
|
||||
|
||||
// This is an endpoint tool, call via backend
|
||||
let result = self
|
||||
@@ -580,16 +721,7 @@ impl<B: McpBackend> Runner<B> {
|
||||
|
||||
let endpoint_tools = self.backend.all_endpoint_tools();
|
||||
for endpoint_tool in endpoint_tools {
|
||||
// Run-by-path tools are gated by script/flow scope (they run an
|
||||
// arbitrary path); every other endpoint by the endpoint scope.
|
||||
let allowed = match run_by_path_scope_kind(&endpoint_tool.name) {
|
||||
Some(kind) => scope_config.has_any(kind),
|
||||
None => {
|
||||
!scope_config.granular
|
||||
|| scope_config.is_allowed("endpoint", &endpoint_tool.name)
|
||||
}
|
||||
};
|
||||
if !allowed {
|
||||
if !endpoint_tool_in_scope(scope_config, &endpoint_tool) {
|
||||
continue;
|
||||
}
|
||||
if read_only && !crate::server::is_endpoint_read_only(&endpoint_tool) {
|
||||
@@ -641,59 +773,13 @@ impl<B: McpBackend> Runner<B> {
|
||||
)
|
||||
})?;
|
||||
|
||||
// Authorize the tool. Run-by-path endpoints (runScriptByPath /
|
||||
// runFlowByPath) run an arbitrary `path` and must be checked against the
|
||||
// script/flow scope for that path — the endpoint scope alone would let a
|
||||
// granular token run items outside its allowed paths.
|
||||
match run_by_path_scope_kind(&endpoint_tool.name) {
|
||||
Some(kind) => {
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ErrorData::invalid_params(
|
||||
format!(
|
||||
"Missing required 'path' argument for tool '{}'.",
|
||||
endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
// No `granular` gate: is_allowed already encodes every mode —
|
||||
// true for mcp:all, pattern-matched for granular scopes, and
|
||||
// false for mcp:favorites (a favorites token can't run an
|
||||
// arbitrary path, only its enumerated favorites).
|
||||
if !scope_config.is_allowed(kind, path) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!("Access denied: {} '{}' not in token scope", kind, path),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if scope_config.granular
|
||||
&& !scope_config.is_allowed("endpoint", &endpoint_tool.name)
|
||||
{
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Access denied: endpoint '{}' not in token scope",
|
||||
endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if read_only && !crate::server::is_endpoint_read_only(endpoint_tool) {
|
||||
return Err(ErrorData::internal_error(
|
||||
format!(
|
||||
"Access denied: endpoint '{}' is not read-only and this token is restricted to read-only operations",
|
||||
endpoint_tool.name
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
// Authorize the tool against the token's MCP scopes and read-only flag
|
||||
// (shared with single-workspace mode). Run-by-path endpoints
|
||||
// (runScriptByPath / runFlowByPath) run an arbitrary `path` and are
|
||||
// checked against the script/flow scope for that path — the endpoint
|
||||
// scope alone would let a granular token run items outside its allowed
|
||||
// paths.
|
||||
authorize_endpoint_call(scope_config, endpoint_tool, &args, read_only)?;
|
||||
|
||||
// Workspace-scoped endpoints need an explicit target workspace and a
|
||||
// per-workspace auth; global endpoints (e.g. docs) use the base identity.
|
||||
@@ -746,3 +832,271 @@ impl<B: McpBackend> Runner<B> {
|
||||
)]))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::common::scope::{parse_mcp_scopes, McpScopeConfig};
|
||||
use serde_json::json;
|
||||
use std::borrow::Cow;
|
||||
|
||||
fn cfg(scopes: &[&str]) -> McpScopeConfig {
|
||||
parse_mcp_scopes(&scopes.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
|
||||
}
|
||||
|
||||
fn ep(name: &'static str, method: &'static str) -> EndpointTool {
|
||||
EndpointTool {
|
||||
name: Cow::Borrowed(name),
|
||||
description: Cow::Borrowed(""),
|
||||
instructions: Cow::Borrowed(""),
|
||||
path: Cow::Borrowed("/w/{workspace}/jobs/run/p/{path}"),
|
||||
method: Cow::Borrowed(method),
|
||||
path_params_schema: None,
|
||||
query_params_schema: None,
|
||||
body_schema: None,
|
||||
path_field_renames: None,
|
||||
query_field_renames: None,
|
||||
body_field_renames: None,
|
||||
}
|
||||
}
|
||||
|
||||
// The core invariant: a folder-scoped token (mcp:endpoints:* +
|
||||
// mcp:scripts:f/team/*, as the folder-scope UI emits) must not run a script
|
||||
// outside its allowed folders via runScriptByPath — mcp:endpoints:* alone
|
||||
// must never authorize an arbitrary path.
|
||||
#[test]
|
||||
fn run_by_path_call_enforces_script_scope() {
|
||||
let config = cfg(&[
|
||||
"mcp:scripts:f/team/*",
|
||||
"mcp:flows:f/team/*",
|
||||
"mcp:endpoints:*",
|
||||
]);
|
||||
let tool = ep("runScriptByPath", "POST");
|
||||
|
||||
assert!(
|
||||
authorize_endpoint_call(&config, &tool, &json!({"path": "f/team/deploy"}), false)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
authorize_endpoint_call(&config, &tool, &json!({"path": "f/secret/admin"}), false)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_by_path_call_requires_path_arg() {
|
||||
let tool = ep("runFlowByPath", "POST");
|
||||
assert!(authorize_endpoint_call(&cfg(&["mcp:all"]), &tool, &json!({}), false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_by_path_flow_scope_independent_of_script_scope() {
|
||||
// A flow-only token can run flows by path but not scripts by path.
|
||||
let config = cfg(&["mcp:flows:f/team/*", "mcp:endpoints:*"]);
|
||||
assert!(authorize_endpoint_call(
|
||||
&config,
|
||||
&ep("runFlowByPath", "POST"),
|
||||
&json!({"path": "f/team/x"}),
|
||||
false
|
||||
)
|
||||
.is_ok());
|
||||
assert!(authorize_endpoint_call(
|
||||
&config,
|
||||
&ep("runScriptByPath", "POST"),
|
||||
&json!({"path": "f/team/x"}),
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_run_by_path_gated_by_endpoint_scope() {
|
||||
let get_var = ep("getVariable", "GET");
|
||||
assert!(authorize_endpoint_call(
|
||||
&cfg(&["mcp:endpoints:getVariable"]),
|
||||
&get_var,
|
||||
&json!({"path": "u/a/b"}),
|
||||
false
|
||||
)
|
||||
.is_ok());
|
||||
// A granular token without the endpoint scope is denied.
|
||||
assert!(authorize_endpoint_call(
|
||||
&cfg(&["mcp:scripts:f/team/*"]),
|
||||
&get_var,
|
||||
&json!({"path": "u/a/b"}),
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
// mcp:all (non-granular) allows any endpoint.
|
||||
assert!(authorize_endpoint_call(&cfg(&["mcp:all"]), &get_var, &json!({}), false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_refuses_non_get_endpoint() {
|
||||
// Reaches the read-only check via mcp:all so scope isn't the blocker.
|
||||
assert!(authorize_endpoint_call(
|
||||
&cfg(&["mcp:all"]),
|
||||
&ep("createResource", "POST"),
|
||||
&json!({}),
|
||||
true
|
||||
)
|
||||
.is_err());
|
||||
assert!(authorize_endpoint_call(
|
||||
&cfg(&["mcp:all"]),
|
||||
&ep("getVariable", "GET"),
|
||||
&json!({"path": "u/a/b"}),
|
||||
true
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_run_by_path_needs_runnable_scope() {
|
||||
let tool = ep("runScriptByPath", "POST");
|
||||
// An endpoint-only token cannot run any script, so the tool isn't listed.
|
||||
assert!(!endpoint_tool_in_scope(&cfg(&["mcp:endpoints:*"]), &tool));
|
||||
// A script-scoped token can, so it is listed.
|
||||
assert!(endpoint_tool_in_scope(
|
||||
&cfg(&["mcp:scripts:f/team/*"]),
|
||||
&tool
|
||||
));
|
||||
assert!(endpoint_tool_in_scope(&cfg(&["mcp:all"]), &tool));
|
||||
// Non-run-by-path endpoints are governed by the endpoint scope.
|
||||
let get_var = ep("getVariable", "GET");
|
||||
assert!(endpoint_tool_in_scope(
|
||||
&cfg(&["mcp:endpoints:getVariable"]),
|
||||
&get_var
|
||||
));
|
||||
assert!(!endpoint_tool_in_scope(
|
||||
&cfg(&["mcp:scripts:f/team/*"]),
|
||||
&get_var
|
||||
));
|
||||
}
|
||||
|
||||
// A folder-scoped token must not read/write/delete scripts or flows outside
|
||||
// its allowed paths through the non-run endpoint tools either.
|
||||
#[test]
|
||||
fn path_arg_tools_confined_by_path_patterns() {
|
||||
let config = cfg(&[
|
||||
"mcp:scripts:f/team/*",
|
||||
"mcp:flows:f/team/*",
|
||||
"mcp:endpoints:*",
|
||||
]);
|
||||
for name in ["getScriptByPath", "deleteScriptByPath", "createScript"] {
|
||||
let tool = ep(name, "POST");
|
||||
assert!(
|
||||
authorize_endpoint_call(&config, &tool, &json!({"path": "f/team/x"}), false)
|
||||
.is_ok(),
|
||||
"{name} should allow in-scope path"
|
||||
);
|
||||
assert!(
|
||||
authorize_endpoint_call(&config, &tool, &json!({"path": "f/secret/x"}), false)
|
||||
.is_err(),
|
||||
"{name} should deny out-of-scope path"
|
||||
);
|
||||
// Confinement can't be verified without the path argument.
|
||||
assert!(
|
||||
authorize_endpoint_call(&config, &tool, &json!({}), false).is_err(),
|
||||
"{name} should require the path argument when confined"
|
||||
);
|
||||
}
|
||||
for name in ["getFlowByPath", "deleteFlowByPath", "createFlow"] {
|
||||
let tool = ep(name, "POST");
|
||||
assert!(
|
||||
authorize_endpoint_call(&config, &tool, &json!({"path": "f/team/x"}), false)
|
||||
.is_ok(),
|
||||
"{name} should allow in-scope path"
|
||||
);
|
||||
assert!(
|
||||
authorize_endpoint_call(&config, &tool, &json!({"path": "f/secret/x"}), false)
|
||||
.is_err(),
|
||||
"{name} should deny out-of-scope path"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A token that never expressed path patterns (endpoints-only) is not
|
||||
// confined: the endpoint scope alone authorizes any path.
|
||||
#[test]
|
||||
fn path_arg_tools_unconfined_without_path_patterns() {
|
||||
let config = cfg(&["mcp:endpoints:*"]);
|
||||
for name in ["getScriptByPath", "createScript", "deleteFlowByPath"] {
|
||||
assert!(authorize_endpoint_call(
|
||||
&config,
|
||||
&ep(name, "POST"),
|
||||
&json!({"path": "f/anywhere/x"}),
|
||||
false
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
// A `*` pattern grants every path, so it doesn't confine either.
|
||||
let star = cfg(&["mcp:scripts:*", "mcp:endpoints:*"]);
|
||||
assert!(authorize_endpoint_call(
|
||||
&star,
|
||||
&ep("createScript", "POST"),
|
||||
&json!({"path": "f/anywhere/x"}),
|
||||
false
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
// updateFlow both addresses a flow (URL path) and can move it (body path):
|
||||
// a confined token must have both within scope.
|
||||
#[test]
|
||||
fn update_flow_checks_target_and_destination_paths() {
|
||||
let config = cfg(&["mcp:flows:f/team/*", "mcp:endpoints:*"]);
|
||||
let tool = ep("updateFlow", "POST");
|
||||
assert!(authorize_endpoint_call(
|
||||
&config,
|
||||
&tool,
|
||||
&json!({"path__path": "f/team/a", "path__body": "f/team/b"}),
|
||||
false
|
||||
)
|
||||
.is_ok());
|
||||
// Moving a flow out of the allowed folder is denied.
|
||||
assert!(authorize_endpoint_call(
|
||||
&config,
|
||||
&tool,
|
||||
&json!({"path__path": "f/team/a", "path__body": "f/secret/a"}),
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
// Touching a flow outside the allowed folder is denied.
|
||||
assert!(authorize_endpoint_call(
|
||||
&config,
|
||||
&tool,
|
||||
&json!({"path__path": "f/secret/a", "path__body": "f/team/a"}),
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
// Tools that can't be path-checked (delete-by-hash) or execute arbitrary
|
||||
// code (preview) would bypass path confinement, so a path-confined token is
|
||||
// denied them entirely — and doesn't see them listed.
|
||||
#[test]
|
||||
fn unconfinable_tools_denied_for_path_confined_token() {
|
||||
let confined = cfg(&["mcp:scripts:f/team/*", "mcp:endpoints:*"]);
|
||||
for name in ["deleteScriptByHash", "runScriptPreviewAndWaitResult"] {
|
||||
let tool = ep(name, "POST");
|
||||
assert!(authorize_endpoint_call(&confined, &tool, &json!({}), false).is_err());
|
||||
assert!(!endpoint_tool_in_scope(&confined, &tool));
|
||||
// Without script path patterns the tools stay available.
|
||||
assert!(
|
||||
authorize_endpoint_call(&cfg(&["mcp:endpoints:*"]), &tool, &json!({}), false)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(authorize_endpoint_call(&cfg(&["mcp:all"]), &tool, &json!({}), false).is_ok());
|
||||
assert!(endpoint_tool_in_scope(&cfg(&["mcp:endpoints:*"]), &tool));
|
||||
}
|
||||
// Flow-only confinement doesn't affect script-kind unconfinable tools.
|
||||
let flow_confined = cfg(&["mcp:flows:f/team/*", "mcp:endpoints:*"]);
|
||||
assert!(authorize_endpoint_call(
|
||||
&flow_confined,
|
||||
&ep("deleteScriptByHash", "POST"),
|
||||
&json!({}),
|
||||
false
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user