From 06a5d73bb825988fc21ad9be277445274ec15cf8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 2 Oct 2026 17:14:26 +0200 Subject: [PATCH] feat: restricted job tokens (job_token_scopes on scripts and flows) Co-Authored-By: Claude Opus 5.5 (1M context) --- ...56cb6311ca99b9a7343949ab653e404f65c23.json | 22 + ...cfca8913ba1129fdf4978a8a4738689786160.json | 19 + ...236428a28c455b75e8fa96d9512202cbd936d.json | 23 + ...4585d9c2aba36fdf33bca9739049366bed107.json | 84 +++ ...3b9a1e996440f056b80f3a4e0ef3878e00198.json | 331 ++++++++ ...fa7969335eccad727b9951ebe4bdc8eb08823.json | 101 +++ ...fea97aec4d6d3ec65d3e7402811c7dddbbf64.json | 16 + ...aa7f5755da9997419c4a9d07b30d4da318bea.json | 15 + ...20e1e07f7e50df82fb6c33f79e6faa5b77bab.json | 29 + ...a91a02d6bd218d21a9f43341cca846a5d276e.json | 152 ++++ ...85c4ed3a067e4374517244608c345781dbc5c.json | 23 + ...64eb6af24e8d8aa99b846f21f2ed49c320bdc.json | 16 + ...8b8517561f5b55a30e44801d6a9123a762584.json | 23 + ...3007c6aeccf76bfd07384ffaccf50a24d13d4.json | 102 +++ ...28d84f1ca3c91de86d520dce06ac12d557bd5.json | 24 + ...24bb83ddb9bbb2dad215d8568ae64104bdcb7.json | 23 + ...7099be925f1058c76c6af31e8a620607cf6a1.json | 30 + ...878286dcc2f23101c2e1c8c1d07b6f0deaecd.json | 159 ++++ ...737b3b4b662a89148cf6ac70eb3563ad7f0c5.json | 15 + ...d10fc10d9ec4dc2359f43d79fe255d101cad8.json | 15 + ...ecac07c2e0998c2cee48f254915690d8270d0.json | 324 ++++++++ ...d70694ba2572f1b744fe69b191eb5b3d5a48f.json | 65 ++ ...084f90b3ac06139ffca98670215956150563a.json | 28 + backend/ee-repo-ref.txt | 2 +- .../20261002124918_job_token_scopes.down.sql | 3 + .../20261002124918_job_token_scopes.up.sql | 3 + backend/summarized_schema.txt | 6 +- backend/tests/asset_trigger_dispatch.rs | 2 + backend/tests/batch_rerun.rs | 2 + backend/tests/bun_jobs.rs | 1 + backend/tests/caller_modules_strip.rs | 2 + backend/tests/dedicated_workers.rs | 38 + backend/tests/dependency_map.rs | 1 + backend/tests/error_handler.rs | 3 + backend/tests/flow_engine_parity.rs | 1 + backend/tests/job_payload.rs | 11 + backend/tests/job_token_scopes.rs | 178 +++++ backend/tests/list_jobs.rs | 3 + backend/tests/nativets_stress.rs | 1 + backend/tests/preview_native_tag.rs | 1 + backend/tests/python_jobs.rs | 1 + backend/tests/restart_at_step_api.rs | 4 + backend/tests/restart_flow_version_scope.rs | 1 + backend/tests/success_handler.rs | 1 + backend/tests/worker.rs | 1 + backend/windmill-api-auth/src/auth.rs | 5 +- backend/windmill-api-auth/src/lib.rs | 127 ++-- backend/windmill-api-auth/src/scopes.rs | 519 ++----------- backend/windmill-api-flows/src/flows.rs | 40 +- backend/windmill-api-jobs/src/execution.rs | 6 + backend/windmill-api-scripts/src/scripts.rs | 42 +- .../src/datatable_migrations.rs | 2 + .../windmill-api-workspaces/src/workspaces.rs | 9 +- .../src/workspaces_extra.rs | 4 +- backend/windmill-api/openapi.yaml | 30 + backend/windmill-api/src/agent_runs.rs | 2 + backend/windmill-api/src/ai_evals/run.rs | 2 + backend/windmill-api/src/apps.rs | 7 + backend/windmill-api/src/apps_raw_bundle.rs | 2 + .../windmill-api/src/concurrency_groups.rs | 1 + backend/windmill-api/src/jobs.rs | 75 +- backend/windmill-api/src/mcp/core.rs | 9 +- backend/windmill-api/src/offboarding.rs | 4 +- backend/windmill-api/src/users.rs | 4 +- backend/windmill-api/src/workspaces_export.rs | 5 +- backend/windmill-common/src/auth.rs | 30 +- backend/windmill-common/src/jobs.rs | 5 +- backend/windmill-common/src/lib.rs | 12 +- backend/windmill-common/src/scopes.rs | 706 ++++++++++++++++++ backend/windmill-common/src/scripts.rs | 6 +- backend/windmill-common/src/worker.rs | 4 +- .../src/trigger_dependents.rs | 1 + backend/windmill-queue/src/asset_dispatch.rs | 2 + backend/windmill-queue/src/jobs.rs | 93 ++- backend/windmill-queue/src/schedule.rs | 8 +- backend/windmill-queue/tests/debounce_test.rs | 2 + backend/windmill-test-utils/src/lib.rs | 3 + .../windmill-trigger/src/trigger_helpers.rs | 1 + backend/windmill-types/src/flows.rs | 20 + backend/windmill-types/src/jobs.rs | 4 + backend/windmill-types/src/scripts.rs | 14 +- backend/windmill-worker/src/ai/tools.rs | 2 + backend/windmill-worker/src/bun_executor.rs | 5 + backend/windmill-worker/src/common.rs | 1 + backend/windmill-worker/src/worker_flow.rs | 10 +- .../windmill-worker/src/worker_lockfiles.rs | 1 + cli/src/commands/flow/flow.ts | 17 +- cli/src/commands/script/script.ts | 4 + cli/windmill-utils-internal/src/deploy.ts | 2 + docs/auth-surface.md | 14 + .../src/lib/components/FlowBuilder.svelte | 8 +- .../components/JobTokenScopesSetting.svelte | 76 ++ .../src/lib/components/ScriptBuilder.svelte | 15 +- .../flows/content/FlowSettings.svelte | 7 + .../flows/header/FlowYamlEditor.svelte | 3 + frontend/src/lib/components/flows/types.ts | 1 + .../src/lib/components/flows/utils.svelte.ts | 4 + .../components/settings/ScopeSelector.svelte | 7 +- frontend/src/lib/utils_draft_deploy.ts | 1 + 99 files changed, 3286 insertions(+), 603 deletions(-) create mode 100644 backend/.sqlx/query-0312002d090d7233909922a96a756cb6311ca99b9a7343949ab653e404f65c23.json create mode 100644 backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json create mode 100644 backend/.sqlx/query-32f92624baf4eed5137af20edf8236428a28c455b75e8fa96d9512202cbd936d.json create mode 100644 backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json create mode 100644 backend/.sqlx/query-4d6dd480f3118dc5e099538811d3b9a1e996440f056b80f3a4e0ef3878e00198.json create mode 100644 backend/.sqlx/query-4ff18f19e633d8c649287097372fa7969335eccad727b9951ebe4bdc8eb08823.json create mode 100644 backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json create mode 100644 backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json create mode 100644 backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json create mode 100644 backend/.sqlx/query-921bdbe70e936935823fd202894a91a02d6bd218d21a9f43341cca846a5d276e.json create mode 100644 backend/.sqlx/query-ac94dccaf3709f178ab5636892c85c4ed3a067e4374517244608c345781dbc5c.json create mode 100644 backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json create mode 100644 backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json create mode 100644 backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json create mode 100644 backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json create mode 100644 backend/.sqlx/query-c6faa49fa9385412c8f5107864b24bb83ddb9bbb2dad215d8568ae64104bdcb7.json create mode 100644 backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json create mode 100644 backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json create mode 100644 backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json create mode 100644 backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json create mode 100644 backend/.sqlx/query-eb1ea11d391e5860aa4a6e9d4c1ecac07c2e0998c2cee48f254915690d8270d0.json create mode 100644 backend/.sqlx/query-ec72f5257c99202e43105043c70d70694ba2572f1b744fe69b191eb5b3d5a48f.json create mode 100644 backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json create mode 100644 backend/migrations/20261002124918_job_token_scopes.down.sql create mode 100644 backend/migrations/20261002124918_job_token_scopes.up.sql create mode 100644 backend/tests/job_token_scopes.rs create mode 100644 backend/windmill-common/src/scopes.rs create mode 100644 frontend/src/lib/components/JobTokenScopesSetting.svelte diff --git a/backend/.sqlx/query-0312002d090d7233909922a96a756cb6311ca99b9a7343949ab653e404f65c23.json b/backend/.sqlx/query-0312002d090d7233909922a96a756cb6311ca99b9a7343949ab653e404f65c23.json new file mode 100644 index 0000000000..d83a51a072 --- /dev/null +++ b/backend/.sqlx/query-0312002d090d7233909922a96a756cb6311ca99b9a7343949ab653e404f65c23.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT job_token_scopes FROM v2_job WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "0312002d090d7233909922a96a756cb6311ca99b9a7343949ab653e404f65c23" +} diff --git a/backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json b/backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json new file mode 100644 index 0000000000..5c6ee3d506 --- /dev/null +++ b/backend/.sqlx/query-05b610ad1809d487c32e85b0392cfca8913ba1129fdf4978a8a4738689786160.json @@ -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" +} diff --git a/backend/.sqlx/query-32f92624baf4eed5137af20edf8236428a28c455b75e8fa96d9512202cbd936d.json b/backend/.sqlx/query-32f92624baf4eed5137af20edf8236428a28c455b75e8fa96d9512202cbd936d.json new file mode 100644 index 0000000000..532a7c2f1b --- /dev/null +++ b/backend/.sqlx/query-32f92624baf4eed5137af20edf8236428a28c455b75e8fa96d9512202cbd936d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT job_token_scopes FROM v2_job WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "32f92624baf4eed5137af20edf8236428a28c455b75e8fa96d9512202cbd936d" +} diff --git a/backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json b/backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json new file mode 100644 index 0000000000..a5bde4d661 --- /dev/null +++ b/backend/.sqlx/query-4631200a37f7edec92f3796ab4d4585d9c2aba36fdf33bca9739049366bed107.json @@ -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" +} diff --git a/backend/.sqlx/query-4d6dd480f3118dc5e099538811d3b9a1e996440f056b80f3a4e0ef3878e00198.json b/backend/.sqlx/query-4d6dd480f3118dc5e099538811d3b9a1e996440f056b80f3a4e0ef3878e00198.json new file mode 100644 index 0000000000..684454c4ae --- /dev/null +++ b/backend/.sqlx/query-4d6dd480f3118dc5e099538811d3b9a1e996440f056b80f3a4e0ef3878e00198.json @@ -0,0 +1,331 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_status SET\n flow_status = JSONB_SET(\n JSONB_SET(v2_job_status.flow_status, $2::TEXT[], $3),\n $4::TEXT[], $5\n ) - $6::TEXT[],\n flow_leaf_jobs = CASE\n WHEN $7::TEXT IS NULL\n OR COALESCE(v2_job.flow_innermost_root_job, v2_job_status.id) <> v2_job_status.id\n THEN v2_job_status.flow_leaf_jobs\n ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1\n RETURNING\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json>>\",\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>\",\n v2_job.tag,\n v2_job.script_lang as \"script_lang: ScriptLang\",\n v2_job.same_worker,\n v2_job.pre_run_error,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.flow_innermost_root_job,\n v2_job.root_job,\n v2_job.timeout,\n v2_job.flow_step_id,\n v2_job.cache_ttl,\n v2_job_queue.cache_ignore_s3_path,\n v2_job_queue.priority,\n v2_job.preprocessed,\n v2_job.script_entrypoint_override,\n v2_job.trigger,\n v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\",\n v2_job.visible_to_owner,\n NULL as permissioned_as_end_user_email,\n v2_job.job_token_scopes", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "args: sqlx::types::Json>>", + "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>", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 18, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 23, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 28, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 29, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 33, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "permissioned_as_end_user_email", + "type_info": "Text" + }, + { + "ordinal": 35, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "TextArray", + "Jsonb", + "TextArray", + "Jsonb", + "TextArray", + "Text", + "Jsonb" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null, + true + ] + }, + "hash": "4d6dd480f3118dc5e099538811d3b9a1e996440f056b80f3a4e0ef3878e00198" +} diff --git a/backend/.sqlx/query-4ff18f19e633d8c649287097372fa7969335eccad727b9951ebe4bdc8eb08823.json b/backend/.sqlx/query-4ff18f19e633d8c649287097372fa7969335eccad727b9951ebe4bdc8eb08823.json new file mode 100644 index 0000000000..cdedc27752 --- /dev/null +++ b/backend/.sqlx/query-4ff18f19e633d8c649287097372fa7969335eccad727b9951ebe4bdc8eb08823.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH uuid_table as (\n select gen_random_uuid() as uuid from generate_series(1, $16)\n )\n INSERT INTO v2_job\n (id, workspace_id, raw_code, raw_lock, raw_flow, tag, runnable_id, runnable_path, kind,\n script_lang, created_by, permissioned_as, permissioned_as_email, concurrent_limit,\n concurrency_time_window_s, timeout, args, job_token_scopes)\n (SELECT uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,\n ('{ \"uuid\": \"' || uuid || '\" }')::jsonb, $17 FROM uuid_table)\n RETURNING id AS \"id!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Text", + "Jsonb", + "Varchar", + "Int8", + "Varchar", + { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + }, + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + }, + "Varchar", + "Varchar", + "Varchar", + "Int4", + "Int4", + "Int4", + "Int4", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4ff18f19e633d8c649287097372fa7969335eccad727b9951ebe4bdc8eb08823" +} diff --git a/backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json b/backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json new file mode 100644 index 0000000000..e1f4609346 --- /dev/null +++ b/backend/.sqlx/query-580271c5fe178f5e31f3e211cd7fea97aec4d6d3ec65d3e7402811c7dddbbf64.json @@ -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" +} diff --git a/backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json b/backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json new file mode 100644 index 0000000000..7c305d1e7a --- /dev/null +++ b/backend/.sqlx/query-706f2227e34971d22a577b33f6aaa7f5755da9997419c4a9d07b30d4da318bea.json @@ -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" +} diff --git a/backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json b/backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json new file mode 100644 index 0000000000..57822becd2 --- /dev/null +++ b/backend/.sqlx/query-77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json>\", 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>", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + true + ] + }, + "hash": "77e9cb16c25defc2423621a64f020e1e07f7e50df82fb6c33f79e6faa5b77bab" +} diff --git a/backend/.sqlx/query-921bdbe70e936935823fd202894a91a02d6bd218d21a9f43341cca846a5d276e.json b/backend/.sqlx/query-921bdbe70e936935823fd202894a91a02d6bd218d21a9f43341cca846a5d276e.json new file mode 100644 index 0000000000..2ae86bb94b --- /dev/null +++ b/backend/.sqlx/query-921bdbe70e936935823fd202894a91a02d6bd218d21a9f43341cca846a5d276e.json @@ -0,0 +1,152 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH inserted_job AS (\n INSERT INTO v2_job (\n id, -- 1\n workspace_id, -- 2\n raw_code, -- 3\n raw_lock, -- 4\n raw_flow, -- 5\n tag, -- 6\n parent_job, -- 7\n created_by, -- 8\n permissioned_as, -- 9\n runnable_id, -- 10\n runnable_path, -- 11\n args, -- 12\n kind, -- 13\n trigger, -- 14\n script_lang, -- 15\n same_worker, -- 16\n pre_run_error, -- 17\n permissioned_as_email, -- 18\n visible_to_owner, -- 19\n flow_innermost_root_job, -- 20\n root_job, -- 38\n concurrent_limit, -- 21\n concurrency_time_window_s, -- 22\n timeout, -- 23\n flow_step_id, -- 24\n cache_ttl, -- 25\n priority, -- 26\n trigger_kind, -- 39\n script_entrypoint_override, -- 12\n preprocessed, -- 27,\n labels, -- 44\n job_token_scopes -- 47\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27,\n -- $44 (payload labels) merged with the labels of the runnable's folder, if any\n -- ($45/$46 are runnable_path/workspace_id again, kept separate to avoid parameter type conflicts)\n (SELECT CASE WHEN fl.labels IS NULL THEN $44\n ELSE (SELECT array_agg(DISTINCT lbl) FROM unnest(COALESCE($44, ARRAY[]::TEXT[]) || fl.labels) lbl)\n END\n FROM folder_labels($46, $45) AS fl(labels)), $47)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id, end_user_email)\n values ($1, $32, $33, $34, $35, $36, $37, $2, $41)\n ON CONFLICT (job_id) DO UPDATE SET email = EXCLUDED.email, username = EXCLUDED.username, is_admin = EXCLUDED.is_admin, is_operator = EXCLUDED.is_operator, folders = EXCLUDED.folders, groups = EXCLUDED.groups, workspace_id = EXCLUDED.workspace_id, end_user_email = EXCLUDED.end_user_email\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority, cache_ignore_s3_path, runnable_settings_handle)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 OR $40 THEN now() END, $30, $31, $42, $43)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text", + "Text", + "Jsonb", + "Varchar", + "Uuid", + "Varchar", + "Varchar", + "Int8", + "Varchar", + "Jsonb", + { + "Custom": { + "name": "job_kind", + "kind": { + "Enum": [ + "script", + "preview", + "flow", + "dependencies", + "flowpreview", + "script_hub", + "identity", + "flowdependencies", + "http", + "graphql", + "postgresql", + "noop", + "appdependencies", + "deploymentcallback", + "singlestepflow", + "flowscript", + "flownode", + "appscript", + "aiagent", + "unassigned_script", + "unassigned_flow", + "unassigned_singlestepflow" + ] + } + } + }, + "Varchar", + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + }, + "Bool", + "Text", + "Varchar", + "Bool", + "Uuid", + "Int4", + "Int4", + "Int4", + "Varchar", + "Int4", + "Int2", + "Bool", + "Bool", + "Timestamptz", + "Varchar", + "Int2", + "Varchar", + "Varchar", + "Bool", + "Bool", + "JsonbArray", + "TextArray", + "Uuid", + { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + }, + "Bool", + "Varchar", + "Bool", + "Int8", + "TextArray", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "921bdbe70e936935823fd202894a91a02d6bd218d21a9f43341cca846a5d276e" +} diff --git a/backend/.sqlx/query-ac94dccaf3709f178ab5636892c85c4ed3a067e4374517244608c345781dbc5c.json b/backend/.sqlx/query-ac94dccaf3709f178ab5636892c85c4ed3a067e4374517244608c345781dbc5c.json new file mode 100644 index 0000000000..c64a4b452f --- /dev/null +++ b/backend/.sqlx/query-ac94dccaf3709f178ab5636892c85c4ed3a067e4374517244608c345781dbc5c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT job_token_scopes IS NOT NULL FROM script WHERE hash = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ac94dccaf3709f178ab5636892c85c4ed3a067e4374517244608c345781dbc5c" +} diff --git a/backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json b/backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json new file mode 100644 index 0000000000..db729b97b1 --- /dev/null +++ b/backend/.sqlx/query-b40c9e2d637cf0f2f49fd4c720b64eb6af24e8d8aa99b846f21f2ed49c320bdc.json @@ -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" +} diff --git a/backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json b/backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json new file mode 100644 index 0000000000..47dcca381c --- /dev/null +++ b/backend/.sqlx/query-b48afe9913423a90955011fc6368b8517561f5b55a30e44801d6a9123a762584.json @@ -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" +} diff --git a/backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json b/backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json new file mode 100644 index 0000000000..b1f969f343 --- /dev/null +++ b/backend/.sqlx/query-bfd01122256e922b00acf0e86593007c6aeccf76bfd07384ffaccf50a24d13d4.json @@ -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" +} diff --git a/backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json b/backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json new file mode 100644 index 0000000000..151296d652 --- /dev/null +++ b/backend/.sqlx/query-c4eaa6e3b1d30a72ef1b54b2b7f28d84f1ca3c91de86d520dce06ac12d557bd5.json @@ -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" +} diff --git a/backend/.sqlx/query-c6faa49fa9385412c8f5107864b24bb83ddb9bbb2dad215d8568ae64104bdcb7.json b/backend/.sqlx/query-c6faa49fa9385412c8f5107864b24bb83ddb9bbb2dad215d8568ae64104bdcb7.json new file mode 100644 index 0000000000..e207770b3c --- /dev/null +++ b/backend/.sqlx/query-c6faa49fa9385412c8f5107864b24bb83ddb9bbb2dad215d8568ae64104bdcb7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT job_token_scopes IS NOT NULL FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false AND deleted = false ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c6faa49fa9385412c8f5107864b24bb83ddb9bbb2dad215d8568ae64104bdcb7" +} diff --git a/backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json b/backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json new file mode 100644 index 0000000000..4ae34431dc --- /dev/null +++ b/backend/.sqlx/query-ce3bccee14cd107631c4ddb4a467099be925f1058c76c6af31e8a620607cf6a1.json @@ -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" +} diff --git a/backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json b/backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json new file mode 100644 index 0000000000..ae90b163ff --- /dev/null +++ b/backend/.sqlx/query-d51f555e80b27549b884f334087878286dcc2f23101c2e1c8c1d07b6f0deaecd.json @@ -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" +} diff --git a/backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json b/backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json new file mode 100644 index 0000000000..7f160dc856 --- /dev/null +++ b/backend/.sqlx/query-df1fe2e7ef004b8de0e626f40fd737b3b4b662a89148cf6ac70eb3563ad7f0c5.json @@ -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" +} diff --git a/backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json b/backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json new file mode 100644 index 0000000000..909565b8c6 --- /dev/null +++ b/backend/.sqlx/query-e81eaa501b4af1269a4929ec660d10fc10d9ec4dc2359f43d79fe255d101cad8.json @@ -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" +} diff --git a/backend/.sqlx/query-eb1ea11d391e5860aa4a6e9d4c1ecac07c2e0998c2cee48f254915690d8270d0.json b/backend/.sqlx/query-eb1ea11d391e5860aa4a6e9d4c1ecac07c2e0998c2cee48f254915690d8270d0.json new file mode 100644 index 0000000000..9e79991e39 --- /dev/null +++ b/backend/.sqlx/query-eb1ea11d391e5860aa4a6e9d4c1ecac07c2e0998c2cee48f254915690d8270d0.json @@ -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>>\",\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>\",\n v2_job.tag,\n script_lang as \"script_lang: ScriptLang\",\n same_worker,\n pre_run_error,\n concurrent_limit,\n concurrency_time_window_s,\n flow_innermost_root_job,\n root_job,\n timeout,\n flow_step_id,\n cache_ttl,\n cache_ignore_s3_path,\n v2_job_queue.priority,\n preprocessed,\n script_entrypoint_override,\n trigger,\n trigger_kind as \"trigger_kind: TriggerKindLabel\",\n visible_to_owner,\n NULL as permissioned_as_end_user_email,\n job_token_scopes\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id WHERE v2_job_queue.id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "args: sqlx::types::Json>>", + "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>", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang", + "dbt" + ] + } + } + } + }, + { + "ordinal": 18, + "name": "same_worker", + "type_info": "Bool" + }, + { + "ordinal": 19, + "name": "pre_run_error", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "concurrent_limit", + "type_info": "Int4" + }, + { + "ordinal": 21, + "name": "concurrency_time_window_s", + "type_info": "Int4" + }, + { + "ordinal": 22, + "name": "flow_innermost_root_job", + "type_info": "Uuid" + }, + { + "ordinal": 23, + "name": "root_job", + "type_info": "Uuid" + }, + { + "ordinal": 24, + "name": "timeout", + "type_info": "Int4" + }, + { + "ordinal": 25, + "name": "flow_step_id", + "type_info": "Varchar" + }, + { + "ordinal": 26, + "name": "cache_ttl", + "type_info": "Int4" + }, + { + "ordinal": 27, + "name": "cache_ignore_s3_path", + "type_info": "Bool" + }, + { + "ordinal": 28, + "name": "priority", + "type_info": "Int2" + }, + { + "ordinal": 29, + "name": "preprocessed", + "type_info": "Bool" + }, + { + "ordinal": 30, + "name": "script_entrypoint_override", + "type_info": "Varchar" + }, + { + "ordinal": 31, + "name": "trigger", + "type_info": "Varchar" + }, + { + "ordinal": 32, + "name": "trigger_kind: TriggerKindLabel", + "type_info": { + "Custom": { + "name": "job_trigger_kind", + "kind": { + "Enum": [ + "webhook", + "http", + "websocket", + "kafka", + "email", + "nats", + "schedule", + "app", + "ui", + "postgres", + "sqs", + "gcp", + "mqtt", + "nextcloud", + "google", + "ci_test", + "github", + "azure", + "asset", + "freshness", + "amqp" + ] + } + } + } + }, + { + "ordinal": 33, + "name": "visible_to_owner", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "permissioned_as_end_user_email", + "type_info": "Text" + }, + { + "ordinal": 35, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + false, + true, + false, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + null, + true + ] + }, + "hash": "eb1ea11d391e5860aa4a6e9d4c1ecac07c2e0998c2cee48f254915690d8270d0" +} diff --git a/backend/.sqlx/query-ec72f5257c99202e43105043c70d70694ba2572f1b744fe69b191eb5b3d5a48f.json b/backend/.sqlx/query-ec72f5257c99202e43105043c70d70694ba2572f1b744fe69b191eb5b3d5a48f.json new file mode 100644 index 0000000000..bec4031278 --- /dev/null +++ b/backend/.sqlx/query-ec72f5257c99202e43105043c70d70694ba2572f1b744fe69b191eb5b3d5a48f.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT p.email, p.username, p.is_admin, p.is_operator, p.groups, p.folders, p.end_user_email,\n j.job_token_scopes\n FROM job_perms p LEFT JOIN v2_job j ON j.id = p.job_id\n WHERE p.job_id = $1 AND p.workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "is_operator", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "groups", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "folders", + "type_info": "JsonbArray" + }, + { + "ordinal": 6, + "name": "end_user_email", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "job_token_scopes", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "ec72f5257c99202e43105043c70d70694ba2572f1b744fe69b191eb5b3d5a48f" +} diff --git a/backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json b/backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json new file mode 100644 index 0000000000..ad4991a7cf --- /dev/null +++ b/backend/.sqlx/query-f2146082f64fcdb4acdfe92e42f084f90b3ac06139ffca98670215956150563a.json @@ -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" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index c2c253e8f0..2e26e32e9b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ff4d04f17721d84fb9a1f655af50ff7e4e7dcbc7 +7737c32ebed0901cc143ada358654ee4743b02b3 \ No newline at end of file diff --git a/backend/migrations/20261002124918_job_token_scopes.down.sql b/backend/migrations/20261002124918_job_token_scopes.down.sql new file mode 100644 index 0000000000..83cc51b5a1 --- /dev/null +++ b/backend/migrations/20261002124918_job_token_scopes.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE v2_job DROP COLUMN IF EXISTS job_token_scopes; +ALTER TABLE flow DROP COLUMN IF EXISTS job_token_scopes; +ALTER TABLE script DROP COLUMN IF EXISTS job_token_scopes; diff --git a/backend/migrations/20261002124918_job_token_scopes.up.sql b/backend/migrations/20261002124918_job_token_scopes.up.sql new file mode 100644 index 0000000000..d053f60086 --- /dev/null +++ b/backend/migrations/20261002124918_job_token_scopes.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE script ADD COLUMN IF NOT EXISTS job_token_scopes text[]; +ALTER TABLE flow ADD COLUMN IF NOT EXISTS job_token_scopes text[]; +ALTER TABLE v2_job ADD COLUMN IF NOT EXISTS job_token_scopes text[]; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 169eb81681..54bf2719b1 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -97,7 +97,7 @@ draft: workspace_id(char), path(char), typ(draft_type), value(json), created_at( email_to_igroup: email(char), igroup(char) email_trigger: path(char), local_part(char), workspaced_local_part(bool), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) favorite: usr(char), workspace_id(char), path(char), favorite_kind(favorite_kind) -flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[]) +flow: workspace_id(char), path(char), summary(text), description(text), value(jsonb), edited_by(char), edited_at(ts), archived(bool), schema(json), extra_perms(jsonb), dependency_job(uuid), draft_only(bool), tag(char), ws_error_handler_muted(bool), dedicated_worker(bool), timeout(int), visible_to_runner_only(bool), concurrency_key(char), versions(bigint[]), on_behalf_of(varchar), on_behalf_of_email(text), lock_error_logs(text), labels(text[]), job_token_scopes(text[]) FK: (workspace_id) -> workspace(id) flow_conversation: id(uuid), workspace_id(char), flow_path(char), title(char), created_at(ts), updated_at(ts), created_by(char), is_test(bool) FK: (workspace_id) -> workspace(id) @@ -183,7 +183,7 @@ resume_job: id(uuid), job(uuid), flow(uuid), created_at(ts), value(jsonb), appro runnable_settings: hash(bigint), debouncing_settings(bigint), concurrency_settings(bigint) schedule: workspace_id(char), path(char), edited_by(char), edited_at(ts), schedule(char), enabled(bool), script_path(char), args(jsonb), extra_perms(jsonb), is_flow(bool), email(char), error(text), timezone(char), on_failure(char), on_recovery(char), on_failure_times(int), on_failure_exact(bool), on_failure_extra_args(jsonb), on_recovery_times(int), on_recovery_extra_args(jsonb), ws_error_handler_muted(bool), retry(jsonb), summary(char), no_flow_overlap(bool), tag(char), paused_until(ts), on_success(char), on_success_extra_args(jsonb), cron_version(text), description(text), dynamic_skip(char), labels(text[]) FK: (workspace_id) -> workspace(id) -script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint), labels(text[]), on_behalf_of(varchar), on_behalf_of_email(text) +script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint), labels(text[]), on_behalf_of(varchar), on_behalf_of_email(text), job_token_scopes(text[]) FK: (workspace_id) -> workspace(id) skip_workspace_diff_tally: workspace_id(char), added_at(ts) sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), labels(text[]) @@ -205,7 +205,7 @@ usr: workspace_id(char), username(char), email(char), is_admin(bool), created_at FK: (workspace_id) -> workspace(id) usr_to_group: workspace_id(char), group_(char), usr(char) FK: (workspace_id, group_) -> group_(workspace_id, name) | (workspace_id) -> workspace(id) -v2_job: id(uuid), raw_code(text), raw_lock(text), raw_flow(jsonb), tag(char), workspace_id(char), created_at(ts), created_by(char), permissioned_as(char), permissioned_as_email(char), kind(job_kind), runnable_id(bigint), runnable_path(char), parent_job(uuid), root_job(uuid), script_lang(script_lang), script_entrypoint_override(char), flow_step(int), flow_step_id(char), flow_innermost_root_job(uuid), trigger(char), trigger_kind(job_trigger_kind), same_worker(bool), visible_to_owner(bool), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), timeout(int), priority(smallint), preprocessed(bool), args(jsonb), labels(text[]), pre_run_error(text) +v2_job: id(uuid), raw_code(text), raw_lock(text), raw_flow(jsonb), tag(char), workspace_id(char), created_at(ts), created_by(char), permissioned_as(char), permissioned_as_email(char), kind(job_kind), runnable_id(bigint), runnable_path(char), parent_job(uuid), root_job(uuid), script_lang(script_lang), script_entrypoint_override(char), flow_step(int), flow_step_id(char), flow_innermost_root_job(uuid), trigger(char), trigger_kind(job_trigger_kind), same_worker(bool), visible_to_owner(bool), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), timeout(int), priority(smallint), preprocessed(bool), args(jsonb), labels(text[]), pre_run_error(text), job_token_scopes(text[]) v2_job_completed: id(uuid), workspace_id(char), duration_ms(bigint), result(jsonb), deleted(bool), canceled_by(char), canceled_reason(text), flow_status(jsonb), started_at(ts), memory_peak(int), status(job_status), completed_at(ts), worker(char), workflow_as_code_status(jsonb), result_columns(text[]), retries(uuid[]), extras(jsonb) v2_job_debounce_batch: id(uuid), debounce_batch(bigint) v2_job_queue: id(uuid), workspace_id(char), created_at(ts), started_at(ts), scheduled_for(ts), running(bool), canceled_by(char), canceled_reason(text), suspend(int), suspend_until(ts), tag(char), priority(smallint), worker(char), extras(jsonb), cache_ignore_s3_path(bool), runnable_settings_handle(bigint) diff --git a/backend/tests/asset_trigger_dispatch.rs b/backend/tests/asset_trigger_dispatch.rs index c165958a33..2969053185 100644 --- a/backend/tests/asset_trigger_dispatch.rs +++ b/backend/tests/asset_trigger_dispatch.rs @@ -330,6 +330,7 @@ async fn end_to_end_asset_dispatch(db: Pool) -> anyhow::Result<()> { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }; let completed = RunJob::from(job).run_until_complete(&db, false, port).await; assert!( @@ -493,6 +494,7 @@ async fn partition_dynamic_resolved_persisted_and_propagated( concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }; let completed = RunJob::from(job) .arg("tenant_id", json!("acme")) diff --git a/backend/tests/batch_rerun.rs b/backend/tests/batch_rerun.rs index 51a6afa565..58cf27db87 100644 --- a/backend/tests/batch_rerun.rs +++ b/backend/tests/batch_rerun.rs @@ -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, } } diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index 14f8c422a4..50fad28a04 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -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, }) } diff --git a/backend/tests/caller_modules_strip.rs b/backend/tests/caller_modules_strip.rs index e6f96c04ec..a65ca22d1b 100644 --- a/backend/tests/caller_modules_strip.rs +++ b/backend/tests/caller_modules_strip.rs @@ -54,6 +54,7 @@ async fn stored_modules(db: &Pool, payload: JobPayload) -> Option) { concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }; assert_eq!(stored_modules(&db, deployed).await, None); diff --git a/backend/tests/dedicated_workers.rs b/backend/tests/dedicated_workers.rs index 13fb043454..5e9c371f90 100644 --- a/backend/tests/dedicated_workers.rs +++ b/backend/tests/dedicated_workers.rs @@ -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; diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index 59895b7a3a..dad6c86aa3 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -452,6 +452,7 @@ def main(): ws_error_handler_muted: None, labels: None, skip_draft_deletion: None, + job_token_scopes: None, }) .send() .await diff --git a/backend/tests/error_handler.rs b/backend/tests/error_handler.rs index 5d5b7a828b..4f58685221 100644 --- a/backend/tests/error_handler.rs +++ b/backend/tests/error_handler.rs @@ -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) -> 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) -> anyh concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, server.addr.port()) .await; diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index 7ea221c2e8..dc5af327d9 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -2974,6 +2974,7 @@ async fn test_flow_env_marks_sub_flows_only_without_ancestor_env( apply_preprocessor: false, version, labels: None, + job_token_scopes: None, }) .run_until_complete(&db, false, port) .await; diff --git a/backend/tests/job_payload.rs b/backend/tests/job_payload.rs index 1a973f6fe6..0038effc78 100644 --- a/backend/tests/job_payload.rs +++ b/backend/tests/job_payload.rs @@ -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")) diff --git a/backend/tests/job_token_scopes.rs b/backend/tests/job_token_scopes.rs new file mode 100644 index 0000000000..421e1c7401 --- /dev/null +++ b/backend/tests/job_token_scopes.rs @@ -0,0 +1,178 @@ +//! A job token restricted by `job_token_scopes` reaches only what its scopes allow, plus the +//! runtime routes about its own job, and no job it creates holds a wider token than it. + +use reqwest::StatusCode; +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_common::jobs::JobPayload; +use windmill_test_utils::*; + +const OIDC_JOB: &str = "b0000000-0000-0000-0000-000000000001"; +const RUN_JOB: &str = "b0000000-0000-0000-0000-000000000002"; + +async fn insert_job(db: &Pool, id: &str, scopes: &[&str]) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, job_token_scopes) + VALUES ($1, 'test-workspace', 'test-user-3', 'u/test-user-3', 'test3@windmill.dev', + 'script', 'deno', 'u/test-user-3/agent', 'deno', $2)", + ) + .bind(Uuid::parse_str(id)?) + .bind(scopes) + .execute(db) + .await?; + Ok(()) +} + +async fn job_token(db: &Pool, id: &str) -> anyhow::Result { + 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, + 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) -> anyhow::Result<()> { + initialize_tracing().await; + insert_script(&db, "u/test-user-3/open", 515151, None).await?; + insert_script( + &db, + "u/test-user-3/oidc_only", + 525252, + Some(&["oidc:write"]), + ) + .await?; + insert_job(&db, OIDC_JOB, &["oidc:write"]).await?; + insert_job(&db, RUN_JOB, &["jobs:run"]).await?; + + let server = ApiServer::start(db.clone()).await?; + set_jwt_secret().await; + let base = format!( + "http://localhost:{}/api/w/test-workspace", + server.addr.port() + ); + let client = reqwest::Client::new(); + let oidc_token = job_token(&db, OIDC_JOB).await?; + + let refused = [ + client.get(format!("{base}/variables/get_value/u/test-user-3/secret")), + client + .post(format!("{base}/scripts/create")) + .json(&json!({})), + client + .post(format!("{base}/schedules/create")) + .json(&json!({})), + client + .post(format!("{base}/jobs/run/p/u/test-user-3/open")) + .json(&json!({})), + ]; + for request in refused { + let resp = request.bearer_auth(&oidc_token).send().await?; + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "{}", + resp.text().await? + ); + } + let own = client + .get(format!("{base}/jobs_u/get_root_job_id/{OIDC_JOB}")) + .bearer_auth(&oidc_token) + .send() + .await?; + assert_eq!(own.status(), StatusCode::OK, "{}", own.text().await?); + + // A job it starts is capped at its own scopes, intersected with the target's setting. + let run_token = job_token(&db, RUN_JOB).await?; + for (path, expected) in [ + ("open", vec!["jobs:run".to_string()]), + ("oidc_only", vec![]), + ] { + let resp = client + .post(format!("{base}/jobs/run/p/u/test-user-3/{path}")) + .bearer_auth(&run_token) + .json(&json!({})) + .send() + .await?; + assert_eq!(resp.status(), StatusCode::CREATED); + let child = Uuid::parse_str(&resp.text().await?)?; + let scopes: Option> = + sqlx::query_scalar("SELECT job_token_scopes FROM v2_job WHERE id = $1") + .bind(child) + .fetch_one(&db) + .await?; + assert_eq!(scopes, Some(expected), "child of {path}"); + } + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_flow_steps_inherit_the_flow_restriction(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let value = json!({ "modules": [{ "id": "a", "value": { "type": "identity" } }] }); + sqlx::query( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, + schema, extra_perms, versions, job_token_scopes) + VALUES ('test-workspace', 'u/test-user/restricted', '', '', $1, 'test-user', now(), '{}', + '{}', '{7171}', '{oidc:write}')", + ) + .bind(&value) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by) + VALUES (7171, 'test-workspace', 'u/test-user/restricted', $1, '{}', 'test-user')", + ) + .bind(&value) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let flow = RunJob::from(JobPayload::Flow { + path: "u/test-user/restricted".to_string(), + dedicated_worker: None, + apply_preprocessor: false, + version: 7171, + labels: None, + job_token_scopes: Some(vec!["oidc:write".to_string()]), + }) + .run_until_complete(&db, false, server.addr.port()) + .await; + + let step: Option> = + sqlx::query_scalar("SELECT job_token_scopes FROM v2_job WHERE parent_job = $1") + .bind(flow.id) + .fetch_one(&db) + .await?; + assert_eq!(step, Some(vec!["oidc:write".to_string()])); + Ok(()) +} diff --git a/backend/tests/list_jobs.rs b/backend/tests/list_jobs.rs index 29a49c1610..d33618d048 100644 --- a/backend/tests/list_jobs.rs +++ b/backend/tests/list_jobs.rs @@ -957,6 +957,7 @@ async fn test_job_labels_propagated_at_push_time(db: Pool) -> 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) -> 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) -> anyhow::Result<()> { .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: Some(vec!["staging".to_string()]), + job_token_scopes: None, }) .push(&db) .await; diff --git a/backend/tests/nativets_stress.rs b/backend/tests/nativets_stress.rs index 21d3911778..d1e371ef81 100644 --- a/backend/tests/nativets_stress.rs +++ b/backend/tests/nativets_stress.rs @@ -191,6 +191,7 @@ async fn push_job(db: &Pool, content: &str, args: &serde_json::Value) None, None, None, + None, ) .await .expect("push must succeed"); diff --git a/backend/tests/preview_native_tag.rs b/backend/tests/preview_native_tag.rs index eba686396a..39f985cefe 100644 --- a/backend/tests/preview_native_tag.rs +++ b/backend/tests/preview_native_tag.rs @@ -76,6 +76,7 @@ async fn push_preview_and_get_row( None, None, None, + None, ) .await .expect("push must succeed"); diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index 9616df267a..33961e465f 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -1278,6 +1278,7 @@ async fn test_python_wac_v2_with_preprocessor(db: Pool) -> anyhow::Res debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), labels: None, + job_token_scopes: None, }) .arg("who", json!("alice")) .arg("count", json!(7)) diff --git a/backend/tests/restart_at_step_api.rs b/backend/tests/restart_at_step_api.rs index c5ab703f46..2b57de2c64 100644 --- a/backend/tests/restart_at_step_api.rs +++ b/backend/tests/restart_at_step_api.rs @@ -41,6 +41,7 @@ async fn test_api_restart_at_step_nested_happy(db: Pool) -> 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) -> 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) -> 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; diff --git a/backend/tests/restart_flow_version_scope.rs b/backend/tests/restart_flow_version_scope.rs index 82e8f8e4e7..551e8c3619 100644 --- a/backend/tests/restart_flow_version_scope.rs +++ b/backend/tests/restart_flow_version_scope.rs @@ -53,6 +53,7 @@ async fn push_restart( None, None, None, + None, ) .await?; tx.commit().await?; diff --git a/backend/tests/success_handler.rs b/backend/tests/success_handler.rs index fe6127c27e..8fc4182d9e 100644 --- a/backend/tests/success_handler.rs +++ b/backend/tests/success_handler.rs @@ -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; diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index e00ce1172b..5c4354a659 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -5653,6 +5653,7 @@ async fn test_flow_tag_judged_as_written_before_preprocessor( apply_preprocessor: true, version: 1443253234253456, labels: None, + job_token_scopes: None, }) .as_user("test-user-2", "test2@windmill.dev") .run_until_complete(&db, false, port) diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 869f40e14c..abfa054c9b 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -1131,8 +1131,11 @@ pub async fn resolve_opt_job_authed( return Err((err, parts)); } } + let own_job_runtime_route = opt_job_authed.job_id.is_some_and(|job_id| { + crate::scopes::is_own_job_runtime_route(path, method, job_id) + }); let authed = &mut opt_job_authed.authed; - if authed.scopes.is_some() { + if authed.scopes.is_some() && !own_job_runtime_route { transform_old_scope_to_new_scope(authed.scopes.as_mut()); if let Err(err) = crate::scopes::check_scopes_for_route( diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 12ad5f2654..b6f04c65a2 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -35,6 +35,7 @@ use windmill_common::{ }; use scopes::ScopeDefinition; +use windmill_common::scopes::scope_contains; // Re-export key auth types and functions pub use auth::{ @@ -475,11 +476,11 @@ pub fn is_effectively_unscoped(scopes: Option<&[String]>) -> bool { /// refused. An admin check is not a substitute — it answers for the user behind the /// token, not for the token's own scopes. /// -/// This bounds the token making the request, not every route to the key. A job token -/// is minted unscoped from its owner's privileges, so a `jobs:run` token still reaches -/// the key indirectly by running a job as a workspace admin — the same property that -/// lets git-sync export it. Confining that means not inheriting unscoped privilege -/// into job tokens, which is a far wider change than this guard. +/// This bounds the token making the request, not every route to the key. The jobs a +/// scoped user token runs get a token with their owner's privileges, capped only by the +/// runnable's own `job_token_scopes` (see [`caller_scope_ceiling`]), so a `jobs:run` +/// token still reaches the key indirectly by running an unrestricted job as a workspace +/// admin — the same property that lets git-sync export it. pub fn forbid_scoped_token_workspace_key(authed: &ApiAuthed) -> error::Result<()> { if is_effectively_unscoped(authed.scopes.as_deref()) { return Ok(()); @@ -492,6 +493,45 @@ pub fn forbid_scoped_token_workspace_key(authed: &ApiAuthed) -> error::Result<() )) } +/// For a route that authenticates its token itself rather than through the route layer +/// (which checks scopes): a job token restricted by `job_token_scopes` must still hold +/// `required` there. Other credentials keep the access those routes always gave them. +pub fn check_job_token_scope(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>> { + let Some(job_id) = authed.job_id else { + return Ok(None); + }; + let row = sqlx::query_scalar!( + "SELECT job_token_scopes FROM v2_job WHERE id = $1", + job_id + ) + .fetch_optional(db) + .await?; + Ok(match row { + Some(scopes) => scopes, + // The job row is gone (retention), so only the token's own claims are left. + None => authed.scopes.clone(), + }) +} + /// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes /// a credential on behalf of `authed`: the resulting credential must never be /// more privileged than the caller's own token. @@ -604,83 +644,6 @@ fn first_filter_tags(scopes: Option<&[String]>) -> Option> { }) } -/// Whether `caller` grants at least everything `requested` grants (directional -/// containment). -/// -/// This is intentionally NOT `ScopeDefinition::includes`: that method answers -/// "does this scope grant access to a required action" using OR semantics over -/// resources (any overlap counts, and a `*` on either side matches), which is -/// correct for access checks but unsafe for subset checks — it would let a -/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or -/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that -/// EVERY requested resource is covered by SOME caller resource. -fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool { - if caller.domain != requested.domain { - return false; - } - - // write subsumes read; otherwise the action must match exactly. - match (caller.action.as_str(), requested.action.as_str()) { - (c, r) if c == r || (c == "write" && r == "read") => {} - // Apps only: `write` covers `run` (see `ScopeDefinition::includes`), so an - // app-editor token can mint the narrower run-only credential. - ("write", "run") if caller.domain == "apps" => {} - _ => return false, - } - - if caller.domain == "jobs" && caller.action == "run" { - match (&caller.kind, &requested.kind) { - (Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => { - return false - } - // Caller pinned to a kind, but the request covers any kind. - (Some(_), None) => return false, - _ => {} - } - } - - match (&caller.resource, &requested.resource) { - // Caller is unrestricted on resources: covers everything. - (None, _) => true, - // Caller is resource-restricted but the request is not: broader, unless the - // caller lists `*` and so already spans every path. Kept in step with - // `ScopeDefinition::includes`, which accepts that same grant for a - // whole-collection read: what a token may exercise, it may also delegate. - (Some(caller_resources), None) => caller_resources.iter().any(|r| r == "*"), - (Some(caller_resources), Some(requested_resources)) => { - resource_set_contains(caller_resources, requested_resources) - } - } -} - -/// Every resource in `requested` must be covered by some resource in `caller`. -fn resource_set_contains(caller: &[String], requested: &[String]) -> bool { - if caller.iter().any(|r| r == "*") { - return true; - } - requested - .iter() - .all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req))) -} - -/// Directional: does the single caller resource pattern cover `requested`? -/// `caller` may be an exact path or a `/*` 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), diff --git a/backend/windmill-api-auth/src/scopes.rs b/backend/windmill-api-auth/src/scopes.rs index e100cf88c6..d5a7a4c981 100644 --- a/backend/windmill-api-auth/src/scopes.rs +++ b/backend/windmill-api-auth/src/scopes.rs @@ -6,464 +6,9 @@ * LICENSE-AGPL for a copy of the license. */ -use itertools::Itertools; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; use windmill_common::error::{Error, Result}; -/// Comprehensive scope system for JWT token authorization -/// -/// Scopes follow the format: {domain}:{action}[:{resource}] -/// Examples: -/// - "jobs:read" - Read access to jobs -/// - "scripts:write:f/folder/*" - Write access to scripts in a folder -/// - "*" - Full access (superuser) - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScopeDefinition { - pub domain: String, - pub action: String, - pub kind: Option, // For jobs:run:kind (optional) - pub resource: Option>, -} - -impl ScopeDefinition { - pub fn new( - domain: &str, - action: &str, - kind: Option<&str>, - resource: Option>, - ) -> 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 { - let parts: Vec<&str> = scope.split(':').collect(); - - let into_owned_vec = |resources: &str| -> Vec { - let resources = resources - .split(",") - .collect_vec() - .into_iter() - .map(ToOwned::to_owned) - .collect_vec(); - - resources - }; - - match parts.len() { - 2 => Ok(Self::new(parts[0], parts[1], None, None)), // domain:action - 3 => { - if parts[0] == "jobs" && parts[1] == "run" { - Ok(Self::new(parts[0], parts[1], Some(parts[2]), None)) - } else { - Ok(Self::new( - parts[0], - parts[1], - None, - Some(into_owned_vec(parts[2])), - )) - } - } - 4 => { - if parts[0] == "jobs" && parts[1] == "run" { - Ok(Self::new( - parts[0], - parts[1], - Some(parts[2]), - Some(into_owned_vec(parts[3])), - )) - } else { - Err(Error::BadRequest(format!( - "Invalid 4-part scope: {}", - scope - ))) - } - } - _ => Err(Error::BadRequest(format!( - "Invalid scope format: {}", - scope - ))), - } - } - - pub fn as_string(&self) -> String { - match (&self.kind, &self.resource) { - (Some(kind), Some(resource)) => { - format!( - "{}:{}:{}:{}", - self.domain, - self.action, - kind, - resource.join(",") - ) - } - (Some(kind), None) => { - format!("{}:{}:{}", self.domain, self.action, kind) - } - (None, Some(resource)) => { - format!("{}:{}:{}", self.domain, self.action, resource.join(",")) - } - (None, None) => format!("{}:{}", self.domain, self.action), - } - } - - pub fn includes(&self, other: &ScopeDefinition) -> bool { - if self.domain != other.domain { - return false; - } - - match (self.action.as_str(), other.action.as_str()) { - (a, b) if (a == "write" && b == "read") || (a == b) => {} - // Apps only: `write` can rewrite the app and its policy, so it also covers - // running its components. Not general — `jobs:write` must not grant - // `jobs:run`. The resource check below still confines it to the same app. - ("write", "run") if self.domain == "apps" => {} - _ => return false, - } - - if self.domain == "jobs" && self.action == "run" { - match (&self.kind, &other.kind) { - (Some(self_kind), Some(other_kind)) => { - if self_kind != other_kind { - return false; - } - } - (Some(_), None) => { - return false; - } - (None, _) => { - return true; - } - } - } - - match (&self.resource, &other.resource) { - (Some(self_resources), Some(other_resources)) => { - resources_match(self_resources, other_resources) - } - // A requirement naming no path is the whole domain, so only a grant that - // itself spans every path satisfies it. `*` is that grant — the scope UI - // accepts it as a resource path and `resources_match` already reads it as - // everything — while any listed path leaves the collection unauthorized. - (Some(self_resources), None) => self_resources.iter().any(|r| r == "*"), - (None, _) => true, - } - } -} - -fn resources_match(scope_resources: &[String], accepted_resources: &[String]) -> bool { - if scope_resources.contains(&"*".to_string()) || accepted_resources.contains(&"*".to_string()) { - return true; - } - - if scope_resources.len() <= 4 && accepted_resources.len() <= 4 { - return resources_match_small(scope_resources, accepted_resources); - } - - resources_match_large(scope_resources, accepted_resources) -} - -fn resources_match_small(scope_resources: &[String], accepted_resources: &[String]) -> bool { - for required in accepted_resources { - for scope_resource in scope_resources { - if resource_matches_pattern(scope_resource, required) { - return true; - } - } - } - false -} - -fn resources_match_large(scope_resources: &[String], accepted_resources: &[String]) -> bool { - let mut exact_matches = HashSet::new(); - let mut patterns = Vec::new(); - - for scope_resource in scope_resources { - if scope_resource.contains('*') { - patterns.push(scope_resource); - } else { - exact_matches.insert(scope_resource); - } - } - - for accepted_resource in accepted_resources { - if exact_matches.contains(accepted_resource) { - return true; - } - - for pattern in &patterns { - if resource_matches_pattern(pattern, accepted_resource) { - return true; - } - } - } - - false -} - -fn resource_matches_pattern(scope_resource: &str, accepted_resource: &str) -> bool { - if scope_resource == accepted_resource { - return true; - } - - let matches_wildcard = |pattern: &str, resource: &str| -> bool { - if !pattern.ends_with("/*") { - return false; - } - - let prefix = &pattern[..pattern.len() - 2]; - - if !resource.starts_with(prefix) { - return false; - } - - // If the resource is exactly the prefix, it matches - if resource.len() == prefix.len() { - return true; - } - - // If the resource is longer, the next character must be '/' for a valid match - // This prevents "u/user" from matching "u/use/*" - resource.chars().nth(prefix.len()) == Some('/') - }; - - // Check if either resource is a wildcard pattern and matches the other - matches_wildcard(scope_resource, accepted_resource) - || matches_wildcard(accepted_resource, scope_resource) -} - -// ───────────────────────────────────────────────────────────────── -// Route-level scope checking -// ───────────────────────────────────────────────────────────────── - -/// Available scope domains (top-level API categories) -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ScopeDomain { - // Core resource domains - Jobs, - Scripts, - /// The `/data_metrics` catalog. Its own domain, NOT an alias of `Scripts`: a - /// `data_metrics:read` token must reach only this route, never the broader - /// `/scripts` routes (some of which do no further scope check). - DataMetrics, - Flows, - FlowConversations, - Apps, - Variables, - Resources, - Schedules, - Folders, - Users, - Groups, - Workspaces, - - // Trigger domains - HttpTriggers, - WebsocketTriggers, - KafkaTriggers, - NatsTriggers, - MqttTriggers, - AmqpTriggers, - SqsTriggers, - GcpTriggers, - AzureTriggers, - PostgresTriggers, - EmailTriggers, - - // Native trigger domains - NativeTriggers, - TriggersHistory, - - // System domains - Audit, - Settings, - Workers, - ServiceLogs, - Configs, - OAuth, - AI, - AiEvals, // AI agent eval datasets - - Indexer, - Teams, // Microsoft Teams integration - GitSync, // Git synchronization - - // Special domains - Capture, // Webhook capture - Drafts, // Draft resources - Favorites, // User favorites - Inputs, // Input templates - JobHelpers, // Job helper functions - ConcurrencyGroups, // Concurrency groups - Oidc, // OpenID Connect - Openapi, // OpenAPI generation - - // Additional domains - Acls, // Granular access control lists - RawApps, // Raw application data - AgentWorkers, // Agent workers management - Mcp, // MCP - Docs, // Self-hosted documentation search (read-only) -} - -impl ScopeDomain { - pub fn as_str(&self) -> &'static str { - match self { - Self::Jobs => "jobs", - Self::Scripts => "scripts", - Self::DataMetrics => "data_metrics", - Self::Flows => "flows", - Self::FlowConversations => "flow_conversations", - Self::Apps => "apps", - Self::Variables => "variables", - Self::Resources => "resources", - Self::Schedules => "schedules", - Self::Folders => "folders", - Self::Users => "users", - Self::Groups => "groups", - Self::Workspaces => "workspaces", - Self::HttpTriggers => "http_triggers", - Self::WebsocketTriggers => "websocket_triggers", - Self::KafkaTriggers => "kafka_triggers", - Self::NatsTriggers => "nats_triggers", - Self::MqttTriggers => "mqtt_triggers", - Self::AmqpTriggers => "amqp_triggers", - Self::SqsTriggers => "sqs_triggers", - Self::GcpTriggers => "gcp_triggers", - Self::AzureTriggers => "azure_triggers", - Self::PostgresTriggers => "postgres_triggers", - Self::EmailTriggers => "email_triggers", - Self::NativeTriggers => "native_triggers", - Self::TriggersHistory => "triggers_history", - Self::Audit => "audit", - Self::Settings => "settings", - Self::Workers => "workers", - Self::ServiceLogs => "service_logs", - Self::Configs => "configs", - Self::OAuth => "oauth", - Self::AI => "ai", - Self::AiEvals => "ai_evals", - Self::Capture => "capture", - Self::Drafts => "drafts", - Self::Favorites => "favorites", - Self::Inputs => "inputs", - Self::JobHelpers => "job_helpers", - Self::ConcurrencyGroups => "concurrency_groups", - Self::Oidc => "oidc", - Self::Openapi => "openapi", - Self::Acls => "acls", - Self::RawApps => "raw_apps", - Self::AgentWorkers => "agent_workers", - Self::Indexer => "indexer", - Self::Teams => "teams", - Self::GitSync => "git_sync", - Self::Mcp => "mcp", - Self::Docs => "docs", - } - } - - pub fn from_str(s: &str) -> Option { - match s { - "jobs" | "jobs_u" => Some(Self::Jobs), - "scripts" => Some(Self::Scripts), - // A distinct domain, not an alias of `scripts` (see the enum variant): - // a `data_metrics:read` token must not reach the broader /scripts routes. - "data_metrics" => Some(Self::DataMetrics), - "flows" => Some(Self::Flows), - "flow_conversations" => Some(Self::FlowConversations), - "apps" | "apps_u" => Some(Self::Apps), - "variables" => Some(Self::Variables), - "resources" => Some(Self::Resources), - "schedules" => Some(Self::Schedules), - "folders" => Some(Self::Folders), - "users" => Some(Self::Users), - "groups" => Some(Self::Groups), - "workspaces" => Some(Self::Workspaces), - "http_triggers" => Some(Self::HttpTriggers), - "websocket_triggers" => Some(Self::WebsocketTriggers), - "kafka_triggers" => Some(Self::KafkaTriggers), - "nats_triggers" => Some(Self::NatsTriggers), - "mqtt_triggers" => Some(Self::MqttTriggers), - "amqp_triggers" => Some(Self::AmqpTriggers), - "sqs_triggers" => Some(Self::SqsTriggers), - "gcp_triggers" => Some(Self::GcpTriggers), - "azure_triggers" => Some(Self::AzureTriggers), - "postgres_triggers" => Some(Self::PostgresTriggers), - "email_triggers" => Some(Self::EmailTriggers), - "audit" => Some(Self::Audit), - "settings" => Some(Self::Settings), - "workers" => Some(Self::Workers), - "service_logs" => Some(Self::ServiceLogs), - "configs" => Some(Self::Configs), - "oauth" => Some(Self::OAuth), - "ai" => Some(Self::AI), - "ai_evals" => Some(Self::AiEvals), - "indexer" | "srch" => Some(Self::Indexer), - "teams" => Some(Self::Teams), - "native_triggers" => Some(Self::NativeTriggers), - "triggers_history" => Some(Self::TriggersHistory), - "git_sync" | "github_app" => Some(Self::GitSync), - "capture" => Some(Self::Capture), - "drafts" => Some(Self::Drafts), - "favorites" => Some(Self::Favorites), - "inputs" => Some(Self::Inputs), - "job_helpers" => Some(Self::JobHelpers), - "concurrency_groups" => Some(Self::ConcurrencyGroups), - "oidc" => Some(Self::Oidc), - "openapi" => Some(Self::Openapi), - "acls" => Some(Self::Acls), - "raw_apps" => Some(Self::RawApps), - "agent_workers" => Some(Self::AgentWorkers), - "mcp" => Some(Self::Mcp), - "docs" => Some(Self::Docs), - _ => None, - } - } -} - -/// Available scope actions -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ScopeAction { - Read, // GET operations, list, view - Write, // POST, PUT, PATCH, DELETE operations, create, update, delete - Run, // Special action for running (scripts, flows, etc.) -} - -impl ScopeAction { - pub fn as_str(&self) -> &'static str { - match self { - Self::Read => "read", - Self::Write => "write", - Self::Run => "run", - } - } - - pub fn from_str(s: &str) -> Option { - match s { - "read" => Some(Self::Read), - "write" => Some(Self::Write), - "delete" => Some(Self::Write), - "run" => Some(Self::Run), - _ => None, - } - } - - /// Check if this action includes another action - /// Write includes Read - pub fn includes(&self, other: &ScopeAction) -> bool { - match (self, other) { - (ScopeAction::Write, ScopeAction::Read) => true, - (ScopeAction::Run, ScopeAction::Read) => true, - (a, b) => a == b, - } - } -} +pub use windmill_common::scopes::{ScopeAction, ScopeDefinition, ScopeDomain}; pub fn check_route_access( token_scopes: &[String], @@ -1203,6 +748,37 @@ pub fn scope_for_route(method: &str, path: &str) -> Option { } /// Helper function to check if scopes allow access to a route +/// Routes the runtime of a job calls about that job alone: progress, its root id, its +/// resume and approval urls, a workflow-as-code checkpoint or task, and the results its own +/// flow's input transforms read. A job token restricted by `job_token_scopes` keeps these +/// whatever its scopes, or the job could not run at all, but only for its own job: the job +/// id in the path must be the token's. +pub fn is_own_job_runtime_route(route_path: &str, http_method: &str, job_id: uuid::Uuid) -> bool { + let Some(rest) = route_path.strip_prefix("/api/w/") else { + return false; + }; + let Some((_workspace, rest)) = rest.split_once('/') else { + return false; + }; + let segments: Vec<&str> = rest.split('/').collect(); + let (method_ok, id_index) = match segments.as_slice() { + ["jobs_u", "get" | "get_root_job_id", ..] => (http_method == "GET", 2), + ["job_metrics", "set_progress", ..] => (http_method == "POST", 2), + ["job_metrics", "get_progress", ..] => (http_method == "GET", 2), + ["jobs" | "jobs_u", "resume_urls" | "wac_approval_urls" | "result_by_id", ..] => { + (http_method == "GET", 2) + } + ["jobs", "wac", "inline_checkpoint", ..] => (http_method == "POST", 3), + ["jobs", "run", "workflow_as_code", ..] => (http_method == "POST", 3), + _ => return false, + }; + method_ok + && segments + .get(id_index) + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .is_some_and(|id| id == job_id) +} + pub fn check_scopes_for_route( token_scopes: Option<&[String]>, route_path: &str, @@ -1221,6 +797,37 @@ pub fn check_scopes_for_route( mod tests { use super::*; + #[test] + fn own_job_runtime_routes_admit_only_the_tokens_job() { + let own = uuid::Uuid::new_v4(); + let other = uuid::Uuid::new_v4(); + let ok = |path: &str, method: &str| is_own_job_runtime_route(path, method, own); + assert!(ok( + &format!("/api/w/ws/job_metrics/set_progress/{own}"), + "POST" + )); + assert!(ok( + &format!("/api/w/ws/jobs_u/get_root_job_id/{own}"), + "GET" + )); + assert!(ok(&format!("/api/w/ws/jobs/resume_urls/{own}/0"), "GET")); + assert!(ok( + &format!("/api/w/ws/jobs/wac/inline_checkpoint/{own}"), + "POST" + )); + assert!(ok( + &format!("/api/w/ws/jobs/run/workflow_as_code/{own}/main"), + "POST" + )); + assert!(!ok( + &format!("/api/w/ws/job_metrics/set_progress/{other}"), + "POST" + )); + assert!(!ok(&format!("/api/w/ws/jobs_u/get/{own}"), "POST")); + assert!(!ok(&format!("/api/w/ws/jobs/run/p/{own}"), "POST")); + assert!(!ok("/api/w/ws/variables/get_value/u/admin/secret", "GET")); + } + #[test] fn test_scope_definition_parsing() { let scope = ScopeDefinition::from_scope_string("jobs:read").unwrap(); diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index aa86a7ff07..548566108a 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -803,6 +803,12 @@ async fn create_flow( check_schedule_conflict(&mut tx, &w_id, &nf.path).await?; let schema_str = nf.schema.and_then(|x| serde_json::to_string(&x.0).ok()); + let job_token_scopes = nf + .job_token_scopes + .as_ref() + .and_then(|scopes| scopes.as_deref()) + .map(windmill_common::scopes::validate_job_token_scopes) + .transpose()?; let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( nf.on_behalf_of_email.as_deref(), nf.on_behalf_of.as_deref(), @@ -823,14 +829,14 @@ async fn create_flow( dedicated_worker, visible_to_runner_only, ws_error_handler_muted, value, schema, edited_by, edited_at, labels, - on_behalf_of, on_behalf_of_email + on_behalf_of, on_behalf_of_email, job_token_scopes ) VALUES ( $1, $2, $3, $4, NULL, '', $5, $6, $7, $8, $9, $10::text::json, $11, now(), $12, - $13, $14 + $13, $14, $15 )"#, w_id, nf.path, @@ -846,9 +852,11 @@ async fn create_flow( nf.labels.as_deref() as Option<&[String]>, resolved_on_behalf_of, legacy_on_behalf_of_email, + job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await?; + windmill_common::scopes::log_job_token_scopes_deploy("flow", job_token_scopes.as_deref()); let version = sqlx::query_scalar!( "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) @@ -973,6 +981,7 @@ async fn create_flow( None, None, None, + None, ) .await?; @@ -1152,7 +1161,7 @@ async fn get_flow_version( let mut tx = user_db.begin(&authed).await?; let flow = sqlx::query_as::<_, Flow>( - "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by + "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, flow.job_token_scopes, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3", @@ -1212,6 +1221,7 @@ async fn get_flow_version_by_id( flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, + flow.job_token_scopes, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, @@ -1367,6 +1377,15 @@ async fn update_flow( let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?; let is_new_path = nf.path != flow_path; let schema_str = schema.and_then(|x| serde_json::to_string(&x).ok()); + // Absent keeps the deployed value: a client unaware of the setting must not drop a + // restriction by saving the flow. + let set_job_token_scopes = nf.job_token_scopes.is_some(); + let job_token_scopes = nf + .job_token_scopes + .as_ref() + .and_then(|scopes| scopes.as_deref()) + .map(windmill_common::scopes::validate_job_token_scopes) + .transpose()?; let resolved_on_behalf_of = windmill_common::resolve_on_behalf_of( nf.on_behalf_of_email.as_deref(), nf.on_behalf_of.as_deref(), @@ -1401,7 +1420,8 @@ async fn update_flow( edited_at = now(), labels = COALESCE($13, labels), on_behalf_of = $14, - on_behalf_of_email = $15 + on_behalf_of_email = $15, + job_token_scopes = CASE WHEN $16 THEN $17 ELSE job_token_scopes END WHERE path = $11 AND workspace_id = $12", if is_new_path { flow_path } else { &nf.path }, @@ -1419,19 +1439,24 @@ async fn update_flow( nf.labels.as_deref() as Option<&[String]>, resolved_on_behalf_of, legacy_on_behalf_of_email, + set_job_token_scopes, + job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await .map_err(|e| { error::Error::internal_err(format!("Error updating flow due to flow update: {e:#}")) })?; + if set_job_token_scopes { + windmill_common::scopes::log_job_token_scopes_deploy("flow", job_token_scopes.as_deref()); + } if is_new_path { // if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint sqlx::query!( "INSERT INTO flow - (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) - SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels + (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes) + SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels, job_token_scopes FROM flow WHERE path = $2 AND workspace_id = $3", nf.path, @@ -1695,6 +1720,7 @@ async fn update_flow( None, None, None, + None, ) .await?; @@ -1870,6 +1896,7 @@ async fn get_flow_by_path( flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, + flow.job_token_scopes, folder_labels(flow.workspace_id, flow.path) AS inherited_labels, flow_version.id AS version_id, flow_version.schema, @@ -1911,6 +1938,7 @@ async fn get_flow_by_path( flow.visible_to_runner_only, flow.on_behalf_of, flow.labels, + flow.job_token_scopes, folder_labels(flow.workspace_id, flow.path) AS inherited_labels, flow_version.id AS version_id, flow_version.schema, diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 1a750c38ba..688144eaeb 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -865,6 +865,7 @@ pub async fn run_flow<'c>( chat_input_enabled, early_return, labels, + job_token_scopes, .. } = flow_version_info; @@ -914,6 +915,7 @@ pub async fn run_flow<'c>( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, mut tx) = push( &db, tx, @@ -924,6 +926,7 @@ pub async fn run_flow<'c>( version, apply_preprocessor, labels, + job_token_scopes, }, push_args, authed.display_username(), @@ -950,6 +953,7 @@ pub async fn run_flow<'c>( None, authed.trigger_or_fallback(trigger), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; @@ -1141,6 +1145,7 @@ pub async fn push_script_job_by_path_into_queue<'c>( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -1176,6 +1181,7 @@ pub async fn push_script_job_by_path_into_queue<'c>( None, authed.trigger_or_fallback(trigger), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index bce67755bb..6f7ab36fe6 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -897,6 +897,7 @@ async fn is_noop_deploy_against_parent( ns: &NewScript, parent: &Script, resolved_on_behalf_of: Option<&str>, + resolved_job_token_scopes: Option<&[String]>, db: &DB, ) -> Result { if parent.archived || parent.deleted { @@ -950,6 +951,8 @@ async fn is_noop_deploy_against_parent( // caller-intent flag (auto-resolve parent), not script state auto_parent: _, labels, + // resolved against the deployed value into `resolved_job_token_scopes`, compared below + job_token_scopes: _, // caller-intent flag (preserve user drafts on CLI/git-sync deploys); // transient, never persisted, does not change what the script *is* skip_draft_deletion: _, @@ -1023,6 +1026,9 @@ async fn is_noop_deploy_against_parent( if resolved_on_behalf_of != parent.on_behalf_of.as_deref() { return Ok(false); } + if resolved_job_token_scopes != parent.job_token_scopes.as_deref() { + return Ok(false); + } // Both of a dbt script's derived fields are compared as they WOULD BE STORED, // not as they arrived: the schema comes from the descriptor and the clients // cannot derive it (`windmill-parser-wasm` has no dbt arm), so they send the @@ -1373,6 +1379,21 @@ async fn create_script_internal<'c>( &db, ) .await?; + // Absent keeps what the path already has: a client unaware of the setting must not drop a + // restriction by redeploying. + let resolved_job_token_scopes = match &ns.job_token_scopes { + Some(Some(scopes)) => Some(windmill_common::scopes::validate_job_token_scopes(scopes)?), + Some(None) => None, + None => sqlx::query_scalar!( + "SELECT job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 \ + AND deleted = false ORDER BY created_at DESC LIMIT 1", + &ns.path, + &w_id + ) + .fetch_optional(&db) + .await? + .flatten(), + }; // Written beside the principal only while a worker that still reads it may be live. let legacy_on_behalf_of_email = windmill_common::legacy_on_behalf_of_email(resolved_on_behalf_of.as_deref(), &w_id, &db) @@ -1618,8 +1639,14 @@ async fn create_script_internal<'c>( // CLI pushes must not produce phantom commits on the downstream // git repository. if skip_if_noop - && is_noop_deploy_against_parent(&ns, &ps, resolved_on_behalf_of.as_deref(), &db) - .await? + && is_noop_deploy_against_parent( + &ns, + &ps, + resolved_on_behalf_of.as_deref(), + resolved_job_token_scopes.as_deref(), + &db, + ) + .await? { tracing::info!( workspace_id = %w_id, @@ -2195,8 +2222,8 @@ async fn create_script_internal<'c>( content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ - delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41)", + delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules, labels, on_behalf_of, on_behalf_of_email, job_token_scopes) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42)", &w_id, &hash.0, ns.path, @@ -2240,9 +2267,14 @@ async fn create_script_internal<'c>( ns.labels.as_deref() as Option<&[String]>, resolved_on_behalf_of, legacy_on_behalf_of_email, + resolved_job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await?; + windmill_common::scopes::log_job_token_scopes_deploy( + "script", + resolved_job_token_scopes.as_deref(), + ); // A lock that is not left to a dependency job queues none, so this is the only place its hash // can be recorded. `try_skip_relock` treats a missing hash for an imported script as changed, @@ -2952,6 +2984,7 @@ async fn create_script_internal<'c>( None, None, None, + None, ) .await?; @@ -3075,6 +3108,7 @@ async fn create_script_internal<'c>( None, None, None, + None, ) .await?; tracing::info!("pushed auto-build binary job {job_id} for {script_path}"); diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index f762fda175..988c430b20 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -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?; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 81fead981b..ac838d772b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -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, diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 8ca98f66d1..7e2958bb58 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -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 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d8755f8995..1dd2cd773f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/agent_runs.rs b/backend/windmill-api/src/agent_runs.rs index d2e76dcca0..b844e8c2bf 100644 --- a/backend/windmill-api/src/agent_runs.rs +++ b/backend/windmill-api/src/agent_runs.rs @@ -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?; diff --git a/backend/windmill-api/src/ai_evals/run.rs b/backend/windmill-api/src/ai_evals/run.rs index 42c2fcb2f2..57e75b3f9f 100644 --- a/backend/windmill-api/src/ai_evals/run.rs +++ b/backend/windmill-api/src/ai_evals/run.rs @@ -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?; diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index f01275c953..3ccaec96de 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -2755,6 +2755,7 @@ async fn create_app_internal<'a>( None, None, None, + None, ) .await?; tracing::info!("Pushed app dependency job {}", dependency_job_uuid); @@ -3896,6 +3897,7 @@ async fn update_app_internal<'a>( None, None, None, + None, ) .await?; tracing::info!("Pushed app dependency job {}", dependency_job_uuid); @@ -4490,6 +4492,10 @@ async fn execute_component( let app_trigger = (!is_preview).then(|| TriggerMetadata::new(Some(path.to_string()), JobTriggerKind::App)); + let scope_ceiling = match opt_authed.as_ref() { + Some(authed) => windmill_api_auth::caller_scope_ceiling(&db, authed).await?, + None => None, + }; let (uuid, mut tx) = push( &db, tx, @@ -4525,6 +4531,7 @@ async fn execute_component( end_user_email, app_trigger, None, + scope_ceiling.as_deref(), ) .await?; diff --git a/backend/windmill-api/src/apps_raw_bundle.rs b/backend/windmill-api/src/apps_raw_bundle.rs index f50f94f8eb..61dc4b861d 100644 --- a/backend/windmill-api/src/apps_raw_bundle.rs +++ b/backend/windmill-api/src/apps_raw_bundle.rs @@ -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?; diff --git a/backend/windmill-api/src/concurrency_groups.rs b/backend/windmill-api/src/concurrency_groups.rs index c94608ac7e..aab73c81d9 100644 --- a/backend/windmill-api/src/concurrency_groups.rs +++ b/backend/windmill-api/src/concurrency_groups.rs @@ -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, diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 785c6ff935..1bf89455a9 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -7193,6 +7193,7 @@ pub async fn restart_flow( let tx = PushIsolationLevel::Isolated(user_db, authed.clone().into()); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -7230,6 +7231,7 @@ pub async fn restart_flow( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -7367,7 +7369,11 @@ pub async fn run_workflow_as_code( extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint)); let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) }; check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &args, &authed).await?; - check_scopes(&authed, || format!("jobs:run"))?; + // A job running its own task is its runtime, not a new run: a restricted job token keeps + // it (`is_own_job_runtime_route`), and the task inherits that job's restriction at push. + if authed.job_id != Some(job_id) { + check_scopes(&authed, || format!("jobs:run"))?; + } // The task becomes a child of `job_id`, runs its code and writes into its flow status, so // only that job itself (the SDK's `task` wrapper, on its `WM_TOKEN`) or an admin may push it. if authed.job_id != Some(job_id) && !authed.is_admin { @@ -7512,6 +7518,7 @@ pub async fn run_workflow_as_code( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, mut tx) = push( &db, tx, @@ -7542,6 +7549,7 @@ pub async fn run_workflow_as_code( None, None, None, + scope_ceiling.as_deref(), ) .await?; @@ -7825,6 +7833,7 @@ pub async fn run_wait_result_job_by_path_get( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -7855,6 +7864,7 @@ pub async fn run_wait_result_job_by_path_get( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -7973,6 +7983,7 @@ pub async fn run_wait_result_script_by_path_internal( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -8003,6 +8014,7 @@ pub async fn run_wait_result_script_by_path_internal( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -8055,6 +8067,7 @@ pub async fn run_wait_result_script_by_hash( timeout, has_preprocessor, labels, + job_token_scopes, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings }, .. @@ -8088,6 +8101,7 @@ pub async fn run_wait_result_script_by_hash( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -8105,6 +8119,7 @@ pub async fn run_wait_result_script_by_hash( apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false), labels, + job_token_scopes, }, push_args, authed.display_username(), @@ -8131,6 +8146,7 @@ pub async fn run_wait_result_script_by_hash( None, authed.trigger_or_fallback(None), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -8650,6 +8666,7 @@ async fn run_preview_script( let push_args = PushArgs { extra, args: &preview_args }; check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -8700,6 +8717,7 @@ async fn run_preview_script( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -8847,6 +8865,36 @@ async fn run_inline_script_inner( args: Option>>, user_db: Option, ) -> error::Result { + // An inline run executes with the caller's own token, so it cannot honour a script's + // `job_token_scopes`: such a script only runs as a job. + let restricted = match &target { + InlineScriptTarget::Path(path) => { + sqlx::query_scalar!( + "SELECT job_token_scopes IS NOT NULL FROM script WHERE path = $1 AND workspace_id = $2 \ + AND archived = false AND deleted = false ORDER BY created_at DESC LIMIT 1", + path, + &w_id + ) + .fetch_optional(&db) + .await? + } + InlineScriptTarget::Hash(hash) => { + sqlx::query_scalar!( + "SELECT job_token_scopes IS NOT NULL FROM script WHERE hash = $1 AND workspace_id = $2", + hash, + &w_id + ) + .fetch_optional(&db) + .await? + } + }; + if restricted.flatten().unwrap_or(false) { + return Err(Error::BadRequest( + "This script restricts its job token (job_token_scopes) and cannot be run inline; \ + run it as a job instead" + .to_string(), + )); + } let utils = get_worker_internal_server_inline_utils()?; let authed_owned: windmill_common::db::Authed = authed.clone().into(); let result = utils.run_inline_script.as_ref()(RunInlineScriptFnParams { @@ -9033,6 +9081,7 @@ async fn run_bundle_preview_script( // tracing::info!("is_tar 1: {is_tar}"); // hmap.insert("") + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, ntx) = push( &db, ltx, @@ -9079,6 +9128,7 @@ async fn run_bundle_preview_script( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; job_id = Some(uuid); @@ -9198,6 +9248,7 @@ async fn push_dependencies_job( req.temp_script_refs .map(|v| hm.insert("temp_script_refs".to_owned(), to_raw_value(&v))); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, tx) = push( db, PushIsolationLevel::IsolatedRoot(db.clone()), @@ -9232,6 +9283,7 @@ async fn push_dependencies_job( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -9330,6 +9382,7 @@ async fn push_flow_dependencies_job( req.temp_script_refs .map(|v| args_map.insert("temp_script_refs".to_string(), to_raw_value(&v))); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(db, authed).await?; let (uuid, tx) = push( db, PushIsolationLevel::IsolatedRoot(db.clone()), @@ -9360,6 +9413,7 @@ async fn push_flow_dependencies_job( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -9414,6 +9468,7 @@ async fn add_batch_jobs( ) -> error::JsonResult> { require_super_admin(&db, &authed).await?; + let mut job_token_scopes: Option> = None; let ( hash, path, @@ -9445,11 +9500,13 @@ async fn add_batch_jobs( dedicated_worker, timeout, runnable_settings, + job_token_scopes: script_job_token_scopes, .. // TODO: consider on_behalf_of_email and created_by for batch jobs } = get_latest_deployed_hash_for_path(Some(db_authed), db.clone(), &w_id, &path) .await? .prefetch_cached(&db) .await?; + job_token_scopes = script_job_token_scopes; ( Some(script_hash), Some(path), @@ -9496,7 +9553,7 @@ async fn add_batch_jobs( } else if let Some(path) = batch_info.path { let mut tx = user_db.clone().begin(&authed).await?; let value_json = sqlx::query!( - "SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json>\" FROM flow + "SELECT coalesce(flow_version_lite.value, flow_version.value) as \"value!: sqlx::types::Json>\", flow.job_token_scopes FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] LEFT JOIN flow_version_lite @@ -9507,6 +9564,7 @@ async fn add_batch_jobs( .fetch_optional(&mut *tx) .await? .ok_or_else(|| Error::internal_err(format!("not found flow at path {:?}", path)))?; + job_token_scopes = value_json.job_token_scopes; let value = serde_json::from_str::(value_json.value.get()).map_err(|err| { Error::internal_err(format!( @@ -9582,9 +9640,9 @@ async fn add_batch_jobs( INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, runnable_id, runnable_path, kind, script_lang, created_by, permissioned_as, permissioned_as_email, concurrent_limit, - concurrency_time_window_s, timeout, args) + concurrency_time_window_s, timeout, args, job_token_scopes) (SELECT uuid, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - ('{ "uuid": "' || uuid || '" }')::jsonb FROM uuid_table) + ('{ "uuid": "' || uuid || '" }')::jsonb, $17 FROM uuid_table) RETURNING id AS "id!""#, w_id, raw_code, @@ -9602,6 +9660,7 @@ async fn add_batch_jobs( concurrency_time_window_s, timeout, n, + job_token_scopes.as_deref() as Option<&[String]>, ) .fetch_all(&mut *tx) .await?; @@ -9732,6 +9791,7 @@ async fn run_preview_flow_job( check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&flow_args), &authed) .await?; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, mut tx) = push( &db, tx, @@ -9766,6 +9826,7 @@ async fn run_preview_flow_job( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; @@ -9989,6 +10050,7 @@ async fn run_dynamic_select( let scheduled_for = run_query.get_scheduled_for(&db).await?; let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -10035,6 +10097,7 @@ async fn run_dynamic_select( None, authed.trigger_or_fallback(None), None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -10110,6 +10173,7 @@ pub async fn run_job_by_hash_inner( delete_after_use, delete_after_secs, labels, + job_token_scopes, .. } = script_info; @@ -10142,6 +10206,7 @@ pub async fn run_job_by_hash_inner( ) }; + let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?; let (uuid, tx) = push( &db, tx, @@ -10159,6 +10224,7 @@ pub async fn run_job_by_hash_inner( apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false), labels, + job_token_scopes, }, push_args, authed.display_username(), @@ -10185,6 +10251,7 @@ pub async fn run_job_by_hash_inner( None, authed.trigger_or_fallback(trigger), run_query.suspended_mode, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 472c7bbec9..8f3962da13 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -372,7 +372,8 @@ impl McpBackend for WindmillBackend { token: &str, workspace_id: &str, ) -> BackendResult { - 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 { diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index 26eb06c8e8..b7f83dfd96 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -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 diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index a7f9aeda89..39a213c860 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -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, diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index ea79aaa572..3c3e965248 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -119,6 +119,8 @@ struct ScriptMetadata { pub debouncing_settings: DebouncingSettings, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_token_scopes: Option>, #[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", diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 86b7f173ac..9b1705d88c 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -362,6 +362,9 @@ pub struct JobPerms { pub groups: Vec, pub folders: Vec, pub end_user_email: Option, + /// The job's effective `v2_job.job_token_scopes`, which its token is minted with. + #[serde(default)] + pub job_token_scopes: Option>, } impl From for Authed { @@ -654,7 +657,10 @@ pub async fn get_job_perms<'a, E: sqlx::PgExecutor<'a>>( ) -> sqlx::Result> { sqlx::query_as!( JobPerms, - "SELECT email, username, is_admin, is_operator, groups, folders, end_user_email FROM job_perms WHERE job_id = $1 AND workspace_id = $2", + "SELECT p.email, p.username, p.is_admin, p.is_operator, p.groups, p.folders, p.end_user_email, + j.job_token_scopes + FROM job_perms p LEFT JOIN v2_job j ON j.id = p.job_id + WHERE p.job_id = $1 AND p.workspace_id = $2", job_id, w_id ) @@ -745,17 +751,29 @@ pub async fn create_token_for_owner( } else { get_job_perms(db, job_id, w_id).await }; - let job_authed = match job_perms { - Ok(Some(jp)) => jp.into(), + let (job_authed, job_token_scopes) = match job_perms { + Ok(Some(mut jp)) => { + let scopes = jp.job_token_scopes.take(); + (jp.into(), scopes) + } _ => { tracing::warn!("Could not get permissions for job {job_id} from job_perms table, getting permissions directly..."); - fetch_authed_from_permissioned_as(owner, email, w_id, db) + let authed = fetch_authed_from_permissioned_as(owner, email, w_id, db) .await .map_err(|e| { Error::internal_err(format!( "Could not get permissions directly for job {job_id}: {e:#}" )) - })? + })?; + let scopes = sqlx::query_scalar!( + "SELECT job_token_scopes FROM v2_job WHERE id = $1 AND workspace_id = $2", + job_id, + w_id + ) + .fetch_optional(db) + .await? + .flatten(); + (authed, scopes) } }; @@ -766,7 +784,7 @@ pub async fn create_token_for_owner( Some(*job_id), Some(label.to_string()), audit_span, - None, + crate::scopes::job_token_jwt_scopes(job_token_scopes), ) .await } diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 796d1c999a..04252edff6 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -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, diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index c5c9bde0d3..f5656af5b7 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -126,6 +126,7 @@ pub mod runnable_settings; pub mod runnables; pub mod schedule; pub mod schema; +pub mod scopes; pub mod scripts; pub mod secret_backend; pub mod sensitive_log_masks; @@ -2229,6 +2230,7 @@ pub struct ScriptHashInfo { pub on_behalf_of: Option, pub created_by: String, pub labels: Option>, + pub job_token_scopes: Option>, #[sqlx(flatten)] pub runnable_settings: SR, } @@ -2322,6 +2324,7 @@ impl ScriptHashInfo { on_behalf_of: self.on_behalf_of, created_by: self.created_by, labels: self.labels, + job_token_scopes: self.job_token_scopes, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings: concurrency_settings.maybe_fallback( self.runnable_settings.concurrency_key, @@ -2686,6 +2689,7 @@ async fn get_script_info_for_hash_inner<'e, E: sqlx::PgExecutor<'e>>( on_behalf_of, created_by, labels, + job_token_scopes, path FROM script WHERE hash = $1 AND workspace_id = $2", ) @@ -2707,6 +2711,7 @@ pub struct FlowVersionInfo { pub edited_by: String, pub dedicated_worker: Option, pub labels: Option>, + pub job_token_scopes: Option>, } impl FlowVersionInfo { @@ -2850,7 +2855,8 @@ pub fn get_flow_version_info_from_version< flow.dedicated_worker, flow.on_behalf_of, flow.edited_by, - flow.labels + flow.labels, + flow.job_token_scopes FROM flow_version INNER JOIN flow @@ -2985,9 +2991,10 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>( Option, Option, Option>, + Option>, )> { let r_o = sqlx::query!( - "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels FROM script + "select hash, tag, concurrency_key, concurrent_limit, concurrency_time_window_s, debounce_key, debounce_delay_s, cache_ttl, cache_ignore_s3_path, runnable_settings_handle, language as \"language: ScriptLang\", dedicated_worker, priority, timeout, on_behalf_of, created_by, labels, job_token_scopes FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false AND (lock IS NOT NULL OR $3 = false) ORDER BY created_at DESC LIMIT 1", script_path, @@ -3019,6 +3026,7 @@ pub async fn get_latest_hash_for_path<'c, E: sqlx::PgExecutor<'c>>( on_behalf_of, script.runnable_settings_handle, script.labels, + script.job_token_scopes, )) } diff --git a/backend/windmill-common/src/scopes.rs b/backend/windmill-common/src/scopes.rs new file mode 100644 index 0000000000..4ce3367704 --- /dev/null +++ b/backend/windmill-common/src/scopes.rs @@ -0,0 +1,706 @@ +/* + * Author: Windmill Labs, Inc + * Copyright: Windmill Labs, Inc 2024 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +use crate::error::{Error, Result}; + +/// Comprehensive scope system for JWT token authorization +/// +/// Scopes follow the format: {domain}:{action}[:{resource}] +/// Examples: +/// - "jobs:read" - Read access to jobs +/// - "scripts:write:f/folder/*" - Write access to scripts in a folder +/// - "*" - Full access (superuser) + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScopeDefinition { + pub domain: String, + pub action: String, + pub kind: Option, // For jobs:run:kind (optional) + pub resource: Option>, +} + +impl ScopeDefinition { + pub fn new( + domain: &str, + action: &str, + kind: Option<&str>, + resource: Option>, + ) -> 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 { + let parts: Vec<&str> = scope.split(':').collect(); + + let into_owned_vec = |resources: &str| -> Vec { + let resources = resources + .split(",") + .collect_vec() + .into_iter() + .map(ToOwned::to_owned) + .collect_vec(); + + resources + }; + + match parts.len() { + 2 => Ok(Self::new(parts[0], parts[1], None, None)), // domain:action + 3 => { + if parts[0] == "jobs" && parts[1] == "run" { + Ok(Self::new(parts[0], parts[1], Some(parts[2]), None)) + } else { + Ok(Self::new( + parts[0], + parts[1], + None, + Some(into_owned_vec(parts[2])), + )) + } + } + 4 => { + if parts[0] == "jobs" && parts[1] == "run" { + Ok(Self::new( + parts[0], + parts[1], + Some(parts[2]), + Some(into_owned_vec(parts[3])), + )) + } else { + Err(Error::BadRequest(format!( + "Invalid 4-part scope: {}", + scope + ))) + } + } + _ => Err(Error::BadRequest(format!( + "Invalid scope format: {}", + scope + ))), + } + } + + pub fn as_string(&self) -> String { + match (&self.kind, &self.resource) { + (Some(kind), Some(resource)) => { + format!( + "{}:{}:{}:{}", + self.domain, + self.action, + kind, + resource.join(",") + ) + } + (Some(kind), None) => { + format!("{}:{}:{}", self.domain, self.action, kind) + } + (None, Some(resource)) => { + format!("{}:{}:{}", self.domain, self.action, resource.join(",")) + } + (None, None) => format!("{}:{}", self.domain, self.action), + } + } + + pub fn includes(&self, other: &ScopeDefinition) -> bool { + if self.domain != other.domain { + return false; + } + + match (self.action.as_str(), other.action.as_str()) { + (a, b) if (a == "write" && b == "read") || (a == b) => {} + // Apps only: `write` can rewrite the app and its policy, so it also covers + // running its components. Not general — `jobs:write` must not grant + // `jobs:run`. The resource check below still confines it to the same app. + ("write", "run") if self.domain == "apps" => {} + _ => return false, + } + + if self.domain == "jobs" && self.action == "run" { + match (&self.kind, &other.kind) { + (Some(self_kind), Some(other_kind)) => { + if self_kind != other_kind { + return false; + } + } + (Some(_), None) => { + return false; + } + (None, _) => { + return true; + } + } + } + + match (&self.resource, &other.resource) { + (Some(self_resources), Some(other_resources)) => { + resources_match(self_resources, other_resources) + } + // A requirement naming no path is the whole domain, so only a grant that + // itself spans every path satisfies it. `*` is that grant — the scope UI + // accepts it as a resource path and `resources_match` already reads it as + // everything — while any listed path leaves the collection unauthorized. + (Some(self_resources), None) => self_resources.iter().any(|r| r == "*"), + (None, _) => true, + } + } +} + +fn resources_match(scope_resources: &[String], accepted_resources: &[String]) -> bool { + if scope_resources.contains(&"*".to_string()) || accepted_resources.contains(&"*".to_string()) { + return true; + } + + if scope_resources.len() <= 4 && accepted_resources.len() <= 4 { + return resources_match_small(scope_resources, accepted_resources); + } + + resources_match_large(scope_resources, accepted_resources) +} + +fn resources_match_small(scope_resources: &[String], accepted_resources: &[String]) -> bool { + for required in accepted_resources { + for scope_resource in scope_resources { + if resource_matches_pattern(scope_resource, required) { + return true; + } + } + } + false +} + +fn resources_match_large(scope_resources: &[String], accepted_resources: &[String]) -> bool { + let mut exact_matches = HashSet::new(); + let mut patterns = Vec::new(); + + for scope_resource in scope_resources { + if scope_resource.contains('*') { + patterns.push(scope_resource); + } else { + exact_matches.insert(scope_resource); + } + } + + for accepted_resource in accepted_resources { + if exact_matches.contains(accepted_resource) { + return true; + } + + for pattern in &patterns { + if resource_matches_pattern(pattern, accepted_resource) { + return true; + } + } + } + + false +} + +fn resource_matches_pattern(scope_resource: &str, accepted_resource: &str) -> bool { + if scope_resource == accepted_resource { + return true; + } + + let matches_wildcard = |pattern: &str, resource: &str| -> bool { + if !pattern.ends_with("/*") { + return false; + } + + let prefix = &pattern[..pattern.len() - 2]; + + if !resource.starts_with(prefix) { + return false; + } + + // If the resource is exactly the prefix, it matches + if resource.len() == prefix.len() { + return true; + } + + // If the resource is longer, the next character must be '/' for a valid match + // This prevents "u/user" from matching "u/use/*" + resource.chars().nth(prefix.len()) == Some('/') + }; + + // Check if either resource is a wildcard pattern and matches the other + matches_wildcard(scope_resource, accepted_resource) + || matches_wildcard(accepted_resource, scope_resource) +} + +// ───────────────────────────────────────────────────────────────── +// Route-level scope checking +// ───────────────────────────────────────────────────────────────── + +/// Available scope domains (top-level API categories) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScopeDomain { + // Core resource domains + Jobs, + Scripts, + /// The `/data_metrics` catalog. Its own domain, NOT an alias of `Scripts`: a + /// `data_metrics:read` token must reach only this route, never the broader + /// `/scripts` routes (some of which do no further scope check). + DataMetrics, + Flows, + FlowConversations, + Apps, + Variables, + Resources, + Schedules, + Folders, + Users, + Groups, + Workspaces, + + // Trigger domains + HttpTriggers, + WebsocketTriggers, + KafkaTriggers, + NatsTriggers, + MqttTriggers, + AmqpTriggers, + SqsTriggers, + GcpTriggers, + AzureTriggers, + PostgresTriggers, + EmailTriggers, + + // Native trigger domains + NativeTriggers, + TriggersHistory, + + // System domains + Audit, + Settings, + Workers, + ServiceLogs, + Configs, + OAuth, + AI, + AiEvals, // AI agent eval datasets + + Indexer, + Teams, // Microsoft Teams integration + GitSync, // Git synchronization + + // Special domains + Capture, // Webhook capture + Drafts, // Draft resources + Favorites, // User favorites + Inputs, // Input templates + JobHelpers, // Job helper functions + ConcurrencyGroups, // Concurrency groups + Oidc, // OpenID Connect + Openapi, // OpenAPI generation + + // Additional domains + Acls, // Granular access control lists + RawApps, // Raw application data + AgentWorkers, // Agent workers management + Mcp, // MCP + Docs, // Self-hosted documentation search (read-only) +} + +impl ScopeDomain { + pub fn as_str(&self) -> &'static str { + match self { + Self::Jobs => "jobs", + Self::Scripts => "scripts", + Self::DataMetrics => "data_metrics", + Self::Flows => "flows", + Self::FlowConversations => "flow_conversations", + Self::Apps => "apps", + Self::Variables => "variables", + Self::Resources => "resources", + Self::Schedules => "schedules", + Self::Folders => "folders", + Self::Users => "users", + Self::Groups => "groups", + Self::Workspaces => "workspaces", + Self::HttpTriggers => "http_triggers", + Self::WebsocketTriggers => "websocket_triggers", + Self::KafkaTriggers => "kafka_triggers", + Self::NatsTriggers => "nats_triggers", + Self::MqttTriggers => "mqtt_triggers", + Self::AmqpTriggers => "amqp_triggers", + Self::SqsTriggers => "sqs_triggers", + Self::GcpTriggers => "gcp_triggers", + Self::AzureTriggers => "azure_triggers", + Self::PostgresTriggers => "postgres_triggers", + Self::EmailTriggers => "email_triggers", + Self::NativeTriggers => "native_triggers", + Self::TriggersHistory => "triggers_history", + Self::Audit => "audit", + Self::Settings => "settings", + Self::Workers => "workers", + Self::ServiceLogs => "service_logs", + Self::Configs => "configs", + Self::OAuth => "oauth", + Self::AI => "ai", + Self::AiEvals => "ai_evals", + Self::Capture => "capture", + Self::Drafts => "drafts", + Self::Favorites => "favorites", + Self::Inputs => "inputs", + Self::JobHelpers => "job_helpers", + Self::ConcurrencyGroups => "concurrency_groups", + Self::Oidc => "oidc", + Self::Openapi => "openapi", + Self::Acls => "acls", + Self::RawApps => "raw_apps", + Self::AgentWorkers => "agent_workers", + Self::Indexer => "indexer", + Self::Teams => "teams", + Self::GitSync => "git_sync", + Self::Mcp => "mcp", + Self::Docs => "docs", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "jobs" | "jobs_u" => Some(Self::Jobs), + "scripts" => Some(Self::Scripts), + // A distinct domain, not an alias of `scripts` (see the enum variant): + // a `data_metrics:read` token must not reach the broader /scripts routes. + "data_metrics" => Some(Self::DataMetrics), + "flows" => Some(Self::Flows), + "flow_conversations" => Some(Self::FlowConversations), + "apps" | "apps_u" => Some(Self::Apps), + "variables" => Some(Self::Variables), + "resources" => Some(Self::Resources), + "schedules" => Some(Self::Schedules), + "folders" => Some(Self::Folders), + "users" => Some(Self::Users), + "groups" => Some(Self::Groups), + "workspaces" => Some(Self::Workspaces), + "http_triggers" => Some(Self::HttpTriggers), + "websocket_triggers" => Some(Self::WebsocketTriggers), + "kafka_triggers" => Some(Self::KafkaTriggers), + "nats_triggers" => Some(Self::NatsTriggers), + "mqtt_triggers" => Some(Self::MqttTriggers), + "amqp_triggers" => Some(Self::AmqpTriggers), + "sqs_triggers" => Some(Self::SqsTriggers), + "gcp_triggers" => Some(Self::GcpTriggers), + "azure_triggers" => Some(Self::AzureTriggers), + "postgres_triggers" => Some(Self::PostgresTriggers), + "email_triggers" => Some(Self::EmailTriggers), + "audit" => Some(Self::Audit), + "settings" => Some(Self::Settings), + "workers" => Some(Self::Workers), + "service_logs" => Some(Self::ServiceLogs), + "configs" => Some(Self::Configs), + "oauth" => Some(Self::OAuth), + "ai" => Some(Self::AI), + "ai_evals" => Some(Self::AiEvals), + "indexer" | "srch" => Some(Self::Indexer), + "teams" => Some(Self::Teams), + "native_triggers" => Some(Self::NativeTriggers), + "triggers_history" => Some(Self::TriggersHistory), + "git_sync" | "github_app" => Some(Self::GitSync), + "capture" => Some(Self::Capture), + "drafts" => Some(Self::Drafts), + "favorites" => Some(Self::Favorites), + "inputs" => Some(Self::Inputs), + "job_helpers" => Some(Self::JobHelpers), + "concurrency_groups" => Some(Self::ConcurrencyGroups), + "oidc" => Some(Self::Oidc), + "openapi" => Some(Self::Openapi), + "acls" => Some(Self::Acls), + "raw_apps" => Some(Self::RawApps), + "agent_workers" => Some(Self::AgentWorkers), + "mcp" => Some(Self::Mcp), + "docs" => Some(Self::Docs), + _ => None, + } + } +} + +/// Available scope actions +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ScopeAction { + Read, // GET operations, list, view + Write, // POST, PUT, PATCH, DELETE operations, create, update, delete + Run, // Special action for running (scripts, flows, etc.) +} + +impl ScopeAction { + pub fn as_str(&self) -> &'static str { + match self { + Self::Read => "read", + Self::Write => "write", + Self::Run => "run", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "read" => Some(Self::Read), + "write" => Some(Self::Write), + "delete" => Some(Self::Write), + "run" => Some(Self::Run), + _ => None, + } + } + + /// Check if this action includes another action + /// Write includes Read + pub fn includes(&self, other: &ScopeAction) -> bool { + match (self, other) { + (ScopeAction::Write, ScopeAction::Read) => true, + (ScopeAction::Run, ScopeAction::Read) => true, + (a, b) => a == b, + } + } +} + +/// Whether `caller` grants at least everything `requested` grants (directional +/// containment). +/// +/// This is intentionally NOT `ScopeDefinition::includes`: that method answers +/// "does this scope grant access to a required action" using OR semantics over +/// resources (any overlap counts, and a `*` on either side matches), which is +/// correct for access checks but unsafe for subset checks — it would let a +/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or +/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that +/// EVERY requested resource is covered by SOME caller resource. +pub fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool { + if caller.domain != requested.domain { + return false; + } + + // write subsumes read; otherwise the action must match exactly. + match (caller.action.as_str(), requested.action.as_str()) { + (c, r) if c == r || (c == "write" && r == "read") => {} + // Apps only: `write` covers `run` (see `ScopeDefinition::includes`), so an + // app-editor token can mint the narrower run-only credential. + ("write", "run") if caller.domain == "apps" => {} + _ => return false, + } + + if caller.domain == "jobs" && caller.action == "run" { + match (&caller.kind, &requested.kind) { + (Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => { + return false + } + // Caller pinned to a kind, but the request covers any kind. + (Some(_), None) => return false, + _ => {} + } + } + + match (&caller.resource, &requested.resource) { + // Caller is unrestricted on resources: covers everything. + (None, _) => true, + // Caller is resource-restricted but the request is not: broader, unless the + // caller lists `*` and so already spans every path. Kept in step with + // `ScopeDefinition::includes`, which accepts that same grant for a + // whole-collection read: what a token may exercise, it may also delegate. + (Some(caller_resources), None) => caller_resources.iter().any(|r| r == "*"), + (Some(caller_resources), Some(requested_resources)) => { + resource_set_contains(caller_resources, requested_resources) + } + } +} + +/// Every resource in `requested` must be covered by some resource in `caller`. +fn resource_set_contains(caller: &[String], requested: &[String]) -> bool { + if caller.iter().any(|r| r == "*") { + return true; + } + requested + .iter() + .all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req))) +} + +/// Directional: does the single caller resource pattern cover `requested`? +/// `caller` may be an exact path or a `/*` 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> { + let mut normalized: Vec = 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> { + 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> { + scopes + .iter() + .map(|s| ScopeDefinition::from_scope_string(s).ok()) + .collect() + }; + let (pc, po) = (parse(c), parse(o)); + let mut out: Vec = vec![]; + let mut keep_covered = + |raw: &[String], parsed: &[Option], by: &[Option]| { + for (s, p) in raw.iter().zip(parsed) { + let Some(p) = p else { continue }; + if by.iter().flatten().any(|b| scope_contains(b, p)) + && !out.iter().any(|x| x == s) + { + out.push(s.clone()); + } + } + }; + keep_covered(o, &po, &pc); + keep_covered(c, &pc, &po); + Some(out) + } + } +} + +/// Counts a deploy of a script or flow that restricts its job token, keyed by which shape +/// of restriction it picked, so take-up of the presets can be told from custom lists. +pub fn log_job_token_scopes_deploy(kind: &'static str, scopes: Option<&[String]>) { + let Some(scopes) = scopes else { return }; + let shape = match scopes { + [] => "no_api", + [only] if only == "oidc:write" => "oidc_only", + _ => "custom", + }; + crate::feature_usage::log_feature_usage( + "job_token_scopes", + "deploy", + &format!("{kind}:{shape}"), + ); +} + +/// The `scopes` claim of a job token minted from the job's effective scopes. +pub fn job_token_jwt_scopes(effective: Option>) -> Option> { + 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 { + 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"]) + ); + } +} diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index 9726065c6f..3cedc8c1e9 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -162,6 +162,7 @@ async fn prefetch_cached_script_inner( assets: script.assets, modules: script.modules, labels: script.labels, + job_token_scopes: script.job_token_scopes, inherited_labels: script.inherited_labels, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings: concurrency_settings.maybe_fallback( @@ -461,6 +462,7 @@ pub async fn deploy_relocked_version( modules, auto_parent: None, labels: s.labels, + job_token_scopes: Some(s.job_token_scopes.clone()), skip_draft_deletion: None, }; @@ -481,7 +483,7 @@ pub async fn deploy_relocked_version( dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules, labels, \ - lock_error_logs, created_at) + job_token_scopes, lock_error_logs, created_at) SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \ content, created_by, schema, is_template, extra_perms, $4::text, language, kind, tag, \ @@ -489,7 +491,7 @@ pub async fn deploy_relocked_version( dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ codebase, has_preprocessor, on_behalf_of, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, COALESCE($5::jsonb, modules), labels, \ - $6::text, clock_timestamp() + job_token_scopes, $6::text, clock_timestamp() FROM script WHERE hash = $2 AND workspace_id = $3; ", new_hash, s.hash.0, w_id, lock, modules_json, lock_error_logs).execute(&mut **tx).await?; diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index ca1745a34d..80085bed99 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -800,7 +800,7 @@ fn format_pull_query(peek: String) -> String { flow_innermost_root_job, root_job, flow_step_id, same_worker, pre_run_error, visible_to_owner, tag, concurrent_limit, concurrency_time_window_s, timeout, cache_ttl, priority, raw_code, raw_lock, - raw_flow, script_entrypoint_override, preprocessed + raw_flow, script_entrypoint_override, preprocessed, job_token_scopes FROM v2_job WHERE id = (SELECT id FROM peek) ) SELECT j.id, j.workspace_id, j.parent_job, j.created_by, started_at, scheduled_for, @@ -810,7 +810,7 @@ fn format_pull_query(peek: String) -> String { j.same_worker, j.pre_run_error, j.visible_to_owner, j.tag, j.concurrent_limit, j.concurrency_time_window_s, j.flow_innermost_root_job, j.root_job, j.timeout, j.flow_step_id, j.cache_ttl, q.cache_ignore_s3_path, q.runnable_settings_handle, j.priority, j.raw_code, j.raw_lock, j.raw_flow, - j.script_entrypoint_override, j.preprocessed, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path, + j.script_entrypoint_override, j.preprocessed, j.job_token_scopes, COALESCE(pj.runnable_path, j.args->>'_FLOW_PATH') as parent_runnable_path, COALESCE(p.email, j.permissioned_as_email) as permissioned_as_email, p.username as permissioned_as_username, p.is_admin as permissioned_as_is_admin, p.is_operator as permissioned_as_is_operator, p.groups as permissioned_as_groups, p.folders as permissioned_as_folders, p.end_user_email as permissioned_as_end_user_email FROM q, j diff --git a/backend/windmill-dep-map/src/trigger_dependents.rs b/backend/windmill-dep-map/src/trigger_dependents.rs index d5cfd8e5a4..48e151736a 100644 --- a/backend/windmill-dep-map/src/trigger_dependents.rs +++ b/backend/windmill-dep-map/src/trigger_dependents.rs @@ -210,6 +210,7 @@ pub async fn trigger_dependents_to_recompute_dependencies( None, None, None, + None, ) .await?; diff --git a/backend/windmill-queue/src/asset_dispatch.rs b/backend/windmill-queue/src/asset_dispatch.rs index a3f7154cda..b44541dc9d 100644 --- a/backend/windmill-queue/src/asset_dispatch.rs +++ b/backend/windmill-queue/src/asset_dispatch.rs @@ -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:#}")))?; diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index c2c42c649c..e1786772ed 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -503,6 +503,7 @@ pub async fn push_init_job<'c>( None, None, None, + None, ) .await?; inner_tx.commit().await?; @@ -563,6 +564,7 @@ pub async fn push_periodic_bash_job<'c>( None, None, None, + None, ) .await?; inner_tx.commit().await?; @@ -765,6 +767,19 @@ async fn restart_perpetual_runs_at_path( Ok(true) } +/// The effective token scopes of an earlier job, as the ceiling of a job that re-runs it +/// (a retry, a perpetual restart): the re-run never holds a wider token than the run it +/// replaces. +async fn stored_job_token_scopes(db: &DB, job_id: Uuid) -> Result>, Error> { + Ok(sqlx::query_scalar!( + "SELECT job_token_scopes FROM v2_job WHERE id = $1", + job_id + ) + .fetch_optional(db) + .await? + .flatten()) +} + /// A run of an earlier version at the path, and what its replacement inherits from it. struct PerpetualRunToRestart { id: Uuid, @@ -857,6 +872,7 @@ async fn restart_perpetual_run( // an earlier version the next pass picks up. return Ok(()); } + let scope_ceiling = stored_job_token_scopes(db, run.id).await?; let (_, tx) = push( db, PushIsolationLevel::Transaction(tx), @@ -887,6 +903,7 @@ async fn restart_perpetual_run( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -2103,6 +2120,7 @@ async fn restart_job_if_perpetual_inner( .await? .flatten() .unwrap_or_default(); + let scope_ceiling = stored_job_token_scopes(db, queued_job.id).await?; let (_uuid, tx) = push( db, tx, @@ -2127,6 +2145,9 @@ async fn restart_job_if_perpetual_inner( // TODO(debouncing): handle properly debouncing_settings: DebouncingSettings::default(), labels: None, // labels already set on original job + // The run it replaces caps it (`scope_ceiling`), and that run already + // carried this script's setting. + job_token_scopes: None, }, PushArgs::from(&args.0), &queued_job.created_by, @@ -2153,6 +2174,7 @@ async fn restart_job_if_perpetual_inner( None, None, None, + scope_ceiling.as_deref(), ) .await?; tx.commit().await?; @@ -2411,6 +2433,7 @@ pub async fn maybe_enqueue_native_script_retry( ) .await?; let tx = PushIsolationLevel::IsolatedRoot(db.clone()); + let scope_ceiling = stored_job_token_scopes(db, job.id).await?; let (new_id, mut tx) = match push( db, tx, @@ -2459,6 +2482,7 @@ pub async fn maybe_enqueue_native_script_retry( None, trigger, None, + scope_ceiling.as_deref(), ) .await { @@ -3424,6 +3448,7 @@ pub async fn push_error_handler<'a, 'c, T: Serialize + Send + Sync>( None, None, None, + None, ) .await?; tx.commit().await?; @@ -3513,6 +3538,7 @@ pub async fn push_success_handler<'a, 'c, T: Serialize + Send + Sync>( None, None, None, + None, ) .await?; tx.commit().await?; @@ -3571,6 +3597,11 @@ pub struct MiniPulledJob { pub visible_to_owner: bool, pub permissioned_as_end_user_email: Option, pub runnable_settings_handle: Option, + /// The job's effective token scopes (`v2_job.job_token_scopes`), minted into its token. + /// No `sqlx(default)`: a query that forgets the column must fail rather than mint an + /// unrestricted token. + #[serde(default)] + pub job_token_scopes: Option>, } impl MiniPulledJob { @@ -3622,6 +3653,7 @@ impl MiniPulledJob { runnable_settings_handle: None, concurrent_limit: None, concurrency_time_window_s: None, + job_token_scopes: None, } } } @@ -3865,6 +3897,7 @@ impl MiniPulledJob { .then(|| JobTriggerKind::Schedule.into()), visible_to_owner: job.visible_to_owner.clone(), permissioned_as_end_user_email: None, + job_token_scopes: None, } } pub fn is_flow(&self) -> bool { @@ -3968,6 +4001,7 @@ impl PulledJob { groups, folders, end_user_email: self.job.permissioned_as_end_user_email.clone(), + job_token_scopes: self.job.job_token_scopes.clone(), }), _ => None, }; @@ -4074,7 +4108,8 @@ pub async fn get_mini_pulled_job<'c>( trigger, trigger_kind as \"trigger_kind: TriggerKindLabel\", visible_to_owner, - NULL as permissioned_as_end_user_email + NULL as permissioned_as_end_user_email, + job_token_scopes FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id LEFT JOIN v2_job_status ON v2_job_status.id = v2_job_queue.id WHERE v2_job_queue.id = $1", job_id, ) @@ -5981,6 +6016,10 @@ pub async fn push<'c, 'd>( end_user_email: Option, trigger: Option, suspended_mode: Option, + // Caps the new job's token on top of the target's own `job_token_scopes`: the scopes of + // the job (or job token) this push acts for, `None` when nothing above it restricts it. + // A job may never hold a token wider than the job that created it. + scope_ceiling: Option<&[String]>, ) -> Result<(Uuid, Transaction<'c, Postgres>), Error> { Box::pin(push_inner( db, @@ -6012,6 +6051,7 @@ pub async fn push<'c, 'd>( end_user_email, trigger, suspended_mode, + scope_ceiling, )) .await } @@ -6047,6 +6087,7 @@ async fn push_inner<'c, 'd>( end_user_email: Option, trigger: Option, suspended_mode: Option, + scope_ceiling: Option<&[String]>, ) -> Result<(Uuid, Transaction<'c, Postgres>), Error> { // The worker builds a preview's `_MODULES` arg into the job as its module code. Every // caller-reachable value lands in `args` or `extra` (webhook query and headers go to @@ -6280,6 +6321,8 @@ async fn push_inner<'c, 'd>( debouncing_settings: DebouncingSettings, retry_settings: RetrySettings, labels: Option>, + /// The target's own `job_token_scopes` setting. + job_token_scopes: Option>, /// A `dependencies` job that only compiles an already-deployed script's binary. /// It shares the job kind, but not the queue policy lock generation needs. build_binary_only: bool, @@ -6302,6 +6345,7 @@ async fn push_inner<'c, 'd>( debouncing_settings, retry_settings, labels, + job_token_scopes, build_binary_only, } = match job_payload { JobPayload::ScriptHash { @@ -6316,6 +6360,7 @@ async fn push_inner<'c, 'd>( concurrency_settings, debouncing_settings, labels, + job_token_scopes, } => { if apply_preprocessor { preprocessed = Some(false); @@ -6333,6 +6378,7 @@ async fn push_inner<'c, 'd>( dedicated_worker, _low_level_priority: priority, labels, + job_token_scopes, ..Default::default() } } @@ -6671,20 +6717,21 @@ async fn push_inner<'c, 'd>( // script's `dedicated_worker` (it drives the dedicated tag below), // but the SingleStepFlow payload doesn't — resolve it from the // script row so a dedicated-worker script keeps its dedicated pool. - let dedicated_worker = if let Some(h) = &hash { + // The script's `job_token_scopes` is resolved the same way. + let (dedicated_worker, job_token_scopes) = if let Some(h) = &hash { // Read on the non-RLS pool: push_inner is also entered with RLS // isolation variants under which the script row may be invisible, // which would mis-resolve dedicated_worker routing. - sqlx::query_scalar::<_, Option>( - "SELECT dedicated_worker FROM script WHERE hash = $1 AND workspace_id = $2", + sqlx::query_as::<_, (Option, Option>)>( + "SELECT dedicated_worker, job_token_scopes FROM script WHERE hash = $1 AND workspace_id = $2", ) .bind(h.0) .bind(workspace_id) .fetch_optional(db) .await? - .flatten() + .unwrap_or((None, None)) } else { - None + (None, None) }; break 'ssf JobPayloadUntagged { runnable_id: hash.map(|h| h.0), @@ -6696,6 +6743,7 @@ async fn push_inner<'c, 'd>( }, language, dedicated_worker, + job_token_scopes, concurrency_settings, debouncing_settings, retry_settings: retry.as_ref().map(RetrySettings::from).unwrap_or_default(), @@ -6866,7 +6914,14 @@ async fn push_inner<'c, 'd>( ..Default::default() } } - JobPayload::Flow { path, dedicated_worker, apply_preprocessor, version, labels } => { + JobPayload::Flow { + path, + dedicated_worker, + apply_preprocessor, + version, + labels, + job_token_scopes, + } => { let mut ntx = tx.into_tx().await?; // Do not use the lite version unless all workers are updated. let data = if *DISABLE_FLOW_SCRIPT @@ -6933,6 +6988,7 @@ async fn push_inner<'c, 'd>( concurrency_settings, debouncing_settings, labels, + job_token_scopes, ..Default::default() } } @@ -6997,6 +7053,16 @@ async fn push_inner<'c, 'd>( memory_id: None, no_inherited_flow_env: false, }; + // The restart runs the completed job's stored definition, so it keeps that job's + // restriction rather than reading the flow's current setting. + let job_token_scopes = sqlx::query_scalar::<_, Option>>( + "SELECT job_token_scopes FROM v2_job WHERE id = $1 AND workspace_id = $2", + ) + .bind(completed_job_id) + .bind(workspace_id) + .fetch_optional(db) + .await? + .flatten(); let value = flow_data.value(); let priority = value.priority; let concurrency_settings = value.concurrency_settings.clone(); @@ -7021,6 +7087,7 @@ async fn push_inner<'c, 'd>( _low_level_priority: priority, concurrency_settings, debouncing_settings, + job_token_scopes, ..Default::default() } } @@ -7345,6 +7412,11 @@ async fn push_inner<'c, 'd>( } }; + let job_token_scopes = windmill_common::scopes::intersect_job_token_scopes( + scope_ceiling, + job_token_scopes.as_deref(), + ); + let folders = job_authed .folders .iter() @@ -7443,7 +7515,8 @@ async fn push_inner<'c, 'd>( trigger_kind, -- 39 script_entrypoint_override, -- 12 preprocessed, -- 27, - labels -- 44 + labels, -- 44 + job_token_scopes -- 47 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $38, $21, $22, $23, $24, $25, $26, $39::job_trigger_kind, ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27, @@ -7452,7 +7525,7 @@ async fn push_inner<'c, 'd>( (SELECT CASE WHEN fl.labels IS NULL THEN $44 ELSE (SELECT array_agg(DISTINCT lbl) FROM unnest(COALESCE($44, ARRAY[]::TEXT[]) || fl.labels) lbl) END - FROM folder_labels($46, $45) AS fl(labels))) + FROM folder_labels($46, $45) AS fl(labels)), $47) ), inserted_runtime AS ( INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null) @@ -7515,6 +7588,7 @@ async fn push_inner<'c, 'd>( labels.as_deref() as Option<&[String]>, runnable_path, workspace_id, + job_token_scopes.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .warn_after_seconds(1) @@ -8478,6 +8552,7 @@ pub async fn get_same_worker_job( v2_job.trigger, v2_job.trigger_kind, v2_job.visible_to_owner, + v2_job.job_token_scopes, v2_job.raw_code, v2_job.raw_lock, v2_job.raw_flow, diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 97b175fa9e..81be3cb7e8 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -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?; diff --git a/backend/windmill-queue/tests/debounce_test.rs b/backend/windmill-queue/tests/debounce_test.rs index d74f20ff3f..14f7ec2dd9 100644 --- a/backend/windmill-queue/tests/debounce_test.rs +++ b/backend/windmill-queue/tests/debounce_test.rs @@ -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 { diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 773616a807..f05fff66f8 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -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; diff --git a/backend/windmill-trigger/src/trigger_helpers.rs b/backend/windmill-trigger/src/trigger_helpers.rs index 7e47613d1a..ae805d195e 100644 --- a/backend/windmill-trigger/src/trigger_helpers.rs +++ b/backend/windmill-trigger/src/trigger_helpers.rs @@ -976,6 +976,7 @@ async fn trigger_script_with_retry_and_error_handler<'c>( None, Some(trigger), suspended_mode, + None, ) .await?; diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index a2fc7fd58d..856320ecd4 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -48,6 +48,10 @@ pub struct Flow { pub on_behalf_of: Option, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// 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>, /// 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, #[serde(default)] pub labels: Option>, + /// 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>>, /// 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, #[serde(default)] pub labels: Option>, + #[serde( + default, + deserialize_with = "crate::more_serde::double_option", + skip_serializing_if = "Option::is_none" + )] + pub job_token_scopes: Option>>, #[serde(default)] pub skip_draft_deletion: Option, } @@ -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, } } diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 530b8a948d..9768373b91 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -515,6 +515,8 @@ pub enum JobPayload { concurrency_settings: ConcurrencySettings, debouncing_settings: DebouncingSettings, labels: Option>, + /// The script's `job_token_scopes` setting, read with the rest of the payload. + job_token_scopes: Option>, }, FlowNode { id: FlowNodeId, @@ -579,6 +581,8 @@ pub enum JobPayload { apply_preprocessor: bool, version: i64, labels: Option>, + /// The flow's `job_token_scopes` setting, read with the rest of the payload. + job_token_scopes: Option>, }, RestartedFlow { completed_job_id: Uuid, diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index 9e29731505..1e4a8858ed 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -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 { pub modules: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option>, + /// 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>, /// 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, #[serde(default)] pub labels: Option>, + /// 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>>, /// 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); diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 930a329be4..541b4d823f 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -599,6 +599,8 @@ async fn enqueue_windmill_tool( None, None, None, + // A tool never holds a wider token than the agent calling it. + ctx.job.job_token_scopes.as_deref(), ) .await?; diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index c4a792c6a0..bb47d3e4be 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -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?; diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 9c012fd117..cd8923d408 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -2309,6 +2309,7 @@ mod tests { visible_to_owner: false, permissioned_as_end_user_email: None, runnable_settings_handle: None, + job_token_scopes: None, } } diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 7c1ea23de4..c53e9736d2 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -2402,7 +2402,8 @@ async fn advance_flow_status( v2_job.trigger, v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\", v2_job.visible_to_owner, - NULL as permissioned_as_end_user_email", + NULL as permissioned_as_end_user_email, + v2_job.job_token_scopes", flow, &step_path as &[&str], step, @@ -4746,6 +4747,8 @@ async fn push_next_flow_job( end_user_email, None, None, + // A step never holds a wider token than the flow running it. + flow_job.job_token_scopes.as_deref(), ) .warn_after_seconds(2) .await?; @@ -6339,13 +6342,14 @@ async fn flow_to_payload( ) -> Result { let flow_info = get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?; let on_behalf_of = flow_info.on_behalf_of(w_id, &db).await?; - let FlowVersionInfo { version, tag, .. } = flow_info; + let FlowVersionInfo { version, tag, job_token_scopes, .. } = flow_info; let payload = JobPayload::Flow { path, dedicated_worker: None, apply_preprocessor: false, version, labels: None, + job_token_scopes, }; Ok(JobPayloadWithTag { payload, @@ -6419,6 +6423,7 @@ pub async fn script_to_payload( delete_after_use, delete_after_secs, timeout, + job_token_scopes, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings, debouncing_settings }, .. @@ -6436,6 +6441,7 @@ pub async fn script_to_payload( priority, apply_preprocessor: apply_preprocessor.unwrap_or(false), labels: None, + job_token_scopes, }, tag_override.to_owned().or(tag), delete_after_use, diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 861a9625e9..b608601991 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -137,6 +137,7 @@ async fn maybe_queue_binary_prebuild( None, None, None, + None, ) .await?; tx.commit().await?; diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 863690ce7d..24dd6b5a16 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -52,6 +52,7 @@ export interface FlowFile { schema?: any; on_behalf_of_email?: string; has_on_behalf_of?: boolean; + job_token_scopes?: string[] | null; // Mirrors granular ACLs on the flow path. Omitted from flow.yaml when no // perms are set. The CLI applies diffs through /acls/add and /acls/remove // (see applyExtraPermsDiff) — never through update_flow — so a perm-only @@ -265,12 +266,22 @@ export async function pushFlow( // so a perm-only edit never bumps the flow version. Strip the field from the // body that goes to update_flow / create_flow and treat it as a separate // step both for the up-to-date short-circuit and after the deploy. - const { extra_perms: localPerms, ...localFlowBody } = localFlow as FlowFile & { + const { + extra_perms: localPerms, + job_token_scopes: localJobTokenScopes, + ...localFlowBody + } = localFlow as FlowFile & { extra_perms?: Record; }; + // Always sent, unlike the other settings: the server keeps a restriction the body omits, + // so a flow.yaml without the key has to clear it explicitly. + const jobTokenScopes = localJobTokenScopes ?? null; if (flow) { - if (isSuperset(localFlowBody, flow)) { + if ( + isSuperset(localFlowBody, flow) && + JSON.stringify(jobTokenScopes) === JSON.stringify(flow.job_token_scopes ?? null) + ) { log.info(colors.green(`Flow ${remotePath} is up to date`)); } else { log.info(colors.bold.yellow(`Updating flow ${remotePath}...`)); @@ -281,6 +292,7 @@ export async function pushFlow( path: remotePath.replaceAll(SEP, "/"), deployment_message: message, ...localFlowBody, + job_token_scopes: jobTokenScopes, ...preserveFields, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, @@ -296,6 +308,7 @@ export async function pushFlow( path: remotePath.replaceAll(SEP, "/"), deployment_message: message, ...localFlowBody, + job_token_scopes: jobTokenScopes, ...preserveFields, // Preserve any user draft at this path (see backend skip_draft_deletion). skip_draft_deletion: true, diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 06db3db1bf..4938b6aeb3 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -636,6 +636,9 @@ export async function handleFile( envs: typed?.envs, modules: modules, labels: typed?.labels, + // Always sent: the server keeps a restriction the body omits, so a script.yaml without + // the key has to clear it explicitly. + job_token_scopes: typed?.job_token_scopes ?? null, }; const hasOnBehalfOf = (typed as any)?.has_on_behalf_of ?? !!typed?.on_behalf_of_email; @@ -712,6 +715,7 @@ export async function handleFile( (hasOnBehalfOf ? true : typed.on_behalf_of_email == remote.on_behalf_of_email) && deepEqual(typed.envs, remote.envs) && deepEqual(typed.labels ?? null, remote.labels ?? null) && + deepEqual(typed.job_token_scopes ?? null, remote.job_token_scopes ?? null) && deepEqual(modules ?? null, remote.modules ?? null)) ) { log.info(colors.green(`Script ${remotePath} is up to date`)); diff --git a/cli/windmill-utils-internal/src/deploy.ts b/cli/windmill-utils-internal/src/deploy.ts index 0d70e231cf..d54ff5720e 100644 --- a/cli/windmill-utils-internal/src/deploy.ts +++ b/cli/windmill-utils-internal/src/deploy.ts @@ -889,6 +889,7 @@ export async function getItemValue( summary: flow.summary, description: flow.description, value: flow.value, + job_token_scopes: flow.job_token_scopes ?? null, }; } else if (kind === "script") { const script = await provider.getScriptByPath({ workspace, path }); @@ -898,6 +899,7 @@ export async function getItemValue( schema: script.schema, summary: script.summary, language: script.language, + job_token_scopes: script.job_token_scopes ?? null, }; } else if (kind === "app" || kind === "raw_app") { return await provider.getAppByPath({ workspace, path }); diff --git a/docs/auth-surface.md b/docs/auth-surface.md index 15f2538cc5..6406411988 100644 --- a/docs/auth-surface.md +++ b/docs/auth-surface.md @@ -52,6 +52,20 @@ Symbols, not line numbers, are cited: they drift less. with that reader's permissions, outside the folder too. `ai_skill` bodies are likewise in play for every reader until turned off. The resource ACL is the only control, so granting write on a folder grants that influence. +- **Restricted job tokens** (`job_token_scopes` on scripts and flows): a job's effective scopes + are stored in `v2_job.job_token_scopes` at push and minted into its token + (`create_token_for_owner`); an empty set is minted as `NO_API_ACCESS_SCOPE`, since + `scopes: Some([])` reads as unscoped. Every `push` takes a `scope_ceiling`, and the job gets the + ceiling ∩ the target's own setting: flow steps take their flow job's, agent tools their agent's, + WAC children their parent's, retries and restarts the run they replace, and API pushes the + calling job's (`caller_scope_ceiling`) — a scoped user or webhook token caps nothing. Schedules, + triggers and error/success handlers start from the target's own setting. A restricted token + keeps only the runtime routes about its own job (`is_own_job_runtime_route`); a route that + authenticates a token itself instead of through the route layer must call + `check_job_token_scope`. `$var:`/`$res:` args are resolved through the API with the job's own + token, so they need read scopes. A deploy that omits the setting keeps the deployed value and + `null` clears it, so an unaware client cannot drop a restriction. Write scopes on scripts, + flows, schedules or triggers let a job escape its restriction. - **A remote deploy token is a credential for another instance**, held per account and workspace (`remote_deploy_token`, encrypted under the workspace key, re-keyed by `set_encryption_key`). Its `email` references `password(email)` with `ON DELETE/UPDATE CASCADE`, so whatever deletes or diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 289dfa21d4..7d92e08484 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -618,7 +618,9 @@ on_behalf_of: flow.on_behalf_of, preserve_on_behalf_of: $preserveOnBehalfOf || undefined, deployment_message: deploymentMsg || undefined, - labels: (flow as any).labels + labels: (flow as any).labels, + // Sent explicitly: an omitted field keeps the deployed value. + job_token_scopes: flow.job_token_scopes ?? null } }) // Gated for operators without `manage_triggers`, who cannot have captures to move. @@ -670,7 +672,9 @@ on_behalf_of: flow.on_behalf_of, preserve_on_behalf_of: $preserveOnBehalfOf || undefined, deployment_message: deploymentMsg || undefined, - labels: (flow as any).labels + labels: (flow as any).labels, + // Sent explicitly: an omitted field keeps the deployed value. + job_token_scopes: flow.job_token_scopes ?? null } }) } diff --git a/frontend/src/lib/components/JobTokenScopesSetting.svelte b/frontend/src/lib/components/JobTokenScopesSetting.svelte new file mode 100644 index 0000000000..f3c91bb437 --- /dev/null +++ b/frontend/src/lib/components/JobTokenScopesSetting.svelte @@ -0,0 +1,76 @@ + + +
+ { + if (value != null) { + value = null + } else { + setMode(mode) + } + }} + options={{ right: 'Restrict the job token' }} + /> + {#if value != null} + setMode(v)}> + {#snippet children({ item })} + + + + {/snippet} + + {#if mode === 'custom'} + customScopes, + (scopes) => { + customScopes = scopes + value = [...scopes] + } + } + emptyLabel="No scopes selected. The job token can only reach its own job." + /> + {/if} +

+ The WM_TOKEN of every job of this {kind} can only do what these scopes allow. Jobs + it starts, {kind === 'flow' ? 'its steps, ' : ''}and AI agent tools inherit the restriction. + Variables and resources passed as $var:/$res: inputs, an AI agent's provider + resource, relative imports and object storage each need a read scope. Write scopes on scripts, + flows, schedules or triggers let a job escape the restriction. +

+ {/if} +
diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 05d0f68496..059d0109ef 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -83,6 +83,7 @@ import { fade } from 'svelte/transition' import Popover from './Popover.svelte' import Toggle from './Toggle.svelte' + import JobTokenScopesSetting from './JobTokenScopesSetting.svelte' import ScriptSchema from './ScriptSchema.svelte' import Section from './Section.svelte' import Label from './Label.svelte' @@ -787,7 +788,9 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined, assets: script.assets, modules: script.modules, - labels: script.labels + labels: script.labels, + // Sent explicitly: an omitted field keeps the deployed value. + job_token_scopes: script.job_token_scopes ?? null } }) @@ -2065,6 +2068,16 @@ /> +
+ {#snippet header()} + + Restrict what the token of this script's jobs (WM_TOKEN) can do through the + API, e.g. only mint an OIDC token. Use it for scripts that run untrusted + input, such as AI agents. + + {/snippet} + +
+
+ Job token + +
+ (null) @@ -532,7 +535,7 @@ {#if selectedScopes.length === 0} -

No scopes selected. Token will have full access.

+

{emptyLabel}

{:else if hasAdministratorScope}

Administrator scope grants full access to all resources.