Files
windmill/backend/tests/ws_specific.rs
T
Ruben FiszelandClaude Opus 5 2a21efa11b fix: stop a resource delete from taking variables it does not own (#11102)
* fix: stop a resource delete from taking variables it does not own

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb6mKJWUACuPKA3wZRuyy7

* fix: key the ws_specific cleanup on what the delete actually removed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb6mKJWUACuPKA3wZRuyy7

* fix: attribute a cascaded variable to the resource that actually referenced it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb6mKJWUACuPKA3wZRuyy7

* docs: state the real constraint behind the pre-transaction referrer scan

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nb6mKJWUACuPKA3wZRuyy7

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 10:02:52 +02:00

650 lines
20 KiB
Rust

//! Integration tests for the workspace-specific (ws_specific) feature.
//!
//! Covers three regression-prone areas:
//!
//! 1. **Linked-delete cleanup** — deleting a resource (or variable) must also
//! drop the cross-kind ws_specific row that was auto-inserted by
//! `mark_linked_variables_ws_specific` so a later item recreated at the
//! same path doesn't inherit a stale flag.
//! 2. **list_ws_specific authorization filtering** — the endpoint must hide
//! paths the caller cannot see via the underlying resource/variable RLS.
//! 3. **Resource upsert with `ws_specific: false`** — `create_resource` with
//! `update_if_exists=true` and `ws_specific: false` must clear an
//! existing flag (was previously a silent no-op).
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
/// Helper: does a variable exist at (workspace, path)?
async fn variable_exists(db: &Pool<Postgres>, workspace: &str, path: &str) -> anyhow::Result<bool> {
let n: Option<i64> =
sqlx::query_scalar("SELECT COUNT(*) FROM variable WHERE workspace_id = $1 AND path = $2")
.bind(workspace)
.bind(path)
.fetch_one(db)
.await?;
Ok(n.unwrap_or(0) > 0)
}
/// Helper: mint an API token restricted to `scopes`, using the admin SECRET_TOKEN.
async fn mint_scoped_token(port: u16, scopes: Vec<&str>) -> anyhow::Result<String> {
let resp = authed(
client().post(format!("http://localhost:{port}/api/users/tokens/create")),
"SECRET_TOKEN",
)
.json(&json!({
"label": "scoped",
"scopes": scopes,
"workspace_id": "test-workspace",
}))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"mint scoped token: {}",
resp.text().await?
);
Ok(resp.text().await?)
}
/// Helper: count rows in ws_specific for (workspace, kind, path).
async fn ws_specific_row_count(
db: &Pool<Postgres>,
workspace: &str,
kind: &str,
path: &str,
) -> anyhow::Result<i64> {
let n: Option<i64> = sqlx::query_scalar(
"SELECT COUNT(*) FROM ws_specific
WHERE workspace_id = $1 AND item_kind = $2 AND path = $3",
)
.bind(workspace)
.bind(kind)
.bind(path)
.fetch_one(db)
.await?;
Ok(n.unwrap_or(0))
}
#[sqlx::test(fixtures("ws_specific"))]
async fn test_linked_delete_cleanup(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");
// Create a referenced variable.
let resp = authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/db_pwd",
"value": "hunter2",
"is_secret": false,
"description": ""
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create var: {}", resp.text().await?);
// Create a ws_specific resource that references the variable via $var:.
let resp = authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/db",
"value": { "user": "admin", "password": "$var:u/test-user/db_pwd" },
"description": "",
"resource_type": "object",
"ws_specific": true
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create res: {}", resp.text().await?);
// The auto-mark on save inserts a ws_specific 'variable' row for the
// linked variable.
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "variable", "u/test-user/db_pwd").await?,
1,
"linked variable should be auto-marked ws_specific"
);
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/db").await?,
1
);
// Delete the resource — it should cascade to the linked variable AND
// the ws_specific row for that variable.
let resp = authed(
client().delete(format!("{base}/resources/delete/u/test-user/db")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "delete res: {}", resp.text().await?);
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/db").await?,
0,
"resource ws_specific row should be gone"
);
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "variable", "u/test-user/db_pwd").await?,
0,
"orphaned linked-variable ws_specific row should also be gone"
);
// The same fix applies in reverse: delete_variable must clean the
// ws_specific 'resource' row at the same path.
let resp = authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/twin",
"value": "v",
"is_secret": false,
"description": ""
}))
.send()
.await?;
assert_eq!(resp.status(), 201);
let resp = authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/twin",
"value": { "x": 1 },
"resource_type": "object",
"ws_specific": true
}))
.send()
.await?;
assert_eq!(resp.status(), 201);
// ws_specific row exists for resource at u/test-user/twin
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/twin").await?,
1
);
// Delete the variable at the same path: cascades to the resource AND
// its ws_specific row.
let resp = authed(
client().delete(format!("{base}/variables/delete/u/test-user/twin")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200, "delete var: {}", resp.text().await?);
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/twin").await?,
0,
"ws_specific resource row should be cleaned by variable delete"
);
Ok(())
}
#[sqlx::test(fixtures("ws_specific"))]
async fn test_list_ws_specific_filters_by_rls(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");
// Admin creates two ws_specific items: one in u/test-user/* (private to
// test-user) and one in u/test-user-2/* (private to test-user-2).
for path in ["u/test-user/admin_only", "u/test-user-2/user2_only"] {
let resp = authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": path,
"value": "v",
"is_secret": false,
"description": "",
"ws_specific": true
}))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"create var {path}: {}",
resp.text().await?
);
}
// Admin sees both via list_ws_specific.
let resp = authed(
client().get(format!("{base}/workspaces/list_ws_specific")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(resp.status(), 200);
let admin_items: Vec<serde_json::Value> = resp.json().await?;
let admin_paths: Vec<&str> = admin_items
.iter()
.filter_map(|i| i.get("path").and_then(|p| p.as_str()))
.collect();
assert!(admin_paths.contains(&"u/test-user/admin_only"));
assert!(admin_paths.contains(&"u/test-user-2/user2_only"));
// Non-admin (test-user-2) only sees their own u/test-user-2/* path —
// u/test-user/admin_only is filtered by RLS see_own (path requires
// SPLIT_PART(path,'/',2) = session.user).
let resp = authed(
client().get(format!("{base}/workspaces/list_ws_specific")),
"SECRET_TOKEN_2",
)
.send()
.await?;
assert_eq!(resp.status(), 200);
let user2_items: Vec<serde_json::Value> = resp.json().await?;
let user2_paths: Vec<&str> = user2_items
.iter()
.filter_map(|i| i.get("path").and_then(|p| p.as_str()))
.collect();
assert!(
user2_paths.contains(&"u/test-user-2/user2_only"),
"user2 should see their own ws_specific item, got: {user2_paths:?}"
);
assert!(
!user2_paths.contains(&"u/test-user/admin_only"),
"user2 should NOT see admin's ws_specific item, got: {user2_paths:?}"
);
Ok(())
}
#[sqlx::test(fixtures("ws_specific"))]
async fn test_create_resource_upsert_clears_ws_specific(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");
// Step 1: create resource with ws_specific=true.
let resp = authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/upsert_target",
"value": { "host": "h" },
"resource_type": "object",
"ws_specific": true
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
assert_eq!(
ws_specific_row_count(
&db,
"test-workspace",
"resource",
"u/test-user/upsert_target"
)
.await?,
1
);
// Step 2: upsert (update_if_exists=true) with ws_specific=false — must
// CLEAR the existing row.
let resp = authed(
client().post(format!("{base}/resources/create?update_if_exists=true")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/upsert_target",
"value": { "host": "h2" },
"resource_type": "object",
"ws_specific": false
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
assert_eq!(
ws_specific_row_count(
&db,
"test-workspace",
"resource",
"u/test-user/upsert_target"
)
.await?,
0,
"ws_specific=false on upsert must clear the existing row"
);
// Step 3: upsert without ws_specific (None) leaves whatever's there
// alone — re-flag it true, then upsert with no field, expect row stays.
let resp = authed(
client().post(format!("{base}/resources/create?update_if_exists=true")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/upsert_target",
"value": { "host": "h3" },
"resource_type": "object",
"ws_specific": true
}))
.send()
.await?;
assert_eq!(resp.status(), 201);
assert_eq!(
ws_specific_row_count(
&db,
"test-workspace",
"resource",
"u/test-user/upsert_target"
)
.await?,
1
);
let resp = authed(
client().post(format!("{base}/resources/create?update_if_exists=true")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/upsert_target",
"value": { "host": "h4" },
"resource_type": "object"
// no ws_specific field
}))
.send()
.await?;
assert_eq!(resp.status(), 201);
assert_eq!(
ws_specific_row_count(
&db,
"test-workspace",
"resource",
"u/test-user/upsert_target"
)
.await?,
1,
"absent ws_specific field must leave the existing flag alone"
);
Ok(())
}
/// Regression for GHSA-xmr2-98m6-cjf7: a token scoped only to `resources:write:<r>`
/// must NOT use the resource-delete cascade to delete a linked secret variable it has
/// no `variables:write` scope for. The victim sits at a path the resource owns, which is
/// the only kind the cascade reaches at all.
#[sqlx::test(fixtures("ws_specific"))]
async fn test_scoped_token_cannot_cascade_delete_linked_variable(
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")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/db_victim_secret",
"value": "hunter2",
"is_secret": true,
"description": ""
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create victim: {}", resp.text().await?);
let resp = authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": "u/test-user/db",
"value": { "password": "$var:u/test-user/db_victim_secret" },
"resource_type": "object"
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create res: {}", resp.text().await?);
// resources:write only, no variables:write for the linked secret.
let scoped = mint_scoped_token(port, vec!["resources:write:u/test-user/db"]).await?;
let resp = authed(
client().delete(format!("{base}/resources/delete/u/test-user/db")),
&scoped,
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"scoped resource token must not cascade-delete the linked variable: {}",
resp.text().await?
);
assert!(
variable_exists(&db, "test-workspace", "u/test-user/db_victim_secret").await?,
"victim variable must survive the denied cascade"
);
// The scope check runs after the resource DELETE, so only the rollback keeps the resource
// alive — moving the check out of the transaction would silently delete it on a 403.
let resource_left: Option<i64> =
sqlx::query_scalar("SELECT COUNT(*) FROM resource WHERE workspace_id = $1 AND path = $2")
.bind("test-workspace")
.bind("u/test-user/db")
.fetch_one(&db)
.await?;
assert_eq!(
resource_left.unwrap_or(0),
1,
"the denied delete must roll the resource back too"
);
Ok(())
}
/// Deleting a resource must not take a variable other things still need. Two gates, each
/// with a way past the other: a variable outside the resource's own path is never its to
/// delete, and even one it owns stays if another resource points at it.
#[sqlx::test(fixtures("ws_specific"))]
async fn test_resource_delete_spares_variables_it_does_not_own(
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 create_var = |path: &'static str| {
authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({ "path": path, "value": "hunter2", "is_secret": true, "description": "" }))
.send()
};
let create_res = |path: &'static str, var: &'static str| {
authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": path,
"value": { "password": format!("$var:{var}") },
"resource_type": "object"
}))
.send()
};
// A shared secret at a path of its own, and two resources reading it.
assert_eq!(create_var("u/test-user/shared_canary").await?.status(), 201);
assert_eq!(
create_res("u/test-user/probe_a", "u/test-user/shared_canary")
.await?
.status(),
201
);
assert_eq!(
create_res("u/test-user/probe_b", "u/test-user/shared_canary")
.await?
.status(),
201
);
// A secret the resource at the same path owns, which a second resource also reads.
assert_eq!(create_var("u/test-user/owned").await?.status(), 201);
assert_eq!(
create_res("u/test-user/owned", "u/test-user/owned")
.await?
.status(),
201
);
assert_eq!(
create_res("u/test-user/borrower", "u/test-user/owned")
.await?
.status(),
201
);
for resource in ["u/test-user/probe_a", "u/test-user/owned"] {
let resp = authed(
client().delete(format!("{base}/resources/delete/{resource}")),
"SECRET_TOKEN",
)
.send()
.await?;
assert_eq!(
resp.status(),
200,
"delete {resource}: {}",
resp.text().await?
);
}
assert!(
variable_exists(&db, "test-workspace", "u/test-user/shared_canary").await?,
"a variable the deleted resource only referenced must survive"
);
assert!(
variable_exists(&db, "test-workspace", "u/test-user/owned").await?,
"an owned variable another resource still references must survive"
);
Ok(())
}
/// The bulk cascade follows what RLS actually deleted, not what the caller asked for: a
/// resource the request names but leaves standing neither cascades nor stops counting as a
/// referrer. Both halves matter, and neither covers the other.
#[sqlx::test(fixtures("ws_specific"))]
async fn test_bulk_delete_follows_what_rls_deleted(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 create_var = |path: &'static str| {
authed(
client().post(format!("{base}/variables/create")),
"SECRET_TOKEN",
)
.json(&json!({ "path": path, "value": "hunter2", "is_secret": true, "description": "" }))
.send()
};
// ws_specific so the flag assertion at the end has something to check.
let create_res = |path: &'static str, var: &'static str| {
authed(
client().post(format!("{base}/resources/create")),
"SECRET_TOKEN",
)
.json(&json!({
"path": path,
"value": { "password": format!("$var:{var}") },
"resource_type": "object",
"ws_specific": true
}))
.send()
};
// Private to test-user: a resource and the secret it owns.
assert_eq!(create_var("u/test-user/hidden_pwd").await?.status(), 201);
assert_eq!(
create_res("u/test-user/hidden", "u/test-user/hidden_pwd")
.await?
.status(),
201
);
// test-user-2's own resource and secret, which the private resource above also reads.
assert_eq!(create_var("u/test-user-2/own_pwd").await?.status(), 201);
assert_eq!(
create_res("u/test-user-2/own", "u/test-user-2/own_pwd")
.await?
.status(),
201
);
assert_eq!(
create_res("u/test-user/reader", "u/test-user-2/own_pwd")
.await?
.status(),
201
);
// test-user-2 may write the private secret but has no access to its resource at all.
sqlx::query(
"UPDATE variable SET extra_perms = '{\"u/test-user-2\": true}'::jsonb
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/hidden_pwd'",
)
.execute(&db)
.await?;
let resp = authed(
client().delete(format!("{base}/resources/delete_bulk")),
"SECRET_TOKEN_2",
)
.json(&json!({
"paths": ["u/test-user/hidden", "u/test-user-2/own", "u/test-user/reader"]
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "bulk delete: {}", resp.text().await?);
assert!(
variable_exists(&db, "test-workspace", "u/test-user/hidden_pwd").await?,
"the variable of a resource RLS refused to delete must survive"
);
assert!(
variable_exists(&db, "test-workspace", "u/test-user-2/own_pwd").await?,
"a requested resource RLS left standing still counts as a referrer"
);
// ws_specific has no RLS policy of its own, so clearing it by requested path rather than
// by deleted path would quietly turn a surviving resource workspace-generic.
assert_eq!(
ws_specific_row_count(&db, "test-workspace", "resource", "u/test-user/hidden").await?,
1,
"a resource RLS refused to delete must keep its ws_specific flag"
);
Ok(())
}