fix: confine jobs:run tokens to the jobs of the runnables they may start (#10635)

* fix: confine path-scoped jobs:run tokens to their runnable's jobs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: project singlestepflow onto its runnable and confine kind-only run scopes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep every by-id job read reachable by a jobs:run token

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: whitelist the dbt and wac-approval by-id job reads for run tokens

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: let an apps:run scope satisfy job-read confinement for that app's runs

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: apply run-scope confinement on top of the approval-token read bypass

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: confine the resume-secret job reads to the run scope as well

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-14 18:57:57 +02:00
committed by GitHub
parent 3f07a1a803
commit ee533273dd
5 changed files with 545 additions and 12 deletions
@@ -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"
}
+70
View File
@@ -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
+160
View File
@@ -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<Postgres>) -> 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:<app>` 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)
+155 -9
View File
@@ -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:<path>`
/// 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:<kind>:<path>` 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<Vec<ScopeDefinition>> {
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
+125 -3
View File
@@ -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:<kind>:<path>` 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:<flow>`
/// token inspecting its own run must still reach the steps beneath it.
///
/// An `apps:run|write:<app>` 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:<kind>:<path>` 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<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, job_id, key)): Path<(String, Uuid, String)>,
) -> error::JsonResult<Option<serde_json::Value>> {
// 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,