Files
windmill/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs
T
21d2a49971 feat: configurable workspace roles with per-workspace overrides (#11821)
* feat: configurable workspace roles with per-workspace overrides

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: close role review gaps in pickers, declarative sync and removal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: keep resolve_role_name doc on its function

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: validate overrides on instance role changes and bill admins fully

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: allow pipeline drafts and keep role checks on one connection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: list data pipelines in operator_can_draft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: expect a 403 role refusal when an operator resolves jobs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: require a valid license for custom workspace roles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: freeze workspace roles when the license lapses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin the ee companion for workspace roles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor: broadcast role override changes from the write path, not a trigger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to b5294d38db082bdd104d5358a47695390694c05d

This commit updates the EE repository reference after PR #891 was merged in windmill-ee-private.

Previous ee-repo-ref: 688e00c5982865b14098f530d54280e2311994c0

New ee-repo-ref: b5294d38db082bdd104d5358a47695390694c05d

Automated by sync-ee-ref workflow.

* fix: let an admin row that kept the operator flag hold full rights everywhere

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: treat superadmin members as admins and return role names from get user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-21 15:51:54 +02:00

330 lines
10 KiB
Rust

use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const WS: &str = "test-workspace";
fn operator_client() -> reqwest::Client {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str("Bearer OPERATOR_TOKEN_1").unwrap(),
);
reqwest::ClientBuilder::new()
.default_headers(headers)
.build()
.unwrap()
}
async fn set_builder(db: &Pool<Postgres>, flows: bool) -> anyhow::Result<()> {
sqlx::query(
"UPDATE workspace_settings SET role_overrides = $1::text::jsonb WHERE workspace_id = $2",
)
.bind(format!(
r#"{{"operator": {{"capabilities": {{"flows": "{}"}}}}}}"#,
if flows { "compose" } else { "none" }
))
.bind(WS)
.execute(db)
.await?;
// The right is read through a process-global 60s cache keyed by workspace id.
windmill_common::roles::invalidate_all_roles_cache();
Ok(())
}
async fn add_script(db: &Pool<Postgres>, hash: i64, path: &str, owner: &str) -> anyhow::Result<()> {
sqlx::query(
"INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema,
summary, description, lock, extra_perms)
VALUES ($1, $2, $3, 'x', 'bun', 'script', $4, '{}', '', '', '', '{}')",
)
.bind(WS)
.bind(hash)
.bind(path)
.bind(owner)
.execute(db)
.await?;
Ok(())
}
fn composition_flow_at(path: &str, step_path: &str) -> serde_json::Value {
json!({
"path": path,
"summary": "",
"description": "",
"schema": {},
"value": {"modules": [{
"id": "a",
"value": {"type": "script", "path": step_path, "input_transforms": {}}
}]}
})
}
fn inline_code_flow(path: &str) -> serde_json::Value {
json!({
"path": path,
"summary": "",
"description": "",
"schema": {},
"value": {"modules": [{
"id": "a",
"value": {
"type": "rawscript",
"content": "export async function main() { return 1 }",
"language": "bun",
"input_transforms": {}
}
}]}
})
}
/// The whole boundary in one pass: the builder right lets an operator compose runnables that are
/// already deployed and nothing more, and the endpoints that author code stay shut whether or not
/// it is granted.
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
async fn test_operator_builder_flows_boundary(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
let c = operator_client();
// A composition-only flow references a runnable that exists and the builder can read, so the
// fixture needs one.
add_script(&db, 4241, "u/operator/some_script", "operator").await?;
set_builder(&db, false).await?;
let resp = c
.post(format!("{api}/flows/create"))
.json(&composition_flow_at(
"u/operator/f1",
"u/operator/some_script",
))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"an operator without the builder right must not create a flow"
);
set_builder(&db, true).await?;
// A payload that omits the key, like a git-sync file that predates it, keeps the right.
let resp = reqwest::Client::new()
.post(format!("{api}/workspaces/operator_settings"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&json!({"runs": true}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let notified: Vec<String> =
sqlx::query_scalar("SELECT payload FROM notify_event WHERE channel = $1")
.bind(windmill_common::roles::ROLE_OVERRIDES_CHANGE_CHANNEL)
.fetch_all(&db)
.await?;
assert_eq!(
notified,
vec![WS.to_string()],
"other replicas drop their cached roles only through this event"
);
let resp = c
.post(format!("{api}/flows/create"))
.json(&composition_flow_at(
"u/operator/f1",
"u/operator/some_script",
))
.send()
.await?;
assert!(
resp.status().is_success(),
"a builder must be able to create a composition-only flow: {}",
resp.text().await?
);
let mut tagged = composition_flow_at("u/operator/f5", "u/operator/some_script");
tagged["tag"] = json!("privileged_group");
let resp = c
.post(format!("{api}/flows/create"))
.json(&tagged)
.send()
.await?;
assert_eq!(
resp.status(),
400,
"a builder must not route a flow onto a worker tag the workspace cannot use"
);
let mut with_dyn_code = composition_flow_at("u/operator/f1", "u/operator/some_script");
with_dyn_code["schema"] =
json!({"x-windmill-dyn-select-code": "x", "x-windmill-dyn-select-lang": "bun"});
let resp = c
.post(format!("{api}/flows/update/u/operator/f1"))
.json(&with_dyn_code)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not save a flow carrying dropdown code"
);
let resp = c
.post(format!("{api}/jobs/run/dynamic_select"))
.json(
&json!({"entrypoint_function": "f", "runnable_ref": {"source": "inline", "code": "x"}}),
)
.send()
.await?;
assert_eq!(
resp.status(),
403,
"an operator's inline dropdown refusal must not be a 401"
);
// A dependency job rewrites bookkeeping stored under the path it names, so a builder must not
// aim one at a path it cannot write.
let resp = c
.post(format!("{api}/jobs/run/flow_dependencies"))
.json(&json!({"path": "u/alice/private_flow", "flow_value": {"modules": []}}))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not run a dependency job on a path it cannot write"
);
// A legacy draft has no owner, so it lists as the builder's own, and a script draft is one
// the builder cannot write: that row must read as not writable, not fail the whole list.
sqlx::query(
"INSERT INTO draft (workspace_id, path, typ, value) VALUES ($1, 'u/operator/some_script', 'script', '{}')",
)
.bind(WS)
.execute(&db)
.await?;
let resp = c.get(format!("{api}/drafts/list")).send().await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
let drafts: serde_json::Value = resp.json().await?;
assert_eq!(drafts[0]["can_write"], false, "{drafts}");
let resp = c
.post(format!("{api}/flows/create"))
.json(&inline_code_flow("u/operator/f2"))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not deploy a flow carrying inline code"
);
// Draft storage strips a NUL, turning these keys into the plain ones the check reads.
let mut nul_value = inline_code_flow("u/operator/f1");
let v = nul_value.as_object_mut().unwrap().remove("value").unwrap();
nul_value["value\0"] = v;
let mut nul_dyn = composition_flow_at("u/operator/f1", "u/operator/some_script");
nul_dyn["schema"] = json!({"x-windmill-dyn-select-code\0": "x"});
for (draft, expected) in [
(
composition_flow_at("u/operator/f1", "u/operator/some_script"),
200,
),
(inline_code_flow("u/operator/f1"), 403),
(with_dyn_code.clone(), 403),
(nul_value, 403),
(nul_dyn, 403),
] {
let resp = c
.post(format!("{api}/drafts/update/flow/u/operator/f1"))
.json(&json!({ "value": draft, "force": true }))
.send()
.await?;
let status = resp.status();
assert_eq!(
status,
expected,
"flow draft {draft}: {}",
resp.text().await?
);
}
// Same for the preview path, which runs a request-supplied flow value rather than a stored one.
let resp = c
.post(format!("{api}/jobs/run/preview_flow"))
.json(&json!({"value": inline_code_flow("u/operator/f2")["value"], "args": {}}))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not preview a flow carrying inline code"
);
// Authoring code directly stays shut with the right granted.
let resp = c
.post(format!("{api}/scripts/create"))
.json(&json!({
"path": "u/operator/s1",
"summary": "",
"description": "",
"content": "export async function main() { return 1 }",
"language": "bun",
"is_template": false
}))
.send()
.await?;
assert!(
!resp.status().is_success(),
"a builder must not create a script"
);
// `permissions_test` gives the operator fixture no rights on `u/alice/**`.
add_script(&db, 4243, "u/alice/private", "alice").await?;
let resp = c
.post(format!("{api}/flows/create"))
.json(&composition_flow_at("u/operator/f4", "u/alice/private"))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not compose a runnable it cannot read"
);
add_script(&db, 4242, "u/operator/pinned", "operator").await?;
let pinned = |hash: &str| {
json!({
"path": "u/operator/f3", "summary": "", "description": "", "schema": {},
"value": {"modules": [{
"id": "a",
"value": {
"type": "script", "path": "u/operator/pinned", "hash": hash,
"input_transforms": {}
}
}]}
})
};
let resp = c
.post(format!("{api}/flows/create"))
.json(&pinned("0000000000000000"))
.send()
.await?;
assert_eq!(
resp.status(),
403,
"a builder must not pin a hash that is not a version of the step's path"
);
let resp = c
.post(format!("{api}/flows/create"))
.json(&pinned("0000000000001092"))
.send()
.await?;
assert!(
resp.status().is_success(),
"a builder must be able to pin the real version of a readable script: {}",
resp.text().await?
);
Ok(())
}