mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 08:01:26 +00:00
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:
+35
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user