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

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

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

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

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

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

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

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

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

* fix: exclude restricted runnables from dedicated worker startup

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

* fix: gate restrictions on the release after 1.821.0

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: correct the QueuedJobV2 completion comment

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

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

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

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

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

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

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

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

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

* chore: pin the EE companion merged with EE main

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

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

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

* style: rustfmt scopes tests

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

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

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

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

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

Previous ee-repo-ref: 2b77c0225dca441235daf7bf0a06ba968df0c927

New ee-repo-ref: 259ad3bfeef5285ba80eedc86309b11dca001220

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-10-03 09:33:44 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent e952298f84
commit e7fc1b2e2e
119 changed files with 4511 additions and 709 deletions
@@ -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,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 ) 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)))\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, job_token_scopes)\n values ($1, $32, $33, $34, $35, $36, $37, $2, $41, $47)\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, job_token_scopes = EXCLUDED.job_token_scopes\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": "095fa75c60724664f0355cd6e3a64f84a0f1a31cd6732c0ab994a2d6ac3c3eec"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1 FROM v2_job o, v2_job t,\n LATERAL (SELECT ARRAY_REMOVE(ARRAY[o.id, o.parent_job, o.root_job,\n o.flow_innermost_root_job], NULL) AS run) l\n WHERE o.id = $1 AND t.id = $2 AND t.workspace_id = o.workspace_id\n AND (t.id = ANY(l.run) OR t.parent_job = ANY(l.run)\n OR t.root_job = ANY(l.run) OR t.flow_innermost_root_job = ANY(l.run))\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "10f166464a10fc35df2b6ff1b497b17d6bb71f0ae69cd218e6a3fbc151d19d87"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT job_token_scopes FROM job_perms WHERE job_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token_scopes",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "13f358b7c76cda0b24fcb17dbbe10a00333bbf3b8b43bbccc7a6e1926d82478c"
}
@@ -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_perms.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\n LEFT JOIN job_perms ON job_perms.job_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": "333f7116d660756c36dfc0aa2b8d7ebd10a38cbe5724a554a12291c287b67f6d"
}
@@ -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,22 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, job_token_scopes)\n SELECT unnest($1::uuid[]), $2, $3, $4, $5, $6, $7, $8, $9",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray",
"Varchar",
"Varchar",
"Bool",
"Bool",
"JsonbArray",
"TextArray",
"Varchar",
"TextArray"
]
},
"nullable": []
},
"hash": "4cc81f5e04caefa4f170807c0ce96c69f22d9ea9dc3586099553a8f0e76aad82"
}
@@ -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,253 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as,\n j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle,\n COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\",\n p.job_token_scopes AS \"job_token_scopes?\"\n FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id\n LEFT JOIN job_perms p ON p.job_id = j.id\n WHERE j.id = $1 AND j.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "runnable_id: ScriptHash",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 6,
"name": "flow_innermost_root_job",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 8,
"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": 9,
"name": "permissioned_as",
"type_info": "Varchar"
},
{
"ordinal": 10,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 11,
"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": 12,
"name": "permissioned_as_email",
"type_info": "Varchar"
},
{
"ordinal": 13,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 14,
"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": 15,
"name": "trigger",
"type_info": "Varchar"
},
{
"ordinal": 16,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 17,
"name": "concurrent_limit",
"type_info": "Int4"
},
{
"ordinal": 18,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 19,
"name": "cache_ttl",
"type_info": "Int4"
},
{
"ordinal": 20,
"name": "cache_ignore_s3_path",
"type_info": "Bool"
},
{
"ordinal": 21,
"name": "runnable_settings_handle",
"type_info": "Int8"
},
{
"ordinal": 22,
"name": "build_binary_only!",
"type_info": "Bool"
},
{
"ordinal": 23,
"name": "job_token_scopes?",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false,
false,
true,
false,
true,
true,
true,
true,
false,
false,
false,
true,
false,
true,
true,
true,
true,
true,
false,
true,
true,
true,
null,
true
]
},
"hash": "5a975e486a0cdb0fae4dadd344ad3abc2f67827597ef74fef473e309b1951544"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_token_scopes",
"type_info": "TextArray"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
true
]
},
"hash": "60d53c73e67dd7b29cce910fd76b6c87f2994fa2d5f33e0f3ee9effe28614abe"
}
@@ -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 LEFT JOIN job_perms ON job_perms.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 job_perms.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": "6445041033c95fcf52c79c33f34cf6c41fb443f8bd91f12bce0e53f6e91bfb16"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT $2 IN (parent_job, root_job, flow_innermost_root_job) FROM v2_job\n WHERE id = $1 AND workspace_id = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Text"
]
},
"nullable": [
null
]
},
"hash": "67db536d06c57f195641f178ce651cbd7b8896984115d55318bb6f4100d72d9a"
}
@@ -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"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT restart_unless_cancelled, timeout FROM script WHERE hash = $1 AND workspace_id = $2",
"query": "SELECT restart_unless_cancelled, timeout, job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
@@ -12,6 +12,11 @@
"ordinal": 1,
"name": "timeout",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "job_token_scopes",
"type_info": "TextArray"
}
],
"parameters": {
@@ -21,9 +26,10 @@
]
},
"nullable": [
true,
true,
true
]
},
"hash": "1debd472c9ffd2fc78877484f93db51f9aabed54f9894eda8ad610053ad76ce6"
"hash": "83e9c830ee816d4e4e7193a67823cab7c9a4461d3bfbdc0fe7dbdd28ae2de924"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT q.id AS \"id!\", j.created_by, j.permissioned_as, j.permissioned_as_email, j.trigger, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.preprocessed, j.args AS \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\" FROM v2_job_queue q JOIN v2_job j USING (id) JOIN script s ON s.workspace_id = j.workspace_id AND s.hash = j.runnable_id WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.kind = 'script' AND j.flow_step_id IS NULL AND j.runnable_id != $3 AND q.canceled_by IS NULL AND s.restart_unless_cancelled",
"query": "SELECT q.id AS \"id!\", j.created_by, j.permissioned_as, j.permissioned_as_email, j.trigger, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.preprocessed, j.args AS \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\", p.job_token_scopes AS \"job_token_scopes?\" FROM v2_job_queue q JOIN v2_job j USING (id) JOIN script s ON s.workspace_id = j.workspace_id AND s.hash = j.runnable_id LEFT JOIN job_perms p ON p.job_id = q.id WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.kind = 'script' AND j.flow_step_id IS NULL AND j.runnable_id != $3 AND q.canceled_by IS NULL AND s.restart_unless_cancelled",
"describe": {
"columns": [
{
@@ -71,6 +71,11 @@
"ordinal": 7,
"name": "args: sqlx::types::Json<HashMap<String, Box<RawValue>>>",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "job_token_scopes?",
"type_info": "TextArray"
}
],
"parameters": {
@@ -88,8 +93,9 @@
true,
true,
true,
true,
true
]
},
"hash": "df8586283178b0e684b9a0e359efb16e38fd68a5fe2b01cd77f74a725bbf37a3"
"hash": "aed606f8c8d91a924ac457b4eabdf391947d5fc3da90ef98a7b793c2430eb7bc"
}
@@ -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,65 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, username, is_admin, is_operator, groups, folders, end_user_email,\n job_token_scopes\n FROM job_perms WHERE job_id = $1 AND 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": "c921b1a03e3181bbac351545941affc87853ff2d97f567a8817260cfef43e00e"
}
@@ -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,398 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n v2_job.id,\n v2_job.raw_code,\n v2_job.raw_lock,\n v2_job.raw_flow as \"raw_flow: _\",\n v2_job.tag,\n v2_job.created_at,\n v2_job.created_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job.kind as \"kind: _\",\n v2_job.runnable_id,\n v2_job.runnable_path,\n v2_job.parent_job,\n v2_job.root_job,\n v2_job.script_lang as \"script_lang: _\",\n v2_job.script_entrypoint_override,\n v2_job.flow_step,\n v2_job.flow_step_id,\n v2_job.flow_innermost_root_job,\n v2_job.\"trigger\",\n v2_job.trigger_kind as \"trigger_kind: _\",\n v2_job.same_worker,\n v2_job.visible_to_owner,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.cache_ttl,\n v2_job.timeout,\n v2_job.priority,\n v2_job.preprocessed,\n v2_job.args as \"args: _\",\n v2_job.labels,\n job_perms.job_token_scopes as \"job_token_scopes?\",\n v2_job.pre_run_error,\n\n v2_job_queue.started_at,\n v2_job_queue.scheduled_for,\n v2_job_queue.running,\n v2_job_queue.canceled_by,\n v2_job_queue.canceled_reason,\n v2_job_queue.suspend,\n v2_job_queue.suspend_until,\n v2_job_queue.worker,\n v2_job_queue.extras as \"extras: _\",\n\n v2_job_runtime.ping,\n v2_job_runtime.memory_peak,\n\n v2_job_status.flow_status as \"flow_status: _\",\n v2_job_status.flow_leaf_jobs as \"flow_leaf_jobs: _\",\n v2_job_status.workflow_as_code_status as \"workflow_as_code_status: _\",\n\n concurrency_key.key as \"concurrency_key?\"\n FROM v2_job_queue\n INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id\n LEFT JOIN v2_job_runtime ON v2_job_runtime.id = v2_job_queue.id\n LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id\n LEFT JOIN concurrency_key ON concurrency_key.job_id = v2_job_queue.id\n WHERE v2_job_queue.workspace_id = $1\n AND v2_job_queue.running = false\n AND v2_job.parent_job IS NULL\n AND v2_job.trigger_kind IS DISTINCT FROM 'schedule'\n ORDER BY v2_job.created_at DESC\n LIMIT $2\n OFFSET $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "raw_code",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "raw_lock",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "raw_flow: _",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "permissioned_as",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "permissioned_as_email",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "kind: _",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 10,
"name": "runnable_id",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 12,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 13,
"name": "root_job",
"type_info": "Uuid"
},
{
"ordinal": 14,
"name": "script_lang: _",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby",
"rlang",
"dbt"
]
}
}
}
},
{
"ordinal": 15,
"name": "script_entrypoint_override",
"type_info": "Varchar"
},
{
"ordinal": 16,
"name": "flow_step",
"type_info": "Int4"
},
{
"ordinal": 17,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 18,
"name": "flow_innermost_root_job",
"type_info": "Uuid"
},
{
"ordinal": 19,
"name": "trigger",
"type_info": "Varchar"
},
{
"ordinal": 20,
"name": "trigger_kind: _",
"type_info": {
"Custom": {
"name": "job_trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email",
"nats",
"schedule",
"app",
"ui",
"postgres",
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google",
"ci_test",
"github",
"azure",
"asset",
"freshness",
"amqp"
]
}
}
}
},
{
"ordinal": 21,
"name": "same_worker",
"type_info": "Bool"
},
{
"ordinal": 22,
"name": "visible_to_owner",
"type_info": "Bool"
},
{
"ordinal": 23,
"name": "concurrent_limit",
"type_info": "Int4"
},
{
"ordinal": 24,
"name": "concurrency_time_window_s",
"type_info": "Int4"
},
{
"ordinal": 25,
"name": "cache_ttl",
"type_info": "Int4"
},
{
"ordinal": 26,
"name": "timeout",
"type_info": "Int4"
},
{
"ordinal": 27,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 28,
"name": "preprocessed",
"type_info": "Bool"
},
{
"ordinal": 29,
"name": "args: _",
"type_info": "Jsonb"
},
{
"ordinal": 30,
"name": "labels",
"type_info": "TextArray"
},
{
"ordinal": 31,
"name": "job_token_scopes?",
"type_info": "TextArray"
},
{
"ordinal": 32,
"name": "pre_run_error",
"type_info": "Text"
},
{
"ordinal": 33,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 34,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 35,
"name": "running",
"type_info": "Bool"
},
{
"ordinal": 36,
"name": "canceled_by",
"type_info": "Varchar"
},
{
"ordinal": 37,
"name": "canceled_reason",
"type_info": "Text"
},
{
"ordinal": 38,
"name": "suspend",
"type_info": "Int4"
},
{
"ordinal": 39,
"name": "suspend_until",
"type_info": "Timestamptz"
},
{
"ordinal": 40,
"name": "worker",
"type_info": "Varchar"
},
{
"ordinal": 41,
"name": "extras: _",
"type_info": "Jsonb"
},
{
"ordinal": 42,
"name": "ping",
"type_info": "Timestamptz"
},
{
"ordinal": 43,
"name": "memory_peak",
"type_info": "Int4"
},
{
"ordinal": 44,
"name": "flow_status: _",
"type_info": "Jsonb"
},
{
"ordinal": 45,
"name": "flow_leaf_jobs: _",
"type_info": "Jsonb"
},
{
"ordinal": 46,
"name": "workflow_as_code_status: _",
"type_info": "Jsonb"
},
{
"ordinal": 47,
"name": "concurrency_key?",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
true,
true,
true,
false,
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
false
]
},
"hash": "eefb19c05e6d9650b4dd1e50e10c26f01e117b75ab48e197c7d91d388865da74"
}
@@ -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 @@
fe47a306d3760ac2d5a8e14365f05a3503a3ac35
259ad3bfeef5285ba80eedc86309b11dca001220
@@ -0,0 +1,3 @@
ALTER TABLE job_perms 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 job_perms ADD COLUMN IF NOT EXISTS job_token_scopes text[];
+19 -2
View File
@@ -6167,6 +6167,21 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, node_n
continue;
}
if let Some(job) = job.unwrap() {
// Read while the job is still queued: a re-run (perpetual, retry) takes its cap from
// here once the `job_perms` row is swept after completion. A failed read leaves the
// job for the next sweep rather than completing it with no cap.
let perms =
match windmill_common::auth::get_job_perms(db, &job.id, &job.workspace_id).await {
Ok(perms) => perms,
Err(e) => {
tracing::error!(
"Could not read the permissions of zombie job {}: {e:#}",
job.id
);
continue;
}
};
let job_token_scopes = perms.as_ref().and_then(|p| p.job_token_scopes.clone());
let label = ephemeral_script_token_label(&job.permissioned_as, &job.created_by);
let token = create_token_for_owner(
&db,
@@ -6176,7 +6191,7 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, node_n
job_token_expiry_secs(&db, &job.workspace_id).await,
&job.permissioned_as_email,
&job.id,
None,
perms,
Some(format!("handle_zombie_jobs")),
)
.await
@@ -6198,10 +6213,12 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, node_n
);
let memory_peak = job.memory_peak.unwrap_or(0);
let (_, killpill_rx_never_used) = KillpillSender::new(1);
let mut completed = windmill_queue::MiniCompletedJob::from(job);
completed.job_token_scopes = job_token_scopes;
let _ = handle_job_error(
db,
&client,
&windmill_queue::MiniCompletedJob::from(job),
&completed,
memory_peak,
None,
error::Error::ExecutionErr(error_message.clone()),
+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)
@@ -132,7 +132,7 @@ input: id(uuid), workspace_id(char), runnable_id(char), runnable_type(runnable_t
FK: (workspace_id) -> workspace(id)
instance_group: name(char), summary(char), id(char), scim_display_name(char), external_id(char)
job_logs: job_id(uuid), workspace_id(char), created_at(ts), logs(text), log_offset(int), log_file_index(text[])
job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char)
job_perms: job_id(uuid), email(char), username(char), is_admin(bool), is_operator(bool), created_at(ts), workspace_id(char), groups(text[]), folders(jsonb[]), end_user_email(char), job_token_scopes(text[])
job_resolution: job_id(uuid), workspace_id(char), resolved_at(ts), resolved_by(char), note(text), automatic(bool)
job_result_stream: job_id(uuid), workspace_id(text), stream(text)
job_result_stream_v2: job_id(uuid), workspace_id(text), stream(text), idx(int)
@@ -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[])
+3
View File
@@ -245,6 +245,7 @@ fn make_mini(id: Uuid, runnable_path: &str) -> MiniCompletedJob {
cache_ignore_s3_path: None,
runnable_settings_handle: None,
build_binary_only: false,
job_token_scopes: None,
}
}
@@ -330,6 +331,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 +495,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
@@ -115,6 +115,7 @@ async fn push_binary_prebuild(db: &Pool<Postgres>, tag: Option<&str>) -> anyhow:
None,
None,
None,
None,
)
.await?;
tx.commit().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;
+2
View File
@@ -50,6 +50,7 @@ fn flow_module(id: &str, value: FlowModuleValue) -> FlowModule {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
}
}
@@ -2974,6 +2975,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"))
+354
View File
@@ -0,0 +1,354 @@
//! 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";
const FLOW_JOB: &str = "b0000000-0000-0000-0000-000000000003";
async fn insert_job(
db: &Pool<Postgres>,
id: &str,
parent: Option<&str>,
scopes: &[&str],
) -> anyhow::Result<()> {
let id = Uuid::parse_str(id)?;
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, parent_job)
VALUES ($1, 'test-workspace', 'test-user-3', 'u/test-user-3', 'test3@windmill.dev',
'script', 'deno', 'u/test-user-3/agent', 'deno', $2)",
)
.bind(id)
.bind(parent.map(Uuid::parse_str).transpose()?)
.execute(db)
.await?;
sqlx::query(
"INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups,
workspace_id, job_token_scopes)
VALUES ($1, 'test3@windmill.dev', 'test-user-3', false, false, '{}', '{}',
'test-workspace', $2)",
)
.bind(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, FLOW_JOB, None, &["oidc:write"]).await?;
insert_job(&db, OIDC_JOB, Some(FLOW_JOB), &["oidc:write"]).await?;
insert_job(&db, RUN_JOB, None, &["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?);
// The orchestrator reads a flow's step results with the token of the step that just ran.
for (job, refused) in [(FLOW_JOB, false), (RUN_JOB, true)] {
let resp = client
.get(format!("{base}/jobs/result_by_id/{job}/a"))
.bearer_auth(&oidc_token)
.send()
.await?;
assert_eq!(resp.status() == StatusCode::FORBIDDEN, refused, "{job}");
}
// Its progress reaches only the flow it runs in.
for (flow, refused) in [(FLOW_JOB, false), (RUN_JOB, true)] {
let resp = client
.post(format!("{base}/job_metrics/set_progress/{OIDC_JOB}"))
.bearer_auth(&oidc_token)
.json(&json!({ "percent": 50, "flow_job_id": flow }))
.send()
.await?;
assert_eq!(resp.status() == StatusCode::FORBIDDEN, refused, "{flow}");
}
// Imported queue rows bypass push, so a restricted token cannot import, even an admin's.
let admin_job = Uuid::parse_str("b0000000-0000-0000-0000-000000000004")?;
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag)
VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'test@windmill.dev',
'script', 'deno', 'u/test-user/agent', 'deno')",
)
.bind(admin_job)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups,
workspace_id, job_token_scopes)
VALUES ($1, 'test@windmill.dev', 'test-user', true, false, '{}', '{}', 'test-workspace',
'{jobs:run}')",
)
.bind(admin_job)
.execute(&db)
.await?;
let admin_token = windmill_common::auth::create_token_for_owner(
&db,
"test-workspace",
"u/test-user",
"ephemeral-script",
300,
"test@windmill.dev",
&admin_job,
None,
None,
)
.await?;
let resp = client
.post(format!("{base}/jobs/queue/import"))
.bearer_auth(&admin_token)
.json(&json!([]))
.send()
.await?;
assert_eq!(resp.status(), StatusCode::FORBIDDEN, "{}", resp.text().await?);
// Nor can it, even an admin's, place a child in an unrelated run: the flow-run routes
// trust that lineage.
let resp = client
.post(format!(
"{base}/jobs/run/p/u/test-user-3/open?root_job={RUN_JOB}"
))
.bearer_auth(&admin_token)
.json(&json!({}))
.send()
.await?;
assert_eq!(resp.status(), StatusCode::CREATED, "{}", resp.text().await?);
let child = Uuid::parse_str(&resp.text().await?)?;
let claimed: bool = sqlx::query_scalar(
"SELECT $2 IN (parent_job, root_job, flow_innermost_root_job) IS TRUE FROM v2_job
WHERE id = $1",
)
.bind(child)
.bind(Uuid::parse_str(RUN_JOB)?)
.fetch_one(&db)
.await?;
assert!(!claimed);
// 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 job_perms WHERE job_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" },
"job_token_scopes": ["oidc:write", "variables:read"] },
{ "id": "b", "value": { "type": "identity" } },
] });
sqlx::query(
"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at,
schema, extra_perms, versions)
VALUES ('test-workspace', 'u/test-user/agent_flow', '', '', $1, 'test-user', now(), '{}',
'{}', '{7171}')",
)
.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/agent_flow', $1, '{}', 'test-user')",
)
.bind(&value)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let scopes = |v: &[&str]| Some(v.iter().map(|s| s.to_string()).collect::<Vec<_>>());
// A step's own setting narrows the flow's restriction and never widens it; in an
// unrestricted flow it restricts that step alone.
for (flow_scopes, expected_a, expected_b) in [
(scopes(&["oidc:write"]), scopes(&["oidc:write"]), scopes(&["oidc:write"])),
(None, scopes(&["oidc:write", "variables:read"]), None),
] {
let flow = RunJob::from(JobPayload::Flow {
path: "u/test-user/agent_flow".to_string(),
dedicated_worker: None,
apply_preprocessor: false,
version: 7171,
labels: None,
job_token_scopes: flow_scopes.clone(),
})
.run_until_complete(&db, false, server.addr.port())
.await;
let steps: Vec<(String, Option<Vec<String>>)> = sqlx::query_as(
"SELECT j.flow_step_id, p.job_token_scopes FROM v2_job j
JOIN job_perms p ON p.job_id = j.id
WHERE j.parent_job = $1 ORDER BY j.flow_step_id",
)
.bind(flow.id)
.fetch_all(&db)
.await?;
assert_eq!(
steps,
vec![("a".to_string(), expected_a), ("b".to_string(), expected_b)],
"flow scopes {flow_scopes:?}"
);
}
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_deploy_without_the_field_keeps_the_restriction(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api/w/test-workspace", server.addr.port());
let client = reqwest::Client::new();
let script = |path: &str, scopes: Option<serde_json::Value>| {
let mut body = json!({
"path": path, "summary": "", "description": "", "content": "echo 42", "language": "bash",
});
if let Some(scopes) = scopes {
body["job_token_scopes"] = scopes;
}
body
};
let stored = |path: &'static str| {
let db = db.clone();
async move {
sqlx::query_scalar::<_, Option<Vec<String>>>(
"SELECT job_token_scopes FROM script WHERE path = $1 ORDER BY created_at DESC LIMIT 1",
)
.bind(path)
.fetch_one(&db)
.await
}
};
let resp = client
.post(format!("{base}/scripts/create"))
.bearer_auth("SECRET_TOKEN")
.json(&script("u/test-user/agent", Some(json!(["oidc:write"]))))
.send()
.await?;
assert_eq!(resp.status(), StatusCode::CREATED, "{}", resp.text().await?);
// A rename by a client that does not know the field carries the restriction along.
let resp = client
.post(format!("{base}/scripts/update/u/test-user/agent"))
.bearer_auth("SECRET_TOKEN")
.json(&script("u/test-user/renamed", None))
.send()
.await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
assert_eq!(stored("u/test-user/renamed").await?, Some(vec!["oidc:write".to_string()]));
// An explicit null clears it.
let resp = client
.post(format!("{base}/scripts/update/u/test-user/renamed"))
.bearer_auth("SECRET_TOKEN")
.json(&script("u/test-user/renamed", Some(serde_json::Value::Null)))
.send()
.await?;
assert!(resp.status().is_success(), "{}", resp.text().await?);
assert_eq!(stored("u/test-user/renamed").await?, None);
Ok(())
}
+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;
+9
View File
@@ -212,6 +212,7 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
},
FlowModule {
id: "b".to_string(),
@@ -260,6 +261,7 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
}],
modules_node: None,
}
@@ -282,6 +284,7 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
},
],
same_worker: false,
@@ -398,6 +401,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
},
FlowModule {
id: "b".to_string(),
@@ -455,6 +459,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
},
FlowModule {
id: "e".to_string(),
@@ -498,6 +503,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
},
],
modules_node: None,
@@ -520,6 +526,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
},
FlowModule {
id: "c".to_string(),
@@ -569,6 +576,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
},
],
same_worker: true,
@@ -5791,6 +5799,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)
+39 -1
View File
@@ -104,6 +104,32 @@ pub struct ExpiringAuthCache {
pub job_id: Option<uuid::Uuid>,
}
/// Whether `target` belongs to the flow run of the job `job_id`: the job itself, one of its
/// ancestors, or a job whose parent or root is one of them (a sibling step, a loop or branch
/// iteration, any job pushed as a child of one of them). A lookup that fails reads as no.
async fn job_in_same_flow_run(db: &DB, job_id: uuid::Uuid, target: uuid::Uuid) -> bool {
if job_id == target {
return true;
}
sqlx::query_scalar!(
"SELECT EXISTS (
SELECT 1 FROM v2_job o, v2_job t,
LATERAL (SELECT ARRAY_REMOVE(ARRAY[o.id, o.parent_job, o.root_job,
o.flow_innermost_root_job], NULL) AS run) l
WHERE o.id = $1 AND t.id = $2 AND t.workspace_id = o.workspace_id
AND (t.id = ANY(l.run) OR t.parent_job = ANY(l.run)
OR t.root_job = ANY(l.run) OR t.flow_innermost_root_job = ANY(l.run))
)",
job_id,
target
)
.fetch_one(db)
.await
.ok()
.flatten()
.unwrap_or(false)
}
pub struct AuthCache {
db: DB,
superadmin_secret: Option<String>,
@@ -1131,8 +1157,20 @@ pub async fn resolve_opt_job_authed(
return Err((err, parts));
}
}
let own_job_runtime_route = match opt_job_authed.job_id {
Some(job_id) if opt_job_authed.authed.scopes.is_some() => {
crate::scopes::is_own_job_runtime_route(path, method, job_id)
|| match crate::scopes::flow_run_read_route_job(path, method) {
Some(target) => {
job_in_same_flow_run(&cache.db, job_id, target).await
}
None => false,
}
}
_ => false,
};
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(
+50 -83
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,50 @@ 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);
};
// A job token is minted with scopes exactly when its job is restricted, so an unscoped one
// has nothing to read.
if authed.scopes.is_none() {
return Ok(None);
}
let row = sqlx::query_scalar!(
"SELECT job_token_scopes FROM job_perms WHERE job_id = $1",
job_id
)
.fetch_optional(db)
.await?;
Ok(match row {
Some(scopes) => scopes,
// The job left the queue and its row was swept: 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,84 +649,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" => {}
("write", "cancel") if caller.domain == "jobs" => {}
_ => 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),
+112 -461
View File
@@ -6,469 +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" => {}
("write", "cancel") if self.domain == "jobs" => {}
_ => 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.)
Cancel, // Cancelling jobs (`CANCEL_PATH_ACTIONS`); covered by `jobs:write`
}
impl ScopeAction {
pub fn as_str(&self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Run => "run",
Self::Cancel => "cancel",
}
}
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),
"cancel" => Some(Self::Cancel),
_ => 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,
(ScopeAction::Write, ScopeAction::Cancel) => true,
(a, b) => a == b,
}
}
}
pub use windmill_common::scopes::{ScopeAction, ScopeDefinition, ScopeDomain};
pub fn check_route_access(
token_scopes: &[String],
@@ -1293,6 +833,60 @@ pub fn scope_for_route(method: &str, path: &str) -> Option<String> {
})
}
/// Routes the runtime of a job calls about that job alone: progress, its root id, its
/// resume and approval urls, a workflow-as-code checkpoint or task. A job token restricted
/// by `job_token_scopes` keeps these whatever its scopes, or the job could not run at all,
/// 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", ..] => (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)
}
/// Routes reading the state of a flow run: step results (`results.x` in input transforms,
/// loop and branch results) and the run's user state. The flow orchestrator evaluates a
/// step's inputs with the token of the step that just finished, and the SDK reads user state
/// at the root job, so a restricted job token keeps these for every job of its own run.
/// Returns the job id the path names; the caller checks it against the token's lineage.
pub fn flow_run_read_route_job(route_path: &str, http_method: &str) -> Option<uuid::Uuid> {
let rest = route_path.strip_prefix("/api/w/")?;
let (_workspace, rest) = rest.split_once('/')?;
let segments: Vec<&str> = rest.split('/').collect();
let id = match segments.as_slice() {
["jobs" | "jobs_u", "result_by_id", id, ..] if http_method == "GET" => id,
["jobs_u", "completed", "get_result" | "get_result_maybe", id, ..]
if http_method == "GET" =>
{
id
}
["jobs" | "jobs_u", "flow", "user_states", id, ..]
if http_method == "GET" || http_method == "POST" =>
{
id
}
_ => return None,
};
uuid::Uuid::parse_str(id).ok()
}
/// Helper function to check if scopes allow access to a route
pub fn check_scopes_for_route(
token_scopes: Option<&[String]>,
@@ -1312,6 +906,63 @@ 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"));
let run = |path: &str, method: &str| flow_run_read_route_job(path, method);
assert_eq!(
run(&format!("/api/w/ws/jobs/result_by_id/{other}/b"), "GET"),
Some(other)
);
assert_eq!(
run(
&format!("/api/w/ws/jobs_u/completed/get_result/{other}"),
"GET"
),
Some(other)
);
assert_eq!(
run(
&format!("/api/w/ws/jobs/flow/user_states/{other}/k"),
"POST"
),
Some(other)
);
assert_eq!(
run(
&format!("/api/w/ws/jobs_u/completed/delete/{other}"),
"POST"
),
None
);
}
#[test]
fn test_scope_definition_parsing() {
let scope = ScopeDefinition::from_scope_string("jobs:read").unwrap();
+62 -6
View File
@@ -804,6 +804,24 @@ 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 restricts_steps = windmill_common::scopes::validate_flow_step_job_token_scopes(
&serde_json::from_str::<windmill_common::flows::FlowValue>(nf.value.get())
.map_err(|e| windmill_common::error::Error::BadRequest(e.to_string()))?,
)?;
if job_token_scopes.is_some() || restricts_steps {
windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES
.assert()
.await?;
}
if restricts_steps {
windmill_common::feature_usage::log_feature_usage("job_token_scopes", "deploy", "step:set");
}
let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of(
nf.on_behalf_of_email.as_deref(),
nf.on_behalf_of.as_deref(),
@@ -824,14 +842,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,
@@ -847,9 +865,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)
@@ -974,6 +994,7 @@ async fn create_flow(
None,
None,
None,
None,
)
.await?;
@@ -1153,7 +1174,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",
@@ -1213,6 +1234,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,
@@ -1368,6 +1390,27 @@ 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 restricts_steps = windmill_common::scopes::validate_flow_step_job_token_scopes(
&serde_json::from_str::<windmill_common::flows::FlowValue>(nf.value.get())
.map_err(|e| windmill_common::error::Error::BadRequest(e.to_string()))?,
)?;
if job_token_scopes.is_some() || restricts_steps {
windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES
.assert()
.await?;
}
if restricts_steps {
windmill_common::feature_usage::log_feature_usage("job_token_scopes", "deploy", "step:set");
}
let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of(
nf.on_behalf_of_email.as_deref(),
nf.on_behalf_of.as_deref(),
@@ -1402,7 +1445,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 },
@@ -1420,19 +1464,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,
@@ -1696,6 +1745,7 @@ async fn update_flow(
None,
None,
None,
None,
)
.await?;
@@ -1871,6 +1921,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,
@@ -1912,6 +1963,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,
@@ -2336,6 +2388,7 @@ mod tests {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
},
FlowModule {
id: "b".to_string(),
@@ -2371,6 +2424,7 @@ mod tests {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
},
FlowModule {
id: "c".to_string(),
@@ -2406,6 +2460,7 @@ mod tests {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
},
],
failure_module: Some(Box::new(FlowModule {
@@ -2440,6 +2495,7 @@ mod tests {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
})),
preprocessor_module: None,
same_worker: false,
+11 -2
View File
@@ -124,7 +124,9 @@ pub async fn drop_unclaimable_run_lineage(
/// The jobs of `referenced` that `authed` cannot claim as its own run lineage: anything but the
/// token's own job and that job's `parent_job`, `root_job` and `flow_innermost_root_job`, or for
/// a workspace admin anything outside the workspace.
/// a workspace admin anything outside the workspace. A restricted job token never gets the admin
/// latitude: its flow-run routes trust this lineage (`job_in_same_flow_run`), so a claimed
/// unrelated run would escape its scopes.
pub async fn unclaimable_run_lineage(
db: &DB,
w_id: &str,
@@ -157,7 +159,8 @@ pub async fn unclaimable_run_lineage(
if referenced.is_empty() {
return Ok(referenced);
}
let in_workspace = if authed.is_admin {
let restricted_job_token = authed.job_id.is_some() && authed.scopes.is_some();
let in_workspace = if authed.is_admin && !restricted_job_token {
sqlx::query_scalar!(
"SELECT id FROM v2_job WHERE id = ANY($1) AND workspace_id = $2",
&referenced,
@@ -865,6 +868,7 @@ pub async fn run_flow<'c>(
chat_input_enabled,
early_return,
labels,
job_token_scopes,
..
} = flow_version_info;
@@ -914,6 +918,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 +929,7 @@ pub async fn run_flow<'c>(
version,
apply_preprocessor,
labels,
job_token_scopes,
},
push_args,
authed.display_username(),
@@ -950,6 +956,7 @@ pub async fn run_flow<'c>(
None,
authed.trigger_or_fallback(trigger),
run_query.suspended_mode,
scope_ceiling.as_deref(),
)
.await?;
@@ -1141,6 +1148,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 +1184,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?;
@@ -173,10 +173,33 @@ struct JobProgressSetRequest {
}
async fn set_job_progress(
authed: windmill_api_auth::ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Json(JobProgressSetRequest { percent, flow_job_id }): Json<JobProgressSetRequest>,
) -> error::JsonResult<()> {
// A restricted job token reaches this route without a scope only for its own job
// (`is_own_job_runtime_route`), so the flow it reports progress to must be its own too.
if let (Some(flow_job_id), Some(token_job), Some(_)) =
(flow_job_id, authed.job_id, &authed.scopes)
{
let own_flow = sqlx::query_scalar!(
"SELECT $2 IN (parent_job, root_job, flow_innermost_root_job) FROM v2_job
WHERE id = $1 AND workspace_id = $3",
token_job,
flow_job_id,
&w_id
)
.fetch_optional(&db)
.await?
.flatten()
.unwrap_or(false);
if !own_flow {
return Err(error::Error::PermissionDenied(format!(
"flow job {flow_job_id} is not this job's flow"
)));
}
}
// If flow_job_id exists, than we should modify flow_status of corresponding module
// Individual jobs and flows are handled differently
if let Some(flow_job_id) = flow_job_id {
@@ -112,6 +112,8 @@ pub struct ExportableQueuedJob {
pub preprocessed: Option<bool>,
pub args: Option<sqlx::types::Json<Box<RawValue>>>,
pub labels: Option<Vec<String>>,
#[serde(default)]
pub job_token_scopes: Option<Vec<String>>,
pub pre_run_error: Option<String>,
// v2_job_queue columns (excluding workspace_id and id/created_at/tag/priority)
@@ -276,6 +278,7 @@ pub async fn export_queued_jobs(
v2_job.preprocessed,
v2_job.args as "args: _",
v2_job.labels,
job_perms.job_token_scopes as "job_token_scopes?",
v2_job.pre_run_error,
v2_job_queue.started_at,
@@ -298,6 +301,7 @@ pub async fn export_queued_jobs(
concurrency_key.key as "concurrency_key?"
FROM v2_job_queue
INNER JOIN v2_job ON v2_job.id = v2_job_queue.id
LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id
LEFT JOIN v2_job_runtime ON v2_job_runtime.id = v2_job_queue.id
LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id
LEFT JOIN concurrency_key ON concurrency_key.job_id = v2_job_queue.id
@@ -445,6 +449,23 @@ pub async fn import_queued_jobs(
));
}
// Imported rows bypass `push`, so nothing would cap their tokens: a restricted job token
// could otherwise queue arbitrary jobs that run with its owner's full permissions.
if authed.job_id.is_some() && authed.scopes.is_some() {
return Err(error::Error::PermissionDenied(
"A restricted job token cannot import queued jobs".to_string(),
));
}
// An imported job gets no `job_perms` row, so its token would be minted without the
// restriction it was queued with.
if let Some(job) = jobs.iter().find(|job| job.job_token_scopes.is_some()) {
return Err(error::Error::BadRequest(format!(
"Queued job {} restricts its job token and cannot be imported; run it again on \
this instance instead",
job.id
)));
}
let mut tx = user_db.begin(&authed).await?;
for job in jobs {
+54 -4
View File
@@ -899,6 +899,7 @@ async fn is_noop_deploy_against_parent(
ns: &NewScript,
parent: &Script<ScriptRunnableSettingsHandle>,
resolved_on_behalf_of: Option<&str>,
resolved_job_token_scopes: Option<&[String]>,
db: &DB,
) -> Result<bool> {
if parent.archived || parent.deleted {
@@ -952,6 +953,8 @@ async fn is_noop_deploy_against_parent(
// caller-intent flag (auto-resolve parent), not script state
auto_parent: _,
labels,
// resolved against the deployed value into `resolved_job_token_scopes`, compared below
job_token_scopes: _,
// caller-intent flag (preserve user drafts on CLI/git-sync deploys);
// transient, never persisted, does not change what the script *is*
skip_draft_deletion: _,
@@ -1025,6 +1028,9 @@ async fn is_noop_deploy_against_parent(
if resolved_on_behalf_of != parent.on_behalf_of.as_deref() {
return Ok(false);
}
if resolved_job_token_scopes != parent.job_token_scopes.as_deref() {
return Ok(false);
}
// Both of a dbt script's derived fields are compared as they WOULD BE STORED,
// not as they arrived: the schema comes from the descriptor and the clients
// cannot derive it (`windmill-parser-wasm` has no dbt arm), so they send the
@@ -1492,6 +1498,37 @@ async fn create_script_internal<'c>(
parent_adopted_from_retired_path = ns.parent_hash.is_some();
}
// Absent keeps the previous version's value, read once the parent is settled (a rename
// adopts its source head above), so a client unaware of the setting cannot drop a
// restriction by redeploying or renaming.
let resolved_job_token_scopes: Option<Vec<String>> = match (&ns.job_token_scopes, &ns.parent_hash) {
(Some(Some(scopes)), _) => {
windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES
.assert()
.await?;
Some(windmill_common::scopes::validate_job_token_scopes(scopes)?)
}
(Some(None), _) => None,
(None, Some(parent_hash)) => sqlx::query_scalar!(
"SELECT job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2",
parent_hash.0,
&w_id
)
.fetch_optional(&db)
.await?
.flatten(),
(None, None) => sqlx::query_scalar!(
"SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 \
AND deleted = false ORDER BY created_at DESC LIMIT 1",
&ns.path,
&w_id
)
.fetch_optional(&db)
.await?
.flatten(),
};
ns.job_token_scopes = Some(resolved_job_token_scopes.clone());
// Before hashing, so the hash and the no-op check see the schema that gets stored.
// `{}` counts as absent: an agent filling every tool argument sends it for "none".
let schema_absent = ns.schema.as_ref().is_none_or(|s| {
@@ -1620,8 +1657,14 @@ async fn create_script_internal<'c>(
// CLI pushes must not produce phantom commits on the downstream
// git repository.
if skip_if_noop
&& is_noop_deploy_against_parent(&ns, &ps, resolved_on_behalf_of.as_deref(), &db)
.await?
&& is_noop_deploy_against_parent(
&ns,
&ps,
resolved_on_behalf_of.as_deref(),
resolved_job_token_scopes.as_deref(),
&db,
)
.await?
{
tracing::info!(
workspace_id = %w_id,
@@ -2197,8 +2240,8 @@ async fn create_script_internal<'c>(
content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \
envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \
dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)",
delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email, job_token_scopes) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42)",
&w_id,
&hash.0,
ns.path,
@@ -2242,9 +2285,14 @@ async fn create_script_internal<'c>(
ns.labels.as_deref() as Option<&[String]>,
resolved_on_behalf_of,
legacy_on_behalf_of_email,
resolved_job_token_scopes.as_deref() as Option<&[String]>,
)
.execute(&mut *tx)
.await?;
windmill_common::scopes::log_job_token_scopes_deploy(
"script",
resolved_job_token_scopes.as_deref(),
);
// A lock that is not left to a dependency job queues none, so this is the only place its hash
// can be recorded. `try_skip_relock` treats a missing hash for an imported script as changed,
@@ -2954,6 +3002,7 @@ async fn create_script_internal<'c>(
None,
None,
None,
None,
)
.await?;
@@ -3077,6 +3126,7 @@ async fn create_script_internal<'c>(
None,
None,
None,
None,
)
.await?;
tracing::info!("pushed auto-build binary job {job_id} for {script_path}");
@@ -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
@@ -2756,6 +2756,7 @@ async fn create_app_internal<'a>(
None,
None,
None,
None,
)
.await?;
tracing::info!("Pushed app dependency job {}", dependency_job_uuid);
@@ -3897,6 +3898,7 @@ async fn update_app_internal<'a>(
None,
None,
None,
None,
)
.await?;
tracing::info!("Pushed app dependency job {}", dependency_job_uuid);
@@ -4491,6 +4493,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,
@@ -4526,6 +4532,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,
+93 -6
View File
@@ -7353,6 +7353,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,
@@ -7390,6 +7391,7 @@ pub async fn restart_flow(
None,
authed.trigger_or_fallback(None),
run_query.suspended_mode,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
@@ -7527,7 +7529,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 {
@@ -7672,6 +7678,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,
@@ -7702,6 +7709,7 @@ pub async fn run_workflow_as_code(
None,
None,
None,
scope_ceiling.as_deref(),
)
.await?;
@@ -7985,6 +7993,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,
@@ -8015,6 +8024,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?;
@@ -8133,6 +8143,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,
@@ -8163,6 +8174,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?;
@@ -8215,6 +8227,7 @@ pub async fn run_wait_result_script_by_hash(
timeout,
has_preprocessor,
labels,
job_token_scopes,
runnable_settings:
ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings },
..
@@ -8248,6 +8261,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,
@@ -8265,6 +8279,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(),
@@ -8291,6 +8306,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?;
@@ -8810,6 +8826,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,
@@ -8860,6 +8877,7 @@ async fn run_preview_script(
None,
authed.trigger_or_fallback(None),
None,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
@@ -8944,7 +8962,8 @@ async fn run_inline_script_by_path(
token,
db,
w_id,
InlineScriptTarget::Path(script_path.to_path().to_string()),
// Resolved to a version by `run_inline_script_inner`.
InlineScriptTarget::Path { path: script_path.to_path().to_string(), hash: 0 },
body.args,
Some(user_db),
)
@@ -9007,6 +9026,42 @@ 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. A path is resolved here, once, and
// the version checked is the version run.
let (target, restricted) = match target {
InlineScriptTarget::Path { path, .. } => {
let authed_ref = authed.to_authed_ref();
let info = get_latest_deployed_hash_for_path(
user_db
.as_ref()
.map(|db| UserDbWithAuthed { db: db.clone(), authed: &authed_ref }),
db.clone(),
&w_id,
&path,
)
.await?;
let restricted = info.job_token_scopes.is_some();
(
InlineScriptTarget::Path { path, hash: info.hash },
restricted,
)
}
InlineScriptTarget::Hash(hash) => {
let restricted = windmill_common::get_script_info_for_hash(None, &db, &w_id, hash)
.await?
.job_token_scopes
.is_some();
(InlineScriptTarget::Hash(hash), restricted)
}
};
if restricted {
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 {
@@ -9193,6 +9248,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,
@@ -9239,6 +9295,7 @@ async fn run_bundle_preview_script(
None,
authed.trigger_or_fallback(None),
None,
scope_ceiling.as_deref(),
)
.await?;
job_id = Some(uuid);
@@ -9358,6 +9415,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()),
@@ -9392,6 +9450,7 @@ async fn push_dependencies_job(
None,
None,
None,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
@@ -9490,6 +9549,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()),
@@ -9520,6 +9580,7 @@ async fn push_flow_dependencies_job(
None,
None,
None,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
@@ -9574,6 +9635,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,
@@ -9605,11 +9667,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),
@@ -9656,7 +9720,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
@@ -9667,6 +9731,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!(
@@ -9679,6 +9744,11 @@ async fn add_batch_jobs(
"Path is required if no value is not provided"
))?
};
if windmill_common::scopes::validate_flow_step_job_token_scopes(&value)? {
windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES
.assert()
.await?;
}
add_virtual_items_if_necessary(&mut value.modules);
let flow_status = FlowStatus::new(&value);
(
@@ -9722,7 +9792,8 @@ async fn add_batch_jobs(
let language = language.unwrap_or(ScriptLang::Deno);
let tag = if let Some(dedicated_worker) = dedicated_worker {
if dedicated_worker && path.is_some() {
// Same rule as `push`: a dedicated worker would run it with its own unscoped token.
if dedicated_worker && path.is_some() && job_token_scopes.is_none() {
windmill_common::worker::dedicated_worker_tag(&w_id, &path.clone().unwrap())
} else {
format!("{}", language.as_str())
@@ -9790,8 +9861,8 @@ async fn add_batch_jobs(
.await?;
sqlx::query!(
"INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id)
SELECT unnest($1::uuid[]), $2, $3, $4, $5, $6, $7, $8",
"INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, job_token_scopes)
SELECT unnest($1::uuid[]), $2, $3, $4, $5, $6, $7, $8, $9",
&uuids,
authed.email,
authed.username,
@@ -9800,6 +9871,7 @@ async fn add_batch_jobs(
&[],
&[],
w_id,
job_token_scopes.as_deref() as Option<&[String]>,
)
.execute(&mut *tx)
.await?;
@@ -9850,6 +9922,13 @@ async fn run_preview_flow_job(
// jobs:run scope so a narrowly-scoped token cannot escape its scope. See run_preview_script.
check_scopes(&authed, || format!("jobs:run"))?;
require_path_read_access_for_preview(&authed, &raw_flow.path)?;
// Step restrictions apply to a preview as they do to a deployed run, so they are checked
// the same way: an invalid entry or an older worker would leave a step unrestricted.
if windmill_common::scopes::validate_flow_step_job_token_scopes(&raw_flow.value)? {
windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES
.assert()
.await?;
}
// A builder must be able to test what it composes, but the submitted value is not the stored
// one: without this the preview is a way to run inline code the write path refuses.
if authed.is_operator {
@@ -9892,6 +9971,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,
@@ -9926,6 +10006,7 @@ async fn run_preview_flow_job(
None,
authed.trigger_or_fallback(None),
None,
scope_ceiling.as_deref(),
)
.await?;
@@ -10149,6 +10230,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,
@@ -10195,6 +10277,7 @@ async fn run_dynamic_select(
None,
authed.trigger_or_fallback(None),
None,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
@@ -10270,6 +10353,7 @@ pub async fn run_job_by_hash_inner(
delete_after_use,
delete_after_secs,
labels,
job_token_scopes,
..
} = script_info;
@@ -10302,6 +10386,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,
@@ -10319,6 +10404,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(),
@@ -10345,6 +10431,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",
+25 -7
View File
@@ -362,6 +362,10 @@ pub struct JobPerms {
pub groups: Vec<String>,
pub folders: Vec<serde_json::Value>,
pub end_user_email: Option<String>,
/// The job's effective scopes, stored on its `job_perms` row at push and minted into its
/// token.
#[serde(default)]
pub job_token_scopes: Option<Vec<String>>,
}
impl From<JobPerms> for Authed {
@@ -654,7 +658,9 @@ 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 email, username, is_admin, is_operator, groups, folders, end_user_email,
job_token_scopes
FROM job_perms WHERE job_id = $1 AND 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)
}
// A failed read must not mint as if the job had no row: that would drop its restriction.
Err(e) => {
return Err(Error::internal_err(format!(
"Could not read permissions for job {job_id}: {e:#}"
)))
}
// Push writes a job's `job_perms` row and its scopes in one statement, so a job with no
// row was never restricted.
Ok(None) => {
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:#}"
))
})?
})?;
(authed, None)
}
};
@@ -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
}
+6 -2
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,
@@ -481,7 +484,8 @@ pub struct RunInlinePreviewScriptFnParams {
}
pub enum InlineScriptTarget {
Path(String),
/// A script addressed by path, pinned to the version the caller resolved and checked.
Path { path: String, hash: i64 },
Hash(i64),
}
+10 -2
View File
@@ -127,6 +127,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;
@@ -2247,6 +2248,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,
}
@@ -2340,6 +2342,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,
@@ -2704,6 +2707,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",
)
@@ -2725,6 +2729,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 {
@@ -2868,7 +2873,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
@@ -3003,9 +3009,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,
@@ -3037,6 +3044,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,
))
}
@@ -19,6 +19,10 @@ pub const MIN_VERSION_SUPPORTS_BINARY_PREBUILD: VC = vc(1, 789, 0, "Auto-build b
// dependency job asks bun for a v1 lockfile, and refuses to store one bun raised anyway.
// Must name the release this ships in.
pub const MIN_VERSION_SUPPORTS_BUN_LOCKFILE_V2: VC = vc(1, 794, 0, "Bun v2 lockfiles");
// A worker that predates `job_token_scopes` mints the token of every job it pulls without
// them, so a restriction set while one is live is silently ignored on the jobs it runs. Must
// name the release this ships in.
pub const MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES: VC = vc(1, 822, 0, "Restricted job tokens");
pub const MIN_VERSION_SUPPORTS_NODE_DEBOUNCING: VC = vc(1, 658, 0, "Flow node debouncing");
pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage");
pub const MIN_VERSION_SUPPORTS_SYNC_JOBS_DEBOUNCING: VC = vc(1, 602, 0, "Sync jobs debouncing");
+748
View File
@@ -0,0 +1,748 @@
/*
* 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" => {}
("write", "cancel") if self.domain == "jobs" => {}
_ => 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.)
Cancel, // Cancelling jobs (`CANCEL_PATH_ACTIONS`); covered by `jobs:write`
}
impl ScopeAction {
pub fn as_str(&self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Run => "run",
Self::Cancel => "cancel",
}
}
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),
"cancel" => Some(Self::Cancel),
_ => 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,
(ScopeAction::Write, ScopeAction::Cancel) => 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" => {}
("write", "cancel") if caller.domain == "jobs" => {}
_ => 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)
}
}
}
/// Validates the `job_token_scopes` of every step and agent tool of a flow, returning whether
/// any of them sets one.
pub fn validate_flow_step_job_token_scopes(value: &crate::flows::FlowValue) -> Result<bool> {
let mut any = false;
let mut check = |module: &crate::flows::FlowModule| -> anyhow::Result<()> {
if let Some(scopes) = &module.job_token_scopes {
any = true;
validate_job_token_scopes(scopes)
.map_err(|e| anyhow::anyhow!("step {}: {e}", module.id))?;
}
Ok(())
};
let extra: Vec<crate::flows::FlowModule> = value
.failure_module
.iter()
.chain(value.preprocessor_module.iter())
.map(|m| (**m).clone())
.collect();
crate::flows::FlowModule::traverse_modules(&value.modules, &mut check)
.and_then(|()| crate::flows::FlowModule::traverse_modules(&extra, &mut check))
.map_err(|e| Error::BadRequest(e.to_string()))?;
Ok(any)
}
/// 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 cap a flow step or agent tool's own `job_token_scopes` puts on its jobs. A definition
/// can reach the worker without the deploy-time validation (a raw flow), so a setting that
/// does not validate restricts the step to no API access rather than being trusted as is.
pub fn step_job_token_scopes(scopes: Option<&[String]>) -> Option<Vec<String>> {
scopes.map(|s| validate_job_token_scopes(s).unwrap_or_default())
}
/// 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"])
);
// A step setting that skipped deploy validation restricts rather than widens.
assert_eq!(
step_job_token_scopes(Some(&v(&["if_jobs:filter_tags:bun"]))),
Some(vec![])
);
}
}
+4 -2
View File
@@ -164,6 +164,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(
@@ -463,6 +464,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,
};
@@ -483,7 +485,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, \
@@ -491,7 +493,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?;
+1 -1
View File
@@ -811,7 +811,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, p.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:#}")))?;
+122 -65
View File
@@ -74,7 +74,7 @@ use windmill_common::{
add_virtual_items_if_necessary, FlowModule, FlowModuleValue, FlowValue, InputTransform,
Retry, StopAfterIf,
},
jobs::{get_payload_tag_from_prefixed_path, JobKind, JobPayload, QueuedJob, RawCode},
jobs::{get_payload_tag_from_prefixed_path, JobKind, JobPayload, RawCode},
min_version::{MIN_VERSION_IS_AT_LEAST_1_432, MIN_VERSION_IS_AT_LEAST_1_440},
schedule::Schedule,
scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang},
@@ -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?;
@@ -725,9 +727,11 @@ async fn restart_perpetual_runs_at_path(
PerpetualRunToRestart,
"SELECT q.id AS \"id!\", j.created_by, j.permissioned_as, j.permissioned_as_email, \
j.trigger, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.preprocessed, \
j.args AS \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\" \
j.args AS \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\", \
p.job_token_scopes AS \"job_token_scopes?\" \
FROM v2_job_queue q JOIN v2_job j USING (id) \
JOIN script s ON s.workspace_id = j.workspace_id AND s.hash = j.runnable_id \
LEFT JOIN job_perms p ON p.job_id = q.id \
WHERE j.workspace_id = $1 AND j.runnable_path = $2 AND j.kind = 'script' \
AND j.flow_step_id IS NULL AND j.runnable_id != $3 AND q.canceled_by IS NULL \
AND s.restart_unless_cancelled",
@@ -765,6 +769,20 @@ async fn restart_perpetual_runs_at_path(
Ok(true)
}
/// A worker older than `job_token_scopes` ignores a step's or agent tool's own restriction, so
/// a flow that sets one is refused while such a worker is live, whichever way the flow arrived
/// (a deploy, a restart, a preview, a standalone agent, an eval). Free when every worker is
/// current.
async fn refuse_step_scopes_on_outdated_workers(value: &FlowValue) -> Result<(), Error> {
let gate = &windmill_common::min_version::MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES;
if !gate.met().await
&& windmill_common::scopes::validate_flow_step_job_token_scopes(value).unwrap_or(true)
{
gate.assert().await?;
}
Ok(())
}
/// A run of an earlier version at the path, and what its replacement inherits from it.
struct PerpetualRunToRestart {
id: Uuid,
@@ -777,6 +795,8 @@ struct PerpetualRunToRestart {
/// completion swaps in what a preprocessor returned.
preprocessed: Option<bool>,
args: Option<sqlx::types::Json<HashMap<String, Box<RawValue>>>>,
/// Its effective token scopes: the replacement never holds a wider token.
job_token_scopes: Option<Vec<String>>,
}
/// What every run at the path moves to.
@@ -857,6 +877,7 @@ async fn restart_perpetual_run(
// an earlier version the next pass picks up.
return Ok(());
}
let scope_ceiling = run.job_token_scopes;
let (_, tx) = push(
db,
PushIsolationLevel::Transaction(tx),
@@ -887,6 +908,7 @@ async fn restart_perpetual_run(
None,
None,
None,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
@@ -1251,7 +1273,7 @@ lazy_static::lazy_static! {
pub static ref MAX_RESULT_SIZE_MB: usize = std::env::var("MAX_RESULT_SIZE_MB").unwrap_or("500".to_string()).parse().unwrap_or(500);
// Cache for perpetual-restart settings (restart_unless_cancelled, timeout) - keyed by (hash, workspace_id)
static ref RESTART_UNLESS_CANCELLED_CACHE: Cache<(i64, String), (bool, Option<i32>)> = Cache::new(10000);
static ref RESTART_UNLESS_CANCELLED_CACHE: Cache<(i64, String), (bool, Option<i32>, Option<Vec<String>>)> = Cache::new(10000);
// Cache for workspace error handler settings with 60s TTL
// Key: workspace_id, Value: (error_handler, error_handler_extra_args, error_handler_muted_on_cancel, error_handler_muted_on_user_path, report_to_instance_alerts, expiry_timestamp)
@@ -2037,13 +2059,13 @@ async fn restart_job_if_perpetual_inner(
) -> Result<(), Error> {
let cache_key = (hash.0, queued_job.workspace_id.clone());
let (restart, script_timeout) = if let Some(cached) =
let (restart, script_timeout, script_job_token_scopes) = if let Some(cached) =
RESTART_UNLESS_CANCELLED_CACHE.get(&cache_key)
{
cached
} else {
let row = sqlx::query!(
"SELECT restart_unless_cancelled, timeout FROM script WHERE hash = $1 AND workspace_id = $2",
"SELECT restart_unless_cancelled, timeout, job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2",
hash.0,
&queued_job.workspace_id
)
@@ -2054,10 +2076,12 @@ async fn restart_job_if_perpetual_inner(
.as_ref()
.and_then(|r| r.restart_unless_cancelled)
.unwrap_or(false);
let script_timeout = row.and_then(|r| r.timeout);
let script_timeout = row.as_ref().and_then(|r| r.timeout);
let script_job_token_scopes = row.and_then(|r| r.job_token_scopes);
RESTART_UNLESS_CANCELLED_CACHE.insert(cache_key, (restart, script_timeout));
(restart, script_timeout)
let cached = (restart, script_timeout, script_job_token_scopes);
RESTART_UNLESS_CANCELLED_CACHE.insert(cache_key, cached.clone());
cached
};
if restart {
@@ -2103,6 +2127,8 @@ async fn restart_job_if_perpetual_inner(
.await?
.flatten()
.unwrap_or_default();
// The replacement never holds a wider token than the run it replaces.
let scope_ceiling = queued_job.job_token_scopes.clone();
let (_uuid, tx) = push(
db,
tx,
@@ -2127,6 +2153,7 @@ async fn restart_job_if_perpetual_inner(
// TODO(debouncing): handle properly
debouncing_settings: DebouncingSettings::default(),
labels: None, // labels already set on original job
job_token_scopes: script_job_token_scopes,
},
PushArgs::from(&args.0),
&queued_job.created_by,
@@ -2153,6 +2180,7 @@ async fn restart_job_if_perpetual_inner(
None,
None,
None,
scope_ceiling.as_deref(),
)
.await?;
tx.commit().await?;
@@ -2411,6 +2439,8 @@ pub async fn maybe_enqueue_native_script_retry(
)
.await?;
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
// The retry never holds a wider token than the attempt it replaces.
let scope_ceiling = job.job_token_scopes.clone();
let (new_id, mut tx) = match push(
db,
tx,
@@ -2459,6 +2489,7 @@ pub async fn maybe_enqueue_native_script_retry(
None,
trigger,
None,
scope_ceiling.as_deref(),
)
.await
{
@@ -3424,6 +3455,7 @@ pub async fn push_error_handler<'a, 'c, T: Serialize + Send + Sync>(
None,
None,
None,
None,
)
.await?;
tx.commit().await?;
@@ -3513,6 +3545,7 @@ pub async fn push_success_handler<'a, 'c, T: Serialize + Send + Sync>(
None,
None,
None,
None,
)
.await?;
tx.commit().await?;
@@ -3571,6 +3604,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 (`job_perms.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 +3660,7 @@ impl MiniPulledJob {
runnable_settings_handle: None,
concurrent_limit: None,
concurrency_time_window_s: None,
job_token_scopes: None,
}
}
}
@@ -3659,6 +3698,10 @@ pub struct MiniCompletedJob {
/// the server would reject every completion it sends, not just build jobs.
#[serde(default)]
pub build_binary_only: bool,
/// The job's effective token scopes, carried from the pull: a re-run (retry, perpetual
/// restart) caps itself with them after the job's `job_perms` row may have been swept.
#[serde(default)]
pub job_token_scopes: Option<Vec<String>>,
}
impl From<QueuedJobV2> for MiniCompletedJob {
@@ -3686,9 +3729,11 @@ impl From<QueuedJobV2> for MiniCompletedJob {
cache_ttl: job.cache_ttl,
cache_ignore_s3_path: job.cache_ignore_s3_path,
runnable_settings_handle: job.runnable_settings_handle,
// `QueuedJobV2` carries no args, and nothing reaches the restart gate
// through this conversion — the worker completes jobs from the pulled job.
// `QueuedJobV2` carries no args, which is what marks a binary-build job.
build_binary_only: false,
// Nor scopes: a caller whose completion can re-run the job (the monitor's zombie
// recovery) fills them in from `job_perms` while the job is still queued.
job_token_scopes: None,
}
}
}
@@ -3719,6 +3764,7 @@ impl From<MiniPulledJob> for MiniCompletedJob {
cache_ttl: job.cache_ttl,
cache_ignore_s3_path: job.cache_ignore_s3_path,
runnable_settings_handle: job.runnable_settings_handle,
job_token_scopes: job.job_token_scopes.clone(),
build_binary_only: crate::binary_prebuild::is_build_binary_job(
job.args.as_ref().map(|x| &x.0),
),
@@ -3751,6 +3797,7 @@ impl From<Arc<MiniPulledJob>> for MiniCompletedJob {
cache_ttl: job.cache_ttl,
cache_ignore_s3_path: job.cache_ignore_s3_path,
runnable_settings_handle: job.runnable_settings_handle,
job_token_scopes: job.job_token_scopes.clone(),
build_binary_only: crate::binary_prebuild::is_build_binary_job(
job.args.as_ref().map(|x| &x.0),
),
@@ -3825,48 +3872,6 @@ impl MiniPulledJob {
.and_then(|f| f.chat_input_enabled)
}
pub fn from(job: &QueuedJob) -> MiniPulledJob {
MiniPulledJob {
workspace_id: job.workspace_id.clone(),
id: job.id,
args: job.args.clone(),
parent_job: job.parent_job.clone(),
created_by: job.created_by.clone(),
started_at: job.started_at.clone(),
scheduled_for: job.scheduled_for,
runnable_path: job.script_path.clone(),
kind: job.job_kind,
runnable_id: job.script_hash.clone(),
canceled_reason: job.canceled_reason.clone(),
canceled_by: job.canceled_by.clone(),
permissioned_as: job.permissioned_as.clone(),
permissioned_as_email: job.email.clone(),
flow_status: job.flow_status.clone(),
tag: job.tag.clone(),
script_lang: job.language.clone(),
same_worker: job.same_worker,
pre_run_error: job.pre_run_error.clone(),
concurrent_limit: job.concurrent_limit.clone(),
concurrency_time_window_s: job.concurrency_time_window_s.clone(),
runnable_settings_handle: job.runnable_settings_handle,
flow_innermost_root_job: job.root_job.clone(), // QueuedJob is taken from v2_as_queue, where root_job corresponds to flow_innermost_root_job in v2_job
root_job: None,
timeout: job.timeout.clone(),
flow_step_id: job.flow_step_id.clone(),
cache_ttl: job.cache_ttl.clone(),
cache_ignore_s3_path: job.cache_ignore_s3_path.clone(),
priority: job.priority.clone(),
preprocessed: job.preprocessed.clone(),
script_entrypoint_override: job.script_entrypoint_override.clone(),
trigger: job.schedule_path.clone(),
trigger_kind: job
.schedule_path
.is_some()
.then(|| JobTriggerKind::Schedule.into()),
visible_to_owner: job.visible_to_owner.clone(),
permissioned_as_end_user_email: None,
}
}
pub fn is_flow(&self) -> bool {
self.kind.is_flow()
}
@@ -3968,6 +3973,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,8 +4080,10 @@ 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
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",
NULL as permissioned_as_end_user_email,
job_perms.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
LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id WHERE v2_job_queue.id = $1",
job_id,
)
.fetch_optional(e)
@@ -5767,8 +5775,10 @@ pub fn get_mini_completed_job<'a, 'e, A: sqlx::Acquire<'e, Database = Postgres>
"SELECT
j.id, j.workspace_id, j.runnable_id AS \"runnable_id: ScriptHash\", q.scheduled_for, q.started_at, j.parent_job, j.flow_innermost_root_job, j.runnable_path, j.kind as \"kind!: JobKind\", j.permissioned_as,
j.created_by, j.script_lang AS \"script_lang: ScriptLang\", j.permissioned_as_email, j.flow_step_id, j.trigger_kind AS \"trigger_kind: TriggerKindLabel\", j.trigger, j.priority, j.concurrent_limit, j.tag, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle,
COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\"
COALESCE(j.args->'build_binary_only' = 'true'::jsonb, false) AS \"build_binary_only!\",
p.job_token_scopes AS \"job_token_scopes?\"
FROM v2_job j LEFT JOIN v2_job_queue q ON j.id = q.id
LEFT JOIN job_perms p ON p.job_id = j.id
WHERE j.id = $1 AND j.workspace_id = $2",
id,
w_id
@@ -6018,6 +6028,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,
@@ -6049,6 +6063,7 @@ pub async fn push<'c, 'd>(
end_user_email,
trigger,
suspended_mode,
scope_ceiling,
))
.await
}
@@ -6084,6 +6099,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
@@ -6317,6 +6333,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,
@@ -6339,6 +6357,7 @@ async fn push_inner<'c, 'd>(
debouncing_settings,
retry_settings,
labels,
job_token_scopes,
build_binary_only,
} = match job_payload {
JobPayload::ScriptHash {
@@ -6353,6 +6372,7 @@ async fn push_inner<'c, 'd>(
concurrency_settings,
debouncing_settings,
labels,
job_token_scopes,
} => {
if apply_preprocessor {
preprocessed = Some(false);
@@ -6370,6 +6390,7 @@ async fn push_inner<'c, 'd>(
dedicated_worker,
_low_level_priority: priority,
labels,
job_token_scopes,
..Default::default()
}
}
@@ -6585,6 +6606,7 @@ async fn push_inner<'c, 'd>(
..Default::default()
},
JobPayload::RawFlow { mut value, path, restarted_from } => {
refuse_step_scopes_on_outdated_workers(&value).await?;
add_virtual_items_if_necessary(&mut value.modules);
let flow_status: FlowStatus = match restarted_from {
@@ -6708,20 +6730,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),
@@ -6733,6 +6756,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(),
@@ -6903,7 +6927,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
@@ -6922,6 +6953,7 @@ async fn push_inner<'c, 'd>(
tx = PushIsolationLevel::Transaction(ntx);
let mut value = data.value().clone();
refuse_step_scopes_on_outdated_workers(&value).await?;
let priority = value.priority;
let cache_ttl = value.cache_ttl.map(|x| x as i32);
let cache_ignore_s3_path = value.cache_ignore_s3_path;
@@ -6970,6 +7002,7 @@ async fn push_inner<'c, 'd>(
concurrency_settings,
debouncing_settings,
labels,
job_token_scopes,
..Default::default()
}
}
@@ -7034,7 +7067,21 @@ async fn push_inner<'c, 'd>(
memory_id: None,
no_inherited_flow_env: false,
};
// The completed job's own scopes are gone with its `job_perms` row, so the restart
// takes the flow's current setting (and the restarting caller's ceiling).
let job_token_scopes = match &flow_path {
Some(flow_path) => sqlx::query_scalar::<_, Option<Vec<String>>>(
"SELECT job_token_scopes FROM flow WHERE path = $1 AND workspace_id = $2",
)
.bind(flow_path)
.bind(workspace_id)
.fetch_optional(db)
.await?
.flatten(),
None => None,
};
let value = flow_data.value();
refuse_step_scopes_on_outdated_workers(value).await?;
let priority = value.priority;
let concurrency_settings = value.concurrency_settings.clone();
let debouncing_settings = value.debouncing_settings.clone();
@@ -7058,6 +7105,7 @@ async fn push_inner<'c, 'd>(
_low_level_priority: priority,
concurrency_settings,
debouncing_settings,
job_token_scopes,
..Default::default()
}
}
@@ -7177,7 +7225,14 @@ async fn push_inner<'c, 'd>(
.map(|e| (Some(e.0), e.1))
.unwrap_or_else(|| (None, None));
let tag = if dedicated_worker.is_some_and(|x| x) {
let job_token_scopes = windmill_common::scopes::intersect_job_token_scopes(
scope_ceiling,
job_token_scopes.as_deref(),
);
// A dedicated worker runs every job it serves with its own unscoped worker token, so a
// job with a restricted token runs on the regular workers of its language instead.
let tag = if dedicated_worker.is_some_and(|x| x) && job_token_scopes.is_none() {
let flow_prefix = if job_kind == JobKind::Flow || job_kind == JobKind::FlowDependencies {
"flow/"
} else {
@@ -7505,9 +7560,9 @@ async fn push_inner<'c, 'd>(
INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)
),
inserted_job_perms AS (
INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email)
values ($1, $32, $33, $34, $35, $36, $37, $2, $41)
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
INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email, job_token_scopes)
values ($1, $32, $33, $34, $35, $36, $37, $2, $41, $47)
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, job_token_scopes = EXCLUDED.job_token_scopes
)
INSERT INTO v2_job_queue
(workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle)
@@ -7562,6 +7617,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)
@@ -8525,6 +8581,7 @@ pub async fn get_same_worker_job(
v2_job.trigger,
v2_job.trigger_kind,
v2_job.visible_to_owner,
p.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 {
@@ -45,6 +45,7 @@ mod native_retry {
cache_ignore_s3_path: None,
runnable_settings_handle: handle,
build_binary_only: false,
job_token_scopes: None,
}
}
@@ -165,6 +166,39 @@ mod native_retry {
Ok(())
}
// A retry keeps the restriction of the run it replaces even once that run's `job_perms`
// row is gone (swept after it left the queue): the completed job carries its scopes.
#[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))]
async fn retry_keeps_the_restriction_of_a_swept_run(db: Pool<Postgres>) -> anyhow::Result<()> {
let retry = Retry {
constant: ConstantDelay { attempts: 1, seconds: 1 },
exponential: Default::default(),
retry_if: None,
};
let handle = insert_rs(
RunnableSettings {
debouncing_settings: None,
concurrency_settings: None,
retry_settings: RetrySettings::from(&retry).insert_cached(&db).await?,
},
&db,
)
.await?;
let root_id = Uuid::new_v4();
let mut root = mini(root_id, None, handle);
root.job_token_scopes = Some(vec!["jobs:run".to_string()]);
assert!(maybe_enqueue_native_script_retry(&db, &root, &None, &no_result).await?);
let (r1_id, ..) = retry_by_attempt(&db, root_id, 1).await.expect("retry 1 exists");
let scopes: Option<Vec<String>> =
sqlx::query_scalar("SELECT job_token_scopes FROM job_perms WHERE job_id = $1")
.bind(r1_id)
.fetch_one(&db)
.await?;
assert_eq!(scopes, Some(vec!["jobs:run".to_string()]));
Ok(())
}
// Cancellation always wins over a pending retry.
#[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))]
async fn canceled_job_does_not_retry(db: Pool<Postgres>) -> anyhow::Result<()> {
@@ -93,6 +93,7 @@ mod schedule_push {
cache_ignore_s3_path: None,
runnable_settings_handle: None,
build_binary_only: false,
job_token_scopes: None,
}
}
+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?;
+29
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,
}
}
@@ -615,6 +635,9 @@ pub struct FlowModule {
pub pass_flow_input_directly: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub debouncing: Option<DebouncingSettings>,
/// Caps the token of the jobs this step runs, on top of the flow's own restriction.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_token_scopes: Option<Vec<String>>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
@@ -902,6 +925,9 @@ pub struct AgentTool {
/// Overrides the description auto-derived from the underlying runnable.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Caps the token of this tool's jobs, on top of the agent step's own restriction.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_token_scopes: Option<Vec<String>>,
pub value: ToolValue,
}
@@ -915,6 +941,7 @@ impl AgentTool {
self.id = flow_module.id;
self.summary = flow_module.summary;
self.job_token_scopes = flow_module.job_token_scopes;
self.value = ToolValue::FlowModule(module_value);
}
}
@@ -927,6 +954,7 @@ impl From<&AgentTool> for Option<FlowModule> {
id: tool.id.clone(),
value: to_raw_value(module_value),
summary: tool.summary.clone(),
job_token_scopes: tool.job_token_scopes.clone(),
..Default::default()
}),
ToolValue::Mcp(_) => None,
@@ -1331,6 +1359,7 @@ pub fn add_virtual_items_if_necessary(modules: &mut Vec<FlowModule>) {
apply_preprocessor: None,
pass_flow_input_directly: None,
debouncing: None,
job_token_scopes: None,
});
}
}
+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);
+10
View File
@@ -599,6 +599,16 @@ async fn enqueue_windmill_tool(
None,
None,
None,
// A tool never holds a wider token than the agent calling it, and its own setting
// narrows that further.
windmill_common::scopes::intersect_job_token_scopes(
ctx.job.job_token_scopes.as_deref(),
windmill_common::scopes::step_job_token_scopes(
tool_module.job_token_scopes.as_deref(),
)
.as_deref(),
)
.as_deref(),
)
.await?;
@@ -2745,6 +2745,7 @@ mod tests {
let mut its = HashMap::new();
its.insert(key.to_string(), js(expr));
AgentTool {
job_token_scopes: None,
id: id.to_string(),
summary: None,
description: None,
@@ -2772,6 +2773,7 @@ mod tests {
script_tool("a", "x", "authoring_flow_expr"),
script_tool("b", "y", "keep_me"),
AgentTool {
job_token_scopes: None,
id: "m".to_string(),
summary: None,
description: None,
@@ -2819,6 +2821,7 @@ mod tests {
fn narrow_roster_keeps_the_entries_a_run_named() {
fn named(id: &str, summary: &str) -> AgentTool {
AgentTool {
job_token_scopes: None,
id: id.to_string(),
summary: Some(summary.to_string()),
description: None,
@@ -2834,6 +2837,7 @@ mod tests {
}
fn mcp(id: &str, summary: &str, path: &str) -> AgentTool {
AgentTool {
job_token_scopes: None,
id: id.to_string(),
summary: Some(summary.to_string()),
description: None,
@@ -2846,6 +2850,7 @@ mod tests {
}
fn websearch(id: &str, summary: Option<&str>) -> AgentTool {
AgentTool {
job_token_scopes: None,
id: id.to_string(),
summary: summary.map(str::to_string),
description: None,
@@ -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,
}
}
+10 -28
View File
@@ -16,8 +16,6 @@ use tokio::time::timeout;
// Re-export proxy env-var snapshots so callers (including EE modules)
// can keep importing them via `crate::{NO_PROXY, HTTP_PROXY, HTTPS_PROXY}`.
use windmill_common::client::AuthedClient;
use windmill_common::db::UserDbWithAuthed;
use windmill_common::get_latest_deployed_hash_for_path;
use windmill_common::jobs::InlineScriptTarget;
use windmill_common::jobs::RunInlineScriptFnParams;
use windmill_common::jobs::WorkerInternalServerInlineUtils;
@@ -3595,8 +3593,12 @@ pub async fn run_worker(
// dispatch by path and return before that check, so a job sent down them
// would run with whatever arguments survived the failure.
let fails_before_running = job.pre_run_error.is_some();
// A dedicated worker or flow runner runs every job it gets with its own
// unscoped worker token, so a job with a restricted token runs here, with the
// token minted for it, whichever tag brought it.
let restricted = job.job_token_scopes.is_some();
if !dedicated_workers.is_empty() && !fails_before_running {
if !dedicated_workers.is_empty() && !fails_before_running && !restricted {
let dedicated_worker_tx = job.runnable_path.as_ref().and_then(|path| {
// For flow steps inside branches/loops, runnable_path includes
// nesting segments (e.g. f/flow/branchone-0/a) but the dedicated
@@ -3641,7 +3643,9 @@ pub async fn run_worker(
NextJob::Http(_) => None,
};
if let Some(flow_runners) = flow_runners.filter(|_| !fails_before_running) {
if let Some(flow_runners) =
flow_runners.filter(|_| !fails_before_running && !restricted)
{
let key_o = job.flow_step_id.as_ref().map(|x| x.to_string());
if let Some(key) = key_o {
if let Some(flow_runner_tx) = flow_runners.runners.get(&key) {
@@ -7234,30 +7238,8 @@ pub fn init_worker_internal_server_inline_utils(
run_inline_script: Arc::new(|params: RunInlineScriptFnParams| {
Box::pin(async move {
let (script_hash, runnable_path) = match params.target {
InlineScriptTarget::Path(ref path) => {
let db = params
.conn
.as_sql()
.ok_or_else(|| {
error::Error::InternalErr(
"run_inline_script by path requires a SQL connection"
.to_string(),
)
})?
.clone();
let authed_ref = params.user_db.as_ref().map(|(_, a)| a.to_authed_ref());
let user_db_authed =
params.user_db.as_ref().zip(authed_ref.as_ref()).map(
|((udb, _), ar)| UserDbWithAuthed { db: udb.clone(), authed: ar },
);
let script_hash_info = get_latest_deployed_hash_for_path(
user_db_authed,
db,
&params.workspace_id,
path,
)
.await?;
(ScriptHash(script_hash_info.hash), Some(path.clone()))
InlineScriptTarget::Path { ref path, hash } => {
(ScriptHash(hash), Some(path.clone()))
}
InlineScriptTarget::Hash(hash) => (ScriptHash(hash), None),
};
+32 -3
View File
@@ -2366,6 +2366,7 @@ async fn advance_flow_status(
THEN v2_job_status.flow_leaf_jobs
ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END
FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id
LEFT JOIN job_perms ON job_perms.job_id = v2_job_queue.id
WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1
RETURNING
v2_job_queue.workspace_id,
@@ -2402,7 +2403,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,
job_perms.job_token_scopes",
flow,
&step_path as &[&str],
step,
@@ -4327,11 +4329,15 @@ async fn push_next_flow_job(
};
// only start runners if we're not already in a squash for loop
// Runners would run its steps with their own unscoped token: a restricted flow, or a loop
// that restricts itself or any step in it, runs them as regular jobs.
let start_runners = flow_runners.is_none()
&& flow_job.job_token_scopes.is_none()
&& matches!(
next_status,
NextStatus::NextLoopIteration { start_runners: true, .. }
);
)
&& !restricts_any_step(module);
let do_not_pass_runners = matches!(next_status, NextStatus::NextStep { .. })
&& flow_runners
@@ -4746,6 +4752,16 @@ async fn push_next_flow_job(
end_user_email,
None,
None,
// A step never holds a wider token than the flow running it, and its own setting
// narrows that further.
windmill_common::scopes::intersect_job_token_scopes(
flow_job.job_token_scopes.as_deref(),
windmill_common::scopes::step_job_token_scopes(
module.job_token_scopes.as_deref(),
)
.as_deref(),
)
.as_deref(),
)
.warn_after_seconds(2)
.await?;
@@ -6330,6 +6346,16 @@ pub fn raw_script_to_payload(
}
}
/// Whether `module`, or any step or agent tool under it, sets `job_token_scopes`.
fn restricts_any_step(module: &FlowModule) -> bool {
let mut any = false;
let _ = FlowModule::traverse_modules(&vec![module.clone()], &mut |m: &FlowModule| {
any |= m.job_token_scopes.is_some();
Ok(())
});
any
}
async fn flow_to_payload(
path: String,
delete_after_use: bool,
@@ -6339,13 +6365,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 +6446,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 +6464,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
@@ -273,12 +274,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}...`));
@@ -289,6 +300,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,
@@ -304,6 +316,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,

Some files were not shown because too many files have changed in this diff Show More