From 87a1d7ea1455d991174b566063f178605027bb7f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 2 Oct 2026 21:21:06 +0200 Subject: [PATCH] fix: validate step scopes in batch flows, fail closed on unvalidated step scopes Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL --- backend/windmill-api/src/jobs.rs | 5 +++++ backend/windmill-common/src/scopes.rs | 12 ++++++++++++ backend/windmill-queue/src/jobs.rs | 2 +- backend/windmill-worker/src/ai/tools.rs | 5 ++++- backend/windmill-worker/src/worker_flow.rs | 5 ++++- 5 files changed, 26 insertions(+), 3 deletions(-) diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 26478c7e9d..2f0c8a3400 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -9746,6 +9746,11 @@ async fn add_batch_jobs( "Path is required if no value is not provided" ))? }; + if windmill_common::scopes::validate_flow_step_job_token_scopes(&value)? { + windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES + .assert() + .await?; + } add_virtual_items_if_necessary(&mut value.modules); let flow_status = FlowStatus::new(&value); ( diff --git a/backend/windmill-common/src/scopes.rs b/backend/windmill-common/src/scopes.rs index d4420defe7..35f4497fac 100644 --- a/backend/windmill-common/src/scopes.rs +++ b/backend/windmill-common/src/scopes.rs @@ -664,6 +664,13 @@ pub fn log_job_token_scopes_deploy(kind: &'static str, scopes: Option<&[String]> ); } +/// The cap a flow step or agent tool's own `job_token_scopes` puts on its jobs. A definition +/// can reach the worker without the deploy-time validation (a raw flow), so a setting that +/// does not validate restricts the step to no API access rather than being trusted as is. +pub fn step_job_token_scopes(scopes: Option<&[String]>) -> Option> { + scopes.map(|s| validate_job_token_scopes(s).unwrap_or_default()) +} + /// The `scopes` claim of a job token minted from the job's effective scopes. pub fn job_token_jwt_scopes(effective: Option>) -> Option> { match effective { @@ -732,5 +739,10 @@ mod job_token_scopes_tests { validate_job_token_scopes(&v(&["oidc:write", " oidc:write"])).unwrap(), v(&["oidc:write"]) ); + // A step setting that skipped deploy validation restricts rather than widens. + assert_eq!( + step_job_token_scopes(Some(&v(&["if_jobs:filter_tags:bun"]))), + Some(vec![]) + ); } } diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 6a2ba70563..ca20d13570 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -3741,7 +3741,7 @@ impl From for MiniCompletedJob { cache_ttl: job.cache_ttl, cache_ignore_s3_path: job.cache_ignore_s3_path, runnable_settings_handle: job.runnable_settings_handle, - // `QueuedJobV2` carries no args; a dependency job never completes through here. + // `QueuedJobV2` carries no args, which is what marks a binary-build job. build_binary_only: false, // Nor scopes: a caller whose completion can re-run the job (the monitor's zombie // recovery) fills them in from `job_perms` while the job is still queued. diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 0e90b081ec..9e50ddd6c0 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -603,7 +603,10 @@ async fn enqueue_windmill_tool( // narrows that further. windmill_common::scopes::intersect_job_token_scopes( ctx.job.job_token_scopes.as_deref(), - tool_module.job_token_scopes.as_deref(), + windmill_common::scopes::step_job_token_scopes( + tool_module.job_token_scopes.as_deref(), + ) + .as_deref(), ) .as_deref(), ) diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index dedae9c5ac..445b9af720 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -4756,7 +4756,10 @@ async fn push_next_flow_job( // narrows that further. windmill_common::scopes::intersect_job_token_scopes( flow_job.job_token_scopes.as_deref(), - module.job_token_scopes.as_deref(), + windmill_common::scopes::step_job_token_scopes( + module.job_token_scopes.as_deref(), + ) + .as_deref(), ) .as_deref(), )