mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 16:02:59 +00:00
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:
co-authored by
Claude Opus 5.5
parent
7646cb3281
commit
2ca3f56140
+139
@@ -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"
|
||||
}
|
||||
+398
@@ -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"
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user