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>
This commit is contained in:
Ruben Fiszel
2026-10-02 17:55:03 +02:00
co-authored by Claude Opus 5.5
parent 7646cb3281
commit 2ca3f56140
14 changed files with 654 additions and 79 deletions
@@ -0,0 +1,139 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO v2_job (\n id, raw_code, raw_lock, raw_flow, tag, workspace_id, created_at, created_by,\n permissioned_as, permissioned_as_email, kind, runnable_id, runnable_path,\n parent_job, root_job, script_lang, script_entrypoint_override, flow_step,\n flow_step_id, flow_innermost_root_job, trigger, trigger_kind, same_worker,\n visible_to_owner, concurrent_limit, concurrency_time_window_s, cache_ttl,\n timeout, priority, preprocessed, args, labels, pre_run_error, job_token_scopes\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,\n $11, $12, $13, $14, $15, $16, $17, $18, $19, $20,\n $21, $22, $23, $24, $25, $26, $27, $28, $29, $30,\n $31, $32, $33, $34\n )\n ON CONFLICT (id) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"Jsonb",
"Varchar",
"Varchar",
"Timestamptz",
"Varchar",
"Varchar",
"Varchar",
{
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
},
"Int8",
"Varchar",
"Uuid",
"Uuid",
{
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby",
"rlang",
"dbt"
]
}
}
},
"Varchar",
"Int4",
"Varchar",
"Uuid",
"Varchar",
{
"Custom": {
"name": "job_trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email",
"nats",
"schedule",
"app",
"ui",
"postgres",
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google",
"ci_test",
"github",
"azure",
"asset",
"freshness",
"amqp"
]
}
}
},
"Bool",
"Bool",
"Int4",
"Int4",
"Int4",
"Int4",
"Int2",
"Bool",
"Jsonb",
"TextArray",
"Text",
"TextArray"
]
},
"nullable": []
},
"hash": "310bc5d8444925455ac2ff0904f91f36d84fb4797caccc11ea74bb7bc9890854"
}
@@ -0,0 +1,398 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n v2_job.id,\n v2_job.raw_code,\n v2_job.raw_lock,\n v2_job.raw_flow as \"raw_flow: _\",\n v2_job.tag,\n v2_job.created_at,\n v2_job.created_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job.kind as \"kind: _\",\n v2_job.runnable_id,\n v2_job.runnable_path,\n v2_job.parent_job,\n v2_job.root_job,\n v2_job.script_lang as \"script_lang: _\",\n v2_job.script_entrypoint_override,\n v2_job.flow_step,\n v2_job.flow_step_id,\n v2_job.flow_innermost_root_job,\n v2_job.\"trigger\",\n v2_job.trigger_kind as \"trigger_kind: _\",\n v2_job.same_worker,\n v2_job.visible_to_owner,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.cache_ttl,\n v2_job.timeout,\n v2_job.priority,\n v2_job.preprocessed,\n v2_job.args as \"args: _\",\n v2_job.labels,\n v2_job.job_token_scopes,\n v2_job.pre_run_error,\n\n v2_job_queue.started_at,\n v2_job_queue.scheduled_for,\n v2_job_queue.running,\n v2_job_queue.canceled_by,\n v2_job_queue.canceled_reason,\n v2_job_queue.suspend,\n v2_job_queue.suspend_until,\n v2_job_queue.worker,\n v2_job_queue.extras as \"extras: _\",\n\n v2_job_runtime.ping,\n v2_job_runtime.memory_peak,\n\n v2_job_status.flow_status as \"flow_status: _\",\n v2_job_status.flow_leaf_jobs as \"flow_leaf_jobs: _\",\n v2_job_status.workflow_as_code_status as \"workflow_as_code_status: _\",\n\n concurrency_key.key as \"concurrency_key?\"\n FROM v2_job_queue\n INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n LEFT JOIN v2_job_runtime ON v2_job_runtime.id = v2_job_queue.id\n LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id\n LEFT JOIN concurrency_key ON concurrency_key.job_id = v2_job_queue.id\n WHERE v2_job_queue.workspace_id = $1\n AND v2_job_queue.running = false\n AND v2_job.parent_job IS NULL\n AND v2_job.trigger_kind IS DISTINCT FROM 'schedule'\n ORDER BY v2_job.created_at DESC\n LIMIT $2\n OFFSET $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "raw_code",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "raw_lock",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "raw_flow: _",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "permissioned_as",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "permissioned_as_email",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "kind: _",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 10,
"name": "runnable_id",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 12,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 13,
"name": "root_job",
"type_info": "Uuid"
},
{
"ordinal": 14,
"name": "script_lang: _",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby",
"rlang",
"dbt"
]
}
}
}
},
{
"ordinal": 15,
"name": "script_entrypoint_override",
"type_info": "Varchar"
},
{
"ordinal": 16,
"name": "flow_step",
"type_info": "Int4"
},
{
"ordinal": 17,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 18,
"name": "flow_innermost_root_job",
"type_info": "Uuid"
},
{
"ordinal": 19,
"name": "trigger",
"type_info": "Varchar"
},
{
"ordinal": 20,
"name": "trigger_kind: _",
"type_info": {
"Custom": {
"name": "job_trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email",
"nats",
"schedule",
"app",
"ui",
"postgres",
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google",
"ci_test",
"github",
"azure",
"asset",
"freshness",
"amqp"
]
}
}
}
},
{
"ordinal": 21,
"name": "same_worker",
"type_info": "Bool"
},
{
"ordinal": 22,
"name": "visible_to_owner",
"type_info": "Bool"
},
{
"ordinal": 23,
"name": "concurrent_limit",
"type_info": "Int4"
},
{
"ordinal": 24,
"name": "concurrency_time_window_s",
"type_info": "Int4"
},
{
"ordinal": 25,
"name": "cache_ttl",
"type_info": "Int4"
},
{
"ordinal": 26,
"name": "timeout",
"type_info": "Int4"
},
{
"ordinal": 27,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 28,
"name": "preprocessed",
"type_info": "Bool"
},
{
"ordinal": 29,
"name": "args: _",
"type_info": "Jsonb"
},
{
"ordinal": 30,
"name": "labels",
"type_info": "TextArray"
},
{
"ordinal": 31,
"name": "job_token_scopes",
"type_info": "TextArray"
},
{
"ordinal": 32,
"name": "pre_run_error",
"type_info": "Text"
},
{
"ordinal": 33,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 34,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 35,
"name": "running",
"type_info": "Bool"
},
{
"ordinal": 36,
"name": "canceled_by",
"type_info": "Varchar"
},
{
"ordinal": 37,
"name": "canceled_reason",
"type_info": "Text"
},
{
"ordinal": 38,
"name": "suspend",
"type_info": "Int4"
},
{
"ordinal": 39,
"name": "suspend_until",
"type_info": "Timestamptz"
},
{
"ordinal": 40,
"name": "worker",
"type_info": "Varchar"
},
{
"ordinal": 41,
"name": "extras: _",
"type_info": "Jsonb"
},
{
"ordinal": 42,
"name": "ping",
"type_info": "Timestamptz"
},
{
"ordinal": 43,
"name": "memory_peak",
"type_info": "Int4"
},
{
"ordinal": 44,
"name": "flow_status: _",
"type_info": "Jsonb"
},
{
"ordinal": 45,
"name": "flow_leaf_jobs: _",
"type_info": "Jsonb"
},
{
"ordinal": 46,
"name": "workflow_as_code_status: _",
"type_info": "Jsonb"
},
{
"ordinal": 47,
"name": "concurrency_key?",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
true,
true,
true,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
false
]
},
"hash": "984092e2bb9e4b380d7200524b4e10fdf4013033b128fbc47b65e240da847dc3"
}
+59
View File
@@ -193,3 +193,62 @@ async fn test_flow_steps_inherit_the_flow_restriction(db: Pool<Postgres>) -> any
assert_eq!(step, Some(vec!["oidc:write".to_string()]));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_deploy_without_the_field_keeps_the_restriction(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api/w/test-workspace", server.addr.port());
let client = reqwest::Client::new();
let script = |path: &str, scopes: Option<serde_json::Value>| {
let mut body = json!({
"path": path, "summary": "", "description": "", "content": "echo 42", "language": "bash",
});
if let Some(scopes) = scopes {
body["job_token_scopes"] = scopes;
}
body
};
let stored = |path: &'static str| {
let db = db.clone();
async move {
sqlx::query_scalar::<_, Option<Vec<String>>>(
"SELECT job_token_scopes FROM script WHERE path = $1 ORDER BY created_at DESC LIMIT 1",
)
.bind(path)
.fetch_one(&db)
.await
}
};
let resp = client
.post(format!("{base}/scripts/create"))
.bearer_auth("SECRET_TOKEN")
.json(&script("u/test-user/agent", Some(json!(["oidc:write"]))))
.send()
.await?;
assert_eq!(resp.status(), StatusCode::CREATED, "{}", resp.text().await?);
// A rename by a client that does not know the field carries the restriction along.
let resp = client
.post(format!("{base}/scripts/update/u/test-user/agent"))
.bearer_auth("SECRET_TOKEN")
.json(&script("u/test-user/renamed", None))
.send()
.await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
assert_eq!(stored("u/test-user/renamed").await?, Some(vec!["oidc:write".to_string()]));
// An explicit null clears it.
let resp = client
.post(format!("{base}/scripts/update/u/test-user/renamed"))
.bearer_auth("SECRET_TOKEN")
.json(&script("u/test-user/renamed", Some(serde_json::Value::Null)))
.send()
.await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
assert_eq!(stored("u/test-user/renamed").await?, None);
Ok(())
}
+1 -1
View File
@@ -106,7 +106,7 @@ pub struct ExpiringAuthCache {
/// Whether `target` belongs to the flow run of the job `job_id`: the job itself, one of its
/// ancestors, or a job whose parent or root is one of them (a sibling step, a loop or branch
/// iteration). A lookup that fails reads as no.
/// iteration, any job pushed as a child of one of them). A lookup that fails reads as no.
async fn job_in_same_flow_run(db: &DB, job_id: uuid::Uuid, target: uuid::Uuid) -> bool {
if job_id == target {
return true;
+1 -1
View File
@@ -747,7 +747,6 @@ pub fn scope_for_route(method: &str, path: &str) -> Option<String> {
})
}
/// Helper function to check if scopes allow access to a route
/// Routes the runtime of a job calls about that job alone: progress, its root id, its
/// resume and approval urls, a workflow-as-code checkpoint or task. A job token restricted
/// by `job_token_scopes` keeps these whatever its scopes, or the job could not run at all,
@@ -802,6 +801,7 @@ pub fn flow_run_read_route_job(route_path: &str, http_method: &str) -> Option<uu
uuid::Uuid::parse_str(id).ok()
}
/// Helper function to check if scopes allow access to a route
pub fn check_scopes_for_route(
token_scopes: Option<&[String]>,
route_path: &str,
+6 -2
View File
@@ -112,6 +112,8 @@ pub struct ExportableQueuedJob {
pub preprocessed: Option<bool>,
pub args: Option<sqlx::types::Json<Box<RawValue>>>,
pub labels: Option<Vec<String>>,
#[serde(default)]
pub job_token_scopes: Option<Vec<String>>,
pub pre_run_error: Option<String>,
// v2_job_queue columns (excluding workspace_id and id/created_at/tag/priority)
@@ -276,6 +278,7 @@ pub async fn export_queued_jobs(
v2_job.preprocessed,
v2_job.args as "args: _",
v2_job.labels,
v2_job.job_token_scopes,
v2_job.pre_run_error,
v2_job_queue.started_at,
@@ -456,12 +459,12 @@ pub async fn import_queued_jobs(
parent_job, root_job, script_lang, script_entrypoint_override, flow_step,
flow_step_id, flow_innermost_root_job, trigger, trigger_kind, same_worker,
visible_to_owner, concurrent_limit, concurrency_time_window_s, cache_ttl,
timeout, priority, preprocessed, args, labels, pre_run_error
timeout, priority, preprocessed, args, labels, pre_run_error, job_token_scopes
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
$21, $22, $23, $24, $25, $26, $27, $28, $29, $30,
$31, $32, $33
$31, $32, $33, $34
)
ON CONFLICT (id) DO NOTHING
"#,
@@ -498,6 +501,7 @@ pub async fn import_queued_jobs(
job.args as _,
job.labels as _,
job.pre_run_error,
job.job_token_scopes.as_deref() as Option<&[String]>,
)
.execute(&mut *tx)
.await?;
+31 -28
View File
@@ -1379,34 +1379,6 @@ async fn create_script_internal<'c>(
&db,
)
.await?;
// Absent keeps what the previous version has (the parent, or else the latest at the path):
// a client unaware of the setting must not drop a restriction by redeploying or renaming.
let resolved_job_token_scopes = 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(),
};
// Written beside the principal only while a worker that still reads it may be live.
let legacy_on_behalf_of_email =
windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db)
@@ -1524,6 +1496,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| {
+2 -1
View File
@@ -9620,7 +9620,8 @@ async fn add_batch_jobs(
let language = language.unwrap_or(ScriptLang::Deno);
let tag = if let Some(dedicated_worker) = dedicated_worker {
if dedicated_worker && path.is_some() {
// Same rule as `push`: a dedicated worker would run it with its own unscoped token.
if dedicated_worker && path.is_some() && job_token_scopes.is_none() {
windmill_common::worker::dedicated_worker_tag(&w_id, &path.clone().unwrap())
} else {
format!("{}", language.as_str())
+1 -44
View File
@@ -74,7 +74,7 @@ use windmill_common::{
add_virtual_items_if_necessary, FlowModule, FlowModuleValue, FlowValue, InputTransform,
Retry, StopAfterIf,
},
jobs::{get_payload_tag_from_prefixed_path, JobKind, JobPayload, QueuedJob, RawCode},
jobs::{get_payload_tag_from_prefixed_path, JobKind, JobPayload, RawCode},
min_version::{MIN_VERSION_IS_AT_LEAST_1_432, MIN_VERSION_IS_AT_LEAST_1_440},
schedule::Schedule,
scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang},
@@ -3857,49 +3857,6 @@ impl MiniPulledJob {
.and_then(|f| f.chat_input_enabled)
}
pub fn from(job: &QueuedJob) -> MiniPulledJob {
MiniPulledJob {
workspace_id: job.workspace_id.clone(),
id: job.id,
args: job.args.clone(),
parent_job: job.parent_job.clone(),
created_by: job.created_by.clone(),
started_at: job.started_at.clone(),
scheduled_for: job.scheduled_for,
runnable_path: job.script_path.clone(),
kind: job.job_kind,
runnable_id: job.script_hash.clone(),
canceled_reason: job.canceled_reason.clone(),
canceled_by: job.canceled_by.clone(),
permissioned_as: job.permissioned_as.clone(),
permissioned_as_email: job.email.clone(),
flow_status: job.flow_status.clone(),
tag: job.tag.clone(),
script_lang: job.language.clone(),
same_worker: job.same_worker,
pre_run_error: job.pre_run_error.clone(),
concurrent_limit: job.concurrent_limit.clone(),
concurrency_time_window_s: job.concurrency_time_window_s.clone(),
runnable_settings_handle: job.runnable_settings_handle,
flow_innermost_root_job: job.root_job.clone(), // QueuedJob is taken from v2_as_queue, where root_job corresponds to flow_innermost_root_job in v2_job
root_job: None,
timeout: job.timeout.clone(),
flow_step_id: job.flow_step_id.clone(),
cache_ttl: job.cache_ttl.clone(),
cache_ignore_s3_path: job.cache_ignore_s3_path.clone(),
priority: job.priority.clone(),
preprocessed: job.preprocessed.clone(),
script_entrypoint_override: job.script_entrypoint_override.clone(),
trigger: job.schedule_path.clone(),
trigger_kind: job
.schedule_path
.is_some()
.then(|| JobTriggerKind::Schedule.into()),
visible_to_owner: job.visible_to_owner.clone(),
permissioned_as_end_user_email: None,
job_token_scopes: None,
}
}
pub fn is_flow(&self) -> bool {
self.kind.is_flow()
}
+5 -1
View File
@@ -3641,7 +3641,11 @@ pub async fn run_worker(
NextJob::Http(_) => None,
};
if let Some(flow_runners) = flow_runners.filter(|_| !fails_before_running) {
// A flow runner runs every step it gets with its own unscoped worker token, so
// a step with a restricted token runs here, with the token minted for it.
if let Some(flow_runners) = flow_runners
.filter(|_| !fails_before_running && job.job_token_scopes.is_none())
{
let key_o = job.flow_step_id.as_ref().map(|x| x.to_string());
if let Some(key) = key_o {
if let Some(flow_runner_tx) = flow_runners.runners.get(&key) {
@@ -4328,7 +4328,10 @@ async fn push_next_flow_job(
};
// only start runners if we're not already in a squash for loop
// Runners would run its steps with their own unscoped token: a restricted flow runs them
// as regular jobs.
let start_runners = flow_runners.is_none()
&& flow_job.job_token_scopes.is_none()
&& matches!(
next_status,
NextStatus::NextLoopIteration { start_runners: true, .. }
@@ -458,6 +458,8 @@ export async function deployItem(
path,
requestBody: {
...flow,
// Sent even when unset: the backend keeps a restriction an omitted field leaves out.
job_token_scopes: flow.job_token_scopes ?? null,
preserve_on_behalf_of: preserveOnBehalfOf,
on_behalf_of_email: onBehalfOf,
// Usernames are per-workspace, so the source's principal names nobody in
@@ -471,6 +473,8 @@ export async function deployItem(
workspace: workspaceTo,
requestBody: {
...flow,
// Sent even when unset: the backend keeps a restriction an omitted field leaves out.
job_token_scopes: flow.job_token_scopes ?? null,
preserve_on_behalf_of: preserveOnBehalfOf,
on_behalf_of_email: onBehalfOf,
// Usernames are per-workspace, so the source's principal names nobody in
@@ -498,6 +502,7 @@ export async function deployItem(
requestBody: {
...script,
lock: script.lock,
job_token_scopes: script.job_token_scopes ?? null,
parent_hash: parentHash,
preserve_on_behalf_of: preserveOnBehalfOf,
on_behalf_of_email: onBehalfOf,
@@ -65,7 +65,8 @@
The <code>WM_TOKEN</code> of every job of this {kind} can only do what these scopes allow. Jobs
it starts, {kind === 'flow' ? 'its steps, ' : ''}and AI agent tools inherit the restriction.
Variables and resources passed as <code>$var:</code>/<code>$res:</code> inputs, an AI agent's provider
resource, relative imports and object storage each need a read scope. Write scopes on scripts,
resource, relative imports and object storage each need a read scope, and script state
(<code>getState</code>/<code>setState</code>) needs <code>resources:write</code>. Write scopes on scripts,
flows, schedules or triggers let a job escape the restriction. Jobs with a restricted token never
run on dedicated workers.
</p>
@@ -46,6 +46,7 @@
if (script.visible_to_runner_only) exportData.visible_to_runner_only = script.visible_to_runner_only
if (script.auto_kind) exportData.auto_kind = script.auto_kind
if (script.has_preprocessor) exportData.has_preprocessor = script.has_preprocessor
if (script.job_token_scopes != null) exportData.job_token_scopes = script.job_token_scopes
if (script.modules && Object.keys(script.modules).length > 0) exportData.modules = script.modules
scriptData = exportData