mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 16:03:24 +00:00
* fix: require variables:read for the token variable of an mcp resource A token scoped to resources:read/write on an MCP resource could have the API decrypt the variable the resource's `token` points at and send it as a bearer to the resource's URL. The url and header `$var:` references already required variables:read on the variable; the token reference now does too, checked before the OAuth refresh touches that variable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: let an mcp resource's scope cover the variables it references A scoped token reading or calling an MCP resource resolved its token variable with the resource scope alone, but its url and header variables also needed variables:read on each. The resource scope now covers all three; RLS still bounds them to secrets the caller can read. Reverts the token-side check from the previous commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: scope resource references when they are written, not when they are read A token scoped to a resource now reads everything that resource references (`$var:`, `$jsonvar:`, `$res:`), through get_value_interpolated and the MCP tool routes alike. What makes that safe moves to the write: create, update, update_value and version restore refuse a scoped token adding a reference it holds no read scope on. References the stored value already carries are kept, since whoever put them there was allowed to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: hold every resource reference writer to its own user's read rights A resource-scoped reader resolves stored references as itself, so an unscoped session could plant a reference to a secret its own user cannot read and have a reader's scoped token resolve it. Adding a reference now also requires the writer's user to see its target under RLS. A reference to nothing yet stays allowed, since a promotion pushes resources before the secrets they use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: vet only variable references on write and keep nested resources scoped A nested `$res:` brought along the variables of a resource the writer could see but whose references it could not read, and what it references can change after it is written. It needs its own read scope again, and the write check covers only `$var:`/`$jsonvar:`. An MCP token stored as a bare path resolved as a variable without being vetted on write. It now needs its own variables:read scope, and the `$var:` prefix is parsed with strip_prefix, as the write check does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep variable scopes on result-cache resources The worker writes job output to g/results/<hash> straight to the table, so its references never pass the write check. A token scoped to such a resource still needs read scope on the variables it references. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: require read rights on kept references and pin result-cache resources to g/results/ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: check resource variable references at read time, with wildcard resource scopes covering matching variables Returns to the read-time rule: a variable a resource references needs variables:read on it, unless it is the resource's linked secret or a wildcard pattern in the token's resources: scopes matches it. The MCP token reference is now held to that rule like the url and header references. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1031 lines
36 KiB
Rust
1031 lines
36 KiB
Rust
use serde_json::json;
|
|
use sqlx::{Pool, Postgres};
|
|
#[cfg(feature = "mcp")]
|
|
use uuid::Uuid;
|
|
|
|
use windmill_test_utils::*;
|
|
|
|
fn resource_url(port: u16, endpoint: &str, path: &str) -> String {
|
|
format!("http://localhost:{port}/api/w/test-workspace/resources/{endpoint}/{path}")
|
|
}
|
|
|
|
fn client() -> reqwest::Client {
|
|
reqwest::Client::new()
|
|
}
|
|
|
|
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
|
}
|
|
|
|
async fn authed_get(port: u16, endpoint: &str, path: &str) -> reqwest::Response {
|
|
authed(client().get(resource_url(port, endpoint, path)))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "resources_test"))]
|
|
async fn test_resource_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api/w/test-workspace/resources");
|
|
|
|
// --- exists ---
|
|
let resp = authed_get(port, "exists", "u/test-user/simple_resource").await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(resp.json::<bool>().await?, true);
|
|
|
|
let resp = authed_get(port, "exists", "u/test-user/nonexistent").await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(resp.json::<bool>().await?, false);
|
|
|
|
// --- get ---
|
|
let resp = authed_get(port, "get", "u/test-user/simple_resource").await;
|
|
assert_eq!(resp.status(), 200);
|
|
let body = resp.json::<serde_json::Value>().await?;
|
|
assert_eq!(body["path"], "u/test-user/simple_resource");
|
|
assert_eq!(body["resource_type"], "object");
|
|
assert_eq!(body["description"], "Simple resource");
|
|
assert_eq!(body["value"], json!({"host": "localhost", "port": 5432}));
|
|
|
|
let resp = authed_get(port, "get", "u/test-user/nonexistent").await;
|
|
assert_eq!(resp.status(), 404);
|
|
|
|
// --- get_value ---
|
|
let resp = authed_get(port, "get_value", "u/test-user/simple_resource").await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
json!({"host": "localhost", "port": 5432})
|
|
);
|
|
|
|
let resp = authed_get(port, "get_value", "u/test-user/null_resource").await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
serde_json::Value::Null
|
|
);
|
|
|
|
let resp = authed_get(port, "get_value", "u/test-user/nonexistent").await;
|
|
assert_eq!(resp.status(), 404);
|
|
|
|
// --- get_value_interpolated ---
|
|
let resp = authed_get(
|
|
port,
|
|
"get_value_interpolated",
|
|
"u/test-user/simple_resource",
|
|
)
|
|
.await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
json!({"host": "localhost", "port": 5432})
|
|
);
|
|
|
|
// $var: interpolation
|
|
let resp = authed_get(
|
|
port,
|
|
"get_value_interpolated",
|
|
"u/test-user/resource_with_var",
|
|
)
|
|
.await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
json!({"host": "localhost", "password": "hunter2"})
|
|
);
|
|
|
|
// $res: interpolation
|
|
let resp = authed_get(
|
|
port,
|
|
"get_value_interpolated",
|
|
"u/test-user/resource_with_res",
|
|
)
|
|
.await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
json!({"host": "localhost", "credentials": {"user": "admin", "password": "secret123"}})
|
|
);
|
|
|
|
// mixed $var: and $res: refs
|
|
let resp = authed_get(port, "get_value_interpolated", "u/test-user/resource_mixed").await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
json!({"host": "localhost", "password": "hunter2", "credentials": {"user": "admin", "password": "secret123"}})
|
|
);
|
|
|
|
// chained $res: -> $var:
|
|
let resp = authed_get(
|
|
port,
|
|
"get_value_interpolated",
|
|
"u/test-user/chained_resource",
|
|
)
|
|
.await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
json!({"service": "myapi", "auth": {"key": "sk-abc123"}})
|
|
);
|
|
|
|
// null value
|
|
let resp = authed_get(port, "get_value_interpolated", "u/test-user/null_resource").await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
serde_json::Value::Null
|
|
);
|
|
|
|
// not found
|
|
let resp = authed_get(port, "get_value_interpolated", "u/test-user/nonexistent").await;
|
|
assert_eq!(resp.status(), 404);
|
|
|
|
// array passthrough
|
|
let resp = authed_get(
|
|
port,
|
|
"get_value_interpolated",
|
|
"u/test-user/resource_with_array",
|
|
)
|
|
.await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
json!({"hosts": ["host1", "host2"], "port": 5432})
|
|
);
|
|
|
|
// scalar $var: ref
|
|
let resp = authed_get(
|
|
port,
|
|
"get_value_interpolated",
|
|
"u/test-user/scalar_var_resource",
|
|
)
|
|
.await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(resp.json::<serde_json::Value>().await?, json!("hunter2"));
|
|
|
|
// --- list ---
|
|
let resp = authed(client().get(format!("{base}/list")))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let list = resp.json::<Vec<serde_json::Value>>().await?;
|
|
assert!(
|
|
list.len() >= 10,
|
|
"expected at least 10 resources from fixture, got {}",
|
|
list.len()
|
|
);
|
|
assert!(list
|
|
.iter()
|
|
.any(|r| r["path"] == "u/test-user/simple_resource"));
|
|
|
|
// list with resource_type filter
|
|
let resp = authed(client().get(format!("{base}/list?resource_type=mcp_server")))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let list = resp.json::<Vec<serde_json::Value>>().await?;
|
|
assert!(list.iter().all(|r| r["resource_type"] == "mcp_server"));
|
|
|
|
// --- list_search ---
|
|
let resp = authed(client().get(format!("{base}/list_search")))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let list = resp.json::<Vec<serde_json::Value>>().await?;
|
|
assert!(!list.is_empty());
|
|
|
|
// Values are capped so the search modal never has to hold a whole workspace of
|
|
// resource content in memory.
|
|
let find = |path: &str| {
|
|
list.iter()
|
|
.find(|r| r["path"] == path)
|
|
.unwrap_or_else(|| panic!("{path} missing from list_search"))
|
|
.clone()
|
|
};
|
|
let oversized = find("u/test-user/oversized_resource");
|
|
assert_eq!(oversized["value"].as_str().unwrap().chars().count(), 4000);
|
|
assert_eq!(oversized["truncated"], true);
|
|
|
|
let simple = find("u/test-user/simple_resource");
|
|
assert!(simple["value"].as_str().unwrap().contains("\"host\""));
|
|
assert_eq!(simple["truncated"], false);
|
|
|
|
// A null value must still come back as searchable text, not null.
|
|
assert_eq!(find("u/test-user/null_resource")["value"], "");
|
|
|
|
// --- list_names ---
|
|
let resp = authed(client().get(format!("{base}/list_names/object")))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
resp.json::<Vec<serde_json::Value>>().await?;
|
|
|
|
// --- create ---
|
|
let resp = authed(client().post(format!("{base}/create")))
|
|
.json(&json!({
|
|
"path": "u/test-user/new_resource",
|
|
"value": {"url": "https://example.com"},
|
|
"description": "Created in test",
|
|
"resource_type": "object"
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 201);
|
|
|
|
// verify it exists
|
|
let resp = authed_get(port, "exists", "u/test-user/new_resource").await;
|
|
assert_eq!(resp.json::<bool>().await?, true);
|
|
|
|
// verify value
|
|
let resp = authed_get(port, "get_value", "u/test-user/new_resource").await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
json!({"url": "https://example.com"})
|
|
);
|
|
|
|
// create duplicate -> 400
|
|
let resp = authed(client().post(format!("{base}/create")))
|
|
.json(&json!({
|
|
"path": "u/test-user/new_resource",
|
|
"value": {},
|
|
"resource_type": "object"
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 400);
|
|
|
|
// create with update_if_exists -> 201
|
|
let resp = authed(client().post(format!("{base}/create?update_if_exists=true")))
|
|
.json(&json!({
|
|
"path": "u/test-user/new_resource",
|
|
"value": {"url": "https://updated.com"},
|
|
"description": "Updated via upsert",
|
|
"resource_type": "object"
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 201);
|
|
|
|
let resp = authed_get(port, "get_value", "u/test-user/new_resource").await;
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
json!({"url": "https://updated.com"})
|
|
);
|
|
|
|
// --- update (description) ---
|
|
let resp = authed(client().post(resource_url(port, "update", "u/test-user/new_resource")))
|
|
.json(&json!({"description": "Updated description"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let resp = authed_get(port, "get", "u/test-user/new_resource").await;
|
|
let body = resp.json::<serde_json::Value>().await?;
|
|
assert_eq!(body["description"], "Updated description");
|
|
|
|
// --- update (resource_type) ---
|
|
// An update that only changes resource_type must persist it.
|
|
let resp = authed(client().post(resource_url(port, "update", "u/test-user/new_resource")))
|
|
.json(&json!({"resource_type": "mcp_server"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let resp = authed_get(port, "get", "u/test-user/new_resource").await;
|
|
let body = resp.json::<serde_json::Value>().await?;
|
|
assert_eq!(body["resource_type"], "mcp_server");
|
|
|
|
// --- update_value ---
|
|
let resp = authed(client().post(resource_url(
|
|
port,
|
|
"update_value",
|
|
"u/test-user/new_resource",
|
|
)))
|
|
.json(&json!({"value": {"url": "https://final.com"}}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let resp = authed_get(port, "get_value", "u/test-user/new_resource").await;
|
|
assert_eq!(
|
|
resp.json::<serde_json::Value>().await?,
|
|
json!({"url": "https://final.com"})
|
|
);
|
|
|
|
// --- delete ---
|
|
let resp = authed(client().delete(resource_url(port, "delete", "u/test-user/new_resource")))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let resp = authed_get(port, "exists", "u/test-user/new_resource").await;
|
|
assert_eq!(resp.json::<bool>().await?, false);
|
|
|
|
// delete nonexistent -> 404
|
|
let resp = authed(client().delete(resource_url(port, "delete", "u/test-user/new_resource")))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 404);
|
|
|
|
// --- file_resource_type_to_file_ext_map ---
|
|
let resp = authed(client().get(format!("{base}/file_resource_type_to_file_ext_map")))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let ext_map = resp.json::<serde_json::Value>().await?;
|
|
// Verify the map includes fileset type info with is_fileset flag (no format_extension)
|
|
let fileset_info = &ext_map["test_fileset"];
|
|
assert_eq!(fileset_info["format_extension"], serde_json::Value::Null);
|
|
assert_eq!(fileset_info["is_fileset"], true);
|
|
// Verify non-fileset file type
|
|
let file_info = &ext_map["test_file"];
|
|
assert_eq!(file_info["format_extension"], "txt");
|
|
assert_eq!(file_info["is_fileset"], false);
|
|
|
|
// --- fileset resource value ---
|
|
let resp = authed_get(port, "get_value", "u/test-user/fileset_resource").await;
|
|
assert_eq!(resp.status(), 200);
|
|
let fileset_val = resp.json::<serde_json::Value>().await?;
|
|
assert_eq!(fileset_val["config.yaml"], "key: value");
|
|
assert_eq!(fileset_val["data/input.json"], "{\"items\": []}");
|
|
|
|
// --- resource types ---
|
|
|
|
// type/exists
|
|
let resp = authed_get(port, "type/exists", "test_db").await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(resp.json::<bool>().await?, true);
|
|
|
|
let resp = authed_get(port, "type/exists", "nonexistent_type").await;
|
|
assert_eq!(resp.status(), 200);
|
|
assert_eq!(resp.json::<bool>().await?, false);
|
|
|
|
// type/get
|
|
let resp = authed_get(port, "type/get", "test_db").await;
|
|
assert_eq!(resp.status(), 200);
|
|
let body = resp.json::<serde_json::Value>().await?;
|
|
assert_eq!(body["name"], "test_db");
|
|
assert_eq!(body["description"], "Test DB type");
|
|
|
|
let resp = authed_get(port, "type/get", "nonexistent_type").await;
|
|
assert_eq!(resp.status(), 404);
|
|
|
|
// type/list
|
|
let resp = authed(client().get(format!("{base}/type/list")))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let list = resp.json::<Vec<serde_json::Value>>().await?;
|
|
assert!(list.iter().any(|rt| rt["name"] == "test_db"));
|
|
|
|
// type/listnames
|
|
let resp = authed(client().get(format!("{base}/type/listnames")))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let names = resp.json::<Vec<String>>().await?;
|
|
assert!(names.contains(&"test_db".to_string()));
|
|
|
|
// type/create
|
|
let resp = authed(client().post(format!("{base}/type/create")))
|
|
.json(&json!({
|
|
"name": "new_test_type",
|
|
"description": "A new type",
|
|
"schema": {"type": "object"}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 201);
|
|
|
|
let resp = authed_get(port, "type/exists", "new_test_type").await;
|
|
assert_eq!(resp.json::<bool>().await?, true);
|
|
|
|
// type/create duplicate -> 400
|
|
let resp = authed(client().post(format!("{base}/type/create")))
|
|
.json(&json!({
|
|
"name": "new_test_type",
|
|
"description": "Duplicate",
|
|
"schema": {"type": "object"}
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 400);
|
|
|
|
// type/update
|
|
let resp = authed(client().post(resource_url(port, "type/update", "new_test_type")))
|
|
.json(&json!({"description": "Updated type desc"}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let resp = authed_get(port, "type/get", "new_test_type").await;
|
|
let body = resp.json::<serde_json::Value>().await?;
|
|
assert_eq!(body["description"], "Updated type desc");
|
|
|
|
// display_name: an update that omits it, as a push from a CLI predating the field does,
|
|
// keeps it; an explicit null clears it.
|
|
for (update, expected) in [
|
|
(
|
|
json!({"display_name": "New Test Type"}),
|
|
json!("New Test Type"),
|
|
),
|
|
(
|
|
json!({"description": "Updated type desc"}),
|
|
json!("New Test Type"),
|
|
),
|
|
(json!({"display_name": null}), serde_json::Value::Null),
|
|
] {
|
|
let resp = authed(client().post(resource_url(port, "type/update", "new_test_type")))
|
|
.json(&update)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
let resp = authed_get(port, "type/get", "new_test_type").await;
|
|
let body = resp.json::<serde_json::Value>().await?;
|
|
assert_eq!(body["display_name"], expected);
|
|
}
|
|
|
|
// type/delete
|
|
let resp = authed(client().delete(resource_url(port, "type/delete", "new_test_type")))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let resp = authed_get(port, "type/exists", "new_test_type").await;
|
|
assert_eq!(resp.json::<bool>().await?, false);
|
|
|
|
// --- fileset resource type CRUD ---
|
|
|
|
// type/get for fileset type - verify is_fileset is returned
|
|
let resp = authed_get(port, "type/get", "test_fileset").await;
|
|
assert_eq!(resp.status(), 200);
|
|
let body = resp.json::<serde_json::Value>().await?;
|
|
assert_eq!(body["name"], "test_fileset");
|
|
assert_eq!(body["is_fileset"], true);
|
|
assert_eq!(body["format_extension"], serde_json::Value::Null);
|
|
|
|
// type/get for non-fileset type - verify is_fileset is false
|
|
let resp = authed_get(port, "type/get", "test_db").await;
|
|
assert_eq!(resp.status(), 200);
|
|
let body = resp.json::<serde_json::Value>().await?;
|
|
assert_eq!(body["is_fileset"], false);
|
|
|
|
// type/create fileset type (no format_extension needed)
|
|
let resp = authed(client().post(format!("{base}/type/create")))
|
|
.json(&json!({
|
|
"name": "new_fileset_type",
|
|
"description": "A fileset type",
|
|
"schema": {},
|
|
"is_fileset": true
|
|
}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 201);
|
|
|
|
let resp = authed_get(port, "type/get", "new_fileset_type").await;
|
|
let body = resp.json::<serde_json::Value>().await?;
|
|
assert_eq!(body["is_fileset"], true);
|
|
assert_eq!(body["format_extension"], serde_json::Value::Null);
|
|
|
|
// type/update - set is_fileset on existing type
|
|
let resp = authed(client().post(resource_url(port, "type/update", "new_fileset_type")))
|
|
.json(&json!({"is_fileset": false}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let resp = authed_get(port, "type/get", "new_fileset_type").await;
|
|
let body = resp.json::<serde_json::Value>().await?;
|
|
assert_eq!(body["is_fileset"], false);
|
|
|
|
// cleanup
|
|
let resp = authed(client().delete(resource_url(port, "type/delete", "new_fileset_type")))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Regression test: the resource-value interpolation cache
|
|
/// (`get_value_interpolated?allow_cache=true`) must be identity-scoped. test-user-2
|
|
/// (folder access) warms the cache; test-user-3 (no access) must then be denied rather
|
|
/// than served the cached, already-decrypted value. Pre-fix the unscoped key returned
|
|
/// a 200 with the secret here.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "resource_cache_rls"))]
|
|
async fn test_resource_value_cache_is_identity_scoped(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let url = format!(
|
|
"{}?allow_cache=true",
|
|
resource_url(port, "get_value_interpolated", "f/secret/cache_target")
|
|
);
|
|
let get = |token: &str| {
|
|
client()
|
|
.get(url.as_str())
|
|
.header("Authorization", format!("Bearer {token}"))
|
|
};
|
|
|
|
// test-user-2 has folder access and WARMS the cache.
|
|
let resp = get("SECRET_TOKEN_2").send().await?;
|
|
assert_eq!(resp.status(), 200);
|
|
assert!(resp.text().await?.contains("LEAKED_FOLDER_SECRET"));
|
|
|
|
// test-user-3 has no folder access: must miss the cache and be denied (401), not leak.
|
|
let resp = get("SECRET_TOKEN_3").send().await?;
|
|
assert_eq!(resp.status(), 401);
|
|
assert!(!resp.text().await?.contains("LEAKED_FOLDER_SECRET"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A token scoped to one resource must not read, through the `$var:`/`$res:` references it
|
|
/// writes into that resource, what its scopes would refuse to read directly.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_interpolated_references_need_the_token_scopes(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api/w/test-workspace");
|
|
|
|
let resp = authed(client().post(format!("{base}/variables/create")))
|
|
.json(&json!({"path": "u/test-user/secret", "value": "CANARY", "is_secret": true, "description": ""}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201);
|
|
let resp = authed(client().post(format!("{base}/resources/create")))
|
|
.json(&json!({"path": "u/test-user/other", "value": {"pw": "OTHER"}, "resource_type": "object", "description": ""}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201);
|
|
|
|
let mint = |scopes: serde_json::Value| async move {
|
|
let resp =
|
|
authed(client().post(format!("http://localhost:{port}/api/users/tokens/create")))
|
|
.json(
|
|
&json!({"label": "scoped", "scopes": scopes, "workspace_id": "test-workspace"}),
|
|
)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 201);
|
|
resp.text().await.unwrap()
|
|
};
|
|
let probe_only = mint(json!(["resources:write:u/test-user/probe"])).await;
|
|
let with_var = mint(json!([
|
|
"resources:read:u/test-user/probe",
|
|
"variables:read:u/test-user/secret"
|
|
]))
|
|
.await;
|
|
|
|
let read_probe = |value: serde_json::Value, token: String| {
|
|
let base = base.clone();
|
|
let probe_only = probe_only.clone();
|
|
async move {
|
|
let resp = client()
|
|
.post(format!("{base}/resources/create?update_if_exists=true"))
|
|
.bearer_auth(&probe_only)
|
|
.json(&json!({"path": "u/test-user/probe", "value": value, "resource_type": "object", "description": ""}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 201);
|
|
let resp = client()
|
|
.get(format!(
|
|
"{base}/resources/get_value_interpolated/u/test-user/probe"
|
|
))
|
|
.bearer_auth(&token)
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
(resp.status().as_u16(), resp.text().await.unwrap())
|
|
}
|
|
};
|
|
|
|
for value in [
|
|
json!({"v": "$var:u/test-user/secret"}),
|
|
json!({"v": "$jsonvar:u/test-user/secret"}),
|
|
json!({"v": ["$res:u/test-user/other"]}),
|
|
] {
|
|
let (status, body) = read_probe(value.clone(), probe_only.clone()).await;
|
|
assert_eq!(status, 403, "{value}: {body}");
|
|
assert!(
|
|
!body.contains("CANARY") && !body.contains("OTHER"),
|
|
"{body}"
|
|
);
|
|
}
|
|
|
|
let (status, body) =
|
|
read_probe(json!({"v": "$var:u/test-user/secret"}), with_var.clone()).await;
|
|
assert_eq!((status, body.as_str()), (200, r#"{"v":"CANARY"}"#));
|
|
|
|
// A wildcard resource pattern extends to the variables it matches, and only to those.
|
|
let wildcard = mint(json!(["resources:read:u/test-user/*"])).await;
|
|
let (status, body) =
|
|
read_probe(json!({"v": "$var:u/test-user/secret"}), wildcard.clone()).await;
|
|
assert_eq!((status, body.as_str()), (200, r#"{"v":"CANARY"}"#));
|
|
let elsewhere = mint(json!(["resources:read:u/test-user/probe,f/other/*"])).await;
|
|
let (status, body) = read_probe(json!({"v": "$var:u/test-user/secret"}), elsewhere).await;
|
|
assert_eq!(status, 403, "{body}");
|
|
|
|
// The resource's own linked secrets (at its path, or `<path>_<field>`) are part of it.
|
|
for (path, value) in [
|
|
("u/test-user/probe", "LINKED"),
|
|
("u/test-user/probe_key", "KEY"),
|
|
] {
|
|
let resp = authed(client().post(format!("{base}/variables/create")))
|
|
.json(&json!({"path": path, "value": value, "is_secret": true, "description": ""}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201);
|
|
}
|
|
let (status, body) = read_probe(
|
|
json!({"pw": "$var:u/test-user/probe", "key": "$var:u/test-user/probe_key"}),
|
|
probe_only.clone(),
|
|
)
|
|
.await;
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(
|
|
serde_json::from_str::<serde_json::Value>(&body)?,
|
|
json!({"pw": "LINKED", "key": "KEY"})
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// A resource whose value contains a `$WM_*` contextual variable (e.g. `$WM_TOKEN`) is
|
|
/// job-dependent and must NEVER be cached — even when first read WITHOUT a `job_id`, where the
|
|
/// placeholder is left unresolved (caching that would serve a stale placeholder to a later job
|
|
/// read). Any other value — plain, or a non-`$WM_` `$`-string like `$HOME` (which is NOT
|
|
/// interpolated, so it's constant) — is job-independent and IS cached, with the entry shared
|
|
/// across job contexts (a read carrying a `job_id` still hits it, keeping the hit ratio up).
|
|
/// We prove all three by warming each (no job_id), deleting the row directly (cache survives),
|
|
/// then re-reading: the job-independent ones are still served from cache — even under a
|
|
/// `job_id` — while the `$WM_*` one was never cached and 404s.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_resource_cache_handles_job_context(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let base = format!("http://localhost:{port}/api/w/test-workspace/resources");
|
|
|
|
let plain = "u/test-user/plain_res";
|
|
let dollar = "u/test-user/dollar_res"; // non-$WM_ `$`-string: not interpolated, cacheable
|
|
let jobctx = "u/test-user/jobctx_res";
|
|
for (path, value) in [
|
|
(plain, json!({"v": 1})),
|
|
(dollar, json!({"d": "$HOME"})),
|
|
(jobctx, json!({"j": "$WM_JOB_ID"})),
|
|
] {
|
|
let resp = authed(client().post(format!("{base}/create")))
|
|
.json(
|
|
&json!({ "path": path, "value": value, "description": "", "resource_type": "object" }),
|
|
)
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 201);
|
|
}
|
|
|
|
let get = |path: &str, query: &str| {
|
|
let url = format!("{base}/get_value_interpolated/{path}?{query}");
|
|
async move { authed(client().get(url)).send().await.unwrap() }
|
|
};
|
|
|
|
// Warm all three WITHOUT a job context (the placeholder is left unresolved for `jobctx`).
|
|
for path in [plain, dollar, jobctx] {
|
|
assert_eq!(get(path, "allow_cache=true").await.status(), 200);
|
|
}
|
|
|
|
// Delete the rows directly — bypasses the API/NOTIFY, so the in-memory cache survives.
|
|
for path in [plain, dollar, jobctx] {
|
|
sqlx::query("DELETE FROM resource WHERE workspace_id = 'test-workspace' AND path = $1")
|
|
.bind(path)
|
|
.execute(&db)
|
|
.await?;
|
|
}
|
|
|
|
// Job-independent values are cached and still served even under a job_id (a random uuid is
|
|
// fine: a cache hit short-circuits before any job lookup). `$HOME` is a non-`$WM_` string,
|
|
// so it's not interpolated and stays cacheable.
|
|
for path in [plain, dollar] {
|
|
let resp = get(
|
|
path,
|
|
"allow_cache=true&job_id=11111111-1111-4111-8111-111111111111",
|
|
)
|
|
.await;
|
|
assert_eq!(
|
|
resp.status(),
|
|
200,
|
|
"job-independent resource ({path}) must stay cached and be served under a job_id"
|
|
);
|
|
}
|
|
|
|
// The `$WM_*` resource was never cached → the (now deleted) row is not found.
|
|
let resp = get(jobctx, "allow_cache=true").await;
|
|
assert_ne!(
|
|
resp.status(),
|
|
200,
|
|
"resource with a $WM_* contextual variable must not be cached"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Without an embeddings index the resource type search reads the table, so it has to apply the
|
|
/// same scope as the index: the workspace's own types and the global ones in `admins`, never
|
|
/// another workspace's.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_resource_type_search_without_embeddings(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other-workspace', 'test-user')")
|
|
.execute(&db)
|
|
.await?;
|
|
for (workspace, name, description) in [
|
|
(
|
|
"test-workspace",
|
|
"c_acme_billing",
|
|
"Acme internal billing API",
|
|
),
|
|
(
|
|
"other-workspace",
|
|
"c_other_billing",
|
|
"Another workspace's billing API",
|
|
),
|
|
] {
|
|
sqlx::query(
|
|
"INSERT INTO resource_type (workspace_id, name, schema, description, created_by) VALUES ($1, $2, '{}'::jsonb, $3, 'test-user')",
|
|
)
|
|
.bind(workspace)
|
|
.bind(name)
|
|
.bind(description)
|
|
.execute(&db)
|
|
.await?;
|
|
}
|
|
|
|
let search = |text: &str| {
|
|
let url = format!(
|
|
"http://localhost:{port}/api/w/test-workspace/embeddings/query_resource_types?text={text}"
|
|
);
|
|
async move {
|
|
let resp = authed(client().get(url)).send().await.unwrap();
|
|
assert_eq!(resp.status(), 200);
|
|
resp.json::<Vec<serde_json::Value>>()
|
|
.await
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|rt| rt["name"].as_str().unwrap().to_string())
|
|
.collect::<Vec<_>>()
|
|
}
|
|
};
|
|
|
|
let billing = search("billing").await;
|
|
assert_eq!(billing.first().map(String::as_str), Some("c_acme_billing"));
|
|
assert!(!billing.contains(&"c_other_billing".to_string()));
|
|
// `ai_skill` is seeded in `admins` by the migrations.
|
|
assert!(search("skill").await.contains(&"ai_skill".to_string()));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(feature = "mcp")]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base", "resources_test"))]
|
|
async fn test_mcp_tools(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// unauthenticated -> 401
|
|
let resp = client()
|
|
.get(resource_url(port, "mcp_tools", "u/test-user/mcp_valid"))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), 401);
|
|
|
|
// not found -> 404
|
|
let resp = authed_get(port, "mcp_tools", "u/test-user/nonexistent").await;
|
|
assert_eq!(resp.status(), 404);
|
|
|
|
// null value -> 400
|
|
let resp = authed_get(port, "mcp_tools", "u/test-user/mcp_null").await;
|
|
assert_eq!(resp.status(), 400);
|
|
|
|
// invalid format -> 400 with parse error message
|
|
let resp = authed_get(port, "mcp_tools", "u/test-user/mcp_invalid_format").await;
|
|
assert_eq!(resp.status(), 400);
|
|
let body = resp.text().await?;
|
|
assert!(
|
|
body.contains("Failed to parse MCP resource"),
|
|
"expected parse error, got: {body}"
|
|
);
|
|
|
|
// valid MCP resource but unreachable server -> 500
|
|
let resp = authed_get(port, "mcp_tools", "u/test-user/mcp_valid").await;
|
|
assert_eq!(resp.status(), 500);
|
|
let body = resp.text().await?;
|
|
assert!(
|
|
body.contains("Failed to connect to MCP server"),
|
|
"expected connection error, got: {body}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(feature = "mcp")]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_mcp_endpoint_tools_list(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
let resp = authed(client().get(format!(
|
|
"http://localhost:{port}/api/mcp/w/test-workspace/list_tools"
|
|
)))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let tools: Vec<serde_json::Value> = resp.json().await?;
|
|
|
|
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
|
|
|
|
assert!(
|
|
tool_names.contains(&"getJob"),
|
|
"getJob not found in MCP endpoint tools: {tool_names:?}"
|
|
);
|
|
assert!(
|
|
tool_names.contains(&"getJobLogs"),
|
|
"getJobLogs not found in MCP endpoint tools: {tool_names:?}"
|
|
);
|
|
|
|
// Verify getJob has the expected path and method
|
|
let get_job_tool = tools.iter().find(|t| t["name"] == "getJob").unwrap();
|
|
assert_eq!(get_job_tool["path"], "/w/{workspace}/jobs_u/get/{id}");
|
|
assert_eq!(get_job_tool["method"], "GET");
|
|
|
|
// Verify getJobLogs has the expected path and method
|
|
let get_job_logs_tool = tools.iter().find(|t| t["name"] == "getJobLogs").unwrap();
|
|
assert_eq!(
|
|
get_job_logs_tool["path"],
|
|
"/w/{workspace}/jobs_u/get_logs/{id}"
|
|
);
|
|
assert_eq!(get_job_logs_tool["method"], "GET");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(feature = "mcp")]
|
|
async fn insert_completed_job_with_logs(db: &Pool<Postgres>) -> Uuid {
|
|
let id = Uuid::new_v4();
|
|
sqlx::query(
|
|
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args)
|
|
VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)",
|
|
)
|
|
.bind(id)
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status)
|
|
VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')",
|
|
)
|
|
.bind(id)
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO job_logs (job_id, workspace_id, logs, log_offset)
|
|
VALUES ($1, 'test-workspace', 'hello world test log', 0)",
|
|
)
|
|
.bind(id)
|
|
.execute(db)
|
|
.await
|
|
.unwrap();
|
|
|
|
id
|
|
}
|
|
|
|
#[cfg(feature = "mcp")]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_mcp_client_get_job_and_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
use rmcp::model::{
|
|
CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation,
|
|
InitializeRequestParams,
|
|
};
|
|
use rmcp::service::{RoleClient, RunningService};
|
|
use rmcp::transport::streamable_http_client::{
|
|
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
|
|
};
|
|
use rmcp::ServiceExt;
|
|
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
let server = ApiServer::start_mcp(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
let job_id = insert_completed_job_with_logs(&db).await;
|
|
|
|
// Create a token with MCP scopes
|
|
sqlx::query(
|
|
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
|
|
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
// Connect as MCP client
|
|
let config = StreamableHttpClientTransportConfig::with_uri(format!(
|
|
"http://localhost:{port}/api/mcp/w/test-workspace/mcp"
|
|
))
|
|
.auth_header("MCP_TOKEN");
|
|
let transport = StreamableHttpClientTransport::from_config(config);
|
|
|
|
let client_info = ClientInfo::new(
|
|
ClientCapabilities::default(),
|
|
Implementation::new("test-client", "0.0.1"),
|
|
);
|
|
|
|
let client: RunningService<RoleClient, InitializeRequestParams> =
|
|
client_info.serve(transport).await?;
|
|
|
|
// --- Test getJob ---
|
|
let result = client
|
|
.call_tool(
|
|
CallToolRequestParams::new("getJob")
|
|
.with_arguments(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
|
)
|
|
.await?;
|
|
|
|
let text = result
|
|
.content
|
|
.first()
|
|
.and_then(|c| c.as_text())
|
|
.expect("getJob should return text content");
|
|
let job: serde_json::Value = serde_json::from_str(&text.text)?;
|
|
assert_eq!(job["id"], job_id.to_string());
|
|
assert_eq!(job["workspace_id"], "test-workspace");
|
|
assert_eq!(job["created_by"], "test-user");
|
|
assert_eq!(job["job_kind"], "script");
|
|
assert!(
|
|
job["success"].as_bool().unwrap_or(false),
|
|
"job should be successful: {job}"
|
|
);
|
|
|
|
// --- Test getJobLogs ---
|
|
let result = client
|
|
.call_tool(
|
|
CallToolRequestParams::new("getJobLogs")
|
|
.with_arguments(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
|
)
|
|
.await?;
|
|
|
|
let text = result
|
|
.content
|
|
.first()
|
|
.and_then(|c| c.as_text())
|
|
.expect("getJobLogs should return text content");
|
|
// The logs endpoint returns text/plain, which gets wrapped as a JSON string by call_endpoint
|
|
let logs: String = serde_json::from_str(&text.text)?;
|
|
assert!(
|
|
logs.contains("hello world test log"),
|
|
"expected logs to contain test log, got: {logs}"
|
|
);
|
|
|
|
client.cancel().await?;
|
|
Ok(())
|
|
}
|