From 2ca3f561406c270e9fef9d896fa8d66831e08b46 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 2 Oct 2026 17:55:03 +0200 Subject: [PATCH] fix: keep restricted jobs off flow runners, preserve scopes on rename and promotion Co-Authored-By: Claude Opus 5.5 (1M context) --- ...91f36d84fb4797caccc11ea74bb7bc9890854.json | 139 ++++++ ...e10fdf4013033b128fbc47b65e240da847dc3.json | 398 ++++++++++++++++++ backend/tests/job_token_scopes.rs | 59 +++ backend/windmill-api-auth/src/auth.rs | 2 +- backend/windmill-api-auth/src/scopes.rs | 2 +- backend/windmill-api-jobs/src/jobs_export.rs | 8 +- backend/windmill-api-scripts/src/scripts.rs | 59 +-- backend/windmill-api/src/jobs.rs | 3 +- backend/windmill-queue/src/jobs.rs | 45 +- backend/windmill-worker/src/worker.rs | 6 +- backend/windmill-worker/src/worker_flow.rs | 3 + cli/windmill-utils-internal/src/deploy.ts | 5 + .../components/JobTokenScopesSetting.svelte | 3 +- .../components/scripts/WacExportDrawer.svelte | 1 + 14 files changed, 654 insertions(+), 79 deletions(-) create mode 100644 backend/.sqlx/query-310bc5d8444925455ac2ff0904f91f36d84fb4797caccc11ea74bb7bc9890854.json create mode 100644 backend/.sqlx/query-984092e2bb9e4b380d7200524b4e10fdf4013033b128fbc47b65e240da847dc3.json diff --git a/backend/.sqlx/query-310bc5d8444925455ac2ff0904f91f36d84fb4797caccc11ea74bb7bc9890854.json b/backend/.sqlx/query-310bc5d8444925455ac2ff0904f91f36d84fb4797caccc11ea74bb7bc9890854.json new file mode 100644 index 0000000000..f397796470 --- /dev/null +++ b/backend/.sqlx/query-310bc5d8444925455ac2ff0904f91f36d84fb4797caccc11ea74bb7bc9890854.json @@ -0,0 +1,139 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO v2_job (\n id, raw_code, raw_lock, raw_flow, tag, workspace_id, created_at, created_by,\n permissioned_as, permissioned_as_email, kind, runnable_id, runnable_path,\n parent_job, root_job, script_lang, script_entrypoint_override, flow_step,\n flow_step_id, flow_innermost_root_job, trigger, trigger_kind, same_worker,\n visible_to_owner, concurrent_limit, concurrency_time_window_s, cache_ttl,\n timeout, priority, preprocessed, args, labels, pre_run_error, job_token_scopes\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,\n $11, $12, $13, $14, $15, $16, $17, $18, $19, $20,\n $21, $22, $23, $24, $25, $26, $27, $28, $29, $30,\n $31, $32, $33, $34\n )\n ON CONFLICT (id) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Jsonb", + "Varchar", + "Varchar", + "Timestamptz", + "Varchar", + "Varchar", + "Varchar", + { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + }, + "Int8", + "Varchar", + "Uuid", + "Uuid", + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + }, + "Varchar", + "Int4", + "Varchar", + "Uuid", + "Varchar", + { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + }, + "Bool", + "Bool", + "Int4", + "Int4", + "Int4", + "Int4", + "Int2", + "Bool", + "Jsonb", + "TextArray", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "310bc5d8444925455ac2ff0904f91f36d84fb4797caccc11ea74bb7bc9890854" +} diff --git a/backend/.sqlx/query-984092e2bb9e4b380d7200524b4e10fdf4013033b128fbc47b65e240da847dc3.json b/backend/.sqlx/query-984092e2bb9e4b380d7200524b4e10fdf4013033b128fbc47b65e240da847dc3.json new file mode 100644 index 0000000000..ab62dbe775 --- /dev/null +++ b/backend/.sqlx/query-984092e2bb9e4b380d7200524b4e10fdf4013033b128fbc47b65e240da847dc3.json @@ -0,0 +1,398 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n v2_job.id,\n v2_job.raw_code,\n v2_job.raw_lock,\n v2_job.raw_flow as \"raw_flow: _\",\n v2_job.tag,\n v2_job.created_at,\n v2_job.created_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job.kind as \"kind: _\",\n v2_job.runnable_id,\n v2_job.runnable_path,\n v2_job.parent_job,\n v2_job.root_job,\n v2_job.script_lang as \"script_lang: _\",\n v2_job.script_entrypoint_override,\n v2_job.flow_step,\n v2_job.flow_step_id,\n v2_job.flow_innermost_root_job,\n v2_job.\"trigger\",\n v2_job.trigger_kind as \"trigger_kind: _\",\n v2_job.same_worker,\n v2_job.visible_to_owner,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.cache_ttl,\n v2_job.timeout,\n v2_job.priority,\n v2_job.preprocessed,\n v2_job.args as \"args: _\",\n v2_job.labels,\n v2_job.job_token_scopes,\n v2_job.pre_run_error,\n\n v2_job_queue.started_at,\n v2_job_queue.scheduled_for,\n v2_job_queue.running,\n v2_job_queue.canceled_by,\n v2_job_queue.canceled_reason,\n v2_job_queue.suspend,\n v2_job_queue.suspend_until,\n v2_job_queue.worker,\n v2_job_queue.extras as \"extras: _\",\n\n v2_job_runtime.ping,\n v2_job_runtime.memory_peak,\n\n v2_job_status.flow_status as \"flow_status: _\",\n v2_job_status.flow_leaf_jobs as \"flow_leaf_jobs: _\",\n v2_job_status.workflow_as_code_status as \"workflow_as_code_status: _\",\n\n concurrency_key.key as \"concurrency_key?\"\n FROM v2_job_queue\n INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n LEFT JOIN v2_job_runtime ON v2_job_runtime.id = v2_job_queue.id\n LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id\n LEFT JOIN concurrency_key ON concurrency_key.job_id = v2_job_queue.id\n WHERE v2_job_queue.workspace_id = $1\n AND v2_job_queue.running = false\n AND v2_job.parent_job IS NULL\n AND v2_job.trigger_kind IS DISTINCT FROM 'schedule'\n ORDER BY v2_job.created_at DESC\n LIMIT $2\n OFFSET $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "raw_code", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "raw_lock", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "raw_flow: _", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "permissioned_as_email", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "kind: _", + "type_info": { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + } + }, + { + "ordinal": 10, + "name": "runnable_id", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "parent_job", + "type_info": "Uuid" + }, + { + "ordinal": 13, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 14, + "name": "script_lang: _", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 15, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "flow_step", + "type_info": "Int4" + }, + { + "ordinal": 17, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 18, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 19, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 20, + "name": "trigger_kind: _", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 21, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 22, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 23, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 24, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 26, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 28, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 29, + "name": "args: _", + "type_info": "Jsonb" + }, + { + "ordinal": 30, + "name": "labels", + "type_info": "TextArray" + }, + { + "ordinal": 31, + "name": "job_token_scopes", + "type_info": "TextArray" + }, + { + "ordinal": 32, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 33, + "name": "started_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 34, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 35, + "name": "running", + "type_info": "Bool" + }, + { + "ordinal": 36, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 37, + "name": "canceled_reason", + "type_info": "Text" + }, + { + "ordinal": 38, + "name": "suspend", + "type_info": "Int4" + }, + { + "ordinal": 39, + "name": "suspend_until", + "type_info": "Timestamptz" + }, + { + "ordinal": 40, + "name": "worker", + "type_info": "Varchar" + }, + { + "ordinal": 41, + "name": "extras: _", + "type_info": "Jsonb" + }, + { + "ordinal": 42, + "name": "ping", + "type_info": "Timestamptz" + }, + { + "ordinal": 43, + "name": "memory_peak", + "type_info": "Int4" + }, + { + "ordinal": 44, + "name": "flow_status: _", + "type_info": "Jsonb" + }, + { + "ordinal": 45, + "name": "flow_leaf_jobs: _", + "type_info": "Jsonb" + }, + { + "ordinal": 46, + "name": "workflow_as_code_status: _", + "type_info": "Jsonb" + }, + { + "ordinal": 47, + "name": "concurrency_key?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + true, + true, + true, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + false + ] + }, + "hash": "984092e2bb9e4b380d7200524b4e10fdf4013033b128fbc47b65e240da847dc3" +} diff --git a/backend/tests/job_token_scopes.rs b/backend/tests/job_token_scopes.rs index 4bd408524b..13838400a8 100644 --- a/backend/tests/job_token_scopes.rs +++ b/backend/tests/job_token_scopes.rs @@ -193,3 +193,62 @@ async fn test_flow_steps_inherit_the_flow_restriction(db: Pool) -> any assert_eq!(step, Some(vec!["oidc:write".to_string()])); Ok(()) } + +#[sqlx::test(fixtures("base"))] +async fn test_deploy_without_the_field_keeps_the_restriction( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base = format!("http://localhost:{}/api/w/test-workspace", server.addr.port()); + let client = reqwest::Client::new(); + let script = |path: &str, scopes: Option| { + let mut body = json!({ + "path": path, "summary": "", "description": "", "content": "echo 42", "language": "bash", + }); + if let Some(scopes) = scopes { + body["job_token_scopes"] = scopes; + } + body + }; + let stored = |path: &'static str| { + let db = db.clone(); + async move { + sqlx::query_scalar::<_, Option>>( + "SELECT job_token_scopes FROM script WHERE path = $1 ORDER BY created_at DESC LIMIT 1", + ) + .bind(path) + .fetch_one(&db) + .await + } + }; + + let resp = client + .post(format!("{base}/scripts/create")) + .bearer_auth("SECRET_TOKEN") + .json(&script("u/test-user/agent", Some(json!(["oidc:write"])))) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED, "{}", resp.text().await?); + + // A rename by a client that does not know the field carries the restriction along. + let resp = client + .post(format!("{base}/scripts/update/u/test-user/agent")) + .bearer_auth("SECRET_TOKEN") + .json(&script("u/test-user/renamed", None)) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + assert_eq!(stored("u/test-user/renamed").await?, Some(vec!["oidc:write".to_string()])); + + // An explicit null clears it. + let resp = client + .post(format!("{base}/scripts/update/u/test-user/renamed")) + .bearer_auth("SECRET_TOKEN") + .json(&script("u/test-user/renamed", Some(serde_json::Value::Null))) + .send() + .await?; + assert!(resp.status().is_success(), "{}", resp.text().await?); + assert_eq!(stored("u/test-user/renamed").await?, None); + Ok(()) +} diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 7f71c95ed8..ced8385ef5 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -106,7 +106,7 @@ pub struct ExpiringAuthCache { /// Whether `target` belongs to the flow run of the job `job_id`: the job itself, one of its /// ancestors, or a job whose parent or root is one of them (a sibling step, a loop or branch -/// iteration). A lookup that fails reads as no. +/// iteration, any job pushed as a child of one of them). A lookup that fails reads as no. async fn job_in_same_flow_run(db: &DB, job_id: uuid::Uuid, target: uuid::Uuid) -> bool { if job_id == target { return true; diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 0ab63ed373..1c3bf56ef9 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -747,7 +747,6 @@ pub fn scope_for_route(method: &str, path: &str) -> Option { }) } -/// Helper function to check if scopes allow access to a route /// Routes the runtime of a job calls about that job alone: progress, its root id, its /// resume and approval urls, a workflow-as-code checkpoint or task. A job token restricted /// by `job_token_scopes` keeps these whatever its scopes, or the job could not run at all, @@ -802,6 +801,7 @@ pub fn flow_run_read_route_job(route_path: &str, http_method: &str) -> Option, route_path: &str, diff --git a/backend/windmill-api-jobs/src/jobs_export.rs b/backend/windmill-api-jobs/src/jobs_export.rs index 6389134031..a9fba09d49 100644 --- a/backend/windmill-api-jobs/src/jobs_export.rs +++ b/backend/windmill-api-jobs/src/jobs_export.rs @@ -112,6 +112,8 @@ pub struct ExportableQueuedJob { pub preprocessed: Option, pub args: Option>>, pub labels: Option>, + #[serde(default)] + pub job_token_scopes: Option>, pub pre_run_error: Option, // v2_job_queue columns (excluding workspace_id and id/created_at/tag/priority) @@ -276,6 +278,7 @@ pub async fn export_queued_jobs( v2_job.preprocessed, v2_job.args as "args: _", v2_job.labels, + v2_job.job_token_scopes, v2_job.pre_run_error, v2_job_queue.started_at, @@ -456,12 +459,12 @@ pub async fn import_queued_jobs( parent_job, root_job, script_lang, script_entrypoint_override, flow_step, flow_step_id, flow_innermost_root_job, trigger, trigger_kind, same_worker, visible_to_owner, concurrent_limit, concurrency_time_window_s, cache_ttl, - timeout, priority, preprocessed, args, labels, pre_run_error + timeout, priority, preprocessed, args, labels, pre_run_error, job_token_scopes ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, - $31, $32, $33 + $31, $32, $33, $34 ) ON CONFLICT (id) DO NOTHING "#, @@ -498,6 +501,7 @@ pub async fn import_queued_jobs( job.args as _, job.labels as _, job.pre_run_error, + job.job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await?; diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 8a2ebf763f..348deaa034 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -1379,34 +1379,6 @@ async fn create_script_internal<'c>( &db, ) .await?; - // Absent keeps what the previous version has (the parent, or else the latest at the path): - // a client unaware of the setting must not drop a restriction by redeploying or renaming. - let resolved_job_token_scopes = match (&ns.job_token_scopes, &ns.parent_hash) { - (Some(Some(scopes)), _) => { - windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES - .assert() - .await?; - Some(windmill_common::scopes::validate_job_token_scopes(scopes)?) - } - (Some(None), _) => None, - (None, Some(parent_hash)) => sqlx::query_scalar!( - "SELECT job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", - parent_hash.0, - &w_id - ) - .fetch_optional(&db) - .await? - .flatten(), - (None, None) => sqlx::query_scalar!( - "SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 \ - AND deleted = false ORDER BY created_at DESC LIMIT 1", - &ns.path, - &w_id - ) - .fetch_optional(&db) - .await? - .flatten(), - }; // Written beside the principal only while a worker that still reads it may be live. let legacy_on_behalf_of_email = windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db) @@ -1524,6 +1496,37 @@ async fn create_script_internal<'c>( parent_adopted_from_retired_path = ns.parent_hash.is_some(); } + // Absent keeps the previous version's value, read once the parent is settled (a rename + // adopts its source head above), so a client unaware of the setting cannot drop a + // restriction by redeploying or renaming. + let resolved_job_token_scopes: Option> = match (&ns.job_token_scopes, &ns.parent_hash) { + (Some(Some(scopes)), _) => { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + Some(windmill_common::scopes::validate_job_token_scopes(scopes)?) + } + (Some(None), _) => None, + (None, Some(parent_hash)) => sqlx::query_scalar!( + "SELECT job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", + parent_hash.0, + &w_id + ) + .fetch_optional(&db) + .await? + .flatten(), + (None, None) => sqlx::query_scalar!( + "SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 \ + AND deleted = false ORDER BY created_at DESC LIMIT 1", + &ns.path, + &w_id + ) + .fetch_optional(&db) + .await? + .flatten(), + }; + ns.job_token_scopes = Some(resolved_job_token_scopes.clone()); + // Before hashing, so the hash and the no-op check see the schema that gets stored. // `{}` counts as absent: an agent filling every tool argument sends it for "none". let schema_absent = ns.schema.as_ref().is_none_or(|s| { diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 1bf89455a9..4379dc5808 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -9620,7 +9620,8 @@ async fn add_batch_jobs( let language = language.unwrap_or(ScriptLang::Deno); let tag = if let Some(dedicated_worker) = dedicated_worker { - if dedicated_worker && path.is_some() { + // Same rule as `push`: a dedicated worker would run it with its own unscoped token. + if dedicated_worker && path.is_some() && job_token_scopes.is_none() { windmill_common::worker::dedicated_worker_tag(&w_id, &path.clone().unwrap()) } else { format!("{}", language.as_str()) diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 25912d6ff7..1a75a49869 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -74,7 +74,7 @@ use windmill_common::{ add_virtual_items_if_necessary, FlowModule, FlowModuleValue, FlowValue, InputTransform, Retry, StopAfterIf, }, - jobs::{get_payload_tag_from_prefixed_path, JobKind, JobPayload, QueuedJob, RawCode}, + jobs::{get_payload_tag_from_prefixed_path, JobKind, JobPayload, RawCode}, min_version::{MIN_VERSION_IS_AT_LEAST_1_432, MIN_VERSION_IS_AT_LEAST_1_440}, schedule::Schedule, scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang}, @@ -3857,49 +3857,6 @@ impl MiniPulledJob { .and_then(|f| f.chat_input_enabled) } - pub fn from(job: &QueuedJob) -> MiniPulledJob { - MiniPulledJob { - workspace_id: job.workspace_id.clone(), - id: job.id, - args: job.args.clone(), - parent_job: job.parent_job.clone(), - created_by: job.created_by.clone(), - started_at: job.started_at.clone(), - scheduled_for: job.scheduled_for, - runnable_path: job.script_path.clone(), - kind: job.job_kind, - runnable_id: job.script_hash.clone(), - canceled_reason: job.canceled_reason.clone(), - canceled_by: job.canceled_by.clone(), - permissioned_as: job.permissioned_as.clone(), - permissioned_as_email: job.email.clone(), - flow_status: job.flow_status.clone(), - tag: job.tag.clone(), - script_lang: job.language.clone(), - same_worker: job.same_worker, - pre_run_error: job.pre_run_error.clone(), - concurrent_limit: job.concurrent_limit.clone(), - concurrency_time_window_s: job.concurrency_time_window_s.clone(), - runnable_settings_handle: job.runnable_settings_handle, - flow_innermost_root_job: job.root_job.clone(), // QueuedJob is taken from v2_as_queue, where root_job corresponds to flow_innermost_root_job in v2_job - root_job: None, - timeout: job.timeout.clone(), - flow_step_id: job.flow_step_id.clone(), - cache_ttl: job.cache_ttl.clone(), - cache_ignore_s3_path: job.cache_ignore_s3_path.clone(), - priority: job.priority.clone(), - preprocessed: job.preprocessed.clone(), - script_entrypoint_override: job.script_entrypoint_override.clone(), - trigger: job.schedule_path.clone(), - trigger_kind: job - .schedule_path - .is_some() - .then(|| JobTriggerKind::Schedule.into()), - visible_to_owner: job.visible_to_owner.clone(), - permissioned_as_end_user_email: None, - job_token_scopes: None, - } - } pub fn is_flow(&self) -> bool { self.kind.is_flow() } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 6b51073b84..0f6c022edd 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -3641,7 +3641,11 @@ pub async fn run_worker( NextJob::Http(_) => None, }; - if let Some(flow_runners) = flow_runners.filter(|_| !fails_before_running) { + // A flow runner runs every step it gets with its own unscoped worker token, so + // a step with a restricted token runs here, with the token minted for it. + if let Some(flow_runners) = flow_runners + .filter(|_| !fails_before_running && job.job_token_scopes.is_none()) + { let key_o = job.flow_step_id.as_ref().map(|x| x.to_string()); if let Some(key) = key_o { if let Some(flow_runner_tx) = flow_runners.runners.get(&key) { diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index c53e9736d2..2bb1db3756 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -4328,7 +4328,10 @@ async fn push_next_flow_job( }; // only start runners if we're not already in a squash for loop + // Runners would run its steps with their own unscoped token: a restricted flow runs them + // as regular jobs. let start_runners = flow_runners.is_none() + && flow_job.job_token_scopes.is_none() && matches!( next_status, NextStatus::NextLoopIteration { start_runners: true, .. } diff --git a/cli/windmill-utils-internal/src/deploy.ts b/cli/windmill-utils-internal/src/deploy.ts index d54ff5720e..71447c6b1f 100644 --- a/cli/windmill-utils-internal/src/deploy.ts +++ b/cli/windmill-utils-internal/src/deploy.ts @@ -458,6 +458,8 @@ export async function deployItem( path, requestBody: { ...flow, + // Sent even when unset: the backend keeps a restriction an omitted field leaves out. + job_token_scopes: flow.job_token_scopes ?? null, preserve_on_behalf_of: preserveOnBehalfOf, on_behalf_of_email: onBehalfOf, // Usernames are per-workspace, so the source's principal names nobody in @@ -471,6 +473,8 @@ export async function deployItem( workspace: workspaceTo, requestBody: { ...flow, + // Sent even when unset: the backend keeps a restriction an omitted field leaves out. + job_token_scopes: flow.job_token_scopes ?? null, preserve_on_behalf_of: preserveOnBehalfOf, on_behalf_of_email: onBehalfOf, // Usernames are per-workspace, so the source's principal names nobody in @@ -498,6 +502,7 @@ export async function deployItem( requestBody: { ...script, lock: script.lock, + job_token_scopes: script.job_token_scopes ?? null, parent_hash: parentHash, preserve_on_behalf_of: preserveOnBehalfOf, on_behalf_of_email: onBehalfOf, diff --git a/frontend/src/lib/components/JobTokenScopesSetting.svelte b/frontend/src/lib/components/JobTokenScopesSetting.svelte index 1f6d3df065..22ee6ce7cb 100644 --- a/frontend/src/lib/components/JobTokenScopesSetting.svelte +++ b/frontend/src/lib/components/JobTokenScopesSetting.svelte @@ -65,7 +65,8 @@ The WM_TOKEN of every job of this {kind} can only do what these scopes allow. Jobs it starts, {kind === 'flow' ? 'its steps, ' : ''}and AI agent tools inherit the restriction. Variables and resources passed as $var:/$res: inputs, an AI agent's provider - resource, relative imports and object storage each need a read scope. Write scopes on scripts, + resource, relative imports and object storage each need a read scope, and script state + (getState/setState) needs resources:write. Write scopes on scripts, flows, schedules or triggers let a job escape the restriction. Jobs with a restricted token never run on dedicated workers.

diff --git a/frontend/src/lib/components/scripts/WacExportDrawer.svelte b/frontend/src/lib/components/scripts/WacExportDrawer.svelte index 59f1dca1b0..ee2c402216 100644 --- a/frontend/src/lib/components/scripts/WacExportDrawer.svelte +++ b/frontend/src/lib/components/scripts/WacExportDrawer.svelte @@ -46,6 +46,7 @@ if (script.visible_to_runner_only) exportData.visible_to_runner_only = script.visible_to_runner_only if (script.auto_kind) exportData.auto_kind = script.auto_kind if (script.has_preprocessor) exportData.has_preprocessor = script.has_preprocessor + if (script.job_token_scopes != null) exportData.job_token_scopes = script.job_token_scopes if (script.modules && Object.keys(script.modules).length > 0) exportData.modules = script.modules scriptData = exportData