feat: restricted job tokens per script and flow (#11484)

* feat: restricted job tokens (job_token_scopes on scripts and flows)

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

* fix: admit flow-run reads, skip dedicated workers, gate on worker version

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

* fix: keep restricted jobs off flow runners, preserve scopes on rename and promotion

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

* fix: keep restricted jobs off every dedicated handoff, confine progress flow id

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

* fix: exclude restricted runnables from dedicated worker startup

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

* fix: gate restrictions on the release after 1.821.0

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

* fix: store per-job scopes on job_perms instead of v2_job, pin inline runs to the checked version

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

* feat: step-level job_token_scopes for flow steps and agent tools

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: fail closed on perms read errors, refuse restricted queue imports, gate step scopes in previews

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: carry a job's scopes on its completion so a re-run keeps the caller's cap

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: carry a zombie job's scopes into its completion

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: leave a zombie for the next sweep when its scopes cannot be read

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* docs: correct the QueuedJobV2 completion comment

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: validate step scopes in batch flows, fail closed on unvalidated step scopes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: refuse flows with step or tool restrictions at push while an older worker is live

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: apply the step-scope worker gate to flow restarts

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: list the job token toggle with the other step and flow settings

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* chore: pin the EE companion merged with EE main

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* perf: skip scope lookups for unrestricted jobs; list job token setting last

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* style: rustfmt scopes tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* fix: confine restricted job tokens to their own run lineage; drop remaining extra lookups

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FHGE3ynoAu6yrgeg4kLwL

* chore: update ee-repo-ref to 259ad3bfeef5285ba80eedc86309b11dca001220

This commit updates the EE repository reference after PR #843 was merged in windmill-ee-private.

Previous ee-repo-ref: 2b77c0225dca441235daf7bf0a06ba968df0c927

New ee-repo-ref: 259ad3bfeef5285ba80eedc86309b11dca001220

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-10-03 09:33:44 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent e952298f84
commit e7fc1b2e2e
119 changed files with 4511 additions and 709 deletions
+54 -4
View File
@@ -899,6 +899,7 @@ async fn is_noop_deploy_against_parent(
ns: &NewScript,
parent: &Script<ScriptRunnableSettingsHandle>,
resolved_on_behalf_of: Option<&str>,
resolved_job_token_scopes: Option<&[String]>,
db: &DB,
) -> Result<bool> {
if parent.archived || parent.deleted {
@@ -952,6 +953,8 @@ async fn is_noop_deploy_against_parent(
// caller-intent flag (auto-resolve parent), not script state
auto_parent: _,
labels,
// resolved against the deployed value into `resolved_job_token_scopes`, compared below
job_token_scopes: _,
// caller-intent flag (preserve user drafts on CLI/git-sync deploys);
// transient, never persisted, does not change what the script *is*
skip_draft_deletion: _,
@@ -1025,6 +1028,9 @@ async fn is_noop_deploy_against_parent(
if resolved_on_behalf_of != parent.on_behalf_of.as_deref() {
return Ok(false);
}
if resolved_job_token_scopes != parent.job_token_scopes.as_deref() {
return Ok(false);
}
// Both of a dbt script's derived fields are compared as they WOULD BE STORED,
// not as they arrived: the schema comes from the descriptor and the clients
// cannot derive it (`windmill-parser-wasm` has no dbt arm), so they send the
@@ -1492,6 +1498,37 @@ async fn create_script_internal<'c>(
parent_adopted_from_retired_path = ns.parent_hash.is_some();
}
// Absent keeps the previous version's value, read once the parent is settled (a rename
// adopts its source head above), so a client unaware of the setting cannot drop a
// restriction by redeploying or renaming.
let resolved_job_token_scopes: Option<Vec<String>> = match (&ns.job_token_scopes, &ns.parent_hash) {
(Some(Some(scopes)), _) => {
windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES
.assert()
.await?;
Some(windmill_common::scopes::validate_job_token_scopes(scopes)?)
}
(Some(None), _) => None,
(None, Some(parent_hash)) => sqlx::query_scalar!(
"SELECT job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2",
parent_hash.0,
&w_id
)
.fetch_optional(&db)
.await?
.flatten(),
(None, None) => sqlx::query_scalar!(
"SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 \
AND deleted = false ORDER BY created_at DESC LIMIT 1",
&ns.path,
&w_id
)
.fetch_optional(&db)
.await?
.flatten(),
};
ns.job_token_scopes = Some(resolved_job_token_scopes.clone());
// Before hashing, so the hash and the no-op check see the schema that gets stored.
// `{}` counts as absent: an agent filling every tool argument sends it for "none".
let schema_absent = ns.schema.as_ref().is_none_or(|s| {
@@ -1620,8 +1657,14 @@ async fn create_script_internal<'c>(
// CLI pushes must not produce phantom commits on the downstream
// git repository.
if skip_if_noop
&& is_noop_deploy_against_parent(&ns, &ps, resolved_on_behalf_of.as_deref(), &db)
.await?
&& is_noop_deploy_against_parent(
&ns,
&ps,
resolved_on_behalf_of.as_deref(),
resolved_job_token_scopes.as_deref(),
&db,
)
.await?
{
tracing::info!(
workspace_id = %w_id,
@@ -2197,8 +2240,8 @@ async fn create_script_internal<'c>(
content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \
envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)",
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email, job_token_scopes) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42)",
&w_id,
&hash.0,
ns.path,
@@ -2242,9 +2285,14 @@ async fn create_script_internal<'c>(
ns.labels.as_deref() as Option<&[String]>,
resolved_on_behalf_of,
legacy_on_behalf_of_email,
resolved_job_token_scopes.as_deref() as Option<&[String]>,
)
.execute(&mut *tx)
.await?;
windmill_common::scopes::log_job_token_scopes_deploy(
"script",
resolved_job_token_scopes.as_deref(),
);
// A lock that is not left to a dependency job queues none, so this is the only place its hash
// can be recorded. `try_skip_relock` treats a missing hash for an imported script as changed,
@@ -2954,6 +3002,7 @@ async fn create_script_internal<'c>(
None,
None,
None,
None,
)
.await?;
@@ -3077,6 +3126,7 @@ async fn create_script_internal<'c>(
None,
None,
None,
None,
)
.await?;
tracing::info!("pushed auto-build binary job {job_id} for {script_path}");