mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-03 08:02:19 +00:00
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:
co-authored by
Claude Opus 5.5
windmill-internal-app[bot]
parent
e952298f84
commit
e7fc1b2e2e
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, job_token_scopes, lock_error_logs, created_at)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, job_token_scopes, $6::text, clock_timestamp()\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160"
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (\n id, -- 1\n workspace_id, -- 2\n raw_code, -- 3\n raw_lock, -- 4\n raw_flow, -- 5\n tag, -- 6\n parent_job, -- 7\n created_by, -- 8\n permissioned_as, -- 9\n runnable_id, -- 10\n runnable_path, -- 11\n args, -- 12\n kind, -- 13\n trigger, -- 14\n script_lang, -- 15\n same_worker, -- 16\n pre_run_error, -- 17\n permissioned_as_email, -- 18\n visible_to_owner, -- 19\n flow_innermost_root_job, -- 20\n root_job, -- 38\n concurrent_limit, -- 21\n concurrency_time_window_s, -- 22\n timeout, -- 23\n flow_step_id, -- 24\n cache_ttl, -- 25\n priority, -- 26\n trigger_kind, -- 39\n script_entrypoint_override, -- 12\n preprocessed, -- 27,\n labels -- 44\n ) 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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n scheduled_for,\n runnable_path,\n kind as \"kind: JobKind\",\n runnable_id as \"runnable_id: ScriptHash\",\n canceled_reason,\n canceled_by,\n permissioned_as,\n permissioned_as_email,\n flow_status as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n cache_ignore_s3_path,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: TriggerKindLabel\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email,\n job_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"
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n flow_version.id AS version,\n flow_version.value->>'early_return' as early_return,\n flow_version.value->>'preprocessor_module' IS NOT NULL as has_preprocessor,\n flow_version.value->>'failure_module' IS NOT NULL as has_failure_module,\n (flow_version.value->>'chat_input_enabled')::boolean as chat_input_enabled,\n flow.tag,\n flow.dedicated_worker,\n flow.on_behalf_of,\n flow.edited_by,\n flow.labels,\n flow.job_token_scopes\n FROM\n flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE\n flow_version.workspace_id = $1 AND\n flow_version.path = $2 AND\n flow_version.id = $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "version",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "early_return",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "has_preprocessor",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "has_failure_module",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "chat_input_enabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "tag",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "dedicated_worker",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "on_behalf_of",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "labels",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes\n FROM flow\n WHERE path = $2 AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64"
|
||||
}
|
||||
+253
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_status SET\n flow_status = JSONB_SET(\n JSONB_SET(v2_job_status.flow_status, $2::TEXT[], $3),\n $4::TEXT[], $5\n ) - $6::TEXT[],\n flow_leaf_jobs = CASE\n WHEN $7::TEXT IS NULL\n OR COALESCE(v2_job.flow_innermost_root_job, v2_job_status.id) <> v2_job_status.id\n THEN v2_job_status.flow_leaf_jobs\n ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n 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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of, on_behalf_of_email, assets, modules, job_token_scopes\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, delete_after_secs, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n -- Same three forms and the same prefix-first rule as permissioned_as_exists,\n -- superadmin fallback included: one acting outside their workspaces has no usr\n -- row but still authenticates.\n CASE WHEN on_behalf_of LIKE 'u/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar\n AND u.username = substring(on_behalf_of from 3)\n UNION ALL\n SELECT 1 FROM password p WHERE p.super_admin\n AND (p.username = substring(on_behalf_of from 3)\n OR p.email = substring(on_behalf_of from 3))))\n WHEN on_behalf_of LIKE 'g/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM group_ g WHERE g.workspace_id = $1::varchar\n AND g.name = substring(on_behalf_of from 3)))\n ELSE\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $1::varchar\n AND u.username = on_behalf_of\n UNION ALL\n SELECT 1 FROM password p WHERE p.email = on_behalf_of\n AND p.super_admin))\n END, on_behalf_of_email, assets, modules, job_token_scopes\n FROM script\n WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json<Box<RawValue>>\", flow.job_token_scopes FROM flow\n LEFT JOIN flow_version\n ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]\n LEFT JOIN flow_version_lite\n ON flow_version_lite.id = flow_version.id\n WHERE flow.path = $1 AND flow.workspace_id = $2 LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value!: sqlx::types::Json<Box<RawValue>>",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab"
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
+8
-2
@@ -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"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes)\n SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584"
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email, job_token_scopes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int8",
|
||||
"Varchar",
|
||||
"Int8Array",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "script_lang",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"python3",
|
||||
"deno",
|
||||
"go",
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
"snowflake",
|
||||
"graphql",
|
||||
"powershell",
|
||||
"mssql",
|
||||
"php",
|
||||
"bunnative",
|
||||
"rust",
|
||||
"ansible",
|
||||
"csharp",
|
||||
"oracledb",
|
||||
"nu",
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby",
|
||||
"rlang",
|
||||
"dbt"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Custom": {
|
||||
"name": "script_kind",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"trigger",
|
||||
"failure",
|
||||
"command",
|
||||
"approval",
|
||||
"preprocessor"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar",
|
||||
"VarcharArray",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Int2",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Int4",
|
||||
"Int4",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
"Int4",
|
||||
"Bool",
|
||||
"Int8",
|
||||
"Jsonb",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH inserted AS (\n INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes)\n SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes\n FROM flow\n WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3\n RETURNING 1\n ) SELECT COUNT(*) FROM inserted",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5"
|
||||
}
|
||||
+65
@@ -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"
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n ws_error_handler_muted = $7,\n value = $8,\n schema = $9::text::json,\n edited_by = $10,\n edited_at = now(),\n labels = COALESCE($13, labels),\n on_behalf_of = $14,\n on_behalf_of_email = $15,\n job_token_scopes = CASE WHEN $16 THEN $17 ELSE job_token_scopes END\n WHERE\n path = $11 AND workspace_id = $12",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Bool",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1"
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels, job_token_scopes FROM script\n WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false)\n ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "tag",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "concurrency_key",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "concurrent_limit",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "concurrency_time_window_s",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "debounce_key",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "debounce_delay_s",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "cache_ttl",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "cache_ignore_s3_path",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "runnable_settings_handle",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "language: ScriptLang",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "script_lang",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"python3",
|
||||
"deno",
|
||||
"go",
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
"snowflake",
|
||||
"graphql",
|
||||
"powershell",
|
||||
"mssql",
|
||||
"php",
|
||||
"bunnative",
|
||||
"rust",
|
||||
"ansible",
|
||||
"csharp",
|
||||
"oracledb",
|
||||
"nu",
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby",
|
||||
"rlang",
|
||||
"dbt"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "dedicated_worker",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "priority",
|
||||
"type_info": "Int2"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "timeout",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "on_behalf_of",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "labels",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "job_token_scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow (\n workspace_id, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, dependency_job, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, versions, on_behalf_of, on_behalf_of_email, lock_error_logs,\n job_token_scopes\n )\n SELECT $2, path, summary, description, value, edited_by, edited_at,\n archived, schema, extra_perms, NULL, tag,\n ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only,\n concurrency_key, ARRAY[]::bigint[],\n -- Same predicate as clone_scripts.\n CASE WHEN on_behalf_of LIKE 'u/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar\n AND u.username = substring(on_behalf_of from 3)\n UNION ALL\n SELECT 1 FROM password p WHERE p.super_admin\n AND (p.username = substring(on_behalf_of from 3)\n OR p.email = substring(on_behalf_of from 3))))\n WHEN on_behalf_of LIKE 'g/%' THEN\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM group_ g WHERE g.workspace_id = $2::varchar\n AND g.name = substring(on_behalf_of from 3)))\n ELSE\n (SELECT on_behalf_of WHERE EXISTS (\n SELECT 1 FROM usr u WHERE u.workspace_id = $2::varchar\n AND u.username = on_behalf_of\n UNION ALL\n SELECT 1 FROM password p WHERE p.email = on_behalf_of\n AND p.super_admin))\n END, on_behalf_of_email, lock_error_logs, job_token_scopes\n FROM flow\n WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes)\n SELECT $1, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, lock_error_logs, job_token_scopes\n FROM flow WHERE workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8"
|
||||
}
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n v2_job.id,\n v2_job.raw_code,\n v2_job.raw_lock,\n v2_job.raw_flow as \"raw_flow: _\",\n v2_job.tag,\n v2_job.created_at,\n v2_job.created_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job.kind as \"kind: _\",\n v2_job.runnable_id,\n v2_job.runnable_path,\n v2_job.parent_job,\n v2_job.root_job,\n v2_job.script_lang as \"script_lang: _\",\n v2_job.script_entrypoint_override,\n v2_job.flow_step,\n v2_job.flow_step_id,\n v2_job.flow_innermost_root_job,\n v2_job.\"trigger\",\n v2_job.trigger_kind as \"trigger_kind: _\",\n v2_job.same_worker,\n v2_job.visible_to_owner,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.cache_ttl,\n v2_job.timeout,\n v2_job.priority,\n v2_job.preprocessed,\n v2_job.args as \"args: _\",\n v2_job.labels,\n 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"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO flow (\n workspace_id, path, summary, description,\n dependency_job, lock_error_logs, tag,\n dedicated_worker, visible_to_runner_only,\n ws_error_handler_muted,\n value, schema, edited_by, edited_at, labels,\n on_behalf_of, on_behalf_of_email, job_token_scopes\n ) VALUES (\n $1, $2, $3, $4,\n NULL, '', $5,\n $6, $7,\n $8,\n $9, $10::text::json, $11, now(), $12,\n $13, $14, $15\n )",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
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
@@ -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()),
|
||||
|
||||
@@ -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[])
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -53,6 +53,7 @@ fn script_payload() -> JobPayload {
|
||||
concurrency_settings: ConcurrencySettings::default().into(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ fn flow_payload() -> JobPayload {
|
||||
apply_preprocessor: false,
|
||||
version: FLOW_VERSION,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -854,6 +854,7 @@ fn run_main_script_job(hash: i64) -> RunJob {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ async fn stored_modules(db: &Pool<Postgres>, payload: JobPayload) -> Option<serd
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("push must succeed");
|
||||
@@ -81,6 +82,7 @@ async fn caller_modules_never_reach_a_job(db: Pool<Postgres>) {
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
};
|
||||
assert_eq!(stored_modules(&db, deployed).await, None);
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000001,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -122,6 +123,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000002,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(7))
|
||||
.push(&db)
|
||||
@@ -161,6 +163,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000003,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -205,6 +208,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(21))
|
||||
.push(&db)
|
||||
@@ -247,6 +251,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -265,6 +270,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -327,6 +333,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000004,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
@@ -356,6 +363,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000005,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -391,6 +399,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000006,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -427,6 +436,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000007,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -465,6 +475,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000008,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -500,6 +511,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000009,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -511,6 +523,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000010,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -564,6 +577,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -622,6 +636,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(7))
|
||||
.push(&db)
|
||||
@@ -634,6 +649,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000011,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(7))
|
||||
.push(&db)
|
||||
@@ -683,6 +699,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000012,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -742,6 +759,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000013,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
@@ -777,6 +795,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -795,6 +814,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -848,6 +868,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -867,6 +888,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -926,6 +948,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000014,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(20))
|
||||
.push(&db)
|
||||
@@ -938,6 +961,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000014,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -988,6 +1012,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1038,6 +1063,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1089,6 +1115,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1142,6 +1169,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1160,6 +1188,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1222,6 +1251,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(7))
|
||||
.push(&db)
|
||||
@@ -1240,6 +1270,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.workspace("test-workspace-2")
|
||||
.arg("x", json!(7))
|
||||
@@ -1294,6 +1325,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1338,6 +1370,7 @@ mod dedicated_worker_tests {
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
|
||||
),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1375,6 +1408,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000015,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
@@ -1412,6 +1446,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000019,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
@@ -1451,6 +1486,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000016,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(1))
|
||||
.push(&db)
|
||||
@@ -1490,6 +1526,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000017,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("x", json!(5))
|
||||
.push(&db)
|
||||
@@ -1529,6 +1566,7 @@ mod dedicated_worker_tests {
|
||||
apply_preprocessor: false,
|
||||
labels: None,
|
||||
version: 3000000000000018,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
@@ -115,6 +115,7 @@ async fn push_binary_prebuild(db: &Pool<Postgres>, tag: Option<&str>) -> anyhow:
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -452,6 +452,7 @@ def main():
|
||||
ws_error_handler_muted: None,
|
||||
labels: None,
|
||||
skip_draft_deletion: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -161,6 +161,7 @@ export async function main(path: string, email: string, job_id: string, is_flow:
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
@@ -285,6 +286,7 @@ async fn test_error_handler_muted_on_script(db: Pool<Postgres>) -> anyhow::Resul
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
@@ -381,6 +383,7 @@ async fn test_error_handler_not_triggered_on_success(db: Pool<Postgres>) -> anyh
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -57,6 +57,7 @@ mod job_payload {
|
||||
language: ScriptLang::Deno,
|
||||
priority: None,
|
||||
apply_preprocessor: false,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("world", json!("foo"))
|
||||
.run_until_complete(&db, false, port)
|
||||
@@ -92,6 +93,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete_with(db, false, port, |id| async move {
|
||||
let job = sqlx::query!("SELECT preprocessed FROM v2_job WHERE id = $1", id)
|
||||
@@ -435,6 +437,7 @@ mod job_payload {
|
||||
apply_preprocessor: false,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
@@ -485,6 +488,7 @@ mod job_payload {
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253456,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete_with(db, false, port, |id| async move {
|
||||
let job = sqlx::query!("SELECT preprocessed FROM v2_job WHERE id = $1", id)
|
||||
@@ -556,6 +560,7 @@ mod job_payload {
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
@@ -1150,6 +1155,7 @@ mod job_payload {
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(db, false, port)
|
||||
.await;
|
||||
@@ -1625,6 +1631,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("foo", json!("hello"))
|
||||
.arg("bar", json!("world"))
|
||||
@@ -1676,6 +1683,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("foo", json!("hello"))
|
||||
.arg("bar", json!("world"))
|
||||
@@ -1727,6 +1735,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("foo", json!("hello"))
|
||||
.arg("bar", json!("world"))
|
||||
@@ -1778,6 +1787,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("foo", json!("hello"))
|
||||
.arg("bar", json!("world"))
|
||||
@@ -1831,6 +1841,7 @@ mod job_payload {
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("foo", json!("hello"))
|
||||
.arg("bar", json!("world"))
|
||||
|
||||
@@ -0,0 +1,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(())
|
||||
}
|
||||
@@ -957,6 +957,7 @@ async fn test_job_labels_propagated_at_push_time(db: Pool<Postgres>) -> anyhow::
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: Some(vec!["prod".to_string(), "deploy".to_string()]),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
@@ -1060,6 +1061,7 @@ async fn test_job_label_filter(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: Some(vec!["prod".to_string()]),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
@@ -1077,6 +1079,7 @@ async fn test_job_label_filter(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: Some(vec!["staging".to_string()]),
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
@@ -191,6 +191,7 @@ async fn push_job(db: &Pool<Postgres>, content: &str, args: &serde_json::Value)
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("push must succeed");
|
||||
|
||||
@@ -76,6 +76,7 @@ async fn push_preview_and_get_row(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("push must succeed");
|
||||
|
||||
@@ -1278,6 +1278,7 @@ async fn test_python_wac_v2_with_preprocessor(db: Pool<Postgres>) -> anyhow::Res
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.arg("who", json!("alice"))
|
||||
.arg("count", json!(7))
|
||||
|
||||
@@ -41,6 +41,7 @@ async fn test_api_restart_at_step_nested_happy(db: Pool<Postgres>) -> anyhow::Re
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
@@ -85,6 +86,7 @@ async fn test_api_restart_at_step_top_level_happy(db: Pool<Postgres>) -> anyhow:
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
@@ -118,6 +120,7 @@ async fn test_api_restart_at_step_rejects_unknown_step(db: Pool<Postgres>) -> an
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
@@ -161,6 +164,7 @@ async fn test_api_restart_at_step_rejects_out_of_range_iteration(
|
||||
apply_preprocessor: true,
|
||||
version: 1443253234253454,
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
@@ -53,6 +53,7 @@ async fn push_restart(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -181,6 +181,7 @@ export async function main(path: string, email: string, job_id: string, is_flow:
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30150,6 +30150,12 @@ components:
|
||||
items:
|
||||
type: string
|
||||
default: []
|
||||
job_token_scopes:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job."
|
||||
inherited_labels:
|
||||
type: array
|
||||
items:
|
||||
@@ -30307,6 +30313,12 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
job_token_scopes:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it."
|
||||
skip_draft_deletion:
|
||||
type: boolean
|
||||
description: "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path."
|
||||
@@ -36165,6 +36177,12 @@ components:
|
||||
items:
|
||||
type: string
|
||||
default: []
|
||||
job_token_scopes:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job."
|
||||
inherited_labels:
|
||||
type: array
|
||||
items:
|
||||
@@ -36211,6 +36229,12 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
job_token_scopes:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it."
|
||||
required:
|
||||
- path
|
||||
# Like OpenFlowWPath but `path` is optional: on update the flow is identified by
|
||||
@@ -36247,6 +36271,12 @@ components:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
job_token_scopes:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
type: string
|
||||
description: "Scopes the token of every job of this runnable is restricted to (domain:action[:resource], e.g. oidc:write). Jobs it starts, flow steps and AI agent tools inherit the restriction. Unset, the job token carries the full permissions of the identity the job runs as; an empty list leaves it no API access beyond its own job. Omitting the field keeps the deployed value; null clears it."
|
||||
|
||||
FlowPreview:
|
||||
type: object
|
||||
|
||||
@@ -84,6 +84,7 @@ pub(crate) async fn run_agent(
|
||||
)?;
|
||||
|
||||
let push_authed = authed.clone().into();
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
|
||||
let (uuid, mut tx) = push(
|
||||
&db,
|
||||
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
|
||||
@@ -118,6 +119,7 @@ pub(crate) async fn run_agent(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -760,6 +760,7 @@ async fn push_run_flow(
|
||||
|
||||
let path = subject.path.clone();
|
||||
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
|
||||
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?;
|
||||
let (uuid, tx) = push(
|
||||
db,
|
||||
tx,
|
||||
@@ -790,6 +791,7 @@ async fn push_run_flow(
|
||||
None,
|
||||
authed.trigger_or_fallback(None),
|
||||
None,
|
||||
scope_ceiling.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -372,7 +372,8 @@ impl McpBackend for WindmillBackend {
|
||||
token: &str,
|
||||
workspace_id: &str,
|
||||
) -> BackendResult<ApiAuthed> {
|
||||
self.auth_cache
|
||||
let authed = self
|
||||
.auth_cache
|
||||
.get_authed(Some(workspace_id.to_string()), token)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
@@ -383,7 +384,11 @@ impl McpBackend for WindmillBackend {
|
||||
),
|
||||
None,
|
||||
)
|
||||
})
|
||||
})?;
|
||||
// Job token scopes never include MCP scopes, so a restricted job token gets none.
|
||||
windmill_api_auth::check_job_token_scope(&authed, || "mcp:all".to_string())
|
||||
.map_err(|e| ErrorData::invalid_params(e.to_string(), None))?;
|
||||
Ok(authed)
|
||||
}
|
||||
|
||||
async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult<String> {
|
||||
|
||||
@@ -891,8 +891,8 @@ async fn offboard_user_from_workspace<'c>(
|
||||
let flows_reassigned = sqlx::query_scalar!(
|
||||
r#"WITH inserted AS (
|
||||
INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path, 'u/' || $2 || '/(.*)', $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, lock_error_logs, job_token_scopes
|
||||
FROM flow
|
||||
WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3
|
||||
RETURNING 1
|
||||
|
||||
@@ -665,8 +665,8 @@ async fn update_username_in_workpsace<'c>(
|
||||
// ---- flows ----
|
||||
sqlx::query!(
|
||||
r#"INSERT INTO flow
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at
|
||||
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes)
|
||||
SELECT workspace_id, REGEXP_REPLACE(path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1'), summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, job_token_scopes
|
||||
FROM flow
|
||||
WHERE path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#,
|
||||
new_username,
|
||||
|
||||
@@ -119,6 +119,8 @@ struct ScriptMetadata {
|
||||
pub debouncing_settings: DebouncingSettings,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job_token_scopes: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "is_empty_extra_perms")]
|
||||
pub extra_perms: serde_json::Value,
|
||||
}
|
||||
@@ -906,6 +908,7 @@ pub(crate) async fn tarball_workspace(
|
||||
.then_some(true),
|
||||
modules: script.modules,
|
||||
labels: script.labels,
|
||||
job_token_scopes: script.job_token_scopes,
|
||||
// Same opt-in contract as flow/app: the tarball only surfaces
|
||||
// ACLs when `?preserve_extra_perms=true`. Passing `Null` lets the
|
||||
// `is_empty_extra_perms` skip-serializer drop the field entirely.
|
||||
@@ -966,7 +969,7 @@ pub(crate) async fn tarball_workspace(
|
||||
|
||||
{
|
||||
let flows = sqlx::query_as::<_, Flow>(
|
||||
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
|
||||
"SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow.job_token_scopes, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by
|
||||
FROM flow
|
||||
LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
|
||||
WHERE flow.workspace_id = $1 AND flow.archived = false",
|
||||
|
||||
@@ -362,6 +362,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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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![])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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:#}")))?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ impl RunJob {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("push has to succeed");
|
||||
@@ -296,6 +297,7 @@ impl RunJob {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("push has to succeed");
|
||||
@@ -969,6 +971,7 @@ pub async fn run_deployed_relative_imports(
|
||||
debouncing_settings:
|
||||
windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
labels: None,
|
||||
job_token_scopes: None,
|
||||
})
|
||||
.push(&db2)
|
||||
.await;
|
||||
|
||||
@@ -976,6 +976,7 @@ async fn trigger_script_with_retry_and_error_handler<'c>(
|
||||
None,
|
||||
Some(trigger),
|
||||
suspended_mode,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ pub struct Flow {
|
||||
pub on_behalf_of: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Caps the scopes of the token minted for each job of this flow; `None` = unrestricted.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job_token_scopes: Option<Vec<String>>,
|
||||
/// Labels inherited from the parent folder, computed at read time. Not stored on the flow row.
|
||||
#[sqlx(default)]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -145,6 +149,15 @@ pub struct NewFlow {
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Absent keeps the deployed flow's value, so a client unaware of the setting cannot
|
||||
/// drop a restriction by saving; `null` clears it.
|
||||
#[sqlx(skip)]
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::more_serde::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub job_token_scopes: Option<Option<Vec<String>>>,
|
||||
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
|
||||
/// this flow must NOT delete an existing user draft at the same path.
|
||||
/// Transient — never persisted.
|
||||
@@ -183,6 +196,12 @@ pub struct EditFlow {
|
||||
pub ws_error_handler_muted: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::more_serde::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub job_token_scopes: Option<Option<Vec<String>>>,
|
||||
#[serde(default)]
|
||||
pub skip_draft_deletion: Option<bool>,
|
||||
}
|
||||
@@ -207,6 +226,7 @@ impl EditFlow {
|
||||
preserve_on_behalf_of: self.preserve_on_behalf_of,
|
||||
ws_error_handler_muted: self.ws_error_handler_muted,
|
||||
labels: self.labels,
|
||||
job_token_scopes: self.job_token_scopes,
|
||||
skip_draft_deletion: self.skip_draft_deletion,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +515,8 @@ pub enum JobPayload {
|
||||
concurrency_settings: ConcurrencySettings,
|
||||
debouncing_settings: DebouncingSettings,
|
||||
labels: Option<Vec<String>>,
|
||||
/// The script's `job_token_scopes` setting, read with the rest of the payload.
|
||||
job_token_scopes: Option<Vec<String>>,
|
||||
},
|
||||
FlowNode {
|
||||
id: FlowNodeId,
|
||||
@@ -579,6 +581,8 @@ pub enum JobPayload {
|
||||
apply_preprocessor: bool,
|
||||
version: i64,
|
||||
labels: Option<Vec<String>>,
|
||||
/// The flow's `job_token_scopes` setting, read with the rest of the payload.
|
||||
job_token_scopes: Option<Vec<String>>,
|
||||
},
|
||||
RestartedFlow {
|
||||
completed_job_id: Uuid,
|
||||
|
||||
@@ -331,7 +331,7 @@ pub const SCRIPT_COLUMNS: &str = concat!(
|
||||
"timeout, delete_after_use, delete_after_secs, restart_unless_cancelled, ",
|
||||
"visible_to_runner_only, auto_kind, codebase, has_preprocessor, ",
|
||||
"on_behalf_of, ",
|
||||
"assets, modules, labels, concurrency_key, concurrent_limit, ",
|
||||
"assets, modules, labels, job_token_scopes, concurrency_key, concurrent_limit, ",
|
||||
"concurrency_time_window_s, debounce_key, debounce_delay_s, runnable_settings_handle",
|
||||
);
|
||||
|
||||
@@ -401,6 +401,9 @@ pub struct Script<SR> {
|
||||
pub modules: Option<HashMap<String, ScriptModule>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Caps the scopes of the token minted for each job of this script; `None` = unrestricted.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub job_token_scopes: Option<Vec<String>>,
|
||||
/// Labels inherited from the parent folder, computed at read time. Not stored on the script row.
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -580,6 +583,14 @@ pub struct NewScript {
|
||||
pub auto_parent: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub labels: Option<Vec<String>>,
|
||||
/// Absent keeps the parent version's value, so a client unaware of the setting
|
||||
/// cannot drop a restriction by redeploying; `null` clears it.
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::more_serde::double_option",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub job_token_scopes: Option<Option<Vec<String>>>,
|
||||
/// Caller-intent flag (set by the CLI / git sync): when true, deploying
|
||||
/// this script must NOT delete an existing user draft at the same path.
|
||||
/// Transient — never persisted. Deliberately excluded from `impl Hash`
|
||||
@@ -628,6 +639,7 @@ impl Hash for NewScript {
|
||||
self.preserve_on_behalf_of.hash(state);
|
||||
self.assets.hash(state);
|
||||
self.labels.hash(state);
|
||||
self.job_token_scopes.hash(state);
|
||||
if let Some(modules) = &self.modules {
|
||||
let mut sorted: Vec<_> = modules.iter().collect();
|
||||
sorted.sort_by_key(|(k, _)| *k);
|
||||
|
||||
@@ -599,6 +599,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?;
|
||||
|
||||
|
||||
@@ -2309,6 +2309,7 @@ mod tests {
|
||||
visible_to_owner: false,
|
||||
permissioned_as_end_user_email: None,
|
||||
runnable_settings_handle: None,
|
||||
job_token_scopes: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
¶ms.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),
|
||||
};
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user