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:
Ruben Fiszel
2026-10-02 17:14:26 +02:00
co-authored by Claude Opus 5.5
parent f74113c75a
commit 06a5d73bb8
99 changed files with 3286 additions and 603 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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
View File
@@ -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[];
+3 -3
View File
@@ -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)
+2
View File
@@ -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"))
+2
View File
@@ -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,
}
}
+1
View File
@@ -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,
})
}
+2
View File
@@ -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);
+38
View File
@@ -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;
+1
View File
@@ -452,6 +452,7 @@ def main():
ws_error_handler_muted: None,
labels: None,
skip_draft_deletion: None,
job_token_scopes: None,
})
.send()
.await
+3
View File
@@ -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;
+1
View File
@@ -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;
+11
View File
@@ -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"))
+178
View File
@@ -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(())
}
+3
View File
@@ -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;
+1
View File
@@ -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");
+1
View File
@@ -76,6 +76,7 @@ async fn push_preview_and_get_row(
None,
None,
None,
None,
)
.await
.expect("push must succeed");
+1
View File
@@ -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))
+4
View File
@@ -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?;
+1
View File
@@ -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;
+1
View File
@@ -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)
+4 -1
View File
@@ -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(
+45 -82
View File
@@ -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),
+63 -456
View File
@@ -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();
+34 -6
View File
@@ -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?;
+38 -4
View File
@@ -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
+30
View File
@@ -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
+2
View File
@@ -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?;
+2
View File
@@ -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?;
+7
View File
@@ -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,
+71 -4
View File
@@ -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?;
+7 -2
View File
@@ -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> {
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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",
+24 -6
View File
@@ -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
}
+4 -1
View File
@@ -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,
+10 -2
View File
@@ -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,
))
}
+706
View File
@@ -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"])
);
}
}
+4 -2
View File
@@ -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?;
+2 -2
View File
@@ -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:#}")))?;
+84 -9
View File
@@ -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,
+7 -1
View File
@@ -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 {
+3
View File
@@ -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?;
+20
View File
@@ -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,
}
}
+4
View File
@@ -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,
+13 -1
View File
@@ -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);
+2
View File
@@ -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?;
+1
View File
@@ -2309,6 +2309,7 @@ mod tests {
visible_to_owner: false,
permissioned_as_end_user_email: None,
runnable_settings_handle: None,
job_token_scopes: None,
}
}
+8 -2
View File
@@ -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?;
+15 -2
View File
@@ -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,
+4
View File
@@ -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 });
+14
View File
@@ -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
+1
View File
@@ -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