diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index 041885369c..3c09185650 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -596,6 +596,17 @@ fn map_http_method_to_action(method: &str, route_path: &str) -> ScopeAction { /// Returns `"flows"` or `"scripts"` based on the match, or `None` if no match is found. fn determine_kind_from_route(route_path: &str) -> Option { if route_path.starts_with("jobs") { + // Preview/bundle runs execute arbitrary code with no deployed path, so + // their handlers require the broad `jobs:run` scope: they must carry no + // kind, else the derived scope is narrower than the handler demands. + // Anchor to the endpoint segment so by-path runs of a deployed runnable + // whose path contains "preview" (e.g. `run/p/f/team/preview_report`) are + // still classified by their kind. + if route_path.starts_with("jobs/run/preview") + || route_path.starts_with("jobs/run_wait_result/preview") + { + return None; + } if FLOW_JOBS.iter().any(|path| route_path.starts_with(path)) { return Some("flows".to_string()); } else if SCRIPT_JOBS.iter().any(|path| route_path.starts_with(path)) { @@ -1296,6 +1307,42 @@ mod tests { Some("jobs:run:flows") ); + // Preview/bundle runs have no deployed path and their handlers require the + // broad `jobs:run` scope, so the derived scope must not carry a kind. + for path in [ + "/api/w/ws/jobs/run/preview", + "/api/w/ws/jobs/run/preview_bundle", + "/api/w/ws/jobs/run/preview_flow", + "/api/w/ws/jobs/run_wait_result/preview", + "/api/w/ws/jobs/run_wait_result/preview_flow", + ] { + assert_eq!( + scope_for_route("POST", path).as_deref(), + Some("jobs:run"), + "preview route {path} must derive the broad jobs:run scope" + ); + } + + // By-path runs of a deployed runnable whose path contains "preview" must + // still derive their kind (not be swept into the broad jobs:run above), + // otherwise a `jobs:run:scripts:*`/`jobs:run:flows:*` token is denied. + assert_eq!( + scope_for_route("POST", "/api/w/ws/jobs/run/p/u/alice/preview_report").as_deref(), + Some("jobs:run:scripts") + ); + assert_eq!( + scope_for_route( + "POST", + "/api/w/ws/jobs/run_wait_result/p/f/team/preview_report" + ) + .as_deref(), + Some("jobs:run:scripts") + ); + assert_eq!( + scope_for_route("POST", "/api/w/ws/jobs/run/f/f/team/preview_report").as_deref(), + Some("jobs:run:flows") + ); + // The minted scope actually satisfies the route check it targets. let s = scope_for_route("POST", "/api/w/ws/variables/create").unwrap(); assert!(check_route_access(&[s], "/api/w/ws/variables/create", "POST").is_ok());