diff --git a/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json b/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json new file mode 100644 index 0000000000..557466e8b7 --- /dev/null +++ b/backend/.sqlx/query-84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH RECURSIVE chain(id, parent_job) AS (\n SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT j.id, j.parent_job FROM v2_job j\n JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2\n )\n SELECT j.runnable_path,\n CASE\n WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'\n WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'\n WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN\n CASE WHEN COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' IN ('a', 'main')\n LIMIT 1),\n 'script'\n ) = 'flow' THEN 'flows' ELSE 'scripts' END\n END AS scope_kind,\n CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app\n FROM v2_job j JOIN chain c ON c.id = j.id\n WHERE j.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "runnable_path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "scope_kind", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "launched_by_app", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + true, + null, + null + ] + }, + "hash": "84fcddaf5bc61d607a6e6e5e31de7436b203a3baa7ef0509cb8e6c52270ae3a9" +} diff --git a/backend/tests/fixtures/jobs_read_auth.sql b/backend/tests/fixtures/jobs_read_auth.sql index 03ee69b1f4..d7d11228aa 100644 --- a/backend/tests/fixtures/jobs_read_auth.sql +++ b/backend/tests/fixtures/jobs_read_auth.sql @@ -19,6 +19,76 @@ INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, sc ARRAY['jobs:read', 'if_jobs:filter_tags:deno'] ); +-- A path-scoped run token for test-user-2, as the trigger UI mints per runnable for a +-- webhook caller. test-user-2 created every job this token is asserted against, so the +-- `created_by` grant would otherwise hand it all of them; it must reach only jobs of +-- `f/shared/flow1`. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES ( + encode(sha256('RUN_SCOPED_TOKEN'::bytea), 'hex'), 'RUN_SCOPE', 'RUN_SCOPED_TOKEN', + 'test2@windmill.dev', 'flow webhook token', false, + ARRAY['jobs:run:flows:f/shared/flow1'] +); + +-- Same, scoped to a script. The two jobs below both run through a `singlestepflow` +-- wrapper (native retry / scheduled runs produce these) — one wrapping a script, one +-- wrapping a flow — so the confinement has to project each onto the runnable it wraps +-- rather than onto the wrapper's own `kind`. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES ( + encode(sha256('RUN_SCOPED_SCRIPT_TOKEN'::bytea), 'hex'), 'RUN_SCRIP', 'RUN_SCOPED_SCRIPT_TOKEN', + 'test2@windmill.dev', 'script webhook token', false, + ARRAY['jobs:run:scripts:u/test-user-2/wrapped_script'] +); + +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, raw_flow +) VALUES ( + '14141414-1414-1414-1414-141414141414', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'singlestepflow', 'deno', 'u/test-user-2/wrapped_script', 'deno', true, + '{"modules": [{"id": "a", "value": {"type": "script", "path": "u/test-user-2/wrapped_script"}}]}' +); +INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES + ('14141414-1414-1414-1414-141414141414', 'test-workspace', 1000, 'success'::job_status, + '{"wrapped": "WRAPPED_RESULT"}'); + +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, raw_flow +) VALUES ( + '15151515-1515-1515-1515-151515151515', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'singlestepflow', 'deno', 'f/shared/flow1', 'flow', true, + '{"modules": [{"id": "a", "value": {"type": "flow", "path": "f/shared/flow1"}}]}' +); +INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES + ('15151515-1515-1515-1515-151515151515', 'test-workspace', 1000, 'success'::job_status, + '{"wrapped": "WRAPPED_FLOW_RESULT"}'); + +-- A token pairing an app scope with a run scope, as someone driving an app's components +-- programmatically would build. `APP_INLINE_JOB` is an inline-script component run: no +-- `jobs:run` scope can name its kind, so only the `apps:run` half puts it in reach. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES ( + encode(sha256('APP_RUNNER_TOKEN'::bytea), 'hex'), 'APP_RUNNE', 'APP_RUNNER_TOKEN', + 'test2@windmill.dev', 'app runner token', false, + ARRAY['apps:run:u/test-user-2/dash', 'jobs:run:scripts:u/test-user-2/wrapped_script'] +); + +-- An inline-script component run of app `u/test-user-2/dash`, stamped with the +-- app provenance `execute_component` sets (`trigger_kind = 'app'`). +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, trigger_kind, trigger, args +) VALUES ( + '16161616-1616-1616-1616-161616161616', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'appscript', 'deno', NULL, 'deno', false, 'app', 'u/test-user-2/dash', + '{"component": "arg"}' +); +INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES + ('16161616-1616-1616-1616-161616161616', 'test-workspace', 1000, 'success'::job_status, + '{"inline": "APP_INLINE_RESULT"}'); + -- App embed token for the admin viewer (test-user). Mirrors a minted sandboxed -- low-code app token: carries the `app_embed` sentinel plus the embed scope set. -- Used to assert the token is confined to jobs the viewer LAUNCHED, not every job diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index 317b1c6866..c8c154c250 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -46,6 +46,12 @@ const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777"; const EMBED_OWN_JOB: &str = "12121212-1212-1212-1212-121212121212"; // A QUEUED job launched by the embed viewer (created_by test-user) — cancelable by it. const EMBED_OWN_QUEUED: &str = "13131313-1313-1313-1313-131313131313"; +// `singlestepflow` wrappers (as native retry / scheduled runs produce), one around a +// SCRIPT and one around a FLOW. +const WRAPPED_JOB: &str = "14141414-1414-1414-1414-141414141414"; +const WRAPPED_FLOW_JOB: &str = "15151515-1515-1515-1515-151515151515"; +// An inline-script component run of app `u/test-user-2/dash` (`trigger_kind = 'app'`). +const APP_INLINE_JOB: &str = "16161616-1616-1616-1616-161616161616"; // Queued sub-flow test-user-3 can see (folder `shared`), whose parent top flow they // cannot. Force cancel walks up to that parent. const QUEUED_VISIBLE_MID: &str = "55555555-5555-5555-5555-555555555555"; @@ -380,6 +386,160 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul } } + // ---- PATH-SCOPED RUN TOKEN: confined to jobs of the runnable it may start. + // RUN_SCOPED_TOKEN is test-user-2's `jobs:run:flows:f/shared/flow1` webhook + // token, and test-user-2 created every job asserted on below — so `created_by` + // alone would hand it all of them. + // Its own flow run reads, and so do the steps beneath it: a step's `runnable_path` + // is the inner script's, so the scope has to be satisfied through the ancestor. + for (path, expected) in [ + ( + format!("completed/get_result/{FLOW_JOB}"), + r#""flow": "done""#, + ), + ( + format!("completed/get_result/{STEP_JOB}"), + "STEP_RESULT_INHERITED", + ), + ] { + let (status, body) = get(&base, &path, Some("RUN_SCOPED_TOKEN")).await; + assert!( + status.is_success(), + "run-scoped token must read its own flow run ({path}, got {status}): {body}" + ); + assert!( + body.contains(expected), + "run-scoped token should get {expected} for {path}: {body}" + ); + } + // A job of any other runnable is out of scope, even though the same user created it. + for path in [ + format!("completed/get_result/{VICTIM}"), + format!("get_args/{VICTIM}"), + format!("get_logs/{VICTIM}"), + format!("getupdate/{VICTIM}?only_result=true"), + ] { + let (status, body) = get(&base, &path, Some("RUN_SCOPED_TOKEN")).await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "run-scoped token must not read a job outside its scope ({path}, got {status}): {body}" + ); + for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] { + assert!( + !body.contains(secret), + "run-scoped token response for {path} leaked `{secret}`: {body}" + ); + } + } + // A `singlestepflow` wrapper (native retry / scheduled run) belongs to the runnable + // it wraps, not to the flow domain its `kind` suggests. Each wrapper is readable by + // the token scoped to the wrapped kind, and only by that one. + for (job, reader, denied) in [ + (WRAPPED_JOB, "RUN_SCOPED_SCRIPT_TOKEN", "RUN_SCOPED_TOKEN"), + ( + WRAPPED_FLOW_JOB, + "RUN_SCOPED_TOKEN", + "RUN_SCOPED_SCRIPT_TOKEN", + ), + ] { + let (status, body) = get(&base, &format!("completed/get_result/{job}"), Some(reader)).await; + assert!( + status.is_success() && body.contains("WRAPPED"), + "{reader} must read the singlestepflow wrapping its runnable (got {status}): {body}" + ); + let (status, body) = get(&base, &format!("completed/get_result/{job}"), Some(denied)).await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "{denied} must not read a wrapper around the other kind (got {status}): {body}" + ); + } + + // An `apps:run:` scope is a start grant too: the inline-script component run it + // launched — a kind no `jobs:run` scope can name — stays readable to a token scoped + // to that app, and stays out of reach for one that is only scoped to run jobs. + let (status, body) = get( + &base, + &format!("completed/get_result/{APP_INLINE_JOB}"), + Some("APP_RUNNER_TOKEN"), + ) + .await; + assert!( + status.is_success() && body.contains("APP_INLINE_RESULT"), + "app-scoped token must read the component run its app launched (got {status}): {body}" + ); + let (status, body) = get( + &base, + &format!("completed/get_result/{APP_INLINE_JOB}"), + Some("RUN_SCOPED_SCRIPT_TOKEN"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "a token with no scope on the app must not read its component run (got {status}): {body}" + ); + + // An approval link is a bypass of the read gate, so the confinement is re-applied on + // top of it: it must not become a way for a scoped token to read an out-of-scope job. + // The link itself is untouched — a logged-out approver still reads the same job. + let approval_token = + windmill_common::variables::generate_approval_token("test-workspace", VICTIM.parse()?, &db) + .await?; + let (status, body) = get( + &base, + &format!("get/{VICTIM}?approval_token={approval_token}"), + None, + ) + .await; + assert!( + status.is_success(), + "an approval link must still authorize a logged-out read (got {status}): {body}" + ); + let (status, body) = get( + &base, + &format!("get/{VICTIM}?approval_token={approval_token}"), + Some("RUN_SCOPED_TOKEN"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "an approval link must not lift the run-scope confinement (got {status}): {body}" + ); + + // Same for the resume-secret bypass on the result route, which the approval page uses. + let (status, secret) = get( + &authed_base, + &format!("job_signature/{STEP_JOB}/0"), + Some("SECRET_TOKEN_2"), + ) + .await; + assert!(status.is_success(), "owner must mint a resume secret: {secret}"); + let secret = secret.trim().trim_matches('"').to_string(); + let approval_result = + format!("completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}"); + let (status, body) = get(&base, &approval_result, None).await; + assert!( + status.is_success(), + "a resume secret must still authorize a logged-out result read (got {status}): {body}" + ); + let (status, body) = get(&base, &approval_result, Some("RUN_SCOPED_SCRIPT_TOKEN")).await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "a resume secret must not lift the run-scope confinement (got {status}): {body}" + ); + + // And a run grant is not an enumeration grant: the whole listing surface is denied. + let (status, body) = get(&authed_base, "list", Some("RUN_SCOPED_TOKEN")).await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "run-scoped token must not enumerate jobs (got {status}): {body}" + ); + // ---- APP EMBED TOKEN: cancellation confined to the app's own jobs. The token // may cancel a job it launched (created_by == viewer), but `cancel_job_api` // denies (NotFound) a job created by someone else, even one the (admin) diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index fb3da99ad3..26dd523294 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -692,27 +692,49 @@ fn extract_domain_from_route( ))) } -const RUN_WHITELISTED_GET_PATHS: [&'static str; 20] = [ +/// The reads a `jobs:run` scope implies: following, by id, a run the token started. +/// Every entry is keyed by a job id and confines an authenticated caller to its own +/// runnable — through `require_job_read_access`, through its own `jobs:run:flows:` +/// check, or, where an approval token or resume secret bypasses that gate, through a +/// direct `require_job_within_run_scope`. The one exception is +/// `jobs_u/get_root_job_id/`, which has no check at all but discloses only flow lineage, +/// to anyone, authenticated or not. Workspace-wide enumeration (`jobs/list`, counts, +/// exports) and credential minting (`job_view_token`) are deliberately absent — those are +/// `jobs:read`. Keep by-id read routes here in sync as they are added, or a run token +/// loses the ability to follow its own run through them. +const RUN_WHITELISTED_GET_PATHS: [&'static str; 32] = [ "jobs_u/get_flow/", "jobs_u/get_root_job_id/", "jobs_u/get/", "jobs_u/get_logs/", + "jobs_u/get_completed_logs_tail/", "jobs_u/get_flow_all_logs/", + "jobs_u/get_flow_all_logs_structured/", + "jobs_u/get_flow_all_results/", "jobs_u/get_args/", "jobs_u/get_flow_debug_info/", "jobs_u/completed/get/", "jobs_u/completed/get_result/", "jobs_u/completed/get_result_maybe/", + "jobs_u/completed/get_timing/", + "jobs_u/dispatch_events/", "jobs_u/getupdate/", "jobs_u/getupdate_sse/", "jobs_u/get_log_file/", + "jobs/run_progress/", + "jobs/dbt_graph/", + "jobs/dbt_resumable/", + "jobs/dbt_resumable_script/p/", "jobs/result_by_id/", "jobs/resume_urls/", "jobs/flow/user_states/", "jobs/job_signature/", + "jobs/wac_approval_urls/", "jobs/completed/get/", "jobs/completed/get_result/", "jobs/completed/get_result_maybe/", + "jobs/completed/get_timing/", + "jobs/get_otel_traces/", ]; /// Sentinel scope in app embed tokens. Grants nothing itself; `check_route_access` @@ -829,6 +851,60 @@ fn resource_metadata_route_allowed(suffix: &str) -> bool { || suffix.starts_with("resources/type/") } +/// The `jobs:run` scopes a token's job reads are confined to, or `None` when they are +/// not confined to particular runnables. +/// +/// A run scope is what the trigger UI mints per script or flow and hands to a webhook +/// caller / CI job: it may start the runnables it names and follow those runs, so its +/// by-id job reads must stay within what it can start (enforced by +/// `require_job_read_access`). Both the path (`jobs:run:flows:f/team/etl`) and the +/// kind-only (`jobs:run:scripts`, which legacy `jobs:runscript` tokens carry) forms +/// confine, since `ScopeDefinition::includes` already matches a candidate +/// `jobs:run::` against either. +/// +/// Returns `None` — unconfined — when the token is effectively unscoped, or carries a +/// jobs scope that grants job reads in its own right: `jobs:read`/`jobs:write`, or a +/// bare `jobs:run` (it can start anything, so confining its reads to "what it may run" +/// would restrict nothing). +pub fn job_read_run_confinement(scopes: Option<&[String]>) -> Option> { + let mut confinement = Vec::new(); + for scope in scopes? + .iter() + .filter(|s| !s.starts_with("if_jobs:filter_tags:")) + { + let Ok(scope) = ScopeDefinition::from_scope_string(scope) else { + continue; + }; + if ScopeDomain::from_str(&scope.domain) != Some(ScopeDomain::Jobs) { + continue; + } + match ScopeAction::from_str(&scope.action) { + Some(ScopeAction::Run) if scope.kind.is_some() || scope.resource.is_some() => { + confinement.push(scope) + } + Some(_) => return None, + None => continue, + } + } + (!confinement.is_empty()).then_some(confinement) +} + +/// Whether a job that ran `runnable_path` as `kind` (`scripts` or `flows`) is inside a +/// [`job_read_run_confinement`] set. +pub fn run_confinement_admits( + confinement: &[ScopeDefinition], + kind: &str, + runnable_path: &str, +) -> bool { + let required = ScopeDefinition::new( + ScopeDomain::Jobs.as_str(), + ScopeAction::Run.as_str(), + Some(kind), + Some(vec![runnable_path.to_string()]), + ); + confinement.iter().any(|scope| scope.includes(&required)) +} + fn scope_grants_access( scope: &ScopeDefinition, required_domain: ScopeDomain, @@ -865,15 +941,26 @@ fn scope_grants_access( return Ok(true); } - if !scope_action.includes(&required_action) - && !(scope_domain == ScopeDomain::Jobs - && required_action == ScopeAction::Read - && route_path.is_some_and(|p| { - RUN_WHITELISTED_GET_PATHS - .iter() - .any(|path| p.starts_with(path)) - })) + // `jobs:run` is a grant to *start* a runnable. The only reads it implies are the + // by-id routes a caller needs to follow the run it started + // (`RUN_WHITELISTED_GET_PATHS`) — never workspace-wide enumeration (`jobs/list`, + // counts, exports), which is what `jobs:read` is for. Those by-id reads are in turn + // confined to the runnable a path-scoped token names, by `require_job_read_access`. + // `ScopeAction::Run.includes(&Read)` (which exists so `apps:run` can fetch the app + // it runs) must not reach this domain, so decide it here rather than falling + // through to the hierarchy below. + if scope_domain == ScopeDomain::Jobs + && scope_action == ScopeAction::Run + && required_action == ScopeAction::Read { + return Ok(route_path.is_some_and(|p| { + RUN_WHITELISTED_GET_PATHS + .iter() + .any(|path| p.starts_with(path)) + })); + } + + if !scope_action.includes(&required_action) { return Ok(false); } @@ -1102,6 +1189,65 @@ mod tests { .is_err()); } + #[test] + fn jobs_run_reads_are_limited_to_the_by_id_poll_routes() { + let job = "/api/w/test/jobs_u/completed/get_result/019ff012-6b1e-0d6b-fc0d-0c85d34d9cec"; + let list = "/api/w/test/jobs/list"; + for scope in ["jobs:run", "jobs:run:scripts:u/admin/script"] { + // Following the run it started stays available... + assert!( + check_route_access(&[scope.to_string()], job, "GET").is_ok(), + "{scope} must reach the by-id job poll routes" + ); + // ...but a run grant is not a licence to enumerate the workspace's jobs. + assert!( + check_route_access(&[scope.to_string()], list, "GET").is_err(), + "{scope} must not reach jobs/list" + ); + } + assert!(check_route_access(&["jobs:read".to_string()], list, "GET").is_ok()); + } + + #[test] + fn run_scopes_confine_job_reads_by_kind_and_path() { + let confinement = + job_read_run_confinement(Some(&["jobs:run:flows:f/team/*".to_string()])).unwrap(); + assert!(run_confinement_admits(&confinement, "flows", "f/team/etl")); + // Right path, wrong kind — a script named like the flow is not the flow. + assert!(!run_confinement_admits( + &confinement, + "scripts", + "f/team/etl" + )); + assert!(!run_confinement_admits( + &confinement, + "flows", + "f/other/etl" + )); + + // A kind-only scope confines to that kind, at any path. + let kind_only = job_read_run_confinement(Some(&["jobs:run:scripts".to_string()])).unwrap(); + assert!(run_confinement_admits(&kind_only, "scripts", "u/admin/anything")); + assert!(!run_confinement_admits(&kind_only, "flows", "f/team/etl")); + + // Scopes that grant job reads in their own right leave reads unconfined. + for scopes in [ + vec!["jobs:read".to_string()], + vec!["jobs:run".to_string()], + vec![ + "jobs:run:scripts:u/admin/script".to_string(), + "jobs:read".to_string(), + ], + vec!["if_jobs:filter_tags:deno".to_string()], + ] { + assert!( + job_read_run_confinement(Some(&scopes)).is_none(), + "{scopes:?} must not confine job reads" + ); + } + assert!(job_read_run_confinement(None).is_none()); + } + #[test] fn test_new_domain_parsing() { // Test that new domains are properly parsed diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index acfdc6639b..b79e0b8b14 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -78,7 +78,7 @@ use crate::{ users::{ get_scope_tags, require_owner_of_path, require_path_read_access_for_preview, OptAuthed, }, - utils::{check_scopes, content_plain, require_super_admin}, + utils::{build_scope_path_predicate, check_scopes, content_plain, require_super_admin}, }; use anyhow::Context; use axum::{ @@ -1458,6 +1458,11 @@ pub(crate) async fn require_job_read_access( } } + // A path-scoped `jobs:run` token is likewise hard-restricted to the runnables it + // may start, ahead of every grant below — the token is handed out to run one thing, + // so it must not read jobs of anything else merely because its owner could. + require_job_within_run_scope(db, authed, w_id, job_id).await?; + // Fast path: you can always read a job you launched. This is also load-bearing // for apps — a component job runs as the app policy's `permissioned_as`, but its // `created_by` is the launching viewer, so the RLS probe below would hide it. @@ -1583,6 +1588,94 @@ pub(crate) async fn require_job_read_access( } } +/// Confines a path-scoped `jobs:run::` token to jobs of the runnables it may +/// start. Such a token is minted per script/flow for a webhook or CI caller, which needs +/// to start that runnable and poll the resulting job — nothing more. Without this, the +/// by-id read routes it reaches for polling (`RUN_WHITELISTED_GET_PATHS`) would serve +/// it the args/result/logs of any job its owner's identity can see, defeating the path +/// confinement the scope exists to provide. +/// +/// The scope may be satisfied by the job itself or by any of its `parent_job` ancestors: +/// a flow step's `runnable_path` is the inner runnable's, so a `jobs:run:flows:` +/// token inspecting its own run must still reach the steps beneath it. +/// +/// An `apps:run|write:` scope is a start grant too, so a job an app launched +/// (`trigger_kind = 'app'`, an app-provenance stamp `/jobs/run` cannot forge) satisfies +/// the confinement for a token scoped to that app. Without this, a token holding both +/// could start an app's inline-script component but not read the run back — those jobs +/// are `AppScript`/`Preview` kinds that no `jobs:run` scope can name. +/// +/// No-op — and no query — for every caller whose job reads are not run-confined (see +/// `job_read_run_confinement`), which is all sessions, unscoped tokens and `jobs:read` +/// tokens. +async fn require_job_within_run_scope( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + job_id: &Uuid, +) -> error::Result<()> { + let Some(confinement) = + windmill_api_auth::scopes::job_read_run_confinement(authed.scopes.as_deref()) + else { + return Ok(()); + }; + // `scope_kind` is the runnable kind a `jobs:run::` scope can name, or + // NULL for a job no such scope reaches directly (previews, dependency jobs, + // flow-inlined scripts) — those are still readable as a step of a matching flow, + // through their ancestors. A `singlestepflow` wraps either a script or a flow, so it + // projects onto the wrapped runnable the same way the batch-rerun query does. + let chain = sqlx::query!( + r#"WITH RECURSIVE chain(id, parent_job) AS ( + SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2 + UNION ALL + SELECT j.id, j.parent_job FROM v2_job j + JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2 + ) + SELECT j.runnable_path, + CASE + WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts' + WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows' + WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN + CASE WHEN COALESCE( + (SELECT m->'value'->>'type' + FROM jsonb_array_elements(j.raw_flow->'modules') m + WHERE m->>'id' IN ('a', 'main') + LIMIT 1), + 'script' + ) = 'flow' THEN 'flows' ELSE 'scripts' END + END AS scope_kind, + CASE WHEN j.trigger_kind = 'app' THEN j.trigger END AS launched_by_app + FROM v2_job j JOIN chain c ON c.id = j.id + WHERE j.workspace_id = $2"#, + job_id, + w_id, + ) + .fetch_all(db) + .await?; + + let runs_app = build_scope_path_predicate(authed, "apps", "run"); + let in_scope = chain.iter().any(|job| { + match (job.runnable_path.as_deref(), job.scope_kind.as_deref()) { + (Some(runnable_path), Some(kind)) + if windmill_api_auth::scopes::run_confinement_admits( + &confinement, + kind, + runnable_path, + ) => + { + true + } + _ => job.launched_by_app.as_deref().is_some_and(&runs_app), + } + }); + + if in_scope { + Ok(()) + } else { + Err(Error::NotFound(format!("Job {job_id} not found"))) + } +} + /// Self + every `parent_job` ancestor (intermediate sub-flows up to the top-level /// root) of `job_id`, resolved via the root DB (flow lineage is not sensitive). /// Falls back to `[job_id]` if the row is absent so callers still run their probe. @@ -1902,7 +1995,15 @@ async fn get_job( // same visibility as `jobs/list` (see `require_job_read_access`), or hold a public // share link when logged out — which `public_view_grant` already established above, // so skip re-deriving it here: this handler is what the public run page polls. - if !has_valid_approval_token && !public_view_grant { + if has_valid_approval_token || public_view_grant { + // Both grants skip the gate below, and with it the run-scope confinement that + // gate carries. That confinement is a hard restriction, so re-apply it: holding + // an approval link for a job must not let a scoped token read one outside the + // runnables it may start. + if let Some(authed) = opt_authed.as_ref() { + require_job_within_run_scope(&db, authed, &w_id, &id).await?; + } + } else { require_opt_authed_job_read_access( &db, &user_db, @@ -5276,6 +5377,15 @@ pub async fn get_suspended_job_flow( .flatten() .ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?; + // The resume secret is this route's gate, so it never reaches + // `require_job_read_access` and the run-scope confinement that gate carries. Re-apply + // it against the flow whose args and status are about to be returned: holding a + // resume secret must not let a scoped token read a flow it may not run. Anonymous + // approvers are unaffected. + if let Some(authed) = authed.as_ref() { + require_job_within_run_scope(&db, authed, &w_id, &flow_id).await?; + } + let flow = GetQuery::new() .without_logs() .without_code() @@ -5455,9 +5565,14 @@ pub async fn create_job_signature( pub async fn get_flow_user_state( authed: ApiAuthed, + Extension(db): Extension, Extension(user_db): Extension, Path((w_id, job_id, key)): Path<(String, Uuid, String)>, ) -> error::JsonResult> { + // Reachable by a `jobs:run` token (it is one of the by-id routes a run needs), so + // apply the same run-scope confinement as the other single-job reads. RLS below + // still governs which jobs the owner's identity can see at all. + require_job_within_run_scope(&db, &authed, &w_id, &job_id).await?; let mut tx = user_db.begin(&authed).await?; let r = sqlx::query_scalar!( r#" @@ -10597,7 +10712,14 @@ async fn get_completed_job_result( _ => false, }; - if !approval_secret_ok { + if approval_secret_ok { + // The approval secret skips the gate below, and with it the run-scope + // confinement that gate carries — re-apply it, as `get_job` does for the + // approval token. Anonymous approval access is untouched. + if let Some(authed) = opt_authed.as_ref() { + require_job_within_run_scope(&db, authed, &w_id, &id).await?; + } + } else { require_opt_authed_job_read_access( &db, &user_db,