mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 00:03:00 +00:00
feat: restricted job tokens (job_token_scopes on scripts and flows)
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
f74113c75a
commit
06a5d73bb8
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT job_token_scopes FROM v2_job WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "0312002d090d7233909922a96a756cb6311ca99b9a7343949ab653e404f65c23"
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, 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, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, job_token_scopes, lock_error_logs, created_at)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, 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, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, job_token_scopes, $6::text, clock_timestamp()\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT job_token_scopes FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "32f92624baf4eed5137af20edf8236428a28c455b75e8fa96d9512202cbd936d"
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n flow_version.value->>'failure_module' IS NOT NULL as has_failure_module,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of,\n flow.edited_by,\n flow.labels,\n flow.job_token_scopes\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "version",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "early_return",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "has_preprocessor",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "has_failure_module",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "chat_input_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "tag",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "dedicated_worker",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "on_behalf_of",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "labels",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107"
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_status SET\n flow_status = JSONB_SET(\n JSONB_SET(v2_job_status.flow_status, $2::TEXT[], $3),\n $4::TEXT[], $5\n ) - $6::TEXT[],\n flow_leaf_jobs = CASE\n WHEN $7::TEXT IS NULL\n OR COALESCE(v2_job.flow_innermost_root_job, v2_job_status.id) <> v2_job_status.id\n THEN v2_job_status.flow_leaf_jobs\n ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1\n RETURNING\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n v2_job_queue.scheduled_for,\n v2_job.runnable_path,\n v2_job.kind as \"kind: JobKind\",\n v2_job.runnable_id as \"runnable_id: ScriptHash\",\n v2_job_queue.canceled_reason,\n v2_job_queue.canceled_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job_status.flow_status as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n v2_job.script_lang as \"script_lang: ScriptLang\",\n v2_job.same_worker,\n v2_job.pre_run_error,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.flow_innermost_root_job,\n v2_job.root_job,\n v2_job.timeout,\n v2_job.flow_step_id,\n v2_job.cache_ttl,\n v2_job_queue.cache_ignore_s3_path,\n v2_job_queue.priority,\n v2_job.preprocessed,\n v2_job.script_entrypoint_override,\n v2_job.trigger,\n v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\",\n v2_job.visible_to_owner,\n NULL as permissioned_as_end_user_email,\n v2_job.job_token_scopes",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "args: sqlx::types::Json<HashMap<String, Box<RawValue>>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "started_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "runnable_settings_handle",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "scheduled_for",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "runnable_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "kind: JobKind",
|
||||
"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: ScriptHash",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "canceled_reason",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "canceled_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "permissioned_as",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "permissioned_as_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "flow_status: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "tag",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "script_lang: ScriptLang",
|
||||
"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": 18,
|
||||
"name": "same_worker",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "pre_run_error",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "concurrent_limit",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "concurrency_time_window_s",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "flow_innermost_root_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "root_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "timeout",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "flow_step_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 26,
|
||||
"name": "cache_ttl",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"name": "cache_ignore_s3_path",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"name": "priority",
|
||||
"type_info": "Int2"
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"name": "preprocessed",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "script_entrypoint_override",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"name": "trigger",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 32,
|
||||
"name": "trigger_kind: TriggerKindLabel",
|
||||
"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": 33,
|
||||
"name": "visible_to_owner",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 34,
|
||||
"name": "permissioned_as_end_user_email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 35,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"TextArray",
|
||||
"Jsonb",
|
||||
"TextArray",
|
||||
"Jsonb",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "4d6dd480f3118dc5e099538811d3b9a1e996440f056b80f3a4e0ef3878e00198"
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH uuid_table as (\n select gen_random_uuid() as uuid from generate_series(1, $16)\n )\n INSERT INTO v2_job\n (id, workspace_id, raw_code, raw_lock, raw_flow, tag, runnable_id, runnable_path, kind,\n script_lang, created_by, permissioned_as, permissioned_as_email, concurrent_limit,\n concurrency_time_window_s, timeout, args, job_token_scopes)\n (SELECT uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,\n ('{ \"uuid\": \"' || uuid || '\" }')::jsonb, $17 FROM uuid_table)\n RETURNING id AS \"id!\"",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4ff18f19e633d8c649287097372fa7969335eccad727b9951ebe4bdc8eb08823"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes\n FROM flow\n WHERE path = $2 AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of, on_behalf_of_email, assets, modules, job_token_scopes\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n -- Same three forms and the same prefix-first rule as permissioned_as_exists,\n -- superadmin fallback included: one acting outside their workspaces has no usr\n -- row but still authenticates.\n CASE WHEN on_behalf_of LIKE 'u/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar\n AND u.username = substring(on_behalf_of from 3)\n UNION ALL\n SELECT 1 FROM password p WHERE p.super_admin\n AND (p.username = substring(on_behalf_of from 3)\n OR p.email = substring(on_behalf_of from 3))))\n WHEN on_behalf_of LIKE 'g/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM group_ g WHERE g.workspace_id = $1::varchar\n AND g.name = substring(on_behalf_of from 3)))\n ELSE\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar\n AND u.username = on_behalf_of\n UNION ALL\n SELECT 1 FROM password p WHERE p.email = on_behalf_of\n AND p.super_admin))\n END, on_behalf_of_email, assets, modules, job_token_scopes\n FROM script\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json<Box<RawValue>>\", flow.job_token_scopes FROM flow\n LEFT JOIN flow_version\n ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]\n LEFT JOIN flow_version_lite\n ON flow_version_lite.id = flow_version.id\n WHERE flow.path = $1 AND flow.workspace_id = $2 LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value!: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab"
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (\n id, -- 1\n workspace_id, -- 2\n raw_code, -- 3\n raw_lock, -- 4\n raw_flow, -- 5\n tag, -- 6\n parent_job, -- 7\n created_by, -- 8\n permissioned_as, -- 9\n runnable_id, -- 10\n runnable_path, -- 11\n args, -- 12\n kind, -- 13\n trigger, -- 14\n script_lang, -- 15\n same_worker, -- 16\n pre_run_error, -- 17\n permissioned_as_email, -- 18\n visible_to_owner, -- 19\n flow_innermost_root_job, -- 20\n root_job, -- 38\n concurrent_limit, -- 21\n concurrency_time_window_s, -- 22\n timeout, -- 23\n flow_step_id, -- 24\n cache_ttl, -- 25\n priority, -- 26\n trigger_kind, -- 39\n script_entrypoint_override, -- 12\n preprocessed, -- 27,\n labels, -- 44\n job_token_scopes -- 47\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27,\n -- $44 (payload labels) merged with the labels of the runnable's folder, if any\n -- ($45/$46 are runnable_path/workspace_id again, kept separate to avoid parameter type conflicts)\n (SELECT CASE WHEN fl.labels IS NULL THEN $44\n ELSE (SELECT array_agg(DISTINCT lbl) FROM unnest(COALESCE($44, ARRAY[]::TEXT[]) || fl.labels) lbl)\n END\n FROM folder_labels($46, $45) AS fl(labels)), $47)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email)\n values ($1, $32, $33, $34, $35, $36, $37, $2, $41)\n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31, $42, $43)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
"Uuid",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar",
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Bool",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Uuid",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"Int2",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Timestamptz",
|
||||
"Varchar",
|
||||
"Int2",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"JsonbArray",
|
||||
"TextArray",
|
||||
"Uuid",
|
||||
{
|
||||
"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",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Int8",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "921bdbe70e936935823fd202894a91a02d6bd218d21a9f43341cca846a5d276e"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT job_token_scopes IS NOT NULL FROM script WHERE hash = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ac94dccaf3709f178ab5636892c85c4ed3a067e4374517244608c345781dbc5c"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584"
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, 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, 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)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Varchar",
|
||||
"Int8Array",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Custom": {
|
||||
"name": "script_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"trigger",
|
||||
"failure",
|
||||
"command",
|
||||
"approval",
|
||||
"preprocessor"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar",
|
||||
"VarcharArray",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Int2",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"Bool",
|
||||
"Int8",
|
||||
"Jsonb",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT job_token_scopes IS NOT NULL FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false AND deleted = false ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "?column?",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c6faa49fa9385412c8f5107864b24bb83ddb9bbb2dad215d8568ae64104bdcb7"
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n ws_error_handler_muted = $7,\n value = $8,\n schema = $9::text::json,\n edited_by = $10,\n edited_at = now(),\n labels = COALESCE($13, labels),\n on_behalf_of = $14,\n on_behalf_of_email = $15,\n job_token_scopes = CASE WHEN $16 THEN $17 ELSE job_token_scopes END\n WHERE\n path = $11 AND workspace_id = $12",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1"
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels, job_token_scopes FROM script\n WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false)\n ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "tag",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "concurrency_key",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "concurrent_limit",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "concurrency_time_window_s",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "debounce_key",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "debounce_delay_s",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "cache_ttl",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "cache_ignore_s3_path",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "runnable_settings_handle",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "language: ScriptLang",
|
||||
"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": 11,
|
||||
"name": "dedicated_worker",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "priority",
|
||||
"type_info": "Int2"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "timeout",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "on_behalf_of",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "labels",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs,\n job_token_scopes\n )\n SELECT $2, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, NULL, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[],\n -- Same predicate as clone_scripts.\n CASE WHEN on_behalf_of LIKE 'u/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar\n AND u.username = substring(on_behalf_of from 3)\n UNION ALL\n SELECT 1 FROM password p WHERE p.super_admin\n AND (p.username = substring(on_behalf_of from 3)\n OR p.email = substring(on_behalf_of from 3))))\n WHEN on_behalf_of LIKE 'g/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM group_ g WHERE g.workspace_id = $2::varchar\n AND g.name = substring(on_behalf_of from 3)))\n ELSE\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar\n AND u.username = on_behalf_of\n UNION ALL\n SELECT 1 FROM password p WHERE p.email = on_behalf_of\n AND p.super_admin))\n END, on_behalf_of_email, lock_error_logs, job_token_scopes\n FROM flow\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes)\n SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes\n FROM flow WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8"
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n scheduled_for,\n runnable_path,\n kind as \"kind: JobKind\",\n runnable_id as \"runnable_id: ScriptHash\",\n canceled_reason,\n canceled_by,\n permissioned_as,\n permissioned_as_email,\n flow_status as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n cache_ignore_s3_path,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: TriggerKindLabel\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email,\n job_token_scopes\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id WHERE v2_job_queue.id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "args: sqlx::types::Json<HashMap<String, Box<RawValue>>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "started_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "runnable_settings_handle",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "scheduled_for",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "runnable_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "kind: JobKind",
|
||||
"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: ScriptHash",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "canceled_reason",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "canceled_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "permissioned_as",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "permissioned_as_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "flow_status: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "tag",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "script_lang: ScriptLang",
|
||||
"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": 18,
|
||||
"name": "same_worker",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 19,
|
||||
"name": "pre_run_error",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "concurrent_limit",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 21,
|
||||
"name": "concurrency_time_window_s",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "flow_innermost_root_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "root_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "timeout",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"name": "flow_step_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 26,
|
||||
"name": "cache_ttl",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"name": "cache_ignore_s3_path",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"name": "priority",
|
||||
"type_info": "Int2"
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"name": "preprocessed",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"name": "script_entrypoint_override",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"name": "trigger",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 32,
|
||||
"name": "trigger_kind: TriggerKindLabel",
|
||||
"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": 33,
|
||||
"name": "visible_to_owner",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 34,
|
||||
"name": "permissioned_as_end_user_email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 35,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "eb1ea11d391e5860aa4a6e9d4c1ecac07c2e0998c2cee48f254915690d8270d0"
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT p.email, p.username, p.is_admin, p.is_operator, p.groups, p.folders, p.end_user_email,\n j.job_token_scopes\n FROM job_perms p LEFT JOIN v2_job j ON j.id = p.job_id\n WHERE p.job_id = $1 AND p.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "is_operator",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "groups",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "folders",
|
||||
"type_info": "JsonbArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "end_user_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ec72f5257c99202e43105043c70d70694ba2572f1b744fe69b191eb5b3d5a48f"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, tag,\n dedicated_worker, visible_to_runner_only,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels,\n on_behalf_of, on_behalf_of_email, job_token_scopes\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5,\n $6, $7,\n $8,\n $9, $10::text::json, $11, now(), $12,\n $13, $14, $15\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
ff4d04f17721d84fb9a1f655af50ff7e4e7dcbc7
|
||||
7737c32ebed0901cc143ada358654ee4743b02b3
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE v2_job DROP COLUMN IF EXISTS job_token_scopes;
|
||||
ALTER TABLE flow DROP COLUMN IF EXISTS job_token_scopes;
|
||||
ALTER TABLE script DROP COLUMN IF EXISTS job_token_scopes;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE script ADD COLUMN IF NOT EXISTS job_token_scopes text[];
|
||||
ALTER TABLE flow ADD COLUMN IF NOT EXISTS job_token_scopes text[];
|
||||
ALTER TABLE v2_job ADD COLUMN IF NOT EXISTS job_token_scopes text[];
|
||||
@@ -97,7 +97,7 @@ draft: workspace_id(char), path(char), typ(draft_type), value(json), created_at(
|
||||
email_to_igroup: email(char), igroup(char)
|
||||
email_trigger: path(char), local_part(char), workspaced_local_part(bool), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[])
|
||||
favorite: usr(char), workspace_id(char), path(char), favorite_kind(favorite_kind)
|
||||
flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[])
|
||||
flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[]), job_token_scopes(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char), is_test(bool)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
@@ -183,7 +183,7 @@ resume_job: id(uuid), job(uuid), flow(uuid), created_at(ts), value(jsonb), appro
|
||||
runnable_settings: hash(bigint), debouncing_settings(bigint), concurrency_settings(bigint)
|
||||
schedule: workspace_id(char), path(char), edited_by(char), edited_at(ts), schedule(char), enabled(bool), script_path(char), args(jsonb), extra_perms(jsonb), is_flow(bool), email(char), error(text), timezone(char), on_failure(char), on_recovery(char), on_failure_times(int), on_failure_exact(bool), on_failure_extra_args(jsonb), on_recovery_times(int), on_recovery_extra_args(jsonb), ws_error_handler_muted(bool), retry(jsonb), summary(char), no_flow_overlap(bool), tag(char), paused_until(ts), on_success(char), on_success_extra_args(jsonb), cron_version(text), description(text), dynamic_skip(char), labels(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint), labels(text[]), on_behalf_of(varchar), on_behalf_of_email(text)
|
||||
script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint), labels(text[]), on_behalf_of(varchar), on_behalf_of_email(text), job_token_scopes(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
skip_workspace_diff_tally: workspace_id(char), added_at(ts)
|
||||
sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[])
|
||||
@@ -205,7 +205,7 @@ usr: workspace_id(char), username(char), email(char), is_admin(bool), created_at
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
usr_to_group: workspace_id(char), group_(char), usr(char)
|
||||
FK: (workspace_id, group_) -> group_(workspace_id, name) | (workspace_id) -> workspace(id)
|
||||
v2_job: id(uuid), raw_code(text), raw_lock(text), raw_flow(jsonb), tag(char), workspace_id(char), created_at(ts), created_by(char), permissioned_as(char), permissioned_as_email(char), kind(job_kind), runnable_id(bigint), runnable_path(char), parent_job(uuid), root_job(uuid), script_lang(script_lang), script_entrypoint_override(char), flow_step(int), flow_step_id(char), flow_innermost_root_job(uuid), trigger(char), trigger_kind(job_trigger_kind), same_worker(bool), visible_to_owner(bool), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), timeout(int), priority(smallint), preprocessed(bool), args(jsonb), labels(text[]), pre_run_error(text)
|
||||
v2_job: id(uuid), raw_code(text), raw_lock(text), raw_flow(jsonb), tag(char), workspace_id(char), created_at(ts), created_by(char), permissioned_as(char), permissioned_as_email(char), kind(job_kind), runnable_id(bigint), runnable_path(char), parent_job(uuid), root_job(uuid), script_lang(script_lang), script_entrypoint_override(char), flow_step(int), flow_step_id(char), flow_innermost_root_job(uuid), trigger(char), trigger_kind(job_trigger_kind), same_worker(bool), visible_to_owner(bool), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), timeout(int), priority(smallint), preprocessed(bool), args(jsonb), labels(text[]), pre_run_error(text), job_token_scopes(text[])
|
||||
v2_job_completed: id(uuid), workspace_id(char), duration_ms(bigint), result(jsonb), deleted(bool), canceled_by(char), canceled_reason(text), flow_status(jsonb), started_at(ts), memory_peak(int), status(job_status), completed_at(ts), worker(char), workflow_as_code_status(jsonb), result_columns(text[]), retries(uuid[]), extras(jsonb)
|
||||
v2_job_debounce_batch: id(uuid), debounce_batch(bigint)
|
||||
v2_job_queue: id(uuid), workspace_id(char), created_at(ts), started_at(ts), scheduled_for(ts), running(bool), canceled_by(char), canceled_reason(text), suspend(int), suspend_until(ts), tag(char), priority(smallint), worker(char), extras(jsonb), cache_ignore_s3_path(bool), runnable_settings_handle(bigint)
|
||||
|
||||
@@ -330,6 +330,7 @@ async fn end_to_end_asset_dispatch(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
};
|
||||
let completed = RunJob::from(job).run_until_complete(&db, false, port).await;
|
||||
assert!(
|
||||
@@ -493,6 +494,7 @@ async fn partition_dynamic_resolved_persisted_and_propagated(
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
};
|
||||
let completed = RunJob::from(job)
|
||||
.arg("tenant_id", json!("acme"))
|
||||
|
||||
@@ -53,6 +53,7 @@ fn script_payload() -> JobPayload {
|
||||
concurrency_settings: ConcurrencySettings::default().into(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ fn flow_payload() -> JobPayload {
|
||||
apply_preprocessor: false,
|
||||
version: FLOW_VERSION,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -854,6 +854,7 @@ fn run_main_script_job(hash: i64) -> RunJob {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ async fn stored_modules(db: &Pool<Postgres>, payload: JobPayload) -> Option<serd
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("push must succeed");
|
||||
@@ -81,6 +82,7 @@ async fn caller_modules_never_reach_a_job(db: Pool<Postgres>) {
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
};
|
||||
assert_eq!(stored_modules(&db, deployed).await, None);
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000001,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -122,6 +123,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000002,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(7))
|
||||
.push(&db)
|
||||
@@ -161,6 +163,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000003,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -205,6 +208,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(21))
|
||||
.push(&db)
|
||||
@@ -247,6 +251,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -265,6 +270,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -327,6 +333,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000004,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
@@ -356,6 +363,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000005,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -391,6 +399,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000006,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -427,6 +436,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000007,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -465,6 +475,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000008,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -500,6 +511,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000009,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -511,6 +523,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000010,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -564,6 +577,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -622,6 +636,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(7))
|
||||
.push(&db)
|
||||
@@ -634,6 +649,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000011,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(7))
|
||||
.push(&db)
|
||||
@@ -683,6 +699,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000012,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -742,6 +759,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000013,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
@@ -777,6 +795,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -795,6 +814,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -848,6 +868,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -867,6 +888,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -926,6 +948,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000014,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(20))
|
||||
.push(&db)
|
||||
@@ -938,6 +961,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000014,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -988,6 +1012,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1038,6 +1063,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1089,6 +1115,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1142,6 +1169,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1160,6 +1188,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1222,6 +1251,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(7))
|
||||
.push(&db)
|
||||
@@ -1240,6 +1270,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.workspace("test-workspace-2")
|
||||
.arg("x", json!(7))
|
||||
@@ -1294,6 +1325,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1338,6 +1370,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1375,6 +1408,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000015,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
@@ -1412,6 +1446,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000019,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
@@ -1451,6 +1486,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000016,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(1))
|
||||
.push(&db)
|
||||
@@ -1490,6 +1526,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000017,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1529,6 +1566,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000018,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
@@ -452,6 +452,7 @@ def main():
|
||||
ws_error_handler_muted: None,
|
||||
labels: None,
|
||||
skip_draft_deletion: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -161,6 +161,7 @@ export async function main(path: string, email: string, job_id: string, is_flow:
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
@@ -285,6 +286,7 @@ async fn test_error_handler_muted_on_script(db: Pool<Postgres>) -> anyhow::Resul
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
@@ -381,6 +383,7 @@ async fn test_error_handler_not_triggered_on_success(db: Pool<Postgres>) -> anyh
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
|
||||
@@ -2974,6 +2974,7 @@ async fn test_flow_env_marks_sub_flows_only_without_ancestor_env(
|
||||
apply_preprocessor: false,
|
||||
version,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
@@ -57,6 +57,7 @@ mod job_payload {
|
||||
language: ScriptLang::Deno,
|
||||
priority: None,
|
||||
apply_preprocessor: false,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("world", json!("foo"))
|
||||
.run_until_complete(&db, false, port)
|
||||
@@ -92,6 +93,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete_with(db, false, port, |id| async move {
|
||||
let job = sqlx::query!("SELECT preprocessed FROM v2_job WHERE id = $1", id)
|
||||
@@ -435,6 +437,7 @@ mod job_payload {
|
||||
apply_preprocessor: false,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
@@ -485,6 +488,7 @@ mod job_payload {
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253456,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete_with(db, false, port, |id| async move {
|
||||
let job = sqlx::query!("SELECT preprocessed FROM v2_job WHERE id = $1", id)
|
||||
@@ -556,6 +560,7 @@ mod job_payload {
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
@@ -1150,6 +1155,7 @@ mod job_payload {
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(db, false, port)
|
||||
.await;
|
||||
@@ -1625,6 +1631,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("foo", json!("hello"))
|
||||
.arg("bar", json!("world"))
|
||||
@@ -1676,6 +1683,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("foo", json!("hello"))
|
||||
.arg("bar", json!("world"))
|
||||
@@ -1727,6 +1735,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("foo", json!("hello"))
|
||||
.arg("bar", json!("world"))
|
||||
@@ -1778,6 +1787,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("foo", json!("hello"))
|
||||
.arg("bar", json!("world"))
|
||||
@@ -1831,6 +1841,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("foo", json!("hello"))
|
||||
.arg("bar", json!("world"))
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
//! A job token restricted by `job_token_scopes` reaches only what its scopes allow, plus the
|
||||
//! runtime routes about its own job, and no job it creates holds a wider token than it.
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::jobs::JobPayload;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
const OIDC_JOB: &str = "b0000000-0000-0000-0000-000000000001";
|
||||
const RUN_JOB: &str = "b0000000-0000-0000-0000-000000000002";
|
||||
|
||||
async fn insert_job(db: &Pool<Postgres>, id: &str, scopes: &[&str]) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email,
|
||||
kind, script_lang, runnable_path, tag, job_token_scopes)
|
||||
VALUES ($1, 'test-workspace', 'test-user-3', 'u/test-user-3', 'test3@windmill.dev',
|
||||
'script', 'deno', 'u/test-user-3/agent', 'deno', $2)",
|
||||
)
|
||||
.bind(Uuid::parse_str(id)?)
|
||||
.bind(scopes)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn job_token(db: &Pool<Postgres>, id: &str) -> anyhow::Result<String> {
|
||||
Ok(windmill_common::auth::create_token_for_owner(
|
||||
db,
|
||||
"test-workspace",
|
||||
"u/test-user-3",
|
||||
"ephemeral-script",
|
||||
300,
|
||||
"test3@windmill.dev",
|
||||
&Uuid::parse_str(id)?,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn insert_script(
|
||||
db: &Pool<Postgres>,
|
||||
path: &str,
|
||||
hash: i64,
|
||||
scopes: Option<&[&str]>,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO script (workspace_id, created_by, content, schema, summary, description,
|
||||
path, hash, language, lock, kind, job_token_scopes)
|
||||
VALUES ('test-workspace', 'test-user-3', 'export function main() {}', '{}', '', '',
|
||||
$1, $2, 'deno', '', 'script', $3)",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(hash)
|
||||
.bind(scopes)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_restricted_job_token_is_confined(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
insert_script(&db, "u/test-user-3/open", 515151, None).await?;
|
||||
insert_script(
|
||||
&db,
|
||||
"u/test-user-3/oidc_only",
|
||||
525252,
|
||||
Some(&["oidc:write"]),
|
||||
)
|
||||
.await?;
|
||||
insert_job(&db, OIDC_JOB, &["oidc:write"]).await?;
|
||||
insert_job(&db, RUN_JOB, &["jobs:run"]).await?;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
set_jwt_secret().await;
|
||||
let base = format!(
|
||||
"http://localhost:{}/api/w/test-workspace",
|
||||
server.addr.port()
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
let oidc_token = job_token(&db, OIDC_JOB).await?;
|
||||
|
||||
let refused = [
|
||||
client.get(format!("{base}/variables/get_value/u/test-user-3/secret")),
|
||||
client
|
||||
.post(format!("{base}/scripts/create"))
|
||||
.json(&json!({})),
|
||||
client
|
||||
.post(format!("{base}/schedules/create"))
|
||||
.json(&json!({})),
|
||||
client
|
||||
.post(format!("{base}/jobs/run/p/u/test-user-3/open"))
|
||||
.json(&json!({})),
|
||||
];
|
||||
for request in refused {
|
||||
let resp = request.bearer_auth(&oidc_token).send().await?;
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"{}",
|
||||
resp.text().await?
|
||||
);
|
||||
}
|
||||
let own = client
|
||||
.get(format!("{base}/jobs_u/get_root_job_id/{OIDC_JOB}"))
|
||||
.bearer_auth(&oidc_token)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(own.status(), StatusCode::OK, "{}", own.text().await?);
|
||||
|
||||
// A job it starts is capped at its own scopes, intersected with the target's setting.
|
||||
let run_token = job_token(&db, RUN_JOB).await?;
|
||||
for (path, expected) in [
|
||||
("open", vec!["jobs:run".to_string()]),
|
||||
("oidc_only", vec![]),
|
||||
] {
|
||||
let resp = client
|
||||
.post(format!("{base}/jobs/run/p/u/test-user-3/{path}"))
|
||||
.bearer_auth(&run_token)
|
||||
.json(&json!({}))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let child = Uuid::parse_str(&resp.text().await?)?;
|
||||
let scopes: Option<Vec<String>> =
|
||||
sqlx::query_scalar("SELECT job_token_scopes FROM v2_job WHERE id = $1")
|
||||
.bind(child)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(scopes, Some(expected), "child of {path}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_flow_steps_inherit_the_flow_restriction(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let value = json!({ "modules": [{ "id": "a", "value": { "type": "identity" } }] });
|
||||
sqlx::query(
|
||||
"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at,
|
||||
schema, extra_perms, versions, job_token_scopes)
|
||||
VALUES ('test-workspace', 'u/test-user/restricted', '', '', $1, 'test-user', now(), '{}',
|
||||
'{}', '{7171}', '{oidc:write}')",
|
||||
)
|
||||
.bind(&value)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by)
|
||||
VALUES (7171, 'test-workspace', 'u/test-user/restricted', $1, '{}', 'test-user')",
|
||||
)
|
||||
.bind(&value)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let flow = RunJob::from(JobPayload::Flow {
|
||||
path: "u/test-user/restricted".to_string(),
|
||||
dedicated_worker: None,
|
||||
apply_preprocessor: false,
|
||||
version: 7171,
|
||||
labels: None,
|
||||
job_token_scopes: Some(vec!["oidc:write".to_string()]),
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
|
||||
let step: Option<Vec<String>> =
|
||||
sqlx::query_scalar("SELECT job_token_scopes FROM v2_job WHERE parent_job = $1")
|
||||
.bind(flow.id)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(step, Some(vec!["oidc:write".to_string()]));
|
||||
Ok(())
|
||||
}
|
||||
@@ -957,6 +957,7 @@ async fn test_job_labels_propagated_at_push_time(db: Pool<Postgres>) -> anyhow::
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: Some(vec!["prod".to_string(), "deploy".to_string()]),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
@@ -1060,6 +1061,7 @@ async fn test_job_label_filter(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: Some(vec!["prod".to_string()]),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
@@ -1077,6 +1079,7 @@ async fn test_job_label_filter(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: Some(vec!["staging".to_string()]),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
@@ -191,6 +191,7 @@ async fn push_job(db: &Pool<Postgres>, content: &str, args: &serde_json::Value)
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("push must succeed");
|
||||
|
||||
@@ -76,6 +76,7 @@ async fn push_preview_and_get_row(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("push must succeed");
|
||||
|
||||
@@ -1278,6 +1278,7 @@ async fn test_python_wac_v2_with_preprocessor(db: Pool<Postgres>) -> anyhow::Res
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("who", json!("alice"))
|
||||
.arg("count", json!(7))
|
||||
|
||||
@@ -41,6 +41,7 @@ async fn test_api_restart_at_step_nested_happy(db: Pool<Postgres>) -> anyhow::Re
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
@@ -85,6 +86,7 @@ async fn test_api_restart_at_step_top_level_happy(db: Pool<Postgres>) -> anyhow:
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
@@ -118,6 +120,7 @@ async fn test_api_restart_at_step_rejects_unknown_step(db: Pool<Postgres>) -> an
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
@@ -161,6 +164,7 @@ async fn test_api_restart_at_step_rejects_out_of_range_iteration(
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
@@ -53,6 +53,7 @@ async fn push_restart(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -181,6 +181,7 @@ export async function main(path: string, email: string, job_id: string, is_flow:
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
|
||||
@@ -5653,6 +5653,7 @@ async fn test_flow_tag_judged_as_written_before_preprocessor(
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253456,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.as_user("test-user-2", "test2@windmill.dev")
|
||||
.run_until_complete(&db, false, port)
|
||||
|
||||
@@ -1131,8 +1131,11 @@ pub async fn resolve_opt_job_authed(
|
||||
return Err((err, parts));
|
||||
}
|
||||
}
|
||||
let own_job_runtime_route = opt_job_authed.job_id.is_some_and(|job_id| {
|
||||
crate::scopes::is_own_job_runtime_route(path, method, job_id)
|
||||
});
|
||||
let authed = &mut opt_job_authed.authed;
|
||||
if authed.scopes.is_some() {
|
||||
if authed.scopes.is_some() && !own_job_runtime_route {
|
||||
transform_old_scope_to_new_scope(authed.scopes.as_mut());
|
||||
|
||||
if let Err(err) = crate::scopes::check_scopes_for_route(
|
||||
|
||||
@@ -35,6 +35,7 @@ use windmill_common::{
|
||||
};
|
||||
|
||||
use scopes::ScopeDefinition;
|
||||
use windmill_common::scopes::scope_contains;
|
||||
|
||||
// Re-export key auth types and functions
|
||||
pub use auth::{
|
||||
@@ -475,11 +476,11 @@ pub fn is_effectively_unscoped(scopes: Option<&[String]>) -> bool {
|
||||
/// refused. An admin check is not a substitute — it answers for the user behind the
|
||||
/// token, not for the token's own scopes.
|
||||
///
|
||||
/// This bounds the token making the request, not every route to the key. A job token
|
||||
/// is minted unscoped from its owner's privileges, so a `jobs:run` token still reaches
|
||||
/// the key indirectly by running a job as a workspace admin — the same property that
|
||||
/// lets git-sync export it. Confining that means not inheriting unscoped privilege
|
||||
/// into job tokens, which is a far wider change than this guard.
|
||||
/// This bounds the token making the request, not every route to the key. The jobs a
|
||||
/// scoped user token runs get a token with their owner's privileges, capped only by the
|
||||
/// runnable's own `job_token_scopes` (see [`caller_scope_ceiling`]), so a `jobs:run`
|
||||
/// token still reaches the key indirectly by running an unrestricted job as a workspace
|
||||
/// admin — the same property that lets git-sync export it.
|
||||
pub fn forbid_scoped_token_workspace_key(authed: &ApiAuthed) -> error::Result<()> {
|
||||
if is_effectively_unscoped(authed.scopes.as_deref()) {
|
||||
return Ok(());
|
||||
@@ -492,6 +493,45 @@ pub fn forbid_scoped_token_workspace_key(authed: &ApiAuthed) -> error::Result<()
|
||||
))
|
||||
}
|
||||
|
||||
/// For a route that authenticates its token itself rather than through the route layer
|
||||
/// (which checks scopes): a job token restricted by `job_token_scopes` must still hold
|
||||
/// `required` there. Other credentials keep the access those routes always gave them.
|
||||
pub fn check_job_token_scope<F>(authed: &ApiAuthed, required: F) -> error::Result<()>
|
||||
where
|
||||
F: FnOnce() -> String,
|
||||
{
|
||||
if authed.job_id.is_some() {
|
||||
check_scopes(authed, required)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The cap on the token of a job pushed on `authed`'s request, to pass to `push` as its
|
||||
/// scope ceiling. A job token caps every job it starts at its own job's scopes, so a
|
||||
/// restricted job cannot widen its reach by running another one; the job row is the
|
||||
/// source rather than the token's claims, which the MCP proxy narrows per route. Any
|
||||
/// other credential caps nothing: the jobs it runs act as their owner, as they always did.
|
||||
pub async fn caller_scope_ceiling(
|
||||
db: &windmill_common::DB,
|
||||
authed: &ApiAuthed,
|
||||
) -> error::Result<Option<Vec<String>>> {
|
||||
let Some(job_id) = authed.job_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
let row = sqlx::query_scalar!(
|
||||
"SELECT job_token_scopes FROM v2_job WHERE id = $1",
|
||||
job_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
Ok(match row {
|
||||
Some(scopes) => scopes,
|
||||
// The job row is gone (retention), so only the token's own claims are left.
|
||||
None => authed.scopes.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes
|
||||
/// a credential on behalf of `authed`: the resulting credential must never be
|
||||
/// more privileged than the caller's own token.
|
||||
@@ -604,83 +644,6 @@ fn first_filter_tags(scopes: Option<&[String]>) -> Option<Vec<&str>> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `caller` grants at least everything `requested` grants (directional
|
||||
/// containment).
|
||||
///
|
||||
/// This is intentionally NOT `ScopeDefinition::includes`: that method answers
|
||||
/// "does this scope grant access to a required action" using OR semantics over
|
||||
/// resources (any overlap counts, and a `*` on either side matches), which is
|
||||
/// correct for access checks but unsafe for subset checks — it would let a
|
||||
/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or
|
||||
/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that
|
||||
/// EVERY requested resource is covered by SOME caller resource.
|
||||
fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool {
|
||||
if caller.domain != requested.domain {
|
||||
return false;
|
||||
}
|
||||
|
||||
// write subsumes read; otherwise the action must match exactly.
|
||||
match (caller.action.as_str(), requested.action.as_str()) {
|
||||
(c, r) if c == r || (c == "write" && r == "read") => {}
|
||||
// Apps only: `write` covers `run` (see `ScopeDefinition::includes`), so an
|
||||
// app-editor token can mint the narrower run-only credential.
|
||||
("write", "run") if caller.domain == "apps" => {}
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
if caller.domain == "jobs" && caller.action == "run" {
|
||||
match (&caller.kind, &requested.kind) {
|
||||
(Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => {
|
||||
return false
|
||||
}
|
||||
// Caller pinned to a kind, but the request covers any kind.
|
||||
(Some(_), None) => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match (&caller.resource, &requested.resource) {
|
||||
// Caller is unrestricted on resources: covers everything.
|
||||
(None, _) => true,
|
||||
// Caller is resource-restricted but the request is not: broader, unless the
|
||||
// caller lists `*` and so already spans every path. Kept in step with
|
||||
// `ScopeDefinition::includes`, which accepts that same grant for a
|
||||
// whole-collection read: what a token may exercise, it may also delegate.
|
||||
(Some(caller_resources), None) => caller_resources.iter().any(|r| r == "*"),
|
||||
(Some(caller_resources), Some(requested_resources)) => {
|
||||
resource_set_contains(caller_resources, requested_resources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every resource in `requested` must be covered by some resource in `caller`.
|
||||
fn resource_set_contains(caller: &[String], requested: &[String]) -> bool {
|
||||
if caller.iter().any(|r| r == "*") {
|
||||
return true;
|
||||
}
|
||||
requested
|
||||
.iter()
|
||||
.all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req)))
|
||||
}
|
||||
|
||||
/// Directional: does the single caller resource pattern cover `requested`?
|
||||
/// `caller` may be an exact path or a `<prefix>/*` subtree wildcard; `requested`
|
||||
/// may itself be a subtree wildcard, in which case the whole requested subtree
|
||||
/// must fall within the caller's subtree.
|
||||
fn resource_covers(caller: &str, requested: &str) -> bool {
|
||||
if caller == requested {
|
||||
return true;
|
||||
}
|
||||
let Some(prefix) = caller.strip_suffix("/*") else {
|
||||
// An exact caller resource only covers itself (handled above).
|
||||
return false;
|
||||
};
|
||||
let requested_base = requested.strip_suffix("/*").unwrap_or(requested);
|
||||
requested_base == prefix
|
||||
|| (requested_base.starts_with(prefix)
|
||||
&& requested_base.as_bytes().get(prefix.len()) == Some(&b'/'))
|
||||
}
|
||||
|
||||
/// Returns a predicate that checks whether `path` is within the token's
|
||||
/// scope for `{domain}:{action}:{path}`. For tokens without scope
|
||||
/// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes),
|
||||
|
||||
@@ -6,464 +6,9 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use windmill_common::error::{Error, Result};
|
||||
|
||||
/// Comprehensive scope system for JWT token authorization
|
||||
///
|
||||
/// Scopes follow the format: {domain}:{action}[:{resource}]
|
||||
/// Examples:
|
||||
/// - "jobs:read" - Read access to jobs
|
||||
/// - "scripts:write:f/folder/*" - Write access to scripts in a folder
|
||||
/// - "*" - Full access (superuser)
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScopeDefinition {
|
||||
pub domain: String,
|
||||
pub action: String,
|
||||
pub kind: Option<String>, // For jobs:run:kind (optional)
|
||||
pub resource: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ScopeDefinition {
|
||||
pub fn new(
|
||||
domain: &str,
|
||||
action: &str,
|
||||
kind: Option<&str>,
|
||||
resource: Option<Vec<String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
domain: domain.to_string(),
|
||||
action: action.to_string(),
|
||||
kind: kind.map(|s| s.to_string()),
|
||||
resource: resource,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_scope_string(scope: &str) -> Result<Self> {
|
||||
let parts: Vec<&str> = scope.split(':').collect();
|
||||
|
||||
let into_owned_vec = |resources: &str| -> Vec<String> {
|
||||
let resources = resources
|
||||
.split(",")
|
||||
.collect_vec()
|
||||
.into_iter()
|
||||
.map(ToOwned::to_owned)
|
||||
.collect_vec();
|
||||
|
||||
resources
|
||||
};
|
||||
|
||||
match parts.len() {
|
||||
2 => Ok(Self::new(parts[0], parts[1], None, None)), // domain:action
|
||||
3 => {
|
||||
if parts[0] == "jobs" && parts[1] == "run" {
|
||||
Ok(Self::new(parts[0], parts[1], Some(parts[2]), None))
|
||||
} else {
|
||||
Ok(Self::new(
|
||||
parts[0],
|
||||
parts[1],
|
||||
None,
|
||||
Some(into_owned_vec(parts[2])),
|
||||
))
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
if parts[0] == "jobs" && parts[1] == "run" {
|
||||
Ok(Self::new(
|
||||
parts[0],
|
||||
parts[1],
|
||||
Some(parts[2]),
|
||||
Some(into_owned_vec(parts[3])),
|
||||
))
|
||||
} else {
|
||||
Err(Error::BadRequest(format!(
|
||||
"Invalid 4-part scope: {}",
|
||||
scope
|
||||
)))
|
||||
}
|
||||
}
|
||||
_ => Err(Error::BadRequest(format!(
|
||||
"Invalid scope format: {}",
|
||||
scope
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_string(&self) -> String {
|
||||
match (&self.kind, &self.resource) {
|
||||
(Some(kind), Some(resource)) => {
|
||||
format!(
|
||||
"{}:{}:{}:{}",
|
||||
self.domain,
|
||||
self.action,
|
||||
kind,
|
||||
resource.join(",")
|
||||
)
|
||||
}
|
||||
(Some(kind), None) => {
|
||||
format!("{}:{}:{}", self.domain, self.action, kind)
|
||||
}
|
||||
(None, Some(resource)) => {
|
||||
format!("{}:{}:{}", self.domain, self.action, resource.join(","))
|
||||
}
|
||||
(None, None) => format!("{}:{}", self.domain, self.action),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn includes(&self, other: &ScopeDefinition) -> bool {
|
||||
if self.domain != other.domain {
|
||||
return false;
|
||||
}
|
||||
|
||||
match (self.action.as_str(), other.action.as_str()) {
|
||||
(a, b) if (a == "write" && b == "read") || (a == b) => {}
|
||||
// Apps only: `write` can rewrite the app and its policy, so it also covers
|
||||
// running its components. Not general — `jobs:write` must not grant
|
||||
// `jobs:run`. The resource check below still confines it to the same app.
|
||||
("write", "run") if self.domain == "apps" => {}
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
if self.domain == "jobs" && self.action == "run" {
|
||||
match (&self.kind, &other.kind) {
|
||||
(Some(self_kind), Some(other_kind)) => {
|
||||
if self_kind != other_kind {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
(Some(_), None) => {
|
||||
return false;
|
||||
}
|
||||
(None, _) => {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (&self.resource, &other.resource) {
|
||||
(Some(self_resources), Some(other_resources)) => {
|
||||
resources_match(self_resources, other_resources)
|
||||
}
|
||||
// A requirement naming no path is the whole domain, so only a grant that
|
||||
// itself spans every path satisfies it. `*` is that grant — the scope UI
|
||||
// accepts it as a resource path and `resources_match` already reads it as
|
||||
// everything — while any listed path leaves the collection unauthorized.
|
||||
(Some(self_resources), None) => self_resources.iter().any(|r| r == "*"),
|
||||
(None, _) => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resources_match(scope_resources: &[String], accepted_resources: &[String]) -> bool {
|
||||
if scope_resources.contains(&"*".to_string()) || accepted_resources.contains(&"*".to_string()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if scope_resources.len() <= 4 && accepted_resources.len() <= 4 {
|
||||
return resources_match_small(scope_resources, accepted_resources);
|
||||
}
|
||||
|
||||
resources_match_large(scope_resources, accepted_resources)
|
||||
}
|
||||
|
||||
fn resources_match_small(scope_resources: &[String], accepted_resources: &[String]) -> bool {
|
||||
for required in accepted_resources {
|
||||
for scope_resource in scope_resources {
|
||||
if resource_matches_pattern(scope_resource, required) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn resources_match_large(scope_resources: &[String], accepted_resources: &[String]) -> bool {
|
||||
let mut exact_matches = HashSet::new();
|
||||
let mut patterns = Vec::new();
|
||||
|
||||
for scope_resource in scope_resources {
|
||||
if scope_resource.contains('*') {
|
||||
patterns.push(scope_resource);
|
||||
} else {
|
||||
exact_matches.insert(scope_resource);
|
||||
}
|
||||
}
|
||||
|
||||
for accepted_resource in accepted_resources {
|
||||
if exact_matches.contains(accepted_resource) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for pattern in &patterns {
|
||||
if resource_matches_pattern(pattern, accepted_resource) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn resource_matches_pattern(scope_resource: &str, accepted_resource: &str) -> bool {
|
||||
if scope_resource == accepted_resource {
|
||||
return true;
|
||||
}
|
||||
|
||||
let matches_wildcard = |pattern: &str, resource: &str| -> bool {
|
||||
if !pattern.ends_with("/*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let prefix = &pattern[..pattern.len() - 2];
|
||||
|
||||
if !resource.starts_with(prefix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the resource is exactly the prefix, it matches
|
||||
if resource.len() == prefix.len() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the resource is longer, the next character must be '/' for a valid match
|
||||
// This prevents "u/user" from matching "u/use/*"
|
||||
resource.chars().nth(prefix.len()) == Some('/')
|
||||
};
|
||||
|
||||
// Check if either resource is a wildcard pattern and matches the other
|
||||
matches_wildcard(scope_resource, accepted_resource)
|
||||
|| matches_wildcard(accepted_resource, scope_resource)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Route-level scope checking
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Available scope domains (top-level API categories)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ScopeDomain {
|
||||
// Core resource domains
|
||||
Jobs,
|
||||
Scripts,
|
||||
/// The `/data_metrics` catalog. Its own domain, NOT an alias of `Scripts`: a
|
||||
/// `data_metrics:read` token must reach only this route, never the broader
|
||||
/// `/scripts` routes (some of which do no further scope check).
|
||||
DataMetrics,
|
||||
Flows,
|
||||
FlowConversations,
|
||||
Apps,
|
||||
Variables,
|
||||
Resources,
|
||||
Schedules,
|
||||
Folders,
|
||||
Users,
|
||||
Groups,
|
||||
Workspaces,
|
||||
|
||||
// Trigger domains
|
||||
HttpTriggers,
|
||||
WebsocketTriggers,
|
||||
KafkaTriggers,
|
||||
NatsTriggers,
|
||||
MqttTriggers,
|
||||
AmqpTriggers,
|
||||
SqsTriggers,
|
||||
GcpTriggers,
|
||||
AzureTriggers,
|
||||
PostgresTriggers,
|
||||
EmailTriggers,
|
||||
|
||||
// Native trigger domains
|
||||
NativeTriggers,
|
||||
TriggersHistory,
|
||||
|
||||
// System domains
|
||||
Audit,
|
||||
Settings,
|
||||
Workers,
|
||||
ServiceLogs,
|
||||
Configs,
|
||||
OAuth,
|
||||
AI,
|
||||
AiEvals, // AI agent eval datasets
|
||||
|
||||
Indexer,
|
||||
Teams, // Microsoft Teams integration
|
||||
GitSync, // Git synchronization
|
||||
|
||||
// Special domains
|
||||
Capture, // Webhook capture
|
||||
Drafts, // Draft resources
|
||||
Favorites, // User favorites
|
||||
Inputs, // Input templates
|
||||
JobHelpers, // Job helper functions
|
||||
ConcurrencyGroups, // Concurrency groups
|
||||
Oidc, // OpenID Connect
|
||||
Openapi, // OpenAPI generation
|
||||
|
||||
// Additional domains
|
||||
Acls, // Granular access control lists
|
||||
RawApps, // Raw application data
|
||||
AgentWorkers, // Agent workers management
|
||||
Mcp, // MCP
|
||||
Docs, // Self-hosted documentation search (read-only)
|
||||
}
|
||||
|
||||
impl ScopeDomain {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Jobs => "jobs",
|
||||
Self::Scripts => "scripts",
|
||||
Self::DataMetrics => "data_metrics",
|
||||
Self::Flows => "flows",
|
||||
Self::FlowConversations => "flow_conversations",
|
||||
Self::Apps => "apps",
|
||||
Self::Variables => "variables",
|
||||
Self::Resources => "resources",
|
||||
Self::Schedules => "schedules",
|
||||
Self::Folders => "folders",
|
||||
Self::Users => "users",
|
||||
Self::Groups => "groups",
|
||||
Self::Workspaces => "workspaces",
|
||||
Self::HttpTriggers => "http_triggers",
|
||||
Self::WebsocketTriggers => "websocket_triggers",
|
||||
Self::KafkaTriggers => "kafka_triggers",
|
||||
Self::NatsTriggers => "nats_triggers",
|
||||
Self::MqttTriggers => "mqtt_triggers",
|
||||
Self::AmqpTriggers => "amqp_triggers",
|
||||
Self::SqsTriggers => "sqs_triggers",
|
||||
Self::GcpTriggers => "gcp_triggers",
|
||||
Self::AzureTriggers => "azure_triggers",
|
||||
Self::PostgresTriggers => "postgres_triggers",
|
||||
Self::EmailTriggers => "email_triggers",
|
||||
Self::NativeTriggers => "native_triggers",
|
||||
Self::TriggersHistory => "triggers_history",
|
||||
Self::Audit => "audit",
|
||||
Self::Settings => "settings",
|
||||
Self::Workers => "workers",
|
||||
Self::ServiceLogs => "service_logs",
|
||||
Self::Configs => "configs",
|
||||
Self::OAuth => "oauth",
|
||||
Self::AI => "ai",
|
||||
Self::AiEvals => "ai_evals",
|
||||
Self::Capture => "capture",
|
||||
Self::Drafts => "drafts",
|
||||
Self::Favorites => "favorites",
|
||||
Self::Inputs => "inputs",
|
||||
Self::JobHelpers => "job_helpers",
|
||||
Self::ConcurrencyGroups => "concurrency_groups",
|
||||
Self::Oidc => "oidc",
|
||||
Self::Openapi => "openapi",
|
||||
Self::Acls => "acls",
|
||||
Self::RawApps => "raw_apps",
|
||||
Self::AgentWorkers => "agent_workers",
|
||||
Self::Indexer => "indexer",
|
||||
Self::Teams => "teams",
|
||||
Self::GitSync => "git_sync",
|
||||
Self::Mcp => "mcp",
|
||||
Self::Docs => "docs",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"jobs" | "jobs_u" => Some(Self::Jobs),
|
||||
"scripts" => Some(Self::Scripts),
|
||||
// A distinct domain, not an alias of `scripts` (see the enum variant):
|
||||
// a `data_metrics:read` token must not reach the broader /scripts routes.
|
||||
"data_metrics" => Some(Self::DataMetrics),
|
||||
"flows" => Some(Self::Flows),
|
||||
"flow_conversations" => Some(Self::FlowConversations),
|
||||
"apps" | "apps_u" => Some(Self::Apps),
|
||||
"variables" => Some(Self::Variables),
|
||||
"resources" => Some(Self::Resources),
|
||||
"schedules" => Some(Self::Schedules),
|
||||
"folders" => Some(Self::Folders),
|
||||
"users" => Some(Self::Users),
|
||||
"groups" => Some(Self::Groups),
|
||||
"workspaces" => Some(Self::Workspaces),
|
||||
"http_triggers" => Some(Self::HttpTriggers),
|
||||
"websocket_triggers" => Some(Self::WebsocketTriggers),
|
||||
"kafka_triggers" => Some(Self::KafkaTriggers),
|
||||
"nats_triggers" => Some(Self::NatsTriggers),
|
||||
"mqtt_triggers" => Some(Self::MqttTriggers),
|
||||
"amqp_triggers" => Some(Self::AmqpTriggers),
|
||||
"sqs_triggers" => Some(Self::SqsTriggers),
|
||||
"gcp_triggers" => Some(Self::GcpTriggers),
|
||||
"azure_triggers" => Some(Self::AzureTriggers),
|
||||
"postgres_triggers" => Some(Self::PostgresTriggers),
|
||||
"email_triggers" => Some(Self::EmailTriggers),
|
||||
"audit" => Some(Self::Audit),
|
||||
"settings" => Some(Self::Settings),
|
||||
"workers" => Some(Self::Workers),
|
||||
"service_logs" => Some(Self::ServiceLogs),
|
||||
"configs" => Some(Self::Configs),
|
||||
"oauth" => Some(Self::OAuth),
|
||||
"ai" => Some(Self::AI),
|
||||
"ai_evals" => Some(Self::AiEvals),
|
||||
"indexer" | "srch" => Some(Self::Indexer),
|
||||
"teams" => Some(Self::Teams),
|
||||
"native_triggers" => Some(Self::NativeTriggers),
|
||||
"triggers_history" => Some(Self::TriggersHistory),
|
||||
"git_sync" | "github_app" => Some(Self::GitSync),
|
||||
"capture" => Some(Self::Capture),
|
||||
"drafts" => Some(Self::Drafts),
|
||||
"favorites" => Some(Self::Favorites),
|
||||
"inputs" => Some(Self::Inputs),
|
||||
"job_helpers" => Some(Self::JobHelpers),
|
||||
"concurrency_groups" => Some(Self::ConcurrencyGroups),
|
||||
"oidc" => Some(Self::Oidc),
|
||||
"openapi" => Some(Self::Openapi),
|
||||
"acls" => Some(Self::Acls),
|
||||
"raw_apps" => Some(Self::RawApps),
|
||||
"agent_workers" => Some(Self::AgentWorkers),
|
||||
"mcp" => Some(Self::Mcp),
|
||||
"docs" => Some(Self::Docs),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Available scope actions
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ScopeAction {
|
||||
Read, // GET operations, list, view
|
||||
Write, // POST, PUT, PATCH, DELETE operations, create, update, delete
|
||||
Run, // Special action for running (scripts, flows, etc.)
|
||||
}
|
||||
|
||||
impl ScopeAction {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => "read",
|
||||
Self::Write => "write",
|
||||
Self::Run => "run",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"read" => Some(Self::Read),
|
||||
"write" => Some(Self::Write),
|
||||
"delete" => Some(Self::Write),
|
||||
"run" => Some(Self::Run),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this action includes another action
|
||||
/// Write includes Read
|
||||
pub fn includes(&self, other: &ScopeAction) -> bool {
|
||||
match (self, other) {
|
||||
(ScopeAction::Write, ScopeAction::Read) => true,
|
||||
(ScopeAction::Run, ScopeAction::Read) => true,
|
||||
(a, b) => a == b,
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use windmill_common::scopes::{ScopeAction, ScopeDefinition, ScopeDomain};
|
||||
|
||||
pub fn check_route_access(
|
||||
token_scopes: &[String],
|
||||
@@ -1203,6 +748,37 @@ 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, and the results its own
|
||||
/// flow's input transforms read. A job token restricted by `job_token_scopes` keeps these
|
||||
/// whatever its scopes, or the job could not run at all, but only for its own job: the job
|
||||
/// id in the path must be the token's.
|
||||
pub fn is_own_job_runtime_route(route_path: &str, http_method: &str, job_id: uuid::Uuid) -> bool {
|
||||
let Some(rest) = route_path.strip_prefix("/api/w/") else {
|
||||
return false;
|
||||
};
|
||||
let Some((_workspace, rest)) = rest.split_once('/') else {
|
||||
return false;
|
||||
};
|
||||
let segments: Vec<&str> = rest.split('/').collect();
|
||||
let (method_ok, id_index) = match segments.as_slice() {
|
||||
["jobs_u", "get" | "get_root_job_id", ..] => (http_method == "GET", 2),
|
||||
["job_metrics", "set_progress", ..] => (http_method == "POST", 2),
|
||||
["job_metrics", "get_progress", ..] => (http_method == "GET", 2),
|
||||
["jobs" | "jobs_u", "resume_urls" | "wac_approval_urls" | "result_by_id", ..] => {
|
||||
(http_method == "GET", 2)
|
||||
}
|
||||
["jobs", "wac", "inline_checkpoint", ..] => (http_method == "POST", 3),
|
||||
["jobs", "run", "workflow_as_code", ..] => (http_method == "POST", 3),
|
||||
_ => return false,
|
||||
};
|
||||
method_ok
|
||||
&& segments
|
||||
.get(id_index)
|
||||
.and_then(|id| uuid::Uuid::parse_str(id).ok())
|
||||
.is_some_and(|id| id == job_id)
|
||||
}
|
||||
|
||||
pub fn check_scopes_for_route(
|
||||
token_scopes: Option<&[String]>,
|
||||
route_path: &str,
|
||||
@@ -1221,6 +797,37 @@ pub fn check_scopes_for_route(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn own_job_runtime_routes_admit_only_the_tokens_job() {
|
||||
let own = uuid::Uuid::new_v4();
|
||||
let other = uuid::Uuid::new_v4();
|
||||
let ok = |path: &str, method: &str| is_own_job_runtime_route(path, method, own);
|
||||
assert!(ok(
|
||||
&format!("/api/w/ws/job_metrics/set_progress/{own}"),
|
||||
"POST"
|
||||
));
|
||||
assert!(ok(
|
||||
&format!("/api/w/ws/jobs_u/get_root_job_id/{own}"),
|
||||
"GET"
|
||||
));
|
||||
assert!(ok(&format!("/api/w/ws/jobs/resume_urls/{own}/0"), "GET"));
|
||||
assert!(ok(
|
||||
&format!("/api/w/ws/jobs/wac/inline_checkpoint/{own}"),
|
||||
"POST"
|
||||
));
|
||||
assert!(ok(
|
||||
&format!("/api/w/ws/jobs/run/workflow_as_code/{own}/main"),
|
||||
"POST"
|
||||
));
|
||||
assert!(!ok(
|
||||
&format!("/api/w/ws/job_metrics/set_progress/{other}"),
|
||||
"POST"
|
||||
));
|
||||
assert!(!ok(&format!("/api/w/ws/jobs_u/get/{own}"), "POST"));
|
||||
assert!(!ok(&format!("/api/w/ws/jobs/run/p/{own}"), "POST"));
|
||||
assert!(!ok("/api/w/ws/variables/get_value/u/admin/secret", "GET"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scope_definition_parsing() {
|
||||
let scope = ScopeDefinition::from_scope_string("jobs:read").unwrap();
|
||||
|
||||
@@ -803,6 +803,12 @@ async fn create_flow(
|
||||
check_schedule_conflict(&mut tx, &w_id, &nf.path).await?;
|
||||
|
||||
let schema_str = nf.schema.and_then(|x| serde_json::to_string(&x.0).ok());
|
||||
let job_token_scopes = nf
|
||||
.job_token_scopes
|
||||
.as_ref()
|
||||
.and_then(|scopes| scopes.as_deref())
|
||||
.map(windmill_common::scopes::validate_job_token_scopes)
|
||||
.transpose()?;
|
||||
let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of(
|
||||
nf.on_behalf_of_email.as_deref(),
|
||||
nf.on_behalf_of.as_deref(),
|
||||
@@ -823,14 +829,14 @@ async fn create_flow(
|
||||
dedicated_worker, visible_to_runner_only,
|
||||
ws_error_handler_muted,
|
||||
value, schema, edited_by, edited_at, labels,
|
||||
on_behalf_of, on_behalf_of_email
|
||||
on_behalf_of, on_behalf_of_email, job_token_scopes
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
NULL, '', $5,
|
||||
$6, $7,
|
||||
$8,
|
||||
$9, $10::text::json, $11, now(), $12,
|
||||
$13, $14
|
||||
$13, $14, $15
|
||||
)"#,
|
||||
w_id,
|
||||
nf.path,
|
||||
@@ -846,9 +852,11 @@ async fn create_flow(
|
||||
nf.labels.as_deref() as Option<&[String]>,
|
||||
resolved_on_behalf_of,
|
||||
legacy_on_behalf_of_email,
|
||||
job_token_scopes.as_deref() as Option<&[String]>,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
windmill_common::scopes::log_job_token_scopes_deploy("flow", job_token_scopes.as_deref());
|
||||
|
||||
let version = sqlx::query_scalar!(
|
||||
"INSERT INTO flow_version (workspace_id, path, value, schema, created_by)
|
||||
@@ -973,6 +981,7 @@ async fn create_flow(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1152,7 +1161,7 @@ async fn get_flow_version(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let flow = sqlx::query_as::<_, Flow>(
|
||||
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
|
||||
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow.job_token_scopes, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
|
||||
FROM flow
|
||||
LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id
|
||||
WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3",
|
||||
@@ -1212,6 +1221,7 @@ async fn get_flow_version_by_id(
|
||||
flow.visible_to_runner_only,
|
||||
flow.on_behalf_of,
|
||||
flow.labels,
|
||||
flow.job_token_scopes,
|
||||
flow_version.schema,
|
||||
flow_version.value,
|
||||
flow_version.created_at as edited_at,
|
||||
@@ -1367,6 +1377,15 @@ async fn update_flow(
|
||||
let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?;
|
||||
let is_new_path = nf.path != flow_path;
|
||||
let schema_str = schema.and_then(|x| serde_json::to_string(&x).ok());
|
||||
// Absent keeps the deployed value: a client unaware of the setting must not drop a
|
||||
// restriction by saving the flow.
|
||||
let set_job_token_scopes = nf.job_token_scopes.is_some();
|
||||
let job_token_scopes = nf
|
||||
.job_token_scopes
|
||||
.as_ref()
|
||||
.and_then(|scopes| scopes.as_deref())
|
||||
.map(windmill_common::scopes::validate_job_token_scopes)
|
||||
.transpose()?;
|
||||
let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of(
|
||||
nf.on_behalf_of_email.as_deref(),
|
||||
nf.on_behalf_of.as_deref(),
|
||||
@@ -1401,7 +1420,8 @@ async fn update_flow(
|
||||
edited_at = now(),
|
||||
labels = COALESCE($13, labels),
|
||||
on_behalf_of = $14,
|
||||
on_behalf_of_email = $15
|
||||
on_behalf_of_email = $15,
|
||||
job_token_scopes = CASE WHEN $16 THEN $17 ELSE job_token_scopes END
|
||||
WHERE
|
||||
path = $11 AND workspace_id = $12",
|
||||
if is_new_path { flow_path } else { &nf.path },
|
||||
@@ -1419,19 +1439,24 @@ async fn update_flow(
|
||||
nf.labels.as_deref() as Option<&[String]>,
|
||||
resolved_on_behalf_of,
|
||||
legacy_on_behalf_of_email,
|
||||
set_job_token_scopes,
|
||||
job_token_scopes.as_deref() as Option<&[String]>,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error::Error::internal_err(format!("Error updating flow due to flow update: {e:#}"))
|
||||
})?;
|
||||
if set_job_token_scopes {
|
||||
windmill_common::scopes::log_job_token_scopes_deploy("flow", job_token_scopes.as_deref());
|
||||
}
|
||||
|
||||
if is_new_path {
|
||||
// if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint
|
||||
sqlx::query!(
|
||||
"INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels)
|
||||
SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes)
|
||||
SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes
|
||||
FROM flow
|
||||
WHERE path = $2 AND workspace_id = $3",
|
||||
nf.path,
|
||||
@@ -1695,6 +1720,7 @@ async fn update_flow(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1870,6 +1896,7 @@ async fn get_flow_by_path(
|
||||
flow.visible_to_runner_only,
|
||||
flow.on_behalf_of,
|
||||
flow.labels,
|
||||
flow.job_token_scopes,
|
||||
folder_labels(flow.workspace_id, flow.path) AS inherited_labels,
|
||||
flow_version.id AS version_id,
|
||||
flow_version.schema,
|
||||
@@ -1911,6 +1938,7 @@ async fn get_flow_by_path(
|
||||
flow.visible_to_runner_only,
|
||||
flow.on_behalf_of,
|
||||
flow.labels,
|
||||
flow.job_token_scopes,
|
||||
folder_labels(flow.workspace_id, flow.path) AS inherited_labels,
|
||||
flow_version.id AS version_id,
|
||||
flow_version.schema,
|
||||
|
||||
@@ -865,6 +865,7 @@ pub async fn run_flow<'c>(
|
||||
chat_input_enabled,
|
||||
early_return,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
..
|
||||
} = flow_version_info;
|
||||
|
||||
@@ -914,6 +915,7 @@ pub async fn run_flow<'c>(
|
||||
)
|
||||
};
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?;
|
||||
let (uuid, mut tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -924,6 +926,7 @@ pub async fn run_flow<'c>(
|
||||
version,
|
||||
apply_preprocessor,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
},
|
||||
push_args,
|
||||
authed.display_username(),
|
||||
@@ -950,6 +953,7 @@ pub async fn run_flow<'c>(
|
||||
None,
|
||||
authed.trigger_or_fallback(trigger),
|
||||
run_query.suspended_mode,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1141,6 +1145,7 @@ pub async fn push_script_job_by_path_into_queue<'c>(
|
||||
)
|
||||
};
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -1176,6 +1181,7 @@ pub async fn push_script_job_by_path_into_queue<'c>(
|
||||
None,
|
||||
authed.trigger_or_fallback(trigger),
|
||||
run_query.suspended_mode,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -897,6 +897,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 {
|
||||
@@ -950,6 +951,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: _,
|
||||
@@ -1023,6 +1026,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
|
||||
@@ -1373,6 +1379,21 @@ async fn create_script_internal<'c>(
|
||||
&db,
|
||||
)
|
||||
.await?;
|
||||
// Absent keeps what the path already has: a client unaware of the setting must not drop a
|
||||
// restriction by redeploying.
|
||||
let resolved_job_token_scopes = match &ns.job_token_scopes {
|
||||
Some(Some(scopes)) => Some(windmill_common::scopes::validate_job_token_scopes(scopes)?),
|
||||
Some(None) => 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)
|
||||
@@ -1618,8 +1639,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,
|
||||
@@ -2195,8 +2222,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,
|
||||
@@ -2240,9 +2267,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,
|
||||
@@ -2952,6 +2984,7 @@ async fn create_script_internal<'c>(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -3075,6 +3108,7 @@ async fn create_script_internal<'c>(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("pushed auto-build binary job {job_id} for {script_path}");
|
||||
|
||||
@@ -235,6 +235,7 @@ async fn run_datatable_migration_job(
|
||||
args.insert("database".to_string(), database_arg.clone());
|
||||
let push_args = PushArgs { extra: None, args: &args };
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?;
|
||||
let (uuid, mut tx) = push(
|
||||
db,
|
||||
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
|
||||
@@ -278,6 +279,7 @@ async fn run_datatable_migration_job(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -7637,7 +7637,7 @@ async fn clone_scripts(
|
||||
dedicated_worker, ws_error_handler_muted, priority, timeout,
|
||||
delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,
|
||||
visible_to_runner_only, auto_kind, codebase, has_preprocessor,
|
||||
on_behalf_of, on_behalf_of_email, assets, modules
|
||||
on_behalf_of, on_behalf_of_email, assets, modules, job_token_scopes
|
||||
)
|
||||
SELECT
|
||||
$1, hash, path, parent_hashes, summary, description, content,
|
||||
@@ -7669,7 +7669,7 @@ async fn clone_scripts(
|
||||
UNION ALL
|
||||
SELECT 1 FROM password p WHERE p.email = on_behalf_of
|
||||
AND p.super_admin))
|
||||
END, on_behalf_of_email, assets, modules
|
||||
END, on_behalf_of_email, assets, modules, job_token_scopes
|
||||
FROM script
|
||||
WHERE workspace_id = $2"#,
|
||||
target_workspace_id,
|
||||
@@ -8027,7 +8027,8 @@ async fn clone_flows(
|
||||
workspace_id, path, summary, description, value, edited_by, edited_at,
|
||||
archived, schema, extra_perms, dependency_job, tag,
|
||||
ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,
|
||||
concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs
|
||||
concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs,
|
||||
job_token_scopes
|
||||
)
|
||||
SELECT $2, path, summary, description, value, edited_by, edited_at,
|
||||
archived, schema, extra_perms, NULL, tag,
|
||||
@@ -8053,7 +8054,7 @@ async fn clone_flows(
|
||||
UNION ALL
|
||||
SELECT 1 FROM password p WHERE p.email = on_behalf_of
|
||||
AND p.super_admin))
|
||||
END, on_behalf_of_email, lock_error_logs
|
||||
END, on_behalf_of_email, lock_error_logs, job_token_scopes
|
||||
FROM flow
|
||||
WHERE workspace_id = $1",
|
||||
source_workspace_id,
|
||||
|
||||
@@ -408,8 +408,8 @@ pub(crate) async fn change_workspace_id(
|
||||
info!("Duplicating flow table rows");
|
||||
sqlx::query!(
|
||||
"INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs)
|
||||
SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes)
|
||||
SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes
|
||||
FROM flow WHERE workspace_id = $2",
|
||||
&rw.new_id,
|
||||
&old_id
|
||||
|
||||
@@ -30150,6 +30150,12 @@ components:
|
||||
items:
|
||||
type: string
|
||||
default: []
|
||||
job_token_scopes:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job."
|
||||
inherited_labels:
|
||||
type: array
|
||||
items:
|
||||
@@ -30307,6 +30313,12 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
job_token_scopes:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it."
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path."
|
||||
@@ -36165,6 +36177,12 @@ components:
|
||||
items:
|
||||
type: string
|
||||
default: []
|
||||
job_token_scopes:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job."
|
||||
inherited_labels:
|
||||
type: array
|
||||
items:
|
||||
@@ -36211,6 +36229,12 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
job_token_scopes:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it."
|
||||
required:
|
||||
- path
|
||||
# Like OpenFlowWPath but `path` is optional: on update the flow is identified by
|
||||
@@ -36247,6 +36271,12 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
job_token_scopes:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it."
|
||||
|
||||
FlowPreview:
|
||||
type: object
|
||||
|
||||
@@ -84,6 +84,7 @@ pub(crate) async fn run_agent(
|
||||
)?;
|
||||
|
||||
let push_authed = authed.clone().into();
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, mut tx) = push(
|
||||
&db,
|
||||
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
|
||||
@@ -118,6 +119,7 @@ pub(crate) async fn run_agent(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -760,6 +760,7 @@ async fn push_run_flow(
|
||||
|
||||
let path = subject.path.clone();
|
||||
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
db,
|
||||
tx,
|
||||
@@ -790,6 +791,7 @@ async fn push_run_flow(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -2755,6 +2755,7 @@ async fn create_app_internal<'a>(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("Pushed app dependency job {}", dependency_job_uuid);
|
||||
@@ -3896,6 +3897,7 @@ async fn update_app_internal<'a>(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tracing::info!("Pushed app dependency job {}", dependency_job_uuid);
|
||||
@@ -4490,6 +4492,10 @@ async fn execute_component(
|
||||
let app_trigger =
|
||||
(!is_preview).then(|| TriggerMetadata::new(Some(path.to_string()), JobTriggerKind::App));
|
||||
|
||||
let scope_ceiling = match opt_authed.as_ref() {
|
||||
Some(authed) => windmill_api_auth::caller_scope_ceiling(&db, authed).await?,
|
||||
None => None,
|
||||
};
|
||||
let (uuid, mut tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -4525,6 +4531,7 @@ async fn execute_component(
|
||||
end_user_email,
|
||||
app_trigger,
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ pub(crate) async fn bundle_raw_app_sources(
|
||||
args.insert("runnables".to_string(), runnables.to_owned());
|
||||
|
||||
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
db,
|
||||
tx,
|
||||
@@ -191,6 +192,7 @@ pub(crate) async fn bundle_raw_app_sources(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -56,6 +56,7 @@ async fn get_concurrency_key(
|
||||
.get_authed(Some(job.workspace_id.clone()), &token)
|
||||
.await
|
||||
.ok_or_else(not_found)?;
|
||||
windmill_api_auth::check_job_token_scope(&authed_in_workspace, || "jobs:read".to_string())?;
|
||||
|
||||
require_job_read_access(
|
||||
&db,
|
||||
|
||||
@@ -7193,6 +7193,7 @@ pub async fn restart_flow(
|
||||
|
||||
let tx = PushIsolationLevel::Isolated(user_db, authed.clone().into());
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -7230,6 +7231,7 @@ pub async fn restart_flow(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
run_query.suspended_mode,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -7367,7 +7369,11 @@ pub async fn run_workflow_as_code(
|
||||
extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint));
|
||||
let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) };
|
||||
check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &args, &authed).await?;
|
||||
check_scopes(&authed, || format!("jobs:run"))?;
|
||||
// A job running its own task is its runtime, not a new run: a restricted job token keeps
|
||||
// it (`is_own_job_runtime_route`), and the task inherits that job's restriction at push.
|
||||
if authed.job_id != Some(job_id) {
|
||||
check_scopes(&authed, || format!("jobs:run"))?;
|
||||
}
|
||||
// The task becomes a child of `job_id`, runs its code and writes into its flow status, so
|
||||
// only that job itself (the SDK's `task` wrapper, on its `WM_TOKEN`) or an admin may push it.
|
||||
if authed.job_id != Some(job_id) && !authed.is_admin {
|
||||
@@ -7512,6 +7518,7 @@ pub async fn run_workflow_as_code(
|
||||
)
|
||||
};
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, mut tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -7542,6 +7549,7 @@ pub async fn run_workflow_as_code(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -7825,6 +7833,7 @@ pub async fn run_wait_result_job_by_path_get(
|
||||
)
|
||||
};
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -7855,6 +7864,7 @@ pub async fn run_wait_result_job_by_path_get(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
run_query.suspended_mode,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -7973,6 +7983,7 @@ pub async fn run_wait_result_script_by_path_internal(
|
||||
)
|
||||
};
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -8003,6 +8014,7 @@ pub async fn run_wait_result_script_by_path_internal(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
run_query.suspended_mode,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -8055,6 +8067,7 @@ pub async fn run_wait_result_script_by_hash(
|
||||
timeout,
|
||||
has_preprocessor,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
runnable_settings:
|
||||
ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings },
|
||||
..
|
||||
@@ -8088,6 +8101,7 @@ pub async fn run_wait_result_script_by_hash(
|
||||
)
|
||||
};
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -8105,6 +8119,7 @@ pub async fn run_wait_result_script_by_hash(
|
||||
apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false)
|
||||
&& has_preprocessor.unwrap_or(false),
|
||||
labels,
|
||||
job_token_scopes,
|
||||
},
|
||||
push_args,
|
||||
authed.display_username(),
|
||||
@@ -8131,6 +8146,7 @@ pub async fn run_wait_result_script_by_hash(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
run_query.suspended_mode,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -8650,6 +8666,7 @@ async fn run_preview_script(
|
||||
let push_args = PushArgs { extra, args: &preview_args };
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -8700,6 +8717,7 @@ async fn run_preview_script(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -8847,6 +8865,36 @@ async fn run_inline_script_inner(
|
||||
args: Option<HashMap<String, Box<JsonRawValue>>>,
|
||||
user_db: Option<UserDB>,
|
||||
) -> error::Result<Response> {
|
||||
// An inline run executes with the caller's own token, so it cannot honour a script's
|
||||
// `job_token_scopes`: such a script only runs as a job.
|
||||
let restricted = match &target {
|
||||
InlineScriptTarget::Path(path) => {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT job_token_scopes IS NOT NULL FROM script WHERE path = $1 AND workspace_id = $2 \
|
||||
AND archived = false AND deleted = false ORDER BY created_at DESC LIMIT 1",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
}
|
||||
InlineScriptTarget::Hash(hash) => {
|
||||
sqlx::query_scalar!(
|
||||
"SELECT job_token_scopes IS NOT NULL FROM script WHERE hash = $1 AND workspace_id = $2",
|
||||
hash,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
if restricted.flatten().unwrap_or(false) {
|
||||
return Err(Error::BadRequest(
|
||||
"This script restricts its job token (job_token_scopes) and cannot be run inline; \
|
||||
run it as a job instead"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let utils = get_worker_internal_server_inline_utils()?;
|
||||
let authed_owned: windmill_common::db::Authed = authed.clone().into();
|
||||
let result = utils.run_inline_script.as_ref()(RunInlineScriptFnParams {
|
||||
@@ -9033,6 +9081,7 @@ async fn run_bundle_preview_script(
|
||||
|
||||
// tracing::info!("is_tar 1: {is_tar}");
|
||||
// hmap.insert("")
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, ntx) = push(
|
||||
&db,
|
||||
ltx,
|
||||
@@ -9079,6 +9128,7 @@ async fn run_bundle_preview_script(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
job_id = Some(uuid);
|
||||
@@ -9198,6 +9248,7 @@ async fn push_dependencies_job(
|
||||
req.temp_script_refs
|
||||
.map(|v| hm.insert("temp_script_refs".to_owned(), to_raw_value(&v)));
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
db,
|
||||
PushIsolationLevel::IsolatedRoot(db.clone()),
|
||||
@@ -9232,6 +9283,7 @@ async fn push_dependencies_job(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -9330,6 +9382,7 @@ async fn push_flow_dependencies_job(
|
||||
req.temp_script_refs
|
||||
.map(|v| args_map.insert("temp_script_refs".to_string(), to_raw_value(&v)));
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
db,
|
||||
PushIsolationLevel::IsolatedRoot(db.clone()),
|
||||
@@ -9360,6 +9413,7 @@ async fn push_flow_dependencies_job(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -9414,6 +9468,7 @@ async fn add_batch_jobs(
|
||||
) -> error::JsonResult<Vec<Uuid>> {
|
||||
require_super_admin(&db, &authed).await?;
|
||||
|
||||
let mut job_token_scopes: Option<Vec<String>> = None;
|
||||
let (
|
||||
hash,
|
||||
path,
|
||||
@@ -9445,11 +9500,13 @@ async fn add_batch_jobs(
|
||||
dedicated_worker,
|
||||
timeout,
|
||||
runnable_settings,
|
||||
job_token_scopes: script_job_token_scopes,
|
||||
.. // TODO: consider on_behalf_of_email and created_by for batch jobs
|
||||
} = get_latest_deployed_hash_for_path(Some(db_authed), db.clone(), &w_id, &path)
|
||||
.await?
|
||||
.prefetch_cached(&db)
|
||||
.await?;
|
||||
job_token_scopes = script_job_token_scopes;
|
||||
(
|
||||
Some(script_hash),
|
||||
Some(path),
|
||||
@@ -9496,7 +9553,7 @@ async fn add_batch_jobs(
|
||||
} else if let Some(path) = batch_info.path {
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
let value_json = sqlx::query!(
|
||||
"SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json<Box<RawValue>>\" FROM flow
|
||||
"SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json<Box<RawValue>>\", flow.job_token_scopes FROM flow
|
||||
LEFT JOIN flow_version
|
||||
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
|
||||
LEFT JOIN flow_version_lite
|
||||
@@ -9507,6 +9564,7 @@ async fn add_batch_jobs(
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| Error::internal_err(format!("not found flow at path {:?}", path)))?;
|
||||
job_token_scopes = value_json.job_token_scopes;
|
||||
let value =
|
||||
serde_json::from_str::<FlowValue>(value_json.value.get()).map_err(|err| {
|
||||
Error::internal_err(format!(
|
||||
@@ -9582,9 +9640,9 @@ async fn add_batch_jobs(
|
||||
INSERT INTO v2_job
|
||||
(id, workspace_id, raw_code, raw_lock, raw_flow, tag, runnable_id, runnable_path, kind,
|
||||
script_lang, created_by, permissioned_as, permissioned_as_email, concurrent_limit,
|
||||
concurrency_time_window_s, timeout, args)
|
||||
concurrency_time_window_s, timeout, args, job_token_scopes)
|
||||
(SELECT uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
|
||||
('{ "uuid": "' || uuid || '" }')::jsonb FROM uuid_table)
|
||||
('{ "uuid": "' || uuid || '" }')::jsonb, $17 FROM uuid_table)
|
||||
RETURNING id AS "id!""#,
|
||||
w_id,
|
||||
raw_code,
|
||||
@@ -9602,6 +9660,7 @@ async fn add_batch_jobs(
|
||||
concurrency_time_window_s,
|
||||
timeout,
|
||||
n,
|
||||
job_token_scopes.as_deref() as Option<&[String]>,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
@@ -9732,6 +9791,7 @@ async fn run_preview_flow_job(
|
||||
check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&flow_args), &authed)
|
||||
.await?;
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, mut tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -9766,6 +9826,7 @@ async fn run_preview_flow_job(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -9989,6 +10050,7 @@ async fn run_dynamic_select(
|
||||
let scheduled_for = run_query.get_scheduled_for(&db).await?;
|
||||
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -10035,6 +10097,7 @@ async fn run_dynamic_select(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -10110,6 +10173,7 @@ pub async fn run_job_by_hash_inner(
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
..
|
||||
} = script_info;
|
||||
|
||||
@@ -10142,6 +10206,7 @@ pub async fn run_job_by_hash_inner(
|
||||
)
|
||||
};
|
||||
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
&db,
|
||||
tx,
|
||||
@@ -10159,6 +10224,7 @@ pub async fn run_job_by_hash_inner(
|
||||
apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false)
|
||||
&& has_preprocessor.unwrap_or(false),
|
||||
labels,
|
||||
job_token_scopes,
|
||||
},
|
||||
push_args,
|
||||
authed.display_username(),
|
||||
@@ -10185,6 +10251,7 @@ pub async fn run_job_by_hash_inner(
|
||||
None,
|
||||
authed.trigger_or_fallback(trigger),
|
||||
run_query.suspended_mode,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -372,7 +372,8 @@ impl McpBackend for WindmillBackend {
|
||||
token: &str,
|
||||
workspace_id: &str,
|
||||
) -> BackendResult<ApiAuthed> {
|
||||
self.auth_cache
|
||||
let authed = self
|
||||
.auth_cache
|
||||
.get_authed(Some(workspace_id.to_string()), token)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
@@ -383,7 +384,11 @@ impl McpBackend for WindmillBackend {
|
||||
),
|
||||
None,
|
||||
)
|
||||
})
|
||||
})?;
|
||||
// Job token scopes never include MCP scopes, so a restricted job token gets none.
|
||||
windmill_api_auth::check_job_token_scope(&authed, || "mcp:all".to_string())
|
||||
.map_err(|e| ErrorData::invalid_params(e.to_string(), None))?;
|
||||
Ok(authed)
|
||||
}
|
||||
|
||||
async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult<String> {
|
||||
|
||||
@@ -891,8 +891,8 @@ async fn offboard_user_from_workspace<'c>(
|
||||
let flows_reassigned = sqlx::query_scalar!(
|
||||
r#"WITH inserted AS (
|
||||
INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes
|
||||
FROM flow
|
||||
WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3
|
||||
RETURNING 1
|
||||
|
||||
@@ -665,8 +665,8 @@ async fn update_username_in_workpsace<'c>(
|
||||
// ---- flows ----
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes
|
||||
FROM flow
|
||||
WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
||||
new_username,
|
||||
|
||||
@@ -119,6 +119,8 @@ struct ScriptMetadata {
|
||||
pub debouncing_settings: DebouncingSettings,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job_token_scopes: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "is_empty_extra_perms")]
|
||||
pub extra_perms: serde_json::Value,
|
||||
}
|
||||
@@ -906,6 +908,7 @@ pub(crate) async fn tarball_workspace(
|
||||
.then_some(true),
|
||||
modules: script.modules,
|
||||
labels: script.labels,
|
||||
job_token_scopes: script.job_token_scopes,
|
||||
// Same opt-in contract as flow/app: the tarball only surfaces
|
||||
// ACLs when `?preserve_extra_perms=true`. Passing `Null` lets the
|
||||
// `is_empty_extra_perms` skip-serializer drop the field entirely.
|
||||
@@ -966,7 +969,7 @@ pub(crate) async fn tarball_workspace(
|
||||
|
||||
{
|
||||
let flows = sqlx::query_as::<_, Flow>(
|
||||
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
|
||||
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow.job_token_scopes, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
|
||||
FROM flow
|
||||
LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
|
||||
WHERE flow.workspace_id = $1 AND flow.archived = false",
|
||||
|
||||
@@ -362,6 +362,9 @@ pub struct JobPerms {
|
||||
pub groups: Vec<String>,
|
||||
pub folders: Vec<serde_json::Value>,
|
||||
pub end_user_email: Option<String>,
|
||||
/// The job's effective `v2_job.job_token_scopes`, which its token is minted with.
|
||||
#[serde(default)]
|
||||
pub job_token_scopes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl From<JobPerms> for Authed {
|
||||
@@ -654,7 +657,10 @@ pub async fn get_job_perms<'a, E: sqlx::PgExecutor<'a>>(
|
||||
) -> sqlx::Result<Option<JobPerms>> {
|
||||
sqlx::query_as!(
|
||||
JobPerms,
|
||||
"SELECT email, username, is_admin, is_operator, groups, folders, end_user_email FROM job_perms WHERE job_id = $1 AND workspace_id = $2",
|
||||
"SELECT p.email, p.username, p.is_admin, p.is_operator, p.groups, p.folders, p.end_user_email,
|
||||
j.job_token_scopes
|
||||
FROM job_perms p LEFT JOIN v2_job j ON j.id = p.job_id
|
||||
WHERE p.job_id = $1 AND p.workspace_id = $2",
|
||||
job_id,
|
||||
w_id
|
||||
)
|
||||
@@ -745,17 +751,29 @@ pub async fn create_token_for_owner(
|
||||
} else {
|
||||
get_job_perms(db, job_id, w_id).await
|
||||
};
|
||||
let job_authed = match job_perms {
|
||||
Ok(Some(jp)) => jp.into(),
|
||||
let (job_authed, job_token_scopes) = match job_perms {
|
||||
Ok(Some(mut jp)) => {
|
||||
let scopes = jp.job_token_scopes.take();
|
||||
(jp.into(), scopes)
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("Could not get permissions for job {job_id} from job_perms table, getting permissions directly...");
|
||||
fetch_authed_from_permissioned_as(owner, email, w_id, db)
|
||||
let authed = fetch_authed_from_permissioned_as(owner, email, w_id, db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!(
|
||||
"Could not get permissions directly for job {job_id}: {e:#}"
|
||||
))
|
||||
})?
|
||||
})?;
|
||||
let scopes = sqlx::query_scalar!(
|
||||
"SELECT job_token_scopes FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
job_id,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten();
|
||||
(authed, scopes)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -766,7 +784,7 @@ pub async fn create_token_for_owner(
|
||||
Some(*job_id),
|
||||
Some(label.to_string()),
|
||||
audit_span,
|
||||
None,
|
||||
crate::scopes::job_token_jwt_scopes(job_token_scopes),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ pub async fn script_path_to_payload<'e>(
|
||||
timeout,
|
||||
has_preprocessor,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
..
|
||||
} = script_info;
|
||||
|
||||
@@ -153,6 +154,7 @@ pub async fn script_path_to_payload<'e>(
|
||||
debouncing_settings,
|
||||
concurrency_settings,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
},
|
||||
tag,
|
||||
delete_after_use,
|
||||
@@ -203,7 +205,7 @@ pub async fn get_payload_tag_from_prefixed_path(
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
let FlowVersionInfo { dedicated_worker, tag, version, labels, .. } =
|
||||
let FlowVersionInfo { dedicated_worker, tag, version, labels, job_token_scopes, .. } =
|
||||
get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?;
|
||||
(
|
||||
JobPayload::Flow {
|
||||
@@ -212,6 +214,7 @@ pub async fn get_payload_tag_from_prefixed_path(
|
||||
apply_preprocessor: false,
|
||||
version,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
},
|
||||
tag,
|
||||
None,
|
||||
|
||||
@@ -126,6 +126,7 @@ pub mod runnable_settings;
|
||||
pub mod runnables;
|
||||
pub mod schedule;
|
||||
pub mod schema;
|
||||
pub mod scopes;
|
||||
pub mod scripts;
|
||||
pub mod secret_backend;
|
||||
pub mod sensitive_log_masks;
|
||||
@@ -2229,6 +2230,7 @@ pub struct ScriptHashInfo<SR> {
|
||||
pub on_behalf_of: Option<String>,
|
||||
pub created_by: String,
|
||||
pub labels: Option<Vec<String>>,
|
||||
pub job_token_scopes: Option<Vec<String>>,
|
||||
#[sqlx(flatten)]
|
||||
pub runnable_settings: SR,
|
||||
}
|
||||
@@ -2322,6 +2324,7 @@ impl ScriptHashInfo<ScriptRunnableSettingsHandle> {
|
||||
on_behalf_of: self.on_behalf_of,
|
||||
created_by: self.created_by,
|
||||
labels: self.labels,
|
||||
job_token_scopes: self.job_token_scopes,
|
||||
runnable_settings: ScriptRunnableSettingsInline {
|
||||
concurrency_settings: concurrency_settings.maybe_fallback(
|
||||
self.runnable_settings.concurrency_key,
|
||||
@@ -2686,6 +2689,7 @@ async fn get_script_info_for_hash_inner<'e, E: sqlx::PgExecutor<'e>>(
|
||||
on_behalf_of,
|
||||
created_by,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
path
|
||||
FROM script WHERE hash = $1 AND workspace_id = $2",
|
||||
)
|
||||
@@ -2707,6 +2711,7 @@ pub struct FlowVersionInfo {
|
||||
pub edited_by: String,
|
||||
pub dedicated_worker: Option<bool>,
|
||||
pub labels: Option<Vec<String>>,
|
||||
pub job_token_scopes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl FlowVersionInfo {
|
||||
@@ -2850,7 +2855,8 @@ pub fn get_flow_version_info_from_version<
|
||||
flow.dedicated_worker,
|
||||
flow.on_behalf_of,
|
||||
flow.edited_by,
|
||||
flow.labels
|
||||
flow.labels,
|
||||
flow.job_token_scopes
|
||||
FROM
|
||||
flow_version
|
||||
INNER JOIN flow
|
||||
@@ -2985,9 +2991,10 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>(
|
||||
Option<jobs::OnBehalfOf>,
|
||||
Option<i64>,
|
||||
Option<Vec<String>>,
|
||||
Option<Vec<String>>,
|
||||
)> {
|
||||
let r_o = sqlx::query!(
|
||||
"select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels FROM script
|
||||
"select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels, job_token_scopes FROM script
|
||||
WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false)
|
||||
ORDER BY created_at DESC LIMIT 1",
|
||||
script_path,
|
||||
@@ -3019,6 +3026,7 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>(
|
||||
on_behalf_of,
|
||||
script.runnable_settings_handle,
|
||||
script.labels,
|
||||
script.job_token_scopes,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,706 @@
|
||||
/*
|
||||
* Author: Windmill Labs, Inc
|
||||
* Copyright: Windmill Labs, Inc 2024
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use itertools::Itertools;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Comprehensive scope system for JWT token authorization
|
||||
///
|
||||
/// Scopes follow the format: {domain}:{action}[:{resource}]
|
||||
/// Examples:
|
||||
/// - "jobs:read" - Read access to jobs
|
||||
/// - "scripts:write:f/folder/*" - Write access to scripts in a folder
|
||||
/// - "*" - Full access (superuser)
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ScopeDefinition {
|
||||
pub domain: String,
|
||||
pub action: String,
|
||||
pub kind: Option<String>, // For jobs:run:kind (optional)
|
||||
pub resource: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ScopeDefinition {
|
||||
pub fn new(
|
||||
domain: &str,
|
||||
action: &str,
|
||||
kind: Option<&str>,
|
||||
resource: Option<Vec<String>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
domain: domain.to_string(),
|
||||
action: action.to_string(),
|
||||
kind: kind.map(|s| s.to_string()),
|
||||
resource: resource,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_scope_string(scope: &str) -> Result<Self> {
|
||||
let parts: Vec<&str> = scope.split(':').collect();
|
||||
|
||||
let into_owned_vec = |resources: &str| -> Vec<String> {
|
||||
let resources = resources
|
||||
.split(",")
|
||||
.collect_vec()
|
||||
.into_iter()
|
||||
.map(ToOwned::to_owned)
|
||||
.collect_vec();
|
||||
|
||||
resources
|
||||
};
|
||||
|
||||
match parts.len() {
|
||||
2 => Ok(Self::new(parts[0], parts[1], None, None)), // domain:action
|
||||
3 => {
|
||||
if parts[0] == "jobs" && parts[1] == "run" {
|
||||
Ok(Self::new(parts[0], parts[1], Some(parts[2]), None))
|
||||
} else {
|
||||
Ok(Self::new(
|
||||
parts[0],
|
||||
parts[1],
|
||||
None,
|
||||
Some(into_owned_vec(parts[2])),
|
||||
))
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
if parts[0] == "jobs" && parts[1] == "run" {
|
||||
Ok(Self::new(
|
||||
parts[0],
|
||||
parts[1],
|
||||
Some(parts[2]),
|
||||
Some(into_owned_vec(parts[3])),
|
||||
))
|
||||
} else {
|
||||
Err(Error::BadRequest(format!(
|
||||
"Invalid 4-part scope: {}",
|
||||
scope
|
||||
)))
|
||||
}
|
||||
}
|
||||
_ => Err(Error::BadRequest(format!(
|
||||
"Invalid scope format: {}",
|
||||
scope
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_string(&self) -> String {
|
||||
match (&self.kind, &self.resource) {
|
||||
(Some(kind), Some(resource)) => {
|
||||
format!(
|
||||
"{}:{}:{}:{}",
|
||||
self.domain,
|
||||
self.action,
|
||||
kind,
|
||||
resource.join(",")
|
||||
)
|
||||
}
|
||||
(Some(kind), None) => {
|
||||
format!("{}:{}:{}", self.domain, self.action, kind)
|
||||
}
|
||||
(None, Some(resource)) => {
|
||||
format!("{}:{}:{}", self.domain, self.action, resource.join(","))
|
||||
}
|
||||
(None, None) => format!("{}:{}", self.domain, self.action),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn includes(&self, other: &ScopeDefinition) -> bool {
|
||||
if self.domain != other.domain {
|
||||
return false;
|
||||
}
|
||||
|
||||
match (self.action.as_str(), other.action.as_str()) {
|
||||
(a, b) if (a == "write" && b == "read") || (a == b) => {}
|
||||
// Apps only: `write` can rewrite the app and its policy, so it also covers
|
||||
// running its components. Not general — `jobs:write` must not grant
|
||||
// `jobs:run`. The resource check below still confines it to the same app.
|
||||
("write", "run") if self.domain == "apps" => {}
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
if self.domain == "jobs" && self.action == "run" {
|
||||
match (&self.kind, &other.kind) {
|
||||
(Some(self_kind), Some(other_kind)) => {
|
||||
if self_kind != other_kind {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
(Some(_), None) => {
|
||||
return false;
|
||||
}
|
||||
(None, _) => {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (&self.resource, &other.resource) {
|
||||
(Some(self_resources), Some(other_resources)) => {
|
||||
resources_match(self_resources, other_resources)
|
||||
}
|
||||
// A requirement naming no path is the whole domain, so only a grant that
|
||||
// itself spans every path satisfies it. `*` is that grant — the scope UI
|
||||
// accepts it as a resource path and `resources_match` already reads it as
|
||||
// everything — while any listed path leaves the collection unauthorized.
|
||||
(Some(self_resources), None) => self_resources.iter().any(|r| r == "*"),
|
||||
(None, _) => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resources_match(scope_resources: &[String], accepted_resources: &[String]) -> bool {
|
||||
if scope_resources.contains(&"*".to_string()) || accepted_resources.contains(&"*".to_string()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if scope_resources.len() <= 4 && accepted_resources.len() <= 4 {
|
||||
return resources_match_small(scope_resources, accepted_resources);
|
||||
}
|
||||
|
||||
resources_match_large(scope_resources, accepted_resources)
|
||||
}
|
||||
|
||||
fn resources_match_small(scope_resources: &[String], accepted_resources: &[String]) -> bool {
|
||||
for required in accepted_resources {
|
||||
for scope_resource in scope_resources {
|
||||
if resource_matches_pattern(scope_resource, required) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn resources_match_large(scope_resources: &[String], accepted_resources: &[String]) -> bool {
|
||||
let mut exact_matches = HashSet::new();
|
||||
let mut patterns = Vec::new();
|
||||
|
||||
for scope_resource in scope_resources {
|
||||
if scope_resource.contains('*') {
|
||||
patterns.push(scope_resource);
|
||||
} else {
|
||||
exact_matches.insert(scope_resource);
|
||||
}
|
||||
}
|
||||
|
||||
for accepted_resource in accepted_resources {
|
||||
if exact_matches.contains(accepted_resource) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for pattern in &patterns {
|
||||
if resource_matches_pattern(pattern, accepted_resource) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn resource_matches_pattern(scope_resource: &str, accepted_resource: &str) -> bool {
|
||||
if scope_resource == accepted_resource {
|
||||
return true;
|
||||
}
|
||||
|
||||
let matches_wildcard = |pattern: &str, resource: &str| -> bool {
|
||||
if !pattern.ends_with("/*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let prefix = &pattern[..pattern.len() - 2];
|
||||
|
||||
if !resource.starts_with(prefix) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the resource is exactly the prefix, it matches
|
||||
if resource.len() == prefix.len() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the resource is longer, the next character must be '/' for a valid match
|
||||
// This prevents "u/user" from matching "u/use/*"
|
||||
resource.chars().nth(prefix.len()) == Some('/')
|
||||
};
|
||||
|
||||
// Check if either resource is a wildcard pattern and matches the other
|
||||
matches_wildcard(scope_resource, accepted_resource)
|
||||
|| matches_wildcard(accepted_resource, scope_resource)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Route-level scope checking
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Available scope domains (top-level API categories)
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ScopeDomain {
|
||||
// Core resource domains
|
||||
Jobs,
|
||||
Scripts,
|
||||
/// The `/data_metrics` catalog. Its own domain, NOT an alias of `Scripts`: a
|
||||
/// `data_metrics:read` token must reach only this route, never the broader
|
||||
/// `/scripts` routes (some of which do no further scope check).
|
||||
DataMetrics,
|
||||
Flows,
|
||||
FlowConversations,
|
||||
Apps,
|
||||
Variables,
|
||||
Resources,
|
||||
Schedules,
|
||||
Folders,
|
||||
Users,
|
||||
Groups,
|
||||
Workspaces,
|
||||
|
||||
// Trigger domains
|
||||
HttpTriggers,
|
||||
WebsocketTriggers,
|
||||
KafkaTriggers,
|
||||
NatsTriggers,
|
||||
MqttTriggers,
|
||||
AmqpTriggers,
|
||||
SqsTriggers,
|
||||
GcpTriggers,
|
||||
AzureTriggers,
|
||||
PostgresTriggers,
|
||||
EmailTriggers,
|
||||
|
||||
// Native trigger domains
|
||||
NativeTriggers,
|
||||
TriggersHistory,
|
||||
|
||||
// System domains
|
||||
Audit,
|
||||
Settings,
|
||||
Workers,
|
||||
ServiceLogs,
|
||||
Configs,
|
||||
OAuth,
|
||||
AI,
|
||||
AiEvals, // AI agent eval datasets
|
||||
|
||||
Indexer,
|
||||
Teams, // Microsoft Teams integration
|
||||
GitSync, // Git synchronization
|
||||
|
||||
// Special domains
|
||||
Capture, // Webhook capture
|
||||
Drafts, // Draft resources
|
||||
Favorites, // User favorites
|
||||
Inputs, // Input templates
|
||||
JobHelpers, // Job helper functions
|
||||
ConcurrencyGroups, // Concurrency groups
|
||||
Oidc, // OpenID Connect
|
||||
Openapi, // OpenAPI generation
|
||||
|
||||
// Additional domains
|
||||
Acls, // Granular access control lists
|
||||
RawApps, // Raw application data
|
||||
AgentWorkers, // Agent workers management
|
||||
Mcp, // MCP
|
||||
Docs, // Self-hosted documentation search (read-only)
|
||||
}
|
||||
|
||||
impl ScopeDomain {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Jobs => "jobs",
|
||||
Self::Scripts => "scripts",
|
||||
Self::DataMetrics => "data_metrics",
|
||||
Self::Flows => "flows",
|
||||
Self::FlowConversations => "flow_conversations",
|
||||
Self::Apps => "apps",
|
||||
Self::Variables => "variables",
|
||||
Self::Resources => "resources",
|
||||
Self::Schedules => "schedules",
|
||||
Self::Folders => "folders",
|
||||
Self::Users => "users",
|
||||
Self::Groups => "groups",
|
||||
Self::Workspaces => "workspaces",
|
||||
Self::HttpTriggers => "http_triggers",
|
||||
Self::WebsocketTriggers => "websocket_triggers",
|
||||
Self::KafkaTriggers => "kafka_triggers",
|
||||
Self::NatsTriggers => "nats_triggers",
|
||||
Self::MqttTriggers => "mqtt_triggers",
|
||||
Self::AmqpTriggers => "amqp_triggers",
|
||||
Self::SqsTriggers => "sqs_triggers",
|
||||
Self::GcpTriggers => "gcp_triggers",
|
||||
Self::AzureTriggers => "azure_triggers",
|
||||
Self::PostgresTriggers => "postgres_triggers",
|
||||
Self::EmailTriggers => "email_triggers",
|
||||
Self::NativeTriggers => "native_triggers",
|
||||
Self::TriggersHistory => "triggers_history",
|
||||
Self::Audit => "audit",
|
||||
Self::Settings => "settings",
|
||||
Self::Workers => "workers",
|
||||
Self::ServiceLogs => "service_logs",
|
||||
Self::Configs => "configs",
|
||||
Self::OAuth => "oauth",
|
||||
Self::AI => "ai",
|
||||
Self::AiEvals => "ai_evals",
|
||||
Self::Capture => "capture",
|
||||
Self::Drafts => "drafts",
|
||||
Self::Favorites => "favorites",
|
||||
Self::Inputs => "inputs",
|
||||
Self::JobHelpers => "job_helpers",
|
||||
Self::ConcurrencyGroups => "concurrency_groups",
|
||||
Self::Oidc => "oidc",
|
||||
Self::Openapi => "openapi",
|
||||
Self::Acls => "acls",
|
||||
Self::RawApps => "raw_apps",
|
||||
Self::AgentWorkers => "agent_workers",
|
||||
Self::Indexer => "indexer",
|
||||
Self::Teams => "teams",
|
||||
Self::GitSync => "git_sync",
|
||||
Self::Mcp => "mcp",
|
||||
Self::Docs => "docs",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"jobs" | "jobs_u" => Some(Self::Jobs),
|
||||
"scripts" => Some(Self::Scripts),
|
||||
// A distinct domain, not an alias of `scripts` (see the enum variant):
|
||||
// a `data_metrics:read` token must not reach the broader /scripts routes.
|
||||
"data_metrics" => Some(Self::DataMetrics),
|
||||
"flows" => Some(Self::Flows),
|
||||
"flow_conversations" => Some(Self::FlowConversations),
|
||||
"apps" | "apps_u" => Some(Self::Apps),
|
||||
"variables" => Some(Self::Variables),
|
||||
"resources" => Some(Self::Resources),
|
||||
"schedules" => Some(Self::Schedules),
|
||||
"folders" => Some(Self::Folders),
|
||||
"users" => Some(Self::Users),
|
||||
"groups" => Some(Self::Groups),
|
||||
"workspaces" => Some(Self::Workspaces),
|
||||
"http_triggers" => Some(Self::HttpTriggers),
|
||||
"websocket_triggers" => Some(Self::WebsocketTriggers),
|
||||
"kafka_triggers" => Some(Self::KafkaTriggers),
|
||||
"nats_triggers" => Some(Self::NatsTriggers),
|
||||
"mqtt_triggers" => Some(Self::MqttTriggers),
|
||||
"amqp_triggers" => Some(Self::AmqpTriggers),
|
||||
"sqs_triggers" => Some(Self::SqsTriggers),
|
||||
"gcp_triggers" => Some(Self::GcpTriggers),
|
||||
"azure_triggers" => Some(Self::AzureTriggers),
|
||||
"postgres_triggers" => Some(Self::PostgresTriggers),
|
||||
"email_triggers" => Some(Self::EmailTriggers),
|
||||
"audit" => Some(Self::Audit),
|
||||
"settings" => Some(Self::Settings),
|
||||
"workers" => Some(Self::Workers),
|
||||
"service_logs" => Some(Self::ServiceLogs),
|
||||
"configs" => Some(Self::Configs),
|
||||
"oauth" => Some(Self::OAuth),
|
||||
"ai" => Some(Self::AI),
|
||||
"ai_evals" => Some(Self::AiEvals),
|
||||
"indexer" | "srch" => Some(Self::Indexer),
|
||||
"teams" => Some(Self::Teams),
|
||||
"native_triggers" => Some(Self::NativeTriggers),
|
||||
"triggers_history" => Some(Self::TriggersHistory),
|
||||
"git_sync" | "github_app" => Some(Self::GitSync),
|
||||
"capture" => Some(Self::Capture),
|
||||
"drafts" => Some(Self::Drafts),
|
||||
"favorites" => Some(Self::Favorites),
|
||||
"inputs" => Some(Self::Inputs),
|
||||
"job_helpers" => Some(Self::JobHelpers),
|
||||
"concurrency_groups" => Some(Self::ConcurrencyGroups),
|
||||
"oidc" => Some(Self::Oidc),
|
||||
"openapi" => Some(Self::Openapi),
|
||||
"acls" => Some(Self::Acls),
|
||||
"raw_apps" => Some(Self::RawApps),
|
||||
"agent_workers" => Some(Self::AgentWorkers),
|
||||
"mcp" => Some(Self::Mcp),
|
||||
"docs" => Some(Self::Docs),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Available scope actions
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ScopeAction {
|
||||
Read, // GET operations, list, view
|
||||
Write, // POST, PUT, PATCH, DELETE operations, create, update, delete
|
||||
Run, // Special action for running (scripts, flows, etc.)
|
||||
}
|
||||
|
||||
impl ScopeAction {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => "read",
|
||||
Self::Write => "write",
|
||||
Self::Run => "run",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"read" => Some(Self::Read),
|
||||
"write" => Some(Self::Write),
|
||||
"delete" => Some(Self::Write),
|
||||
"run" => Some(Self::Run),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this action includes another action
|
||||
/// Write includes Read
|
||||
pub fn includes(&self, other: &ScopeAction) -> bool {
|
||||
match (self, other) {
|
||||
(ScopeAction::Write, ScopeAction::Read) => true,
|
||||
(ScopeAction::Run, ScopeAction::Read) => true,
|
||||
(a, b) => a == b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `caller` grants at least everything `requested` grants (directional
|
||||
/// containment).
|
||||
///
|
||||
/// This is intentionally NOT `ScopeDefinition::includes`: that method answers
|
||||
/// "does this scope grant access to a required action" using OR semantics over
|
||||
/// resources (any overlap counts, and a `*` on either side matches), which is
|
||||
/// correct for access checks but unsafe for subset checks — it would let a
|
||||
/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or
|
||||
/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that
|
||||
/// EVERY requested resource is covered by SOME caller resource.
|
||||
pub fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool {
|
||||
if caller.domain != requested.domain {
|
||||
return false;
|
||||
}
|
||||
|
||||
// write subsumes read; otherwise the action must match exactly.
|
||||
match (caller.action.as_str(), requested.action.as_str()) {
|
||||
(c, r) if c == r || (c == "write" && r == "read") => {}
|
||||
// Apps only: `write` covers `run` (see `ScopeDefinition::includes`), so an
|
||||
// app-editor token can mint the narrower run-only credential.
|
||||
("write", "run") if caller.domain == "apps" => {}
|
||||
_ => return false,
|
||||
}
|
||||
|
||||
if caller.domain == "jobs" && caller.action == "run" {
|
||||
match (&caller.kind, &requested.kind) {
|
||||
(Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => {
|
||||
return false
|
||||
}
|
||||
// Caller pinned to a kind, but the request covers any kind.
|
||||
(Some(_), None) => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match (&caller.resource, &requested.resource) {
|
||||
// Caller is unrestricted on resources: covers everything.
|
||||
(None, _) => true,
|
||||
// Caller is resource-restricted but the request is not: broader, unless the
|
||||
// caller lists `*` and so already spans every path. Kept in step with
|
||||
// `ScopeDefinition::includes`, which accepts that same grant for a
|
||||
// whole-collection read: what a token may exercise, it may also delegate.
|
||||
(Some(caller_resources), None) => caller_resources.iter().any(|r| r == "*"),
|
||||
(Some(caller_resources), Some(requested_resources)) => {
|
||||
resource_set_contains(caller_resources, requested_resources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every resource in `requested` must be covered by some resource in `caller`.
|
||||
fn resource_set_contains(caller: &[String], requested: &[String]) -> bool {
|
||||
if caller.iter().any(|r| r == "*") {
|
||||
return true;
|
||||
}
|
||||
requested
|
||||
.iter()
|
||||
.all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req)))
|
||||
}
|
||||
|
||||
/// Directional: does the single caller resource pattern cover `requested`?
|
||||
/// `caller` may be an exact path or a `<prefix>/*` subtree wildcard; `requested`
|
||||
/// may itself be a subtree wildcard, in which case the whole requested subtree
|
||||
/// must fall within the caller's subtree.
|
||||
fn resource_covers(caller: &str, requested: &str) -> bool {
|
||||
if caller == requested {
|
||||
return true;
|
||||
}
|
||||
let Some(prefix) = caller.strip_suffix("/*") else {
|
||||
// An exact caller resource only covers itself (handled above).
|
||||
return false;
|
||||
};
|
||||
let requested_base = requested.strip_suffix("/*").unwrap_or(requested);
|
||||
requested_base == prefix
|
||||
|| (requested_base.starts_with(prefix)
|
||||
&& requested_base.as_bytes().get(prefix.len()) == Some(&b'/'))
|
||||
}
|
||||
|
||||
/// Stands in for an empty set of job token scopes. A token with `scopes: Some([])` is
|
||||
/// unrestricted (an empty list is read as "no scopes defined"), so a job whose effective
|
||||
/// scopes are empty carries this entry instead: it does not parse, and an unparseable
|
||||
/// entry marks a token as scoped while granting nothing.
|
||||
pub const NO_API_ACCESS_SCOPE: &str = "no_api_access";
|
||||
|
||||
/// Validates and normalizes a script or flow `job_token_scopes` setting. `[]` is valid and
|
||||
/// leaves the job only the runtime routes about itself.
|
||||
pub fn validate_job_token_scopes(scopes: &[String]) -> Result<Vec<String>> {
|
||||
let mut normalized: Vec<String> = Vec::with_capacity(scopes.len());
|
||||
for scope in scopes {
|
||||
let scope = scope.trim();
|
||||
// MCP scopes follow the MCP runtime's own grammar, and a list of only
|
||||
// `if_jobs:filter_tags` entries reads as unscoped: neither can cap a job token.
|
||||
if scope.starts_with("mcp:") || scope.starts_with("if_jobs:") {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Job token scopes cannot include '{scope}'"
|
||||
)));
|
||||
}
|
||||
let parsed = ScopeDefinition::from_scope_string(scope)?;
|
||||
if ScopeDomain::from_str(&parsed.domain).is_none()
|
||||
|| ScopeAction::from_str(&parsed.action).is_none()
|
||||
{
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Unknown job token scope '{scope}'"
|
||||
)));
|
||||
}
|
||||
if !normalized.iter().any(|s| s == scope) {
|
||||
normalized.push(scope.to_string());
|
||||
}
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// The scopes a pushed job's token is restricted to: what `ceiling` (the scopes of the job
|
||||
/// or token the push acts for) and `own` (the target's `job_token_scopes` setting) both
|
||||
/// grant. `None` on both sides means unrestricted.
|
||||
///
|
||||
/// Keeps every entry of either side contained by some entry of the other. Overlaps that
|
||||
/// neither side contains whole (two different globs) are dropped, which can only narrow.
|
||||
pub fn intersect_job_token_scopes(
|
||||
ceiling: Option<&[String]>,
|
||||
own: Option<&[String]>,
|
||||
) -> Option<Vec<String>> {
|
||||
match (ceiling, own) {
|
||||
(None, None) => None,
|
||||
(Some(c), None) => Some(c.to_vec()),
|
||||
(None, Some(o)) => Some(o.to_vec()),
|
||||
(Some(c), Some(o)) => {
|
||||
let parse = |scopes: &[String]| -> Vec<Option<ScopeDefinition>> {
|
||||
scopes
|
||||
.iter()
|
||||
.map(|s| ScopeDefinition::from_scope_string(s).ok())
|
||||
.collect()
|
||||
};
|
||||
let (pc, po) = (parse(c), parse(o));
|
||||
let mut out: Vec<String> = vec![];
|
||||
let mut keep_covered =
|
||||
|raw: &[String], parsed: &[Option<ScopeDefinition>], by: &[Option<ScopeDefinition>]| {
|
||||
for (s, p) in raw.iter().zip(parsed) {
|
||||
let Some(p) = p else { continue };
|
||||
if by.iter().flatten().any(|b| scope_contains(b, p))
|
||||
&& !out.iter().any(|x| x == s)
|
||||
{
|
||||
out.push(s.clone());
|
||||
}
|
||||
}
|
||||
};
|
||||
keep_covered(o, &po, &pc);
|
||||
keep_covered(c, &pc, &po);
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts a deploy of a script or flow that restricts its job token, keyed by which shape
|
||||
/// of restriction it picked, so take-up of the presets can be told from custom lists.
|
||||
pub fn log_job_token_scopes_deploy(kind: &'static str, scopes: Option<&[String]>) {
|
||||
let Some(scopes) = scopes else { return };
|
||||
let shape = match scopes {
|
||||
[] => "no_api",
|
||||
[only] if only == "oidc:write" => "oidc_only",
|
||||
_ => "custom",
|
||||
};
|
||||
crate::feature_usage::log_feature_usage(
|
||||
"job_token_scopes",
|
||||
"deploy",
|
||||
&format!("{kind}:{shape}"),
|
||||
);
|
||||
}
|
||||
|
||||
/// The `scopes` claim of a job token minted from the job's effective scopes.
|
||||
pub fn job_token_jwt_scopes(effective: Option<Vec<String>>) -> Option<Vec<String>> {
|
||||
match effective {
|
||||
Some(s) if s.is_empty() => Some(vec![NO_API_ACCESS_SCOPE.to_string()]),
|
||||
s => s,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod job_token_scopes_tests {
|
||||
use super::*;
|
||||
|
||||
fn v(s: &[&str]) -> Vec<String> {
|
||||
s.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_never_widens() {
|
||||
assert_eq!(intersect_job_token_scopes(None, None), None);
|
||||
let oidc = v(&["oidc:write"]);
|
||||
assert_eq!(intersect_job_token_scopes(Some(&oidc), None), Some(oidc.clone()));
|
||||
assert_eq!(intersect_job_token_scopes(None, Some(&oidc)), Some(oidc.clone()));
|
||||
// A child setting asking for more than its parent holds gets only the common part.
|
||||
assert_eq!(
|
||||
intersect_job_token_scopes(
|
||||
Some(&oidc),
|
||||
Some(&v(&["oidc:write", "variables:read"]))
|
||||
),
|
||||
Some(oidc.clone())
|
||||
);
|
||||
// Narrower entries win from either side.
|
||||
assert_eq!(
|
||||
intersect_job_token_scopes(
|
||||
Some(&v(&["variables:write:f/a/*"])),
|
||||
Some(&v(&["variables:read:f/a/b", "scripts:read"]))
|
||||
),
|
||||
Some(v(&["variables:read:f/a/b"]))
|
||||
);
|
||||
assert_eq!(
|
||||
intersect_job_token_scopes(Some(&v(&["jobs:run"])), Some(&v(&["oidc:write"]))),
|
||||
Some(vec![])
|
||||
);
|
||||
// The empty-set marker grants nothing to intersect with.
|
||||
assert_eq!(
|
||||
intersect_job_token_scopes(Some(&v(&[NO_API_ACCESS_SCOPE])), Some(&oidc)),
|
||||
Some(vec![])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_effective_scopes_mint_a_scoped_token() {
|
||||
assert_eq!(
|
||||
job_token_jwt_scopes(Some(vec![])),
|
||||
Some(v(&[NO_API_ACCESS_SCOPE]))
|
||||
);
|
||||
assert_eq!(job_token_jwt_scopes(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_unusable_scopes() {
|
||||
assert!(validate_job_token_scopes(&v(&["mcp:all"])).is_err());
|
||||
assert!(validate_job_token_scopes(&v(&["if_jobs:filter_tags:a"])).is_err());
|
||||
assert!(validate_job_token_scopes(&v(&["nope:write"])).is_err());
|
||||
assert!(validate_job_token_scopes(&v(&["*"])).is_err());
|
||||
assert_eq!(
|
||||
validate_job_token_scopes(&v(&["oidc:write", " oidc:write"])).unwrap(),
|
||||
v(&["oidc:write"])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ async fn prefetch_cached_script_inner(
|
||||
assets: script.assets,
|
||||
modules: script.modules,
|
||||
labels: script.labels,
|
||||
job_token_scopes: script.job_token_scopes,
|
||||
inherited_labels: script.inherited_labels,
|
||||
runnable_settings: ScriptRunnableSettingsInline {
|
||||
concurrency_settings: concurrency_settings.maybe_fallback(
|
||||
@@ -461,6 +462,7 @@ pub async fn deploy_relocked_version(
|
||||
modules,
|
||||
auto_parent: None,
|
||||
labels: s.labels,
|
||||
job_token_scopes: Some(s.job_token_scopes.clone()),
|
||||
skip_draft_deletion: None,
|
||||
};
|
||||
|
||||
@@ -481,7 +483,7 @@ pub async fn deploy_relocked_version(
|
||||
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, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, \
|
||||
lock_error_logs, created_at)
|
||||
job_token_scopes, lock_error_logs, created_at)
|
||||
|
||||
SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \
|
||||
content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, \
|
||||
@@ -489,7 +491,7 @@ pub async fn deploy_relocked_version(
|
||||
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, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, \
|
||||
$6::text, clock_timestamp()
|
||||
job_token_scopes, $6::text, clock_timestamp()
|
||||
|
||||
FROM script WHERE hash = $2 AND workspace_id = $3;
|
||||
", new_hash, s.hash.0, w_id, lock, modules_json, lock_error_logs).execute(&mut **tx).await?;
|
||||
|
||||
@@ -800,7 +800,7 @@ fn format_pull_query(peek: String) -> String {
|
||||
flow_innermost_root_job, root_job, flow_step_id,
|
||||
same_worker, pre_run_error, visible_to_owner, tag, concurrent_limit,
|
||||
concurrency_time_window_s, timeout, cache_ttl, priority, raw_code, raw_lock,
|
||||
raw_flow, script_entrypoint_override, preprocessed
|
||||
raw_flow, script_entrypoint_override, preprocessed, job_token_scopes
|
||||
FROM v2_job
|
||||
WHERE id = (SELECT id FROM peek)
|
||||
) SELECT j.id, j.workspace_id, j.parent_job, j.created_by, started_at, scheduled_for,
|
||||
@@ -810,7 +810,7 @@ fn format_pull_query(peek: String) -> String {
|
||||
j.same_worker, j.pre_run_error, j.visible_to_owner,
|
||||
j.tag, j.concurrent_limit, j.concurrency_time_window_s, j.flow_innermost_root_job, j.root_job,
|
||||
j.timeout, j.flow_step_id, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle, j.priority, j.raw_code, j.raw_lock, j.raw_flow,
|
||||
j.script_entrypoint_override, j.preprocessed, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path,
|
||||
j.script_entrypoint_override, j.preprocessed, j.job_token_scopes, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path,
|
||||
COALESCE(p.email, j.permissioned_as_email) as permissioned_as_email, p.username as permissioned_as_username, p.is_admin as permissioned_as_is_admin,
|
||||
p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email
|
||||
FROM q, j
|
||||
|
||||
@@ -210,6 +210,7 @@ pub async fn trigger_dependents_to_recompute_dependencies(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -822,6 +822,7 @@ async fn push_subscriber(
|
||||
debouncing_settings,
|
||||
concurrency_settings,
|
||||
labels: script.labels,
|
||||
job_token_scopes: script.job_token_scopes,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -904,6 +905,7 @@ async fn push_subscriber(
|
||||
JobTriggerKind::Asset,
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(format!("push asset-triggered job: {e:#}")))?;
|
||||
|
||||
@@ -503,6 +503,7 @@ pub async fn push_init_job<'c>(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
inner_tx.commit().await?;
|
||||
@@ -563,6 +564,7 @@ pub async fn push_periodic_bash_job<'c>(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
inner_tx.commit().await?;
|
||||
@@ -765,6 +767,19 @@ async fn restart_perpetual_runs_at_path(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The effective token scopes of an earlier job, as the ceiling of a job that re-runs it
|
||||
/// (a retry, a perpetual restart): the re-run never holds a wider token than the run it
|
||||
/// replaces.
|
||||
async fn stored_job_token_scopes(db: &DB, job_id: Uuid) -> Result<Option<Vec<String>>, Error> {
|
||||
Ok(sqlx::query_scalar!(
|
||||
"SELECT job_token_scopes FROM v2_job WHERE id = $1",
|
||||
job_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten())
|
||||
}
|
||||
|
||||
/// A run of an earlier version at the path, and what its replacement inherits from it.
|
||||
struct PerpetualRunToRestart {
|
||||
id: Uuid,
|
||||
@@ -857,6 +872,7 @@ async fn restart_perpetual_run(
|
||||
// an earlier version the next pass picks up.
|
||||
return Ok(());
|
||||
}
|
||||
let scope_ceiling = stored_job_token_scopes(db, run.id).await?;
|
||||
let (_, tx) = push(
|
||||
db,
|
||||
PushIsolationLevel::Transaction(tx),
|
||||
@@ -887,6 +903,7 @@ async fn restart_perpetual_run(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -2103,6 +2120,7 @@ async fn restart_job_if_perpetual_inner(
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let scope_ceiling = stored_job_token_scopes(db, queued_job.id).await?;
|
||||
let (_uuid, tx) = push(
|
||||
db,
|
||||
tx,
|
||||
@@ -2127,6 +2145,9 @@ async fn restart_job_if_perpetual_inner(
|
||||
// TODO(debouncing): handle properly
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None, // labels already set on original job
|
||||
// The run it replaces caps it (`scope_ceiling`), and that run already
|
||||
// carried this script's setting.
|
||||
job_token_scopes: None,
|
||||
},
|
||||
PushArgs::from(&args.0),
|
||||
&queued_job.created_by,
|
||||
@@ -2153,6 +2174,7 @@ async fn restart_job_if_perpetual_inner(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -2411,6 +2433,7 @@ pub async fn maybe_enqueue_native_script_retry(
|
||||
)
|
||||
.await?;
|
||||
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
|
||||
let scope_ceiling = stored_job_token_scopes(db, job.id).await?;
|
||||
let (new_id, mut tx) = match push(
|
||||
db,
|
||||
tx,
|
||||
@@ -2459,6 +2482,7 @@ pub async fn maybe_enqueue_native_script_retry(
|
||||
None,
|
||||
trigger,
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -3424,6 +3448,7 @@ pub async fn push_error_handler<'a, 'c, T: Serialize + Send + Sync>(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -3513,6 +3538,7 @@ pub async fn push_success_handler<'a, 'c, T: Serialize + Send + Sync>(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
@@ -3571,6 +3597,11 @@ pub struct MiniPulledJob {
|
||||
pub visible_to_owner: bool,
|
||||
pub permissioned_as_end_user_email: Option<String>,
|
||||
pub runnable_settings_handle: Option<i64>,
|
||||
/// The job's effective token scopes (`v2_job.job_token_scopes`), minted into its token.
|
||||
/// No `sqlx(default)`: a query that forgets the column must fail rather than mint an
|
||||
/// unrestricted token.
|
||||
#[serde(default)]
|
||||
pub job_token_scopes: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl MiniPulledJob {
|
||||
@@ -3622,6 +3653,7 @@ impl MiniPulledJob {
|
||||
runnable_settings_handle: None,
|
||||
concurrent_limit: None,
|
||||
concurrency_time_window_s: None,
|
||||
job_token_scopes: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3865,6 +3897,7 @@ impl MiniPulledJob {
|
||||
.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 {
|
||||
@@ -3968,6 +4001,7 @@ impl PulledJob {
|
||||
groups,
|
||||
folders,
|
||||
end_user_email: self.job.permissioned_as_end_user_email.clone(),
|
||||
job_token_scopes: self.job.job_token_scopes.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
@@ -4074,7 +4108,8 @@ pub async fn get_mini_pulled_job<'c>(
|
||||
trigger,
|
||||
trigger_kind as \"trigger_kind: TriggerKindLabel\",
|
||||
visible_to_owner,
|
||||
NULL as permissioned_as_end_user_email
|
||||
NULL as permissioned_as_end_user_email,
|
||||
job_token_scopes
|
||||
FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id WHERE v2_job_queue.id = $1",
|
||||
job_id,
|
||||
)
|
||||
@@ -5981,6 +6016,10 @@ pub async fn push<'c, 'd>(
|
||||
end_user_email: Option<String>,
|
||||
trigger: Option<TriggerMetadata>,
|
||||
suspended_mode: Option<bool>,
|
||||
// Caps the new job's token on top of the target's own `job_token_scopes`: the scopes of
|
||||
// the job (or job token) this push acts for, `None` when nothing above it restricts it.
|
||||
// A job may never hold a token wider than the job that created it.
|
||||
scope_ceiling: Option<&[String]>,
|
||||
) -> Result<(Uuid, Transaction<'c, Postgres>), Error> {
|
||||
Box::pin(push_inner(
|
||||
db,
|
||||
@@ -6012,6 +6051,7 @@ pub async fn push<'c, 'd>(
|
||||
end_user_email,
|
||||
trigger,
|
||||
suspended_mode,
|
||||
scope_ceiling,
|
||||
))
|
||||
.await
|
||||
}
|
||||
@@ -6047,6 +6087,7 @@ async fn push_inner<'c, 'd>(
|
||||
end_user_email: Option<String>,
|
||||
trigger: Option<TriggerMetadata>,
|
||||
suspended_mode: Option<bool>,
|
||||
scope_ceiling: Option<&[String]>,
|
||||
) -> Result<(Uuid, Transaction<'c, Postgres>), Error> {
|
||||
// The worker builds a preview's `_MODULES` arg into the job as its module code. Every
|
||||
// caller-reachable value lands in `args` or `extra` (webhook query and headers go to
|
||||
@@ -6280,6 +6321,8 @@ async fn push_inner<'c, 'd>(
|
||||
debouncing_settings: DebouncingSettings,
|
||||
retry_settings: RetrySettings,
|
||||
labels: Option<Vec<String>>,
|
||||
/// The target's own `job_token_scopes` setting.
|
||||
job_token_scopes: Option<Vec<String>>,
|
||||
/// A `dependencies` job that only compiles an already-deployed script's binary.
|
||||
/// It shares the job kind, but not the queue policy lock generation needs.
|
||||
build_binary_only: bool,
|
||||
@@ -6302,6 +6345,7 @@ async fn push_inner<'c, 'd>(
|
||||
debouncing_settings,
|
||||
retry_settings,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
build_binary_only,
|
||||
} = match job_payload {
|
||||
JobPayload::ScriptHash {
|
||||
@@ -6316,6 +6360,7 @@ async fn push_inner<'c, 'd>(
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
} => {
|
||||
if apply_preprocessor {
|
||||
preprocessed = Some(false);
|
||||
@@ -6333,6 +6378,7 @@ async fn push_inner<'c, 'd>(
|
||||
dedicated_worker,
|
||||
_low_level_priority: priority,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -6671,20 +6717,21 @@ async fn push_inner<'c, 'd>(
|
||||
// script's `dedicated_worker` (it drives the dedicated tag below),
|
||||
// but the SingleStepFlow payload doesn't — resolve it from the
|
||||
// script row so a dedicated-worker script keeps its dedicated pool.
|
||||
let dedicated_worker = if let Some(h) = &hash {
|
||||
// The script's `job_token_scopes` is resolved the same way.
|
||||
let (dedicated_worker, job_token_scopes) = if let Some(h) = &hash {
|
||||
// Read on the non-RLS pool: push_inner is also entered with RLS
|
||||
// isolation variants under which the script row may be invisible,
|
||||
// which would mis-resolve dedicated_worker routing.
|
||||
sqlx::query_scalar::<_, Option<bool>>(
|
||||
"SELECT dedicated_worker FROM script WHERE hash = $1 AND workspace_id = $2",
|
||||
sqlx::query_as::<_, (Option<bool>, Option<Vec<String>>)>(
|
||||
"SELECT dedicated_worker, job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(h.0)
|
||||
.bind(workspace_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten()
|
||||
.unwrap_or((None, None))
|
||||
} else {
|
||||
None
|
||||
(None, None)
|
||||
};
|
||||
break 'ssf JobPayloadUntagged {
|
||||
runnable_id: hash.map(|h| h.0),
|
||||
@@ -6696,6 +6743,7 @@ async fn push_inner<'c, 'd>(
|
||||
},
|
||||
language,
|
||||
dedicated_worker,
|
||||
job_token_scopes,
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
retry_settings: retry.as_ref().map(RetrySettings::from).unwrap_or_default(),
|
||||
@@ -6866,7 +6914,14 @@ async fn push_inner<'c, 'd>(
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
JobPayload::Flow { path, dedicated_worker, apply_preprocessor, version, labels } => {
|
||||
JobPayload::Flow {
|
||||
path,
|
||||
dedicated_worker,
|
||||
apply_preprocessor,
|
||||
version,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
} => {
|
||||
let mut ntx = tx.into_tx().await?;
|
||||
// Do not use the lite version unless all workers are updated.
|
||||
let data = if *DISABLE_FLOW_SCRIPT
|
||||
@@ -6933,6 +6988,7 @@ async fn push_inner<'c, 'd>(
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -6997,6 +7053,16 @@ async fn push_inner<'c, 'd>(
|
||||
memory_id: None,
|
||||
no_inherited_flow_env: false,
|
||||
};
|
||||
// The restart runs the completed job's stored definition, so it keeps that job's
|
||||
// restriction rather than reading the flow's current setting.
|
||||
let job_token_scopes = sqlx::query_scalar::<_, Option<Vec<String>>>(
|
||||
"SELECT job_token_scopes FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(completed_job_id)
|
||||
.bind(workspace_id)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.flatten();
|
||||
let value = flow_data.value();
|
||||
let priority = value.priority;
|
||||
let concurrency_settings = value.concurrency_settings.clone();
|
||||
@@ -7021,6 +7087,7 @@ async fn push_inner<'c, 'd>(
|
||||
_low_level_priority: priority,
|
||||
concurrency_settings,
|
||||
debouncing_settings,
|
||||
job_token_scopes,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -7345,6 +7412,11 @@ async fn push_inner<'c, 'd>(
|
||||
}
|
||||
};
|
||||
|
||||
let job_token_scopes = windmill_common::scopes::intersect_job_token_scopes(
|
||||
scope_ceiling,
|
||||
job_token_scopes.as_deref(),
|
||||
);
|
||||
|
||||
let folders = job_authed
|
||||
.folders
|
||||
.iter()
|
||||
@@ -7443,7 +7515,8 @@ async fn push_inner<'c, 'd>(
|
||||
trigger_kind, -- 39
|
||||
script_entrypoint_override, -- 12
|
||||
preprocessed, -- 27,
|
||||
labels -- 44
|
||||
labels, -- 44
|
||||
job_token_scopes -- 47
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
|
||||
$19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,
|
||||
($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27,
|
||||
@@ -7452,7 +7525,7 @@ async fn push_inner<'c, 'd>(
|
||||
(SELECT CASE WHEN fl.labels IS NULL THEN $44
|
||||
ELSE (SELECT array_agg(DISTINCT lbl) FROM unnest(COALESCE($44, ARRAY[]::TEXT[]) || fl.labels) lbl)
|
||||
END
|
||||
FROM folder_labels($46, $45) AS fl(labels)))
|
||||
FROM folder_labels($46, $45) AS fl(labels)), $47)
|
||||
),
|
||||
inserted_runtime AS (
|
||||
INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)
|
||||
@@ -7515,6 +7588,7 @@ async fn push_inner<'c, 'd>(
|
||||
labels.as_deref() as Option<&[String]>,
|
||||
runnable_path,
|
||||
workspace_id,
|
||||
job_token_scopes.as_deref() as Option<&[String]>,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.warn_after_seconds(1)
|
||||
@@ -8478,6 +8552,7 @@ pub async fn get_same_worker_job(
|
||||
v2_job.trigger,
|
||||
v2_job.trigger_kind,
|
||||
v2_job.visible_to_owner,
|
||||
v2_job.job_token_scopes,
|
||||
v2_job.raw_code,
|
||||
v2_job.raw_lock,
|
||||
v2_job.raw_flow,
|
||||
|
||||
@@ -103,6 +103,7 @@ async fn get_schedule_metadata<'c>(
|
||||
on_behalf_of,
|
||||
_runnable_settings_handle,
|
||||
_labels,
|
||||
_job_token_scopes,
|
||||
) = windmill_common::get_latest_hash_for_path(
|
||||
&mut **tx,
|
||||
db,
|
||||
@@ -368,7 +369,8 @@ pub async fn push_scheduled_job<'c>(
|
||||
.warn_after_seconds_with_sql(1, "get_flow_version_info_from_version".to_string())
|
||||
.await?;
|
||||
let on_behalf_of = flow_info.on_behalf_of(&schedule.workspace_id, db).await?;
|
||||
let FlowVersionInfo { version, tag, dedicated_worker, labels, .. } = flow_info;
|
||||
let FlowVersionInfo { version, tag, dedicated_worker, labels, job_token_scopes, .. } =
|
||||
flow_info;
|
||||
|
||||
(
|
||||
JobPayload::Flow {
|
||||
@@ -377,6 +379,7 @@ pub async fn push_scheduled_job<'c>(
|
||||
apply_preprocessor: false,
|
||||
version,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
},
|
||||
tag,
|
||||
None,
|
||||
@@ -442,6 +445,7 @@ pub async fn push_scheduled_job<'c>(
|
||||
on_behalf_of,
|
||||
runnable_settings_handle,
|
||||
labels,
|
||||
job_token_scopes,
|
||||
) = windmill_common::get_latest_hash_for_path(
|
||||
&mut *tx,
|
||||
db,
|
||||
@@ -535,6 +539,7 @@ pub async fn push_scheduled_job<'c>(
|
||||
concurrency_time_window_s,
|
||||
),
|
||||
labels,
|
||||
job_token_scopes,
|
||||
},
|
||||
if schedule.tag.as_ref().is_some_and(|x| x != "") {
|
||||
schedule.tag.clone()
|
||||
@@ -664,6 +669,7 @@ pub async fn push_scheduled_job<'c>(
|
||||
JobTriggerKind::Schedule,
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.warn_after_seconds_with_sql(1, "push in push_scheduled_job".to_string())
|
||||
.await?;
|
||||
|
||||
@@ -3458,6 +3458,7 @@ mod debounce {
|
||||
visible_to_owner: false,
|
||||
permissioned_as_end_user_email: None,
|
||||
runnable_settings_handle: rs_handle,
|
||||
job_token_scopes: None,
|
||||
};
|
||||
|
||||
let pulled = PulledJob {
|
||||
@@ -3699,6 +3700,7 @@ mod debounce {
|
||||
visible_to_owner: false,
|
||||
permissioned_as_end_user_email: None,
|
||||
runnable_settings_handle: rs_handle,
|
||||
job_token_scopes: None,
|
||||
};
|
||||
|
||||
let pulled = PulledJob {
|
||||
|
||||
@@ -249,6 +249,7 @@ impl RunJob {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("push has to succeed");
|
||||
@@ -296,6 +297,7 @@ impl RunJob {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("push has to succeed");
|
||||
@@ -969,6 +971,7 @@ pub async fn run_deployed_relative_imports(
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db2)
|
||||
.await;
|
||||
|
||||
@@ -976,6 +976,7 @@ async fn trigger_script_with_retry_and_error_handler<'c>(
|
||||
None,
|
||||
Some(trigger),
|
||||
suspended_mode,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ pub struct Flow {
|
||||
pub on_behalf_of: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Caps the scopes of the token minted for each job of this flow; `None` = unrestricted.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job_token_scopes: Option<Vec<String>>,
|
||||
/// Labels inherited from the parent folder, computed at read time. Not stored on the flow row.
|
||||
#[sqlx(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -145,6 +149,15 @@ pub struct NewFlow {
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Absent keeps the deployed flow's value, so a client unaware of the setting cannot
|
||||
/// drop a restriction by saving; `null` clears it.
|
||||
#[sqlx(skip)]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::more_serde::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub job_token_scopes: Option<Option<Vec<String>>>,
|
||||
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
|
||||
/// this flow must NOT delete an existing user draft at the same path.
|
||||
/// Transient — never persisted.
|
||||
@@ -183,6 +196,12 @@ pub struct EditFlow {
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::more_serde::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub job_token_scopes: Option<Option<Vec<String>>>,
|
||||
#[serde(default)]
|
||||
pub skip_draft_deletion: Option<bool>,
|
||||
}
|
||||
@@ -207,6 +226,7 @@ impl EditFlow {
|
||||
preserve_on_behalf_of: self.preserve_on_behalf_of,
|
||||
ws_error_handler_muted: self.ws_error_handler_muted,
|
||||
labels: self.labels,
|
||||
job_token_scopes: self.job_token_scopes,
|
||||
skip_draft_deletion: self.skip_draft_deletion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +515,8 @@ pub enum JobPayload {
|
||||
concurrency_settings: ConcurrencySettings,
|
||||
debouncing_settings: DebouncingSettings,
|
||||
labels: Option<Vec<String>>,
|
||||
/// The script's `job_token_scopes` setting, read with the rest of the payload.
|
||||
job_token_scopes: Option<Vec<String>>,
|
||||
},
|
||||
FlowNode {
|
||||
id: FlowNodeId,
|
||||
@@ -579,6 +581,8 @@ pub enum JobPayload {
|
||||
apply_preprocessor: bool,
|
||||
version: i64,
|
||||
labels: Option<Vec<String>>,
|
||||
/// The flow's `job_token_scopes` setting, read with the rest of the payload.
|
||||
job_token_scopes: Option<Vec<String>>,
|
||||
},
|
||||
RestartedFlow {
|
||||
completed_job_id: Uuid,
|
||||
|
||||
@@ -331,7 +331,7 @@ pub const SCRIPT_COLUMNS: &str = concat!(
|
||||
"timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, ",
|
||||
"visible_to_runner_only, auto_kind, codebase, has_preprocessor, ",
|
||||
"on_behalf_of, ",
|
||||
"assets, modules, labels, concurrency_key, concurrent_limit, ",
|
||||
"assets, modules, labels, job_token_scopes, concurrency_key, concurrent_limit, ",
|
||||
"concurrency_time_window_s, debounce_key, debounce_delay_s, runnable_settings_handle",
|
||||
);
|
||||
|
||||
@@ -401,6 +401,9 @@ pub struct Script<SR> {
|
||||
pub modules: Option<HashMap<String, ScriptModule>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Caps the scopes of the token minted for each job of this script; `None` = unrestricted.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job_token_scopes: Option<Vec<String>>,
|
||||
/// Labels inherited from the parent folder, computed at read time. Not stored on the script row.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -580,6 +583,14 @@ pub struct NewScript {
|
||||
pub auto_parent: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Absent keeps the parent version's value, so a client unaware of the setting
|
||||
/// cannot drop a restriction by redeploying; `null` clears it.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::more_serde::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub job_token_scopes: Option<Option<Vec<String>>>,
|
||||
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
|
||||
/// this script must NOT delete an existing user draft at the same path.
|
||||
/// Transient — never persisted. Deliberately excluded from `impl Hash`
|
||||
@@ -628,6 +639,7 @@ impl Hash for NewScript {
|
||||
self.preserve_on_behalf_of.hash(state);
|
||||
self.assets.hash(state);
|
||||
self.labels.hash(state);
|
||||
self.job_token_scopes.hash(state);
|
||||
if let Some(modules) = &self.modules {
|
||||
let mut sorted: Vec<_> = modules.iter().collect();
|
||||
sorted.sort_by_key(|(k, _)| *k);
|
||||
|
||||
@@ -599,6 +599,8 @@ async fn enqueue_windmill_tool(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
// A tool never holds a wider token than the agent calling it.
|
||||
ctx.job.job_token_scopes.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -2896,6 +2896,9 @@ pub async fn handle_wac_v2_output(
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
// Capped by the parent at push, which already holds this
|
||||
// script's setting.
|
||||
job_token_scopes: None,
|
||||
})
|
||||
} else {
|
||||
Err(error::Error::internal_err(
|
||||
@@ -3023,6 +3026,7 @@ pub async fn handle_wac_v2_output(
|
||||
apply_preprocessor: false,
|
||||
version: flow_info.version,
|
||||
labels: flow_info.labels.clone(),
|
||||
job_token_scopes: flow_info.job_token_scopes.clone(),
|
||||
};
|
||||
let on_behalf_of = flow_info.on_behalf_of(&job.workspace_id, db).await?;
|
||||
(ChildRunnable::Deployed(payload), on_behalf_of)
|
||||
@@ -3272,6 +3276,7 @@ pub async fn handle_wac_v2_output(
|
||||
None, // end_user_email
|
||||
None, // trigger
|
||||
None, // suspended_mode
|
||||
job.job_token_scopes.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -2309,6 +2309,7 @@ mod tests {
|
||||
visible_to_owner: false,
|
||||
permissioned_as_end_user_email: None,
|
||||
runnable_settings_handle: None,
|
||||
job_token_scopes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2402,7 +2402,8 @@ async fn advance_flow_status(
|
||||
v2_job.trigger,
|
||||
v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\",
|
||||
v2_job.visible_to_owner,
|
||||
NULL as permissioned_as_end_user_email",
|
||||
NULL as permissioned_as_end_user_email,
|
||||
v2_job.job_token_scopes",
|
||||
flow,
|
||||
&step_path as &[&str],
|
||||
step,
|
||||
@@ -4746,6 +4747,8 @@ async fn push_next_flow_job(
|
||||
end_user_email,
|
||||
None,
|
||||
None,
|
||||
// A step never holds a wider token than the flow running it.
|
||||
flow_job.job_token_scopes.as_deref(),
|
||||
)
|
||||
.warn_after_seconds(2)
|
||||
.await?;
|
||||
@@ -6339,13 +6342,14 @@ async fn flow_to_payload(
|
||||
) -> Result<JobPayloadWithTag, Error> {
|
||||
let flow_info = get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?;
|
||||
let on_behalf_of = flow_info.on_behalf_of(w_id, &db).await?;
|
||||
let FlowVersionInfo { version, tag, .. } = flow_info;
|
||||
let FlowVersionInfo { version, tag, job_token_scopes, .. } = flow_info;
|
||||
let payload = JobPayload::Flow {
|
||||
path,
|
||||
dedicated_worker: None,
|
||||
apply_preprocessor: false,
|
||||
version,
|
||||
labels: None,
|
||||
job_token_scopes,
|
||||
};
|
||||
Ok(JobPayloadWithTag {
|
||||
payload,
|
||||
@@ -6419,6 +6423,7 @@ pub async fn script_to_payload(
|
||||
delete_after_use,
|
||||
delete_after_secs,
|
||||
timeout,
|
||||
job_token_scopes,
|
||||
runnable_settings:
|
||||
ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings },
|
||||
..
|
||||
@@ -6436,6 +6441,7 @@ pub async fn script_to_payload(
|
||||
priority,
|
||||
apply_preprocessor: apply_preprocessor.unwrap_or(false),
|
||||
labels: None,
|
||||
job_token_scopes,
|
||||
},
|
||||
tag_override.to_owned().or(tag),
|
||||
delete_after_use,
|
||||
|
||||
@@ -137,6 +137,7 @@ async fn maybe_queue_binary_prebuild(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface FlowFile {
|
||||
schema?: any;
|
||||
on_behalf_of_email?: string;
|
||||
has_on_behalf_of?: boolean;
|
||||
job_token_scopes?: string[] | null;
|
||||
// Mirrors granular ACLs on the flow path. Omitted from flow.yaml when no
|
||||
// perms are set. The CLI applies diffs through /acls/add and /acls/remove
|
||||
// (see applyExtraPermsDiff) — never through update_flow — so a perm-only
|
||||
@@ -265,12 +266,22 @@ export async function pushFlow(
|
||||
// so a perm-only edit never bumps the flow version. Strip the field from the
|
||||
// body that goes to update_flow / create_flow and treat it as a separate
|
||||
// step both for the up-to-date short-circuit and after the deploy.
|
||||
const { extra_perms: localPerms, ...localFlowBody } = localFlow as FlowFile & {
|
||||
const {
|
||||
extra_perms: localPerms,
|
||||
job_token_scopes: localJobTokenScopes,
|
||||
...localFlowBody
|
||||
} = localFlow as FlowFile & {
|
||||
extra_perms?: Record<string, boolean>;
|
||||
};
|
||||
// Always sent, unlike the other settings: the server keeps a restriction the body omits,
|
||||
// so a flow.yaml without the key has to clear it explicitly.
|
||||
const jobTokenScopes = localJobTokenScopes ?? null;
|
||||
|
||||
if (flow) {
|
||||
if (isSuperset(localFlowBody, flow)) {
|
||||
if (
|
||||
isSuperset(localFlowBody, flow) &&
|
||||
JSON.stringify(jobTokenScopes) === JSON.stringify(flow.job_token_scopes ?? null)
|
||||
) {
|
||||
log.info(colors.green(`Flow ${remotePath} is up to date`));
|
||||
} else {
|
||||
log.info(colors.bold.yellow(`Updating flow ${remotePath}...`));
|
||||
@@ -281,6 +292,7 @@ export async function pushFlow(
|
||||
path: remotePath.replaceAll(SEP, "/"),
|
||||
deployment_message: message,
|
||||
...localFlowBody,
|
||||
job_token_scopes: jobTokenScopes,
|
||||
...preserveFields,
|
||||
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
||||
skip_draft_deletion: true,
|
||||
@@ -296,6 +308,7 @@ export async function pushFlow(
|
||||
path: remotePath.replaceAll(SEP, "/"),
|
||||
deployment_message: message,
|
||||
...localFlowBody,
|
||||
job_token_scopes: jobTokenScopes,
|
||||
...preserveFields,
|
||||
// Preserve any user draft at this path (see backend skip_draft_deletion).
|
||||
skip_draft_deletion: true,
|
||||
|
||||
@@ -636,6 +636,9 @@ export async function handleFile(
|
||||
envs: typed?.envs,
|
||||
modules: modules,
|
||||
labels: typed?.labels,
|
||||
// Always sent: the server keeps a restriction the body omits, so a script.yaml without
|
||||
// the key has to clear it explicitly.
|
||||
job_token_scopes: typed?.job_token_scopes ?? null,
|
||||
};
|
||||
|
||||
const hasOnBehalfOf = (typed as any)?.has_on_behalf_of ?? !!typed?.on_behalf_of_email;
|
||||
@@ -712,6 +715,7 @@ export async function handleFile(
|
||||
(hasOnBehalfOf ? true : typed.on_behalf_of_email == remote.on_behalf_of_email) &&
|
||||
deepEqual(typed.envs, remote.envs) &&
|
||||
deepEqual(typed.labels ?? null, remote.labels ?? null) &&
|
||||
deepEqual(typed.job_token_scopes ?? null, remote.job_token_scopes ?? null) &&
|
||||
deepEqual(modules ?? null, remote.modules ?? null))
|
||||
) {
|
||||
log.info(colors.green(`Script ${remotePath} is up to date`));
|
||||
|
||||
@@ -889,6 +889,7 @@ export async function getItemValue(
|
||||
summary: flow.summary,
|
||||
description: flow.description,
|
||||
value: flow.value,
|
||||
job_token_scopes: flow.job_token_scopes ?? null,
|
||||
};
|
||||
} else if (kind === "script") {
|
||||
const script = await provider.getScriptByPath({ workspace, path });
|
||||
@@ -898,6 +899,7 @@ export async function getItemValue(
|
||||
schema: script.schema,
|
||||
summary: script.summary,
|
||||
language: script.language,
|
||||
job_token_scopes: script.job_token_scopes ?? null,
|
||||
};
|
||||
} else if (kind === "app" || kind === "raw_app") {
|
||||
return await provider.getAppByPath({ workspace, path });
|
||||
|
||||
@@ -52,6 +52,20 @@ Symbols, not line numbers, are cited: they drift less.
|
||||
with that reader's permissions, outside the folder too. `ai_skill` bodies are likewise in play
|
||||
for every reader until turned off. The resource ACL is the only control, so granting write on a
|
||||
folder grants that influence.
|
||||
- **Restricted job tokens** (`job_token_scopes` on scripts and flows): a job's effective scopes
|
||||
are stored in `v2_job.job_token_scopes` at push and minted into its token
|
||||
(`create_token_for_owner`); an empty set is minted as `NO_API_ACCESS_SCOPE`, since
|
||||
`scopes: Some([])` reads as unscoped. Every `push` takes a `scope_ceiling`, and the job gets the
|
||||
ceiling ∩ the target's own setting: flow steps take their flow job's, agent tools their agent's,
|
||||
WAC children their parent's, retries and restarts the run they replace, and API pushes the
|
||||
calling job's (`caller_scope_ceiling`) — a scoped user or webhook token caps nothing. Schedules,
|
||||
triggers and error/success handlers start from the target's own setting. A restricted token
|
||||
keeps only the runtime routes about its own job (`is_own_job_runtime_route`); a route that
|
||||
authenticates a token itself instead of through the route layer must call
|
||||
`check_job_token_scope`. `$var:`/`$res:` args are resolved through the API with the job's own
|
||||
token, so they need read scopes. A deploy that omits the setting keeps the deployed value and
|
||||
`null` clears it, so an unaware client cannot drop a restriction. Write scopes on scripts,
|
||||
flows, schedules or triggers let a job escape its restriction.
|
||||
- **A remote deploy token is a credential for another instance**, held per account and workspace
|
||||
(`remote_deploy_token`, encrypted under the workspace key, re-keyed by `set_encryption_key`).
|
||||
Its `email` references `password(email)` with `ON DELETE/UPDATE CASCADE`, so whatever deletes or
|
||||
|
||||
@@ -618,7 +618,9 @@
|
||||
on_behalf_of: flow.on_behalf_of,
|
||||
preserve_on_behalf_of: $preserveOnBehalfOf || undefined,
|
||||
deployment_message: deploymentMsg || undefined,
|
||||
labels: (flow as any).labels
|
||||
labels: (flow as any).labels,
|
||||
// Sent explicitly: an omitted field keeps the deployed value.
|
||||
job_token_scopes: flow.job_token_scopes ?? null
|
||||
}
|
||||
})
|
||||
// Gated for operators without `manage_triggers`, who cannot have captures to move.
|
||||
@@ -670,7 +672,9 @@
|
||||
on_behalf_of: flow.on_behalf_of,
|
||||
preserve_on_behalf_of: $preserveOnBehalfOf || undefined,
|
||||
deployment_message: deploymentMsg || undefined,
|
||||
labels: (flow as any).labels
|
||||
labels: (flow as any).labels,
|
||||
// Sent explicitly: an omitted field keeps the deployed value.
|
||||
job_token_scopes: flow.job_token_scopes ?? null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import Toggle from './Toggle.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ScopeSelector from './settings/ScopeSelector.svelte'
|
||||
|
||||
interface Props {
|
||||
/** `null`/`undefined`: the job token has the full permissions of the identity it runs as. */
|
||||
value: string[] | null | undefined
|
||||
kind: 'script' | 'flow'
|
||||
}
|
||||
|
||||
let { value = $bindable(), kind }: Props = $props()
|
||||
|
||||
const OIDC_ONLY = ['oidc:write']
|
||||
|
||||
type Mode = 'oidc_only' | 'no_api' | 'custom'
|
||||
|
||||
function modeOf(scopes: string[]): Mode {
|
||||
if (scopes.length === 0) return 'no_api'
|
||||
if (scopes.length === 1 && scopes[0] === OIDC_ONLY[0]) return 'oidc_only'
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
// Kept apart from `value`: a custom list can hold exactly what a preset holds.
|
||||
let mode = $state<Mode>(modeOf(value ?? OIDC_ONLY))
|
||||
let customScopes = $state<string[]>(value && modeOf(value) === 'custom' ? [...value] : [])
|
||||
|
||||
function setMode(next: Mode) {
|
||||
mode = next
|
||||
value = next === 'oidc_only' ? [...OIDC_ONLY] : next === 'no_api' ? [] : [...customScopes]
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={value != null}
|
||||
on:change={() => {
|
||||
if (value != null) {
|
||||
value = null
|
||||
} else {
|
||||
setMode(mode)
|
||||
}
|
||||
}}
|
||||
options={{ right: 'Restrict the job token' }}
|
||||
/>
|
||||
{#if value != null}
|
||||
<ToggleButtonGroup selected={mode} onSelected={(v) => setMode(v)}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="oidc_only" label="OIDC token only" {item} />
|
||||
<ToggleButton value="no_api" label="No API access" {item} />
|
||||
<ToggleButton value="custom" label="Custom" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{#if mode === 'custom'}
|
||||
<ScopeSelector
|
||||
bind:selectedScopes={
|
||||
() => customScopes,
|
||||
(scopes) => {
|
||||
customScopes = scopes
|
||||
value = [...scopes]
|
||||
}
|
||||
}
|
||||
emptyLabel="No scopes selected. The job token can only reach its own job."
|
||||
/>
|
||||
{/if}
|
||||
<p class="text-2xs text-secondary">
|
||||
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,
|
||||
flows, schedules or triggers let a job escape the restriction.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -83,6 +83,7 @@
|
||||
import { fade } from 'svelte/transition'
|
||||
import Popover from './Popover.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import JobTokenScopesSetting from './JobTokenScopesSetting.svelte'
|
||||
import ScriptSchema from './ScriptSchema.svelte'
|
||||
import Section from './Section.svelte'
|
||||
import Label from './Label.svelte'
|
||||
@@ -787,7 +788,9 @@
|
||||
preserve_on_behalf_of: preserveOnBehalfOf || undefined,
|
||||
assets: script.assets,
|
||||
modules: script.modules,
|
||||
labels: script.labels
|
||||
labels: script.labels,
|
||||
// Sent explicitly: an omitted field keeps the deployed value.
|
||||
job_token_scopes: script.job_token_scopes ?? null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2065,6 +2068,16 @@
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
<Section label="Job token">
|
||||
{#snippet header()}
|
||||
<Tooltip>
|
||||
Restrict what the token of this script's jobs (WM_TOKEN) can do through the
|
||||
API, e.g. only mint an OIDC token. Use it for scripts that run untrusted
|
||||
input, such as AI agents.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
<JobTokenScopesSetting bind:value={script.job_token_scopes} kind="script" />
|
||||
</Section>
|
||||
<Section
|
||||
label={canPreserve
|
||||
? 'Run on behalf of a specified user'
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Path from '$lib/components/Path.svelte'
|
||||
import FlowCard from '../common/FlowCard.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import JobTokenScopesSetting from '$lib/components/JobTokenScopesSetting.svelte'
|
||||
import { Alert, Button, SecondsInput } from '$lib/components/common'
|
||||
import { getContext } from 'svelte'
|
||||
import type { FlowEditorContext } from '../types'
|
||||
@@ -101,6 +102,7 @@
|
||||
},
|
||||
{ name: 'Error Handler Muted', active: Boolean(flowStore.val.ws_error_handler_muted) },
|
||||
{ name: 'Invisible to Others', active: Boolean(flowStore.val.visible_to_runner_only) },
|
||||
{ name: 'Restricted Job Token', active: flowStore.val.job_token_scopes != null },
|
||||
{ name: 'Shared Directory', active: Boolean(flowStore.val.value.same_worker) },
|
||||
{ name: 'Preserve Step Tags', active: Boolean(flowStore.val.value.preserve_step_tags) },
|
||||
{ name: 'Cache Results', active: Boolean(flowStore.val.value.cache_ttl) },
|
||||
@@ -458,6 +460,11 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs font-medium text-emphasis">Job token</span>
|
||||
<JobTokenScopesSetting bind:value={flowStore.val.job_token_scopes} kind="flow" />
|
||||
</div>
|
||||
|
||||
<!-- On behalf of last editor section -->
|
||||
<span class="inline-flex gap-2">
|
||||
<Toggle
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
if (parsed['on_behalf_of'] !== undefined) {
|
||||
flowStore.val.on_behalf_of = parsed['on_behalf_of']
|
||||
}
|
||||
if (parsed['job_token_scopes'] !== undefined) {
|
||||
flowStore.val.job_token_scopes = parsed['job_token_scopes']
|
||||
}
|
||||
flowStore.val.value = parsed.value
|
||||
flowStore.val.schema = parsed.schema
|
||||
flowStore.val.tag = parsed.tag
|
||||
|
||||
@@ -41,6 +41,7 @@ export type ExtendedOpenFlow = OpenFlow & {
|
||||
visible_to_runner_only?: boolean
|
||||
on_behalf_of_email?: string
|
||||
on_behalf_of?: string
|
||||
job_token_scopes?: string[] | null
|
||||
}
|
||||
|
||||
export type FlowInputEditorState = {
|
||||
|
||||
@@ -107,6 +107,9 @@ export function filteredContentForExport(flow: ExtendedOpenFlow) {
|
||||
if (flow.tag) {
|
||||
o['tag'] = flow.tag
|
||||
}
|
||||
if (flow.job_token_scopes != null) {
|
||||
o['job_token_scopes'] = flow.job_token_scopes
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -119,6 +122,7 @@ export function cleanFlow(flow: OpenFlow | any): OpenFlow & {
|
||||
visible_to_runner_only?: boolean
|
||||
on_behalf_of_email?: string
|
||||
on_behalf_of?: string
|
||||
job_token_scopes?: string[] | null
|
||||
} {
|
||||
const newFlow: Flow = $state.snapshot(flow)
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
class?: string
|
||||
/** Renders above the scope-list card, below the Selected Scopes summary. */
|
||||
topSlot?: Snippet
|
||||
/** What an empty selection means, shown when no scope is selected. */
|
||||
emptyLabel?: string
|
||||
}
|
||||
|
||||
interface ScopeState {
|
||||
@@ -38,7 +40,8 @@
|
||||
selectedScopes = $bindable([]),
|
||||
disabled = false,
|
||||
class: className = '',
|
||||
topSlot
|
||||
topSlot,
|
||||
emptyLabel = 'No scopes selected. Token will have full access.'
|
||||
}: Props = $props()
|
||||
|
||||
let scopeDomains = $state<ScopeDomain[] | null>(null)
|
||||
@@ -532,7 +535,7 @@
|
||||
</div>
|
||||
|
||||
{#if selectedScopes.length === 0}
|
||||
<p class="text-xs text-secondary">No scopes selected. Token will have full access.</p>
|
||||
<p class="text-xs text-secondary">{emptyLabel}</p>
|
||||
{:else if hasAdministratorScope}
|
||||
<p class="text-xs text-secondary"
|
||||
>Administrator scope grants full access to all resources.</p
|
||||
|
||||
@@ -510,6 +510,7 @@ export async function deployDraft(
|
||||
// deploying user without this flag, gated by can_preserve_on_behalf_of.
|
||||
preserve_on_behalf_of: d.on_behalf_of_email ? true : undefined,
|
||||
labels: d.labels,
|
||||
job_token_scopes: d.job_token_scopes,
|
||||
deployment_message: deploymentMessage
|
||||
}
|
||||
// Draft-only flows have NO flow row (they live solely in the
|
||||
|
||||
Reference in New Issue
Block a user