mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 08:02:44 +00:00
feat: add a jobs:run:dependencies token scope for lock generation (#11643)
* feat: add a jobs:run:dependencies token scope for lock generation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: pin lock-job reads to the dependencies scope Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: align scope labels with and without a restrict paths button Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: match a lock job's stored path literally on job reads Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: drop the dependency-lock warning from the jobs scope heading Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNiYsRRDyvkRYwkHda6uyf --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2b55a8afbc
commit
1dc4ed5e7f
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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\n CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\\.chat$', '')\n ELSE j.runnable_path END AS runnable_path,\n CASE\n WHEN a.agent THEN 'agents'\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 LATERAL (SELECT j.kind = 'flowpreview'\n AND j.raw_flow->'modules'->1 IS NULL\n AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a\n WHERE j.workspace_id = $2",
|
||||
"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\n CASE WHEN a.agent THEN regexp_replace(j.runnable_path, '\\.chat$', '')\n ELSE j.runnable_path END AS runnable_path,\n CASE\n WHEN a.agent THEN 'agents'\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 ('dependencies', 'flowdependencies') THEN 'dependencies'\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 LATERAL (SELECT j.kind = 'flowpreview'\n AND j.raw_flow->'modules'->1 IS NULL\n AND j.raw_flow->'modules'->0->>'id' = '__wm_agent_root' AS agent) a\n WHERE j.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -31,5 +31,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "7904ac35748b1de2c7ff764f22787436d0fe74f99f298e5c399233f0d247f2ec"
|
||||
"hash": "8aef4f9d27ec6df07cd671bcf90bf378e9fac4986cd29f69c1f2b8a83ba515c3"
|
||||
}
|
||||
+26
@@ -98,6 +98,32 @@ INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, resu
|
||||
('18181818-1818-1818-1818-181818181818', 'test-workspace', 1000, 'success'::job_status,
|
||||
'{"agent": "AGENT_CHAT_RESULT"}');
|
||||
|
||||
-- A lock job for flow `f/shared/flow1`, as `wmill generate-metadata` queues it, and the
|
||||
-- token CI would lock with.
|
||||
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES (
|
||||
encode(sha256('RUN_SCOPED_DEPS_TOKEN'::bytea), 'hex'), 'RUN_DEPS_', 'RUN_SCOPED_DEPS_TOKEN',
|
||||
'test2@windmill.dev', 'lock token', false,
|
||||
ARRAY['jobs:run:dependencies:f/shared/*']
|
||||
);
|
||||
INSERT INTO public.v2_job (
|
||||
id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email,
|
||||
kind, runnable_path, tag, visible_to_owner
|
||||
) VALUES (
|
||||
'19191919-1919-1919-1919-191919191919', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'flowdependencies', 'f/shared/flow1', 'dependency', true
|
||||
), (
|
||||
-- Queued by a broader token with `*` as its path, which the request body allows.
|
||||
'20202020-2020-2020-2020-202020202020', 'test-workspace', 'test-user-2',
|
||||
'2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev',
|
||||
'dependencies', '*', 'dependency', true
|
||||
);
|
||||
INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES
|
||||
('19191919-1919-1919-1919-191919191919', 'test-workspace', 1000, 'success'::job_status,
|
||||
'{"lock": "DEPS_RESULT"}'),
|
||||
('20202020-2020-2020-2020-202020202020', 'test-workspace', 1000, 'success'::job_status,
|
||||
'{"lock": "WILDCARD_DEPS_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.
|
||||
|
||||
@@ -476,6 +476,44 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
|
||||
);
|
||||
}
|
||||
|
||||
// A lock job belongs to `jobs:run:dependencies`: its path is whatever the caller sent,
|
||||
// so a token scoped to run the flow at that path must not read it.
|
||||
let path = "completed/get_result/19191919-1919-1919-1919-191919191919";
|
||||
let (status, body) = get(&base, path, Some("RUN_SCOPED_DEPS_TOKEN")).await;
|
||||
assert!(
|
||||
status.is_success() && body.contains("DEPS_RESULT"),
|
||||
"dependencies-scoped token must read its lock job (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = get(&base, path, Some("RUN_SCOPED_TOKEN")).await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"a flow-scoped token must not read a lock job (got {status}): {body}"
|
||||
);
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
&format!("completed/get_result/{FLOW_JOB}"),
|
||||
Some("RUN_SCOPED_DEPS_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"dependencies-scoped token must not read a flow run at its path (got {status}): {body}"
|
||||
);
|
||||
// A lock job filed at `*` matches no path-scoped grant: the stored path is not a pattern.
|
||||
let (status, body) = get(
|
||||
&base,
|
||||
"completed/get_result/20202020-2020-2020-2020-202020202020",
|
||||
Some("RUN_SCOPED_DEPS_TOKEN"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
"a path-scoped token must not read a lock job filed at `*` (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.
|
||||
|
||||
@@ -170,10 +170,16 @@ const FLOW_JOBS: [&'static str; 6] = [
|
||||
/// scoped by the agent's path like a deployed runnable's, as `jobs:run:agents:<path>`.
|
||||
const AGENT_JOBS: [&'static str; 1] = ["jobs/run/agent"];
|
||||
|
||||
/// Lock resolution for scripts, apps and flows, scoped as `jobs:run:dependencies:<path>`.
|
||||
/// Resolving a package can run its build or install scripts on the worker, so this is
|
||||
/// still code execution: the scope only takes away the direct ways to run code.
|
||||
const DEPENDENCY_JOBS: [&'static str; 2] = ["jobs/run/dependencies", "jobs/run/flow_dependencies"];
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref RUN_PATH_ACTIONS: Vec<&'static str> = {
|
||||
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "jobs/run/dependencies","jobs/run/flow_dependencies", "apps_u/execute_component", "apps_u/upload_s3_file"];
|
||||
let mut v = vec!["jobs/resume/", "jobs/run/batch_rerun_jobs", "jobs/run/workflow_as_code", "apps_u/execute_component", "apps_u/upload_s3_file"];
|
||||
|
||||
v.extend(DEPENDENCY_JOBS);
|
||||
v.extend(SCRIPT_JOBS);
|
||||
v.extend(FLOW_JOBS);
|
||||
v.extend(AGENT_JOBS);
|
||||
@@ -227,11 +233,13 @@ fn map_http_method_to_action(method: &str, route_path: &str) -> ScopeAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the route path to determine the runnable kind ("agents", "flows" or "scripts").
|
||||
/// Checks the route path to determine the runnable kind ("dependencies", "agents", "flows"
|
||||
/// or "scripts").
|
||||
///
|
||||
/// The order of checks is important:
|
||||
/// - Flow-related paths are checked first to avoid false positives, as some flow paths
|
||||
/// (e.g., `/run_preview_flow`) share prefixes with script paths (e.g., `/run_preview`).
|
||||
/// - Dependency paths are checked before flow paths: `flow_dependencies` starts with `f`.
|
||||
/// - Flow-related paths are checked before script paths to avoid false positives, as some
|
||||
/// flow paths (e.g., `/run_preview_flow`) share prefixes with script paths (e.g., `/run_preview`).
|
||||
///
|
||||
/// Returns the kind based on the match, or `None` if no match is found.
|
||||
fn determine_kind_from_route(route_path: &str) -> Option<String> {
|
||||
@@ -247,6 +255,12 @@ fn determine_kind_from_route(route_path: &str) -> Option<String> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if DEPENDENCY_JOBS
|
||||
.iter()
|
||||
.any(|path| route_path.starts_with(path))
|
||||
{
|
||||
return Some("dependencies".to_string());
|
||||
}
|
||||
if AGENT_JOBS.iter().any(|path| route_path.starts_with(path)) {
|
||||
return Some("agents".to_string());
|
||||
}
|
||||
@@ -586,17 +600,23 @@ pub struct JobReadConfinement {
|
||||
}
|
||||
|
||||
impl JobReadConfinement {
|
||||
/// Whether a job that ran `runnable_path` as `kind` (`scripts`, `flows` or `agents`)
|
||||
/// is inside the confinement. A read path admits every kind; a run scope only its own.
|
||||
/// Whether a job that ran `runnable_path` as `kind` (`scripts`, `flows`, `agents` or
|
||||
/// `dependencies`) is inside the confinement. A read path admits every kind; a run
|
||||
/// scope only its own.
|
||||
pub fn admits(&self, kind: &str, runnable_path: &str) -> bool {
|
||||
if self.read_paths.allows(runnable_path) {
|
||||
return true;
|
||||
}
|
||||
// Lock jobs and previews store the path their caller sent. The matcher reads `*`
|
||||
// in it as a pattern, and `,`/`:` are scope syntax: such a path needs a grant
|
||||
// covering every path, as enqueuing it did.
|
||||
let resource = windmill_common::auth::is_scope_literal_path(runnable_path)
|
||||
.then(|| vec![runnable_path.to_string()]);
|
||||
let required = ScopeDefinition::new(
|
||||
ScopeDomain::Jobs.as_str(),
|
||||
ScopeAction::Run.as_str(),
|
||||
Some(kind),
|
||||
Some(vec![runnable_path.to_string()]),
|
||||
resource,
|
||||
);
|
||||
self.run.iter().any(|scope| scope.includes(&required))
|
||||
}
|
||||
@@ -1715,6 +1735,24 @@ mod tests {
|
||||
scope_for_route("POST", "/api/w/ws/jobs/run/agent/u/x/y").as_deref(),
|
||||
Some("jobs:run:agents")
|
||||
);
|
||||
for path in [
|
||||
"/api/w/ws/jobs/run/dependencies",
|
||||
"/api/w/ws/jobs/run/dependencies_async",
|
||||
"/api/w/ws/jobs/run/flow_dependencies",
|
||||
"/api/w/ws/jobs/run/flow_dependencies_async",
|
||||
] {
|
||||
assert_eq!(
|
||||
scope_for_route("POST", path).as_deref(),
|
||||
Some("jobs:run:dependencies"),
|
||||
"{path}"
|
||||
);
|
||||
assert!(check_route_access(&["jobs:run:dependencies".into()], path, "POST").is_ok());
|
||||
assert!(check_route_access(&["jobs:run:flows".into()], path, "POST").is_err());
|
||||
}
|
||||
// A dependencies grant reaches no other run route.
|
||||
for path in ["/api/w/ws/jobs/run/preview", "/api/w/ws/jobs/run/p/u/x/y"] {
|
||||
assert!(check_route_access(&["jobs:run:dependencies".into()], path, "POST").is_err());
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -2010,9 +2010,11 @@ async fn require_job_within_read_scope(
|
||||
}
|
||||
}
|
||||
// `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
|
||||
// NULL for a job no such scope reaches directly (previews, flow-inlined scripts) —
|
||||
// those are still readable as a step of a matching flow, through their ancestors.
|
||||
// A dependency job's path is the one its `jobs:run:dependencies:<path>` check
|
||||
// admitted, but it is filed under `dependencies`, so it never passes for a run of
|
||||
// the deployed runnable at that path. A `singlestepflow` wraps either a script or a flow, so it
|
||||
// projects onto the wrapped runnable the same way the batch-rerun query does. An agent
|
||||
// run is a preview of the one-step flow `agent_runs::agent_step_flow` builds, filed under the
|
||||
// agent's path (`<path>.chat` for a chat turn); the editor's own runs of it look the same,
|
||||
@@ -2031,6 +2033,7 @@ async fn require_job_within_read_scope(
|
||||
WHEN a.agent THEN 'agents'
|
||||
WHEN j.kind IN ('script', 'script_hub', 'unassigned_script') THEN 'scripts'
|
||||
WHEN j.kind IN ('flow', 'unassigned_flow') THEN 'flows'
|
||||
WHEN j.kind IN ('dependencies', 'flowdependencies') THEN 'dependencies'
|
||||
WHEN j.kind IN ('singlestepflow', 'unassigned_singlestepflow') THEN
|
||||
CASE WHEN COALESCE(
|
||||
(SELECT m->'value'->>'type'
|
||||
@@ -4628,14 +4631,8 @@ async fn cancel_selection(
|
||||
} else {
|
||||
ids
|
||||
};
|
||||
let Json(mut w_cancelled) = cancel_jobs(
|
||||
ids,
|
||||
&db,
|
||||
&authed,
|
||||
workspace_id.as_str(),
|
||||
force_cancel,
|
||||
)
|
||||
.await?;
|
||||
let Json(mut w_cancelled) =
|
||||
cancel_jobs(ids, &db, &authed, workspace_id.as_str(), force_cancel).await?;
|
||||
cancelled.append(&mut w_cancelled);
|
||||
}
|
||||
|
||||
@@ -9529,13 +9526,23 @@ pub struct RunDependenciesResponse {
|
||||
pub dependencies: String,
|
||||
}
|
||||
|
||||
/// The path comes from the request body: one carrying scope syntax (`,`, `:`, `*`) could
|
||||
/// put a granted path beside an ungranted one, so it needs a grant covering every path.
|
||||
fn check_dependencies_scope(authed: &ApiAuthed, path: &str) -> error::Result<()> {
|
||||
if windmill_common::auth::is_scope_literal_path(path) {
|
||||
check_scopes(authed, || format!("jobs:run:dependencies:{path}"))
|
||||
} else {
|
||||
check_scopes(authed, || "jobs:run:dependencies".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn push_dependencies_job(
|
||||
authed: &ApiAuthed,
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
req: RunDependenciesRequest,
|
||||
) -> error::Result<Uuid> {
|
||||
check_scopes(authed, || format!("jobs:run"))?;
|
||||
check_dependencies_scope(authed, &req.entrypoint)?;
|
||||
if authed.is_operator {
|
||||
return Err(error::Error::NotAuthorized(
|
||||
"Operators cannot run dependencies jobs for security reasons".to_string(),
|
||||
@@ -9658,7 +9665,7 @@ async fn push_flow_dependencies_job(
|
||||
w_id: &str,
|
||||
req: RunFlowDependenciesRequest,
|
||||
) -> error::Result<Uuid> {
|
||||
check_scopes(authed, || format!("jobs:run"))?;
|
||||
check_dependencies_scope(authed, &req.path)?;
|
||||
check_operator_can_build_flows(db, w_id, authed.is_operator, "run dependencies jobs").await?;
|
||||
// The dependency job locks whatever inline code this request carries, on a worker. A
|
||||
// composition-only flow has none, so validating here costs a builder nothing and keeps the
|
||||
|
||||
@@ -215,6 +215,11 @@ lazy_static! {
|
||||
label: "Run flows".to_string(),
|
||||
requires_resource_path: true,
|
||||
},
|
||||
ScopeOption {
|
||||
value: "jobs:run:dependencies".to_string(),
|
||||
label: "Generate dependency locks".to_string(),
|
||||
requires_resource_path: true,
|
||||
},
|
||||
],
|
||||
}];
|
||||
|
||||
|
||||
@@ -643,7 +643,7 @@
|
||||
? 'bg-surface-disabled opacity-60'
|
||||
: 'bg-surface-tertiary'}"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between items-center min-h-7">
|
||||
<label
|
||||
class={twMerge(
|
||||
'flex items-center gap-2 flex-1 min-w-0',
|
||||
@@ -677,7 +677,7 @@
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
size="xs"
|
||||
unifiedSize="sm"
|
||||
disabled={isDisabled}
|
||||
variant="default"
|
||||
startIcon={resourcePathArray.length > 0
|
||||
@@ -717,7 +717,7 @@
|
||||
onclick={() => {
|
||||
addResourcePath(scope.value, currentInput)
|
||||
}}
|
||||
size="xs"
|
||||
unifiedSize="sm"
|
||||
disabled={!currentInput.trim()}
|
||||
>
|
||||
Add
|
||||
|
||||
Reference in New Issue
Block a user