From 7ac93f6ee30eb8dfa6ddb9c19697cde93bf7e134 Mon Sep 17 00:00:00 2001 From: wendrul <53628737+wendrul@users.noreply.github.com> Date: Wed, 25 Feb 2026 13:05:22 +0100 Subject: [PATCH] feat: option to preserve on_behalf_of and edited_by for admins and users in the new wm_deployers group (#8079) --- ...be48db3b0cd1c74adbfa2e4039377daa016f0.json | 3 +- ...1f7f387f5055c47f493271d26731336257384.json | 10 +- ...ec6aa89de43ec201c911196763e03e644ca7a.json | 29 + ...d2afaf26e666ffe52e0ea85792ea31b63410c.json | 29 + ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...7d6feba8d43292daf6c5433b66bd8450e8854.json | 29 + ...22c65911ab9cf0414c33a3321a1d43af49795.json | 23 + ...161cfca045966e995e28c7a7ad090cffeda0.json} | 6 +- ...1d3f9d4f56da25f588d281638e449d99a0d9.json} | 4 +- ...c71b61774c8ff8ae218d7a8d6ed0ac0169366.json | 3 +- ...aebe9167431aaa0a902b4558bc141c85ed825.json | 23 + ...7a55be0e31aee3008d5190f09c1f15e5b47dd.json | 14 + ...918ae51fee667472d0cc2796ffdba4138d2ee.json | 23 + ...00b72c11bd226f99eade7e9a0da4605539283.json | 23 + backend/ee-repo-ref.txt | 2 +- ...4000000_create_wm_deployers_group.down.sql | 0 ...224000000_create_wm_deployers_group.up.sql | 5 + backend/tests/dependency_map.rs | 2 + .../tests/fixtures/preserve_on_behalf_of.sql | 186 ++ backend/tests/preserve_on_behalf_of.rs | 2275 +++++++++++++++++ backend/tests/protection_rules.rs | 306 +++ backend/windmill-api-flows/src/flows.rs | 60 +- backend/windmill-api-schedule/src/lib.rs | 107 +- backend/windmill-api-scripts/src/scripts.rs | 56 +- .../windmill-api-workspaces/src/workspaces.rs | 8 + backend/windmill-api/openapi.yaml | 132 + backend/windmill-api/src/apps.rs | 82 +- backend/windmill-common/src/bench.rs | 89 +- backend/windmill-common/src/lib.rs | 44 + backend/windmill-common/src/runtime_assets.rs | 5 +- backend/windmill-common/src/scripts.rs | 19 +- .../src/workspace_dependencies.rs | 7 +- backend/windmill-trigger-http/src/handler.rs | 33 +- backend/windmill-trigger-mqtt/src/handler.rs | 16 +- .../windmill-trigger-postgres/src/handler.rs | 12 +- .../windmill-trigger-websocket/src/handler.rs | 16 +- backend/windmill-trigger/src/handler.rs | 48 + backend/windmill-trigger/src/types.rs | 69 +- backend/windmill-types/src/flows.rs | 1 + backend/windmill-types/src/scripts.rs | 1 + .../lib/components/CompareWorkspaces.svelte | 979 +++---- .../src/lib/components/DeployWorkspace.svelte | 723 ++---- .../components/DeployWorkspaceDrawer.svelte | 11 +- frontend/src/lib/components/Dev.svelte | 6 +- .../src/lib/components/FlowBuilder.svelte | 12 +- .../src/lib/components/GroupEditor.svelte | 8 + .../lib/components/OnBehalfOfSelector.svelte | 94 + .../src/lib/components/ScriptBuilder.svelte | 22 + .../components/WorkspaceDeployLayout.svelte | 156 ++ .../apps/editor/AppEditorHeader.svelte | 8 +- .../apps/editor/AppEditorHeaderDeploy.svelte | 27 +- .../lib/components/common/table/Row.svelte | 8 +- .../components/common/table/RowIcon.svelte | 82 +- .../flows/content/FlowSettings.svelte | 31 +- frontend/src/lib/components/flows/types.ts | 2 + frontend/src/lib/utils_deployable.ts | 76 +- frontend/src/lib/utils_workspace_deploy.ts | 476 ++++ frontend/src/routes/flows/dev/+page.svelte | 4 +- 58 files changed, 5276 insertions(+), 1251 deletions(-) create mode 100644 backend/.sqlx/query-075d4749299af2cb81162bf396bec6aa89de43ec201c911196763e03e644ca7a.json create mode 100644 backend/.sqlx/query-17aafb72843659df9594d6d2466d2afaf26e666ffe52e0ea85792ea31b63410c.json create mode 100644 backend/.sqlx/query-8311a553c44221751ffdbbe6a997d6feba8d43292daf6c5433b66bd8450e8854.json create mode 100644 backend/.sqlx/query-85a6a85fd126a8bfedd65d6b38d22c65911ab9cf0414c33a3321a1d43af49795.json rename backend/.sqlx/{query-4144c87c25a939aafb2f57da189d94d038bcad7a36fbf87e0403c89a979c5b3f.json => query-987d79f7c6d7bc148cc8aab67e47161cfca045966e995e28c7a7ad090cffeda0.json} (80%) rename backend/.sqlx/{query-07f5290e90533eac50b890a0d7f4a5e73ac111c838f687fe8647636827aae8b5.json => query-a9c805423e700b0acceb7c3dc43d1d3f9d4f56da25f588d281638e449d99a0d9.json} (70%) create mode 100644 backend/.sqlx/query-b12fba75788e44daefd9b3540a3aebe9167431aaa0a902b4558bc141c85ed825.json create mode 100644 backend/.sqlx/query-dda45bcc53e94659838e98b6b9e7a55be0e31aee3008d5190f09c1f15e5b47dd.json create mode 100644 backend/.sqlx/query-e1f43cb65201b4f0965a4e18f0c918ae51fee667472d0cc2796ffdba4138d2ee.json create mode 100644 backend/.sqlx/query-e8d948274840699c5f7485ee4bc00b72c11bd226f99eade7e9a0da4605539283.json create mode 100644 backend/migrations/20260224000000_create_wm_deployers_group.down.sql create mode 100644 backend/migrations/20260224000000_create_wm_deployers_group.up.sql create mode 100644 backend/tests/fixtures/preserve_on_behalf_of.sql create mode 100644 backend/tests/preserve_on_behalf_of.rs create mode 100644 backend/tests/protection_rules.rs create mode 100644 frontend/src/lib/components/OnBehalfOfSelector.svelte create mode 100644 frontend/src/lib/components/WorkspaceDeployLayout.svelte create mode 100644 frontend/src/lib/utils_workspace_deploy.ts diff --git a/backend/.sqlx/query-02e526146f3584cd599dec708e1be48db3b0cd1c74adbfa2e4039377daa016f0.json b/backend/.sqlx/query-02e526146f3584cd599dec708e1be48db3b0cd1c74adbfa2e4039377daa016f0.json index 5ca75fe782..027ef3464f 100644 --- a/backend/.sqlx/query-02e526146f3584cd599dec708e1be48db3b0cd1c74adbfa2e4039377daa016f0.json +++ b/backend/.sqlx/query-02e526146f3584cd599dec708e1be48db3b0cd1c74adbfa2e4039377daa016f0.json @@ -20,8 +20,7 @@ "resource", "variable", "ducklake", - "datatable", - "volume" + "datatable" ] } } diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index e7ed0aee65..d29a18c691 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - false, - false, - false, - false, - false, + true, + true, + true, + true, + true, true, true ] diff --git a/backend/.sqlx/query-075d4749299af2cb81162bf396bec6aa89de43ec201c911196763e03e644ca7a.json b/backend/.sqlx/query-075d4749299af2cb81162bf396bec6aa89de43ec201c911196763e03e644ca7a.json new file mode 100644 index 0000000000..6b42ce5735 --- /dev/null +++ b/backend/.sqlx/query-075d4749299af2cb81162bf396bec6aa89de43ec201c911196763e03e644ca7a.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "edited_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "075d4749299af2cb81162bf396bec6aa89de43ec201c911196763e03e644ca7a" +} diff --git a/backend/.sqlx/query-17aafb72843659df9594d6d2466d2afaf26e666ffe52e0ea85792ea31b63410c.json b/backend/.sqlx/query-17aafb72843659df9594d6d2466d2afaf26e666ffe52e0ea85792ea31b63410c.json new file mode 100644 index 0000000000..babc193586 --- /dev/null +++ b/backend/.sqlx/query-17aafb72843659df9594d6d2466d2afaf26e666ffe52e0ea85792ea31b63410c.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "edited_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "17aafb72843659df9594d6d2466d2afaf26e666ffe52e0ea85792ea31b63410c" +} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-8311a553c44221751ffdbbe6a997d6feba8d43292daf6c5433b66bd8450e8854.json b/backend/.sqlx/query-8311a553c44221751ffdbbe6a997d6feba8d43292daf6c5433b66bd8450e8854.json new file mode 100644 index 0000000000..4d2ad74bf6 --- /dev/null +++ b/backend/.sqlx/query-8311a553c44221751ffdbbe6a997d6feba8d43292daf6c5433b66bd8450e8854.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, edited_by FROM http_trigger WHERE path = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "edited_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "8311a553c44221751ffdbbe6a997d6feba8d43292daf6c5433b66bd8450e8854" +} diff --git a/backend/.sqlx/query-85a6a85fd126a8bfedd65d6b38d22c65911ab9cf0414c33a3321a1d43af49795.json b/backend/.sqlx/query-85a6a85fd126a8bfedd65d6b38d22c65911ab9cf0414c33a3321a1d43af49795.json new file mode 100644 index 0000000000..7b1232ab96 --- /dev/null +++ b/backend/.sqlx/query-85a6a85fd126a8bfedd65d6b38d22c65911ab9cf0414c33a3321a1d43af49795.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "on_behalf_of_email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "85a6a85fd126a8bfedd65d6b38d22c65911ab9cf0414c33a3321a1d43af49795" +} diff --git a/backend/.sqlx/query-4144c87c25a939aafb2f57da189d94d038bcad7a36fbf87e0403c89a979c5b3f.json b/backend/.sqlx/query-987d79f7c6d7bc148cc8aab67e47161cfca045966e995e28c7a7ad090cffeda0.json similarity index 80% rename from backend/.sqlx/query-4144c87c25a939aafb2f57da189d94d038bcad7a36fbf87e0403c89a979c5b3f.json rename to backend/.sqlx/query-987d79f7c6d7bc148cc8aab67e47161cfca045966e995e28c7a7ad090cffeda0.json index 5ea52ccd39..a2678738b6 100644 --- a/backend/.sqlx/query-4144c87c25a939aafb2f57da189d94d038bcad7a36fbf87e0403c89a979c5b3f.json +++ b/backend/.sqlx/query-987d79f7c6d7bc148cc8aab67e47161cfca045966e995e28c7a7ad090cffeda0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip\n ", + "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = COALESCE($24, email),\n edited_by = $25\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip\n ", "describe": { "columns": [ { @@ -183,6 +183,8 @@ "Text", "Text", "Text", + "Varchar", + "Varchar", "Varchar" ] }, @@ -220,5 +222,5 @@ true ] }, - "hash": "4144c87c25a939aafb2f57da189d94d038bcad7a36fbf87e0403c89a979c5b3f" + "hash": "987d79f7c6d7bc148cc8aab67e47161cfca045966e995e28c7a7ad090cffeda0" } diff --git a/backend/.sqlx/query-07f5290e90533eac50b890a0d7f4a5e73ac111c838f687fe8647636827aae8b5.json b/backend/.sqlx/query-a9c805423e700b0acceb7c3dc43d1d3f9d4f56da25f588d281638e449d99a0d9.json similarity index 70% rename from backend/.sqlx/query-07f5290e90533eac50b890a0d7f4a5e73ac111c838f687fe8647636827aae8b5.json rename to backend/.sqlx/query-a9c805423e700b0acceb7c3dc43d1d3f9d4f56da25f588d281638e449d99a0d9.json index 811920b354..118d5a5f1c 100644 --- a/backend/.sqlx/query-07f5290e90533eac50b890a0d7f4a5e73ac111c838f687fe8647636827aae8b5.json +++ b/backend/.sqlx/query-a9c805423e700b0acceb7c3dc43d1d3f9d4f56da25f588d281638e449d99a0d9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) \n VALUES ($1, $2, $3, $4::text::json, $5)\n RETURNING id", + "query": "INSERT INTO flow_version (workspace_id, path, value, schema, created_by)\n VALUES ($1, $2, $3, $4::text::json, $5)\n RETURNING id", "describe": { "columns": [ { @@ -22,5 +22,5 @@ false ] }, - "hash": "07f5290e90533eac50b890a0d7f4a5e73ac111c838f687fe8647636827aae8b5" + "hash": "a9c805423e700b0acceb7c3dc43d1d3f9d4f56da25f588d281638e449d99a0d9" } diff --git a/backend/.sqlx/query-a9e29764b5b9d94269e2b8aa755c71b61774c8ff8ae218d7a8d6ed0ac0169366.json b/backend/.sqlx/query-a9e29764b5b9d94269e2b8aa755c71b61774c8ff8ae218d7a8d6ed0ac0169366.json index ef3968cdfa..b39c1b5b31 100644 --- a/backend/.sqlx/query-a9e29764b5b9d94269e2b8aa755c71b61774c8ff8ae218d7a8d6ed0ac0169366.json +++ b/backend/.sqlx/query-a9e29764b5b9d94269e2b8aa755c71b61774c8ff8ae218d7a8d6ed0ac0169366.json @@ -16,8 +16,7 @@ "resource", "variable", "ducklake", - "datatable", - "volume" + "datatable" ] } } diff --git a/backend/.sqlx/query-b12fba75788e44daefd9b3540a3aebe9167431aaa0a902b4558bc141c85ed825.json b/backend/.sqlx/query-b12fba75788e44daefd9b3540a3aebe9167431aaa0a902b4558bc141c85ed825.json new file mode 100644 index 0000000000..57a41de79a --- /dev/null +++ b/backend/.sqlx/query-b12fba75788e44daefd9b3540a3aebe9167431aaa0a902b4558bc141c85ed825.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "policy", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b12fba75788e44daefd9b3540a3aebe9167431aaa0a902b4558bc141c85ed825" +} diff --git a/backend/.sqlx/query-dda45bcc53e94659838e98b6b9e7a55be0e31aee3008d5190f09c1f15e5b47dd.json b/backend/.sqlx/query-dda45bcc53e94659838e98b6b9e7a55be0e31aee3008d5190f09c1f15e5b47dd.json new file mode 100644 index 0000000000..bf545ff976 --- /dev/null +++ b/backend/.sqlx/query-dda45bcc53e94659838e98b6b9e7a55be0e31aee3008d5190f09c1f15e5b47dd.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO group_\n VALUES ($1, 'wm_deployers', 'Members can preserve the original author when deploying to this workspace')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "dda45bcc53e94659838e98b6b9e7a55be0e31aee3008d5190f09c1f15e5b47dd" +} diff --git a/backend/.sqlx/query-e1f43cb65201b4f0965a4e18f0c918ae51fee667472d0cc2796ffdba4138d2ee.json b/backend/.sqlx/query-e1f43cb65201b4f0965a4e18f0c918ae51fee667472d0cc2796ffdba4138d2ee.json new file mode 100644 index 0000000000..639ade6359 --- /dev/null +++ b/backend/.sqlx/query-e1f43cb65201b4f0965a4e18f0c918ae51fee667472d0cc2796ffdba4138d2ee.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "on_behalf_of_email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e1f43cb65201b4f0965a4e18f0c918ae51fee667472d0cc2796ffdba4138d2ee" +} diff --git a/backend/.sqlx/query-e8d948274840699c5f7485ee4bc00b72c11bd226f99eade7e9a0da4605539283.json b/backend/.sqlx/query-e8d948274840699c5f7485ee4bc00b72c11bd226f99eade7e9a0da4605539283.json new file mode 100644 index 0000000000..d18b5265c5 --- /dev/null +++ b/backend/.sqlx/query-e8d948274840699c5f7485ee4bc00b72c11bd226f99eade7e9a0da4605539283.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "on_behalf_of_email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e8d948274840699c5f7485ee4bc00b72c11bd226f99eade7e9a0da4605539283" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 165a5e0e42..b703992dff 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -cb25312072c15c0e9cc375ebc824d41995a52898 +a4546264d41ce7122dfd127f4b17f724eb63c40a diff --git a/backend/migrations/20260224000000_create_wm_deployers_group.down.sql b/backend/migrations/20260224000000_create_wm_deployers_group.down.sql new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/migrations/20260224000000_create_wm_deployers_group.up.sql b/backend/migrations/20260224000000_create_wm_deployers_group.up.sql new file mode 100644 index 0000000000..126c77a101 --- /dev/null +++ b/backend/migrations/20260224000000_create_wm_deployers_group.up.sql @@ -0,0 +1,5 @@ +INSERT INTO group_ (workspace_id, name, summary, extra_perms) +SELECT id, 'wm_deployers', 'Members can preserve the original author when deploying to this workspace', '{}'::jsonb +FROM workspace +WHERE NOT deleted +ON CONFLICT (workspace_id, name) DO UPDATE SET summary = EXCLUDED.summary; diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index bb9510c688..f3c03aa932 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -447,6 +447,7 @@ def main(): deployment_message: None, visible_to_runner_only: None, on_behalf_of_email: None, + preserve_on_behalf_of: None, ws_error_handler_muted: None, }) .send() @@ -508,6 +509,7 @@ def main(): policy: None, deployment_message: None, custom_path: None, + preserve_on_behalf_of: None, }) .send() .await diff --git a/backend/tests/fixtures/preserve_on_behalf_of.sql b/backend/tests/fixtures/preserve_on_behalf_of.sql new file mode 100644 index 0000000000..514c554960 --- /dev/null +++ b/backend/tests/fixtures/preserve_on_behalf_of.sql @@ -0,0 +1,186 @@ +-- Fixture for preserve_on_behalf_of integration tests +-- Extends base.sql with a deployer user in the wm_deployers group + +-- Include all base setup (workspace, admin user, etc.) +INSERT INTO workspace + (id, name, owner) + VALUES ('test-workspace', 'test-workspace', 'test-user') +ON CONFLICT DO NOTHING; + +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin') +ON CONFLICT DO NOTHING; + +INSERT INTO workspace_key(workspace_id, kind, key) VALUES + ('test-workspace', 'cloud', 'test-key') +ON CONFLICT DO NOTHING; + +INSERT INTO workspace_settings (workspace_id) VALUES + ('test-workspace') +ON CONFLICT DO NOTHING; + +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('test-workspace', 'all', 'All users', '{}') +ON CONFLICT DO NOTHING; + +-- Create the wm_deployers group +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('test-workspace', 'wm_deployers', 'Users allowed to deploy and preserve on_behalf_of', '{}') +ON CONFLICT DO NOTHING; + +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username) + VALUES ('test@windmill.dev', 'not-a-real-hash', 'password', true, true, 'Test User', 'test-user') +ON CONFLICT DO NOTHING; + +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('test2@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Test User 2') +ON CONFLICT DO NOTHING; + +-- Deployer user (non-admin but in wm_deployers group) +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('deployer@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Deployer User') +ON CONFLICT DO NOTHING; + +-- Original user whose on_behalf_of should be preserved +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('original@windmill.dev', 'not-a-real-hash', 'password', false, true, 'Original User') +ON CONFLICT DO NOTHING; + +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('test-workspace', 'test2@windmill.dev', 'test-user-2', false, 'User') +ON CONFLICT DO NOTHING; + +-- Deployer user in workspace +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('test-workspace', 'deployer@windmill.dev', 'deployer-user', false, 'User') +ON CONFLICT DO NOTHING; + +-- Original user in workspace (whose on_behalf_of should be preserved) +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('test-workspace', 'original@windmill.dev', 'original-user', false, 'User') +ON CONFLICT DO NOTHING; + +-- Add deployer user to wm_deployers group +INSERT INTO usr_to_group(workspace_id, group_, usr) VALUES + ('test-workspace', 'wm_deployers', 'deployer-user') +ON CONFLICT DO NOTHING; + +-- Tokens for all users +INSERT INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true) +ON CONFLICT DO NOTHING; +INSERT INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false) +ON CONFLICT DO NOTHING; +INSERT INTO token(token, email, label, super_admin) VALUES ('DEPLOYER_TOKEN', 'deployer@windmill.dev', 'deployer token', false) +ON CONFLICT DO NOTHING; +INSERT INTO token(token, email, label, super_admin) VALUES ('ORIGINAL_TOKEN', 'original@windmill.dev', 'original token', false) +ON CONFLICT DO NOTHING; + +GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; +GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; + +CREATE OR REPLACE FUNCTION "notify_insert_on_completed_job" () +RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('completed', NEW.id::text); + RETURN NEW; +END; +$$ LANGUAGE PLPGSQL; + +DROP TRIGGER IF EXISTS "notify_insert_on_completed_job" ON "v2_job_completed"; +CREATE TRIGGER "notify_insert_on_completed_job" + AFTER INSERT ON "v2_job_completed" + FOR EACH ROW +EXECUTE FUNCTION "notify_insert_on_completed_job" (); + +CREATE OR REPLACE FUNCTION "notify_queue" () +RETURNS TRIGGER AS $$ +BEGIN + PERFORM pg_notify('queued', NEW.id::text); + RETURN NEW; +END; +$$ LANGUAGE PLPGSQL; + +DROP TRIGGER IF EXISTS "notify_queue_after_insert" ON "v2_job_queue"; +CREATE TRIGGER "notify_queue_after_insert" + AFTER INSERT ON "v2_job_queue" + FOR EACH ROW +EXECUTE FUNCTION "notify_queue" (); + +DROP TRIGGER IF EXISTS "notify_queue_after_flow_status_update" ON "v2_job_status"; +CREATE TRIGGER "notify_queue_after_flow_status_update" + AFTER UPDATE ON "v2_job_status" + FOR EACH ROW + WHEN (NEW.flow_status IS DISTINCT FROM OLD.flow_status) +EXECUTE FUNCTION "notify_queue" (); + +-- Apply phase 4: +DROP FUNCTION IF EXISTS v2_job_after_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_completed_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_completed_before_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_queue_after_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_queue_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_queue_before_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_runtime_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_runtime_before_update CASCADE; +DROP FUNCTION IF EXISTS v2_job_status_before_insert CASCADE; +DROP FUNCTION IF EXISTS v2_job_status_before_update CASCADE; + +DROP VIEW IF EXISTS completed_job, completed_job_view, job, queue, queue_view CASCADE; + +ALTER TABLE v2_job_queue + DROP COLUMN IF EXISTS __parent_job CASCADE, + DROP COLUMN IF EXISTS __created_by CASCADE, + DROP COLUMN IF EXISTS __script_hash CASCADE, + DROP COLUMN IF EXISTS __script_path CASCADE, + DROP COLUMN IF EXISTS __args CASCADE, + DROP COLUMN IF EXISTS __logs CASCADE, + DROP COLUMN IF EXISTS __raw_code CASCADE, + DROP COLUMN IF EXISTS __canceled CASCADE, + DROP COLUMN IF EXISTS __last_ping CASCADE, + DROP COLUMN IF EXISTS __job_kind CASCADE, + DROP COLUMN IF EXISTS __env_id CASCADE, + DROP COLUMN IF EXISTS __schedule_path CASCADE, + DROP COLUMN IF EXISTS __permissioned_as CASCADE, + DROP COLUMN IF EXISTS __flow_status CASCADE, + DROP COLUMN IF EXISTS __raw_flow CASCADE, + DROP COLUMN IF EXISTS __is_flow_step CASCADE, + DROP COLUMN IF EXISTS __language CASCADE, + DROP COLUMN IF EXISTS __same_worker CASCADE, + DROP COLUMN IF EXISTS __raw_lock CASCADE, + DROP COLUMN IF EXISTS __pre_run_error CASCADE, + DROP COLUMN IF EXISTS __email CASCADE, + DROP COLUMN IF EXISTS __visible_to_owner CASCADE, + DROP COLUMN IF EXISTS __mem_peak CASCADE, + DROP COLUMN IF EXISTS __root_job CASCADE, + DROP COLUMN IF EXISTS __leaf_jobs CASCADE, + DROP COLUMN IF EXISTS __concurrent_limit CASCADE, + DROP COLUMN IF EXISTS __concurrency_time_window_s CASCADE, + DROP COLUMN IF EXISTS __timeout CASCADE, + DROP COLUMN IF EXISTS __flow_step_id CASCADE, + DROP COLUMN IF EXISTS __cache_ttl CASCADE; + +LOCK TABLE v2_job_queue IN ACCESS EXCLUSIVE MODE; +ALTER TABLE v2_job_completed + DROP COLUMN IF EXISTS __parent_job CASCADE, + DROP COLUMN IF EXISTS __created_by CASCADE, + DROP COLUMN IF EXISTS __created_at CASCADE, + DROP COLUMN IF EXISTS __success CASCADE, + DROP COLUMN IF EXISTS __script_hash CASCADE, + DROP COLUMN IF EXISTS __script_path CASCADE, + DROP COLUMN IF EXISTS __args CASCADE, + DROP COLUMN IF EXISTS __logs CASCADE, + DROP COLUMN IF EXISTS __raw_code CASCADE, + DROP COLUMN IF EXISTS __canceled CASCADE, + DROP COLUMN IF EXISTS __job_kind CASCADE, + DROP COLUMN IF EXISTS __env_id CASCADE, + DROP COLUMN IF EXISTS __schedule_path CASCADE, + DROP COLUMN IF EXISTS __permissioned_as CASCADE, + DROP COLUMN IF EXISTS __raw_flow CASCADE, + DROP COLUMN IF EXISTS __is_flow_step CASCADE, + DROP COLUMN IF EXISTS __language CASCADE, + DROP COLUMN IF EXISTS __is_skipped CASCADE, + DROP COLUMN IF EXISTS __raw_lock CASCADE, + DROP COLUMN IF EXISTS __email CASCADE, + DROP COLUMN IF EXISTS __visible_to_owner CASCADE, + DROP COLUMN IF EXISTS __tag CASCADE, + DROP COLUMN IF EXISTS __priority CASCADE; diff --git a/backend/tests/preserve_on_behalf_of.rs b/backend/tests/preserve_on_behalf_of.rs new file mode 100644 index 0000000000..b70cd4148f --- /dev/null +++ b/backend/tests/preserve_on_behalf_of.rs @@ -0,0 +1,2275 @@ +//! Integration tests for preserve_on_behalf_of functionality. +//! +//! Tests verify that when deploying scripts, flows, apps, schedules, and triggers: +//! - Admin users can preserve the original on_behalf_of/email values +//! - Users in the wm_deployers group can preserve these values +//! - Regular users cannot preserve and their email is used instead + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +// ============================================================================ +// Script Tests +// ============================================================================ + +fn new_script_with_on_behalf_of( + path: &str, + on_behalf_of_email: Option<&str>, + preserve: bool, +) -> serde_json::Value { + let mut script = json!({ + "path": path, + "summary": "Test script", + "description": "", + "content": "export async function main() { return 42; }", + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }); + if let Some(email) = on_behalf_of_email { + script["on_behalf_of_email"] = json!(email); + } + if preserve { + script["preserve_on_behalf_of"] = json!(true); + } + script +} + +// ============================================================================ +// Flow Tests +// ============================================================================ + +fn new_flow_with_on_behalf_of( + path: &str, + on_behalf_of_email: Option<&str>, + preserve: bool, +) -> serde_json::Value { + let mut flow = json!({ + "path": path, + "summary": "Test flow", + "description": "", + "value": { "modules": [] }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }); + if let Some(email) = on_behalf_of_email { + flow["on_behalf_of_email"] = json!(email); + } + if preserve { + flow["preserve_on_behalf_of"] = json!(true); + } + flow +} + +// ============================================================================ +// App Tests +// ============================================================================ + +fn new_app_with_on_behalf_of( + path: &str, + on_behalf_of: Option<&str>, + on_behalf_of_email: Option<&str>, + preserve: bool, +) -> serde_json::Value { + let mut policy = json!({ + "execution_mode": "anonymous", + "triggerables": {} + }); + if let Some(obo) = on_behalf_of { + policy["on_behalf_of"] = json!(obo); + } + if let Some(email) = on_behalf_of_email { + policy["on_behalf_of_email"] = json!(email); + } + + let mut app = json!({ + "path": path, + "summary": "Test app", + "value": { + "type": "rawapp", + "inline_script": null + }, + "policy": policy + }); + if preserve { + app["preserve_on_behalf_of"] = json!(true); + } + app +} + +// ============================================================================ +// HTTP Trigger Helpers +// ============================================================================ + +#[cfg(feature = "http_trigger")] +fn new_http_trigger( + path: &str, + script_path: &str, + route_path: &str, + email: Option<&str>, + preserve: bool, +) -> serde_json::Value { + let mut trigger = json!({ + "path": path, + "script_path": script_path, + "is_flow": false, + "route_path": route_path, + "request_type": "async", + "authentication_method": "none", + "http_method": "post", + "is_static_website": false, + "workspaced_route": false, + "wrap_body": false, + "raw_string": false + }); + if let Some(e) = email { + trigger["email"] = json!(e); + } + if preserve { + trigger["preserve_email"] = json!(true); + } + trigger +} + +// ============================================================================ +// WebSocket Trigger Helpers +// ============================================================================ + +#[cfg(feature = "websocket")] +fn new_websocket_trigger( + path: &str, + script_path: &str, + email: Option<&str>, + preserve: bool, +) -> serde_json::Value { + let mut trigger = json!({ + "path": path, + "script_path": script_path, + "is_flow": false, + "url": "wss://echo.websocket.org", + "filters": [], + "can_return_message": false, + "can_return_error_result": false + }); + if let Some(e) = email { + trigger["email"] = json!(e); + } + if preserve { + trigger["preserve_email"] = json!(true); + } + trigger +} + +// ============================================================================ +// Comprehensive Test +// ============================================================================ + +/// Comprehensive test for preserve_on_behalf_of functionality. +/// Tests all entity types in a single test to minimize overhead. +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_preserve_on_behalf_of(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // ======================================== + // 1. Script: Admin preserves on_behalf_of_email + // ======================================== + + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/test-user/script_admin_preserve", + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create script: {}", + resp.text().await? + ); + + let script = sqlx::query!( + "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2", + "u/test-user/script_admin_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + script.on_behalf_of_email.as_deref(), + Some("original@windmill.dev"), + "Admin should preserve on_behalf_of_email" + ); + + // ======================================== + // 2. Script: Deployer (wm_deployers group) preserves on_behalf_of_email + // ======================================== + + let resp = authed( + client().post(format!("{base}/scripts/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/deployer-user/script_deployer_preserve", + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Deployer should create script: {}", + resp.text().await? + ); + + let script = sqlx::query!( + "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2", + "u/deployer-user/script_deployer_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + script.on_behalf_of_email.as_deref(), + Some("original@windmill.dev"), + "Deployer should preserve on_behalf_of_email" + ); + + // ======================================== + // 3. Script: Non-admin without wm_deployers cannot preserve + // ======================================== + + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN_2", + ) + .json(&new_script_with_on_behalf_of( + "u/test-user-2/script_no_preserve", + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Non-admin should create script: {}", + resp.text().await? + ); + + let script = sqlx::query!( + "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2", + "u/test-user-2/script_no_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + script.on_behalf_of_email.as_deref(), + Some("test2@windmill.dev"), + "Non-admin should have their own email as on_behalf_of_email" + ); + + // ======================================== + // 4. Flow: Admin preserves on_behalf_of_email + // ======================================== + + let resp = authed( + client().post(format!("{base}/flows/create")), + "SECRET_TOKEN", + ) + .json(&new_flow_with_on_behalf_of( + "u/test-user/flow_admin_preserve", + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create flow: {}", + resp.text().await? + ); + + let flow = sqlx::query!( + "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", + "u/test-user/flow_admin_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + flow.on_behalf_of_email.as_deref(), + Some("original@windmill.dev"), + "Admin should preserve flow on_behalf_of_email" + ); + + // ======================================== + // 5. Flow: Deployer preserves on_behalf_of_email + // ======================================== + + let resp = authed( + client().post(format!("{base}/flows/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_flow_with_on_behalf_of( + "u/deployer-user/flow_deployer_preserve", + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Deployer should create flow: {}", + resp.text().await? + ); + + let flow = sqlx::query!( + "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", + "u/deployer-user/flow_deployer_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + flow.on_behalf_of_email.as_deref(), + Some("original@windmill.dev"), + "Deployer should preserve flow on_behalf_of_email" + ); + + // ======================================== + // 6. Flow: Non-admin cannot preserve + // ======================================== + + let resp = authed( + client().post(format!("{base}/flows/create")), + "SECRET_TOKEN_2", + ) + .json(&new_flow_with_on_behalf_of( + "u/test-user-2/flow_no_preserve", + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Non-admin should create flow: {}", + resp.text().await? + ); + + let flow = sqlx::query!( + "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", + "u/test-user-2/flow_no_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + flow.on_behalf_of_email.as_deref(), + Some("test2@windmill.dev"), + "Non-admin should have their own email as flow on_behalf_of_email" + ); + + // ======================================== + // 7. App: Admin preserves on_behalf_of + // ======================================== + + let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN") + .json(&new_app_with_on_behalf_of( + "u/test-user/app_admin_preserve", + Some("u/original-user"), + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create app: {}", + resp.text().await? + ); + + let app = sqlx::query!( + "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", + "u/test-user/app_admin_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + let policy = app.policy; + assert_eq!( + policy.get("on_behalf_of").and_then(|v| v.as_str()), + Some("u/original-user"), + "Admin should preserve app on_behalf_of" + ); + assert_eq!( + policy.get("on_behalf_of_email").and_then(|v| v.as_str()), + Some("original@windmill.dev"), + "Admin should preserve app on_behalf_of_email" + ); + + // ======================================== + // 8. App: Deployer preserves on_behalf_of + // ======================================== + + let resp = authed( + client().post(format!("{base}/apps/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_app_with_on_behalf_of( + "u/deployer-user/app_deployer_preserve", + Some("u/original-user"), + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Deployer should create app: {}", + resp.text().await? + ); + + let app = sqlx::query!( + "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", + "u/deployer-user/app_deployer_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + let policy = app.policy; + assert_eq!( + policy.get("on_behalf_of").and_then(|v| v.as_str()), + Some("u/original-user"), + "Deployer should preserve app on_behalf_of" + ); + assert_eq!( + policy.get("on_behalf_of_email").and_then(|v| v.as_str()), + Some("original@windmill.dev"), + "Deployer should preserve app on_behalf_of_email" + ); + + // ======================================== + // 9. App: Non-admin cannot preserve + // ======================================== + + let resp = authed( + client().post(format!("{base}/apps/create")), + "SECRET_TOKEN_2", + ) + .json(&new_app_with_on_behalf_of( + "u/test-user-2/app_no_preserve", + Some("u/original-user"), + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Non-admin should create app: {}", + resp.text().await? + ); + + let app = sqlx::query!( + "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", + "u/test-user-2/app_no_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + let policy = app.policy; + assert_eq!( + policy.get("on_behalf_of").and_then(|v| v.as_str()), + Some("u/test-user-2"), + "Non-admin should have their own permissioned_as as app on_behalf_of" + ); + assert_eq!( + policy.get("on_behalf_of_email").and_then(|v| v.as_str()), + Some("test2@windmill.dev"), + "Non-admin should have their own email as app on_behalf_of_email" + ); + + // ======================================== + // 10. Schedule: Admin preserves email and edited_by + // ======================================== + + // First create a script for the schedule to reference + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/test-user/scheduled_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create scheduled script: {}", + resp.text().await? + ); + + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN", + ) + .json(&json!({ + "path": "u/test-user/schedule_admin_preserve", + "schedule": "0 0 */6 * * *", + "timezone": "UTC", + "script_path": "u/test-user/scheduled_script", + "is_flow": false, + "enabled": false, + "email": "original-user", + "preserve_email": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should create schedule: {}", + resp.text().await? + ); + + let schedule = sqlx::query!( + "SELECT email, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", + "u/test-user/schedule_admin_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + schedule.email, "original@windmill.dev", + "Admin should preserve schedule email" + ); + assert_eq!( + schedule.edited_by, "original-user", + "Admin should preserve schedule edited_by (looked up from email)" + ); + + // ======================================== + // 11. Schedule: Deployer preserves email and edited_by + // ======================================== + + // Create script for deployer + let resp = authed( + client().post(format!("{base}/scripts/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/deployer-user/scheduled_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create scheduled script: {}", + resp.text().await? + ); + + let resp = authed( + client().post(format!("{base}/schedules/create")), + "DEPLOYER_TOKEN", + ) + .json(&json!({ + "path": "u/deployer-user/schedule_deployer_preserve", + "schedule": "0 0 */6 * * *", + "timezone": "UTC", + "script_path": "u/deployer-user/scheduled_script", + "is_flow": false, + "enabled": false, + "email": "original-user", + "preserve_email": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Deployer should create schedule: {}", + resp.text().await? + ); + + let schedule = sqlx::query!( + "SELECT email, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", + "u/deployer-user/schedule_deployer_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + schedule.email, "original@windmill.dev", + "Deployer should preserve schedule email" + ); + assert_eq!( + schedule.edited_by, "original-user", + "Deployer should preserve schedule edited_by" + ); + + // ======================================== + // 12. Schedule: Non-admin cannot preserve + // ======================================== + + // Create script for test-user-2 + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN_2", + ) + .json(&new_script_with_on_behalf_of( + "u/test-user-2/scheduled_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create scheduled script: {}", + resp.text().await? + ); + + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "path": "u/test-user-2/schedule_no_preserve", + "schedule": "0 0 */6 * * *", + "timezone": "UTC", + "script_path": "u/test-user-2/scheduled_script", + "is_flow": false, + "enabled": false, + "email": "original-user", + "preserve_email": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Non-admin should create schedule: {}", + resp.text().await? + ); + + let schedule = sqlx::query!( + "SELECT email, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", + "u/test-user-2/schedule_no_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + schedule.email, "test2@windmill.dev", + "Non-admin should have their own email" + ); + assert_eq!( + schedule.edited_by, "test-user-2", + "Non-admin should have their own username as edited_by" + ); + + // ======================================== + // 13. Script: Without preserve flag, email is NOT preserved + // ======================================== + + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/test-user/script_no_flag", + Some("original@windmill.dev"), + false, // preserve_on_behalf_of = false + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create script: {}", + resp.text().await? + ); + + let script = sqlx::query!( + "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2", + "u/test-user/script_no_flag", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + script.on_behalf_of_email.as_deref(), + Some("test@windmill.dev"), + "Without preserve flag, admin's email should be used" + ); + + Ok(()) +} + +/// Helper to build a script JSON with custom content (to avoid hash conflicts on same path) +fn script_json( + path: &str, + content: &str, + on_behalf_of_email: Option<&str>, + preserve: bool, +) -> serde_json::Value { + let mut script = json!({ + "path": path, + "summary": "Test script", + "description": "", + "content": content, + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }); + if let Some(email) = on_behalf_of_email { + script["on_behalf_of_email"] = json!(email); + } + if preserve { + script["preserve_on_behalf_of"] = json!(true); + } + script +} + +/// Test script update preserves on_behalf_of_email correctly +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_script_update_preserves_on_behalf_of(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // ======================================== + // Admin updates with preserve flag + // ======================================== + + // Original-user creates initial version + let resp = authed( + client().post(format!("{base}/scripts/create")), + "ORIGINAL_TOKEN", + ) + .json(&script_json( + "u/original-user/script_to_update", + "export async function main() { return 1; }", + Some("original@windmill.dev"), + false, + )) + .send() + .await?; + assert_eq!(resp.status(), 201); + let parent_hash: String = resp.text().await?; + + // Admin creates new version with preserve, passing parent_hash + let mut update = script_json( + "u/original-user/script_to_update", + "export async function main() { return 2; }", + Some("original@windmill.dev"), + true, + ); + update["parent_hash"] = json!(parent_hash); + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&update) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should update script: {}", + resp.text().await? + ); + + let script = sqlx::query!( + "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", + "u/original-user/script_to_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + script.on_behalf_of_email.as_deref(), + Some("original@windmill.dev"), + "Admin update should preserve script on_behalf_of_email" + ); + + // ======================================== + // Deployer updates with preserve flag + // ======================================== + + // Admin creates initial version under deployer's path with preserve + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&script_json( + "u/deployer-user/script_deploy_update", + "export async function main() { return 3; }", + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!(resp.status(), 201); + let parent_hash: String = resp.text().await?; + + // Deployer creates new version with preserve + let mut update = script_json( + "u/deployer-user/script_deploy_update", + "export async function main() { return 4; }", + Some("original@windmill.dev"), + true, + ); + update["parent_hash"] = json!(parent_hash); + let resp = authed( + client().post(format!("{base}/scripts/create")), + "DEPLOYER_TOKEN", + ) + .json(&update) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Deployer should update script: {}", + resp.text().await? + ); + + let script = sqlx::query!( + "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", + "u/deployer-user/script_deploy_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + script.on_behalf_of_email.as_deref(), + Some("original@windmill.dev"), + "Deployer update should preserve script on_behalf_of_email" + ); + + // ======================================== + // Non-admin cannot preserve on update + // ======================================== + + // Admin creates initial version under non-admin's path with preserve + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&script_json( + "u/test-user-2/script_nonadmin_update", + "export async function main() { return 5; }", + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!(resp.status(), 201); + let parent_hash: String = resp.text().await?; + + // Non-admin creates new version with preserve (should be denied) + let mut update = script_json( + "u/test-user-2/script_nonadmin_update", + "export async function main() { return 6; }", + Some("original@windmill.dev"), + true, + ); + update["parent_hash"] = json!(parent_hash); + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN_2", + ) + .json(&update) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Non-admin should update script: {}", + resp.text().await? + ); + + let script = sqlx::query!( + "SELECT on_behalf_of_email FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1", + "u/test-user-2/script_nonadmin_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + script.on_behalf_of_email.as_deref(), + Some("test2@windmill.dev"), + "Non-admin update should overwrite script on_behalf_of_email with their own" + ); + + Ok(()) +} + +/// Test flow update preserves on_behalf_of_email correctly +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_flow_update_preserves_on_behalf_of(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // Create initial flow with original email + let resp = authed( + client().post(format!("{base}/flows/create")), + "ORIGINAL_TOKEN", + ) + .json(&new_flow_with_on_behalf_of( + "u/original-user/flow_to_update", + Some("original@windmill.dev"), + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create flow: {}", + resp.text().await? + ); + + // Admin updates with preserve flag + let resp = authed( + client().post(format!( + "{base}/flows/update/u/original-user/flow_to_update" + )), + "SECRET_TOKEN", + ) + .json(&json!({ + "path": "u/original-user/flow_to_update", + "summary": "Updated flow", + "description": "", + "value": { "modules": [] }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + }, + "on_behalf_of_email": "original@windmill.dev", + "preserve_on_behalf_of": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should update flow: {}", + resp.text().await? + ); + + let flow = sqlx::query!( + "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", + "u/original-user/flow_to_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + flow.on_behalf_of_email.as_deref(), + Some("original@windmill.dev"), + "Admin update should preserve flow on_behalf_of_email" + ); + + // ======================================== + // Deployer updates with preserve flag + // ======================================== + + // Admin creates flow under deployer's path with preserve + let resp = authed( + client().post(format!("{base}/flows/create")), + "SECRET_TOKEN", + ) + .json(&new_flow_with_on_behalf_of( + "u/deployer-user/flow_deploy_update", + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create flow: {}", + resp.text().await? + ); + + // Deployer updates at their own path with preserve + let resp = authed( + client().post(format!( + "{base}/flows/update/u/deployer-user/flow_deploy_update" + )), + "DEPLOYER_TOKEN", + ) + .json(&json!({ + "path": "u/deployer-user/flow_deploy_update", + "summary": "Deployer updated flow", + "description": "", + "value": { "modules": [] }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + }, + "on_behalf_of_email": "original@windmill.dev", + "preserve_on_behalf_of": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Deployer should update flow: {}", + resp.text().await? + ); + + let flow = sqlx::query!( + "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", + "u/deployer-user/flow_deploy_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + flow.on_behalf_of_email.as_deref(), + Some("original@windmill.dev"), + "Deployer update should preserve flow on_behalf_of_email" + ); + + // ======================================== + // Non-admin cannot preserve on update + // ======================================== + + // Admin creates flow under non-admin's path with preserve + let resp = authed( + client().post(format!("{base}/flows/create")), + "SECRET_TOKEN", + ) + .json(&new_flow_with_on_behalf_of( + "u/test-user-2/flow_nonadmin_update", + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create flow: {}", + resp.text().await? + ); + + // Non-admin updates at their own path with preserve (should be denied) + let resp = authed( + client().post(format!( + "{base}/flows/update/u/test-user-2/flow_nonadmin_update" + )), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "path": "u/test-user-2/flow_nonadmin_update", + "summary": "Non-admin updated flow", + "description": "", + "value": { "modules": [] }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + }, + "on_behalf_of_email": "original@windmill.dev", + "preserve_on_behalf_of": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Non-admin should update flow: {}", + resp.text().await? + ); + + let flow = sqlx::query!( + "SELECT on_behalf_of_email FROM flow WHERE path = $1 AND workspace_id = $2", + "u/test-user-2/flow_nonadmin_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + flow.on_behalf_of_email.as_deref(), + Some("test2@windmill.dev"), + "Non-admin update should overwrite flow on_behalf_of_email with their own" + ); + + Ok(()) +} + +/// Test app update preserves on_behalf_of correctly +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_app_update_preserves_on_behalf_of(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // Create initial app + let resp = authed( + client().post(format!("{base}/apps/create")), + "ORIGINAL_TOKEN", + ) + .json(&new_app_with_on_behalf_of( + "u/original-user/app_to_update", + Some("u/original-user"), + Some("original@windmill.dev"), + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create app: {}", + resp.text().await? + ); + + // Admin updates with preserve flag + let resp = authed( + client().post(format!("{base}/apps/update/u/original-user/app_to_update")), + "SECRET_TOKEN", + ) + .json(&json!({ + "summary": "Updated app", + "policy": { + "execution_mode": "anonymous", + "triggerables": {}, + "on_behalf_of": "u/original-user", + "on_behalf_of_email": "original@windmill.dev" + }, + "preserve_on_behalf_of": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should update app: {}", + resp.text().await? + ); + + let app = sqlx::query!( + "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", + "u/original-user/app_to_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + let policy = app.policy; + assert_eq!( + policy.get("on_behalf_of").and_then(|v| v.as_str()), + Some("u/original-user"), + "Admin update should preserve app on_behalf_of" + ); + assert_eq!( + policy.get("on_behalf_of_email").and_then(|v| v.as_str()), + Some("original@windmill.dev"), + "Admin update should preserve app on_behalf_of_email" + ); + + // ======================================== + // Deployer updates with preserve flag + // ======================================== + + // Admin creates app under deployer's path with preserve + let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN") + .json(&new_app_with_on_behalf_of( + "u/deployer-user/app_deploy_update", + Some("u/original-user"), + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create app: {}", + resp.text().await? + ); + + // Deployer updates at their own path with preserve + let resp = authed( + client().post(format!( + "{base}/apps/update/u/deployer-user/app_deploy_update" + )), + "DEPLOYER_TOKEN", + ) + .json(&json!({ + "summary": "Deployer updated app", + "policy": { + "execution_mode": "anonymous", + "triggerables": {}, + "on_behalf_of": "u/original-user", + "on_behalf_of_email": "original@windmill.dev" + }, + "preserve_on_behalf_of": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Deployer should update app: {}", + resp.text().await? + ); + + let app = sqlx::query!( + "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", + "u/deployer-user/app_deploy_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + let policy = app.policy; + assert_eq!( + policy.get("on_behalf_of").and_then(|v| v.as_str()), + Some("u/original-user"), + "Deployer update should preserve app on_behalf_of" + ); + assert_eq!( + policy.get("on_behalf_of_email").and_then(|v| v.as_str()), + Some("original@windmill.dev"), + "Deployer update should preserve app on_behalf_of_email" + ); + + // ======================================== + // Non-admin cannot preserve on update + // ======================================== + + // Admin creates app under non-admin's path with preserve + let resp = authed(client().post(format!("{base}/apps/create")), "SECRET_TOKEN") + .json(&new_app_with_on_behalf_of( + "u/test-user-2/app_nonadmin_update", + Some("u/original-user"), + Some("original@windmill.dev"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create app: {}", + resp.text().await? + ); + + // Non-admin updates at their own path with preserve (should be denied) + let resp = authed( + client().post(format!( + "{base}/apps/update/u/test-user-2/app_nonadmin_update" + )), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "summary": "Non-admin updated app", + "policy": { + "execution_mode": "anonymous", + "triggerables": {}, + "on_behalf_of": "u/original-user", + "on_behalf_of_email": "original@windmill.dev" + }, + "preserve_on_behalf_of": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Non-admin should update app: {}", + resp.text().await? + ); + + let app = sqlx::query!( + "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2", + "u/test-user-2/app_nonadmin_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + let policy = app.policy; + assert_eq!( + policy.get("on_behalf_of").and_then(|v| v.as_str()), + Some("u/test-user-2"), + "Non-admin update should overwrite app on_behalf_of with their own" + ); + assert_eq!( + policy.get("on_behalf_of_email").and_then(|v| v.as_str()), + Some("test2@windmill.dev"), + "Non-admin update should overwrite app on_behalf_of_email with their own" + ); + + Ok(()) +} + +/// Test schedule update preserves email/edited_by correctly +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_schedule_update_preserves_email(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // Create script for schedule + let resp = authed( + client().post(format!("{base}/scripts/create")), + "ORIGINAL_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/original-user/scheduled_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create script: {}", + resp.text().await? + ); + + // Create initial schedule + let resp = authed( + client().post(format!("{base}/schedules/create")), + "ORIGINAL_TOKEN", + ) + .json(&json!({ + "path": "u/original-user/schedule_to_update", + "schedule": "0 0 */6 * * *", + "timezone": "UTC", + "script_path": "u/original-user/scheduled_script", + "is_flow": false, + "enabled": false + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Should create schedule: {}", + resp.text().await? + ); + + // Verify initial state + let schedule = sqlx::query!( + "SELECT email, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", + "u/original-user/schedule_to_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!(schedule.email, "original@windmill.dev"); + assert_eq!(schedule.edited_by, "original-user"); + + // Admin updates with preserve flag + let resp = authed( + client().post(format!( + "{base}/schedules/update/u/original-user/schedule_to_update" + )), + "SECRET_TOKEN", + ) + .json(&json!({ + "schedule": "0 0 */12 * * *", + "timezone": "UTC", + "email": "original-user", + "preserve_email": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should update schedule: {}", + resp.text().await? + ); + + let schedule = sqlx::query!( + "SELECT email, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", + "u/original-user/schedule_to_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + schedule.email, "original@windmill.dev", + "Admin update should preserve schedule email" + ); + assert_eq!( + schedule.edited_by, "original-user", + "Admin update should preserve schedule edited_by" + ); + + // ======================================== + // Deployer updates with preserve flag + // ======================================== + + // Create script under deployer's path + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/deployer-user/sched_deploy_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create script: {}", + resp.text().await? + ); + + // Admin creates schedule under deployer's path with preserve + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN", + ) + .json(&json!({ + "path": "u/deployer-user/schedule_deploy_update", + "schedule": "0 0 */6 * * *", + "timezone": "UTC", + "script_path": "u/deployer-user/sched_deploy_script", + "is_flow": false, + "enabled": false, + "email": "original-user", + "preserve_email": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should create schedule: {}", + resp.text().await? + ); + + // Deployer updates at their own path with preserve + let resp = authed( + client().post(format!( + "{base}/schedules/update/u/deployer-user/schedule_deploy_update" + )), + "DEPLOYER_TOKEN", + ) + .json(&json!({ + "schedule": "0 0 */8 * * *", + "timezone": "UTC", + "email": "original-user", + "preserve_email": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Deployer should update schedule: {}", + resp.text().await? + ); + + let schedule = sqlx::query!( + "SELECT email, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", + "u/deployer-user/schedule_deploy_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + schedule.email, "original@windmill.dev", + "Deployer update should preserve schedule email" + ); + assert_eq!( + schedule.edited_by, "original-user", + "Deployer update should preserve schedule edited_by" + ); + + // ======================================== + // Non-admin cannot preserve on update + // ======================================== + + // Create script under non-admin's path + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/test-user-2/sched_nonadmin_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create script: {}", + resp.text().await? + ); + + // Admin creates schedule under non-admin's path with preserve + let resp = authed( + client().post(format!("{base}/schedules/create")), + "SECRET_TOKEN", + ) + .json(&json!({ + "path": "u/test-user-2/schedule_nonadmin_update", + "schedule": "0 0 */6 * * *", + "timezone": "UTC", + "script_path": "u/test-user-2/sched_nonadmin_script", + "is_flow": false, + "enabled": false, + "email": "original-user", + "preserve_email": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should create schedule: {}", + resp.text().await? + ); + + // Non-admin updates at their own path with preserve (should be denied) + let resp = authed( + client().post(format!( + "{base}/schedules/update/u/test-user-2/schedule_nonadmin_update" + )), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "schedule": "0 0 */4 * * *", + "timezone": "UTC", + "email": "original-user", + "preserve_email": true + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Non-admin should update schedule: {}", + resp.text().await? + ); + + let schedule = sqlx::query!( + "SELECT email, edited_by FROM schedule WHERE path = $1 AND workspace_id = $2", + "u/test-user-2/schedule_nonadmin_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + // When preserve is denied, resolve_email returns the authed user's email + assert_eq!( + schedule.email, "test2@windmill.dev", + "Non-admin update should overwrite schedule email with their own" + ); + + Ok(()) +} + +// ============================================================================ +// HTTP Trigger Tests +// ============================================================================ +// All trigger types share the same BaseTriggerData.resolve_email() and +// resolve_edited_by() code path. Testing HTTP triggers validates the +// preservation logic for all trigger types (WebSocket, MQTT, PostgreSQL, +// Kafka, NATS, SQS, GCP, Email). + +/// HTTP Trigger: admin preserve_email tests (HTTP triggers require admin) +#[cfg(feature = "http_trigger")] +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_http_trigger_preserve_email(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // Create script for triggers to reference (admin-only for HTTP triggers) + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/test-user/trigger_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create script: {}", + resp.text().await? + ); + + // ======================================== + // 1. Admin preserves email + // ======================================== + + let resp = authed( + client().post(format!("{base}/http_triggers/create")), + "SECRET_TOKEN", + ) + .json(&new_http_trigger( + "u/test-user/http_admin_preserve", + "u/test-user/trigger_script", + "admin-preserve", + Some("original-user"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create http trigger: {}", + resp.text().await? + ); + + let trigger = sqlx::query!( + "SELECT email, edited_by FROM http_trigger WHERE path = $1 AND workspace_id = $2", + "u/test-user/http_admin_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + trigger.email, "original@windmill.dev", + "Admin should preserve http trigger email" + ); + assert_eq!( + trigger.edited_by, "original-user", + "Admin should preserve http trigger edited_by" + ); + + // ======================================== + // 2. Without preserve flag, email is NOT preserved + // ======================================== + + let resp = authed( + client().post(format!("{base}/http_triggers/create")), + "SECRET_TOKEN", + ) + .json(&new_http_trigger( + "u/test-user/http_no_flag", + "u/test-user/trigger_script", + "no-flag", + Some("original-user"), + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create http trigger: {}", + resp.text().await? + ); + + let trigger = sqlx::query!( + "SELECT email, edited_by FROM http_trigger WHERE path = $1 AND workspace_id = $2", + "u/test-user/http_no_flag", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + trigger.email, "test@windmill.dev", + "Without preserve flag, admin's own email should be used" + ); + assert_eq!( + trigger.edited_by, "test-user", + "Without preserve flag, admin's own username should be used" + ); + + Ok(()) +} + +/// HTTP Trigger update: admin preserves email/edited_by (HTTP triggers require admin) +#[cfg(feature = "http_trigger")] +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_http_trigger_update_preserves_email(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // Create script for the trigger (admin creates everything for HTTP triggers) + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/test-user/http_update_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create script: {}", + resp.text().await? + ); + + // Admin creates initial trigger without preserve (sets admin's own email) + let resp = authed( + client().post(format!("{base}/http_triggers/create")), + "SECRET_TOKEN", + ) + .json(&new_http_trigger( + "u/test-user/http_to_update", + "u/test-user/http_update_script", + "to-update", + Some("original-user"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create http trigger: {}", + resp.text().await? + ); + + // Verify initial state + let trigger = sqlx::query!( + "SELECT email, edited_by FROM http_trigger WHERE path = $1 AND workspace_id = $2", + "u/test-user/http_to_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!(trigger.email, "original@windmill.dev"); + assert_eq!(trigger.edited_by, "original-user"); + + // Admin updates with preserve flag + let resp = authed( + client().post(format!( + "{base}/http_triggers/update/u/test-user/http_to_update" + )), + "SECRET_TOKEN", + ) + .json(&new_http_trigger( + "u/test-user/http_to_update", + "u/test-user/http_update_script", + "to-update", + Some("original-user"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should update http trigger: {}", + resp.text().await? + ); + + let trigger = sqlx::query!( + "SELECT email, edited_by FROM http_trigger WHERE path = $1 AND workspace_id = $2", + "u/test-user/http_to_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + trigger.email, "original@windmill.dev", + "Admin update should preserve http trigger email" + ); + assert_eq!( + trigger.edited_by, "original-user", + "Admin update should preserve http trigger edited_by" + ); + + Ok(()) +} + +// ============================================================================ +// WebSocket Trigger Tests +// ============================================================================ + +/// WebSocket Trigger: admin, deployer, and non-admin preserve_email tests +#[cfg(feature = "websocket")] +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_websocket_trigger_preserve_email(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // Create scripts for triggers to reference + for (token, path) in [ + ("SECRET_TOKEN", "u/test-user/ws_script"), + ("DEPLOYER_TOKEN", "u/deployer-user/ws_script"), + ("SECRET_TOKEN_2", "u/test-user-2/ws_script"), + ] { + let resp = authed(client().post(format!("{base}/scripts/create")), token) + .json(&new_script_with_on_behalf_of(path, None, false)) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create script {path}: {}", + resp.text().await? + ); + } + + // ======================================== + // 1. Admin preserves email + // ======================================== + + let resp = authed( + client().post(format!("{base}/websocket_triggers/create")), + "SECRET_TOKEN", + ) + .json(&new_websocket_trigger( + "u/test-user/ws_admin_preserve", + "u/test-user/ws_script", + Some("original-user"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create websocket trigger: {}", + resp.text().await? + ); + + let trigger = sqlx::query!( + "SELECT email, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", + "u/test-user/ws_admin_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + trigger.email, "original@windmill.dev", + "Admin should preserve websocket trigger email" + ); + assert_eq!( + trigger.edited_by, "original-user", + "Admin should preserve websocket trigger edited_by" + ); + + // ======================================== + // 2. Deployer preserves email + // ======================================== + + let resp = authed( + client().post(format!("{base}/websocket_triggers/create")), + "DEPLOYER_TOKEN", + ) + .json(&new_websocket_trigger( + "u/deployer-user/ws_deployer_preserve", + "u/deployer-user/ws_script", + Some("original-user"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Deployer should create websocket trigger: {}", + resp.text().await? + ); + + let trigger = sqlx::query!( + "SELECT email, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", + "u/deployer-user/ws_deployer_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + trigger.email, "original@windmill.dev", + "Deployer should preserve websocket trigger email" + ); + assert_eq!( + trigger.edited_by, "original-user", + "Deployer should preserve websocket trigger edited_by" + ); + + // ======================================== + // 3. Non-admin cannot preserve + // ======================================== + + let resp = authed( + client().post(format!("{base}/websocket_triggers/create")), + "SECRET_TOKEN_2", + ) + .json(&new_websocket_trigger( + "u/test-user-2/ws_no_preserve", + "u/test-user-2/ws_script", + Some("original-user"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Non-admin should create websocket trigger: {}", + resp.text().await? + ); + + let trigger = sqlx::query!( + "SELECT email, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", + "u/test-user-2/ws_no_preserve", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + trigger.email, "test2@windmill.dev", + "Non-admin should have their own email" + ); + assert_eq!( + trigger.edited_by, "test-user-2", + "Non-admin should have their own username as edited_by" + ); + + Ok(()) +} + +/// WebSocket Trigger update: admin preserves email/edited_by +#[cfg(feature = "websocket")] +#[sqlx::test(fixtures("preserve_on_behalf_of"))] +async fn test_websocket_trigger_update_preserves_email(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // Create script + let resp = authed( + client().post(format!("{base}/scripts/create")), + "ORIGINAL_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/original-user/ws_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create script: {}", + resp.text().await? + ); + + // Create initial trigger + let resp = authed( + client().post(format!("{base}/websocket_triggers/create")), + "ORIGINAL_TOKEN", + ) + .json(&new_websocket_trigger( + "u/original-user/ws_to_update", + "u/original-user/ws_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create websocket trigger: {}", + resp.text().await? + ); + + // Verify initial state + let trigger = sqlx::query!( + "SELECT email, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", + "u/original-user/ws_to_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!(trigger.email, "original@windmill.dev"); + assert_eq!(trigger.edited_by, "original-user"); + + // Admin updates with preserve flag + let resp = authed( + client().post(format!( + "{base}/websocket_triggers/update/u/original-user/ws_to_update" + )), + "SECRET_TOKEN", + ) + .json(&new_websocket_trigger( + "u/original-user/ws_to_update", + "u/original-user/ws_script", + Some("original-user"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should update websocket trigger: {}", + resp.text().await? + ); + + let trigger = sqlx::query!( + "SELECT email, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", + "u/original-user/ws_to_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + trigger.email, "original@windmill.dev", + "Admin update should preserve websocket trigger email" + ); + assert_eq!( + trigger.edited_by, "original-user", + "Admin update should preserve websocket trigger edited_by" + ); + + // ======================================== + // Deployer updates with preserve flag + // ======================================== + + // Create script under deployer's path + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/deployer-user/ws_deploy_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create script: {}", + resp.text().await? + ); + + // Admin creates trigger under deployer's path with preserve + let resp = authed( + client().post(format!("{base}/websocket_triggers/create")), + "SECRET_TOKEN", + ) + .json(&new_websocket_trigger( + "u/deployer-user/ws_deploy_update", + "u/deployer-user/ws_deploy_script", + Some("original-user"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create websocket trigger: {}", + resp.text().await? + ); + + // Deployer updates at their own path with preserve + let resp = authed( + client().post(format!( + "{base}/websocket_triggers/update/u/deployer-user/ws_deploy_update" + )), + "DEPLOYER_TOKEN", + ) + .json(&new_websocket_trigger( + "u/deployer-user/ws_deploy_update", + "u/deployer-user/ws_deploy_script", + Some("original-user"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Deployer should update websocket trigger: {}", + resp.text().await? + ); + + let trigger = sqlx::query!( + "SELECT email, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", + "u/deployer-user/ws_deploy_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + trigger.email, "original@windmill.dev", + "Deployer update should preserve websocket trigger email" + ); + assert_eq!( + trigger.edited_by, "original-user", + "Deployer update should preserve websocket trigger edited_by" + ); + + // ======================================== + // Non-admin cannot preserve on update + // ======================================== + + // Create script under non-admin's path + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script_with_on_behalf_of( + "u/test-user-2/ws_nonadmin_script", + None, + false, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create script: {}", + resp.text().await? + ); + + // Admin creates trigger under non-admin's path with preserve + let resp = authed( + client().post(format!("{base}/websocket_triggers/create")), + "SECRET_TOKEN", + ) + .json(&new_websocket_trigger( + "u/test-user-2/ws_nonadmin_update", + "u/test-user-2/ws_nonadmin_script", + Some("original-user"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should create websocket trigger: {}", + resp.text().await? + ); + + // Non-admin updates at their own path with preserve (should be denied) + let resp = authed( + client().post(format!( + "{base}/websocket_triggers/update/u/test-user-2/ws_nonadmin_update" + )), + "SECRET_TOKEN_2", + ) + .json(&new_websocket_trigger( + "u/test-user-2/ws_nonadmin_update", + "u/test-user-2/ws_nonadmin_script", + Some("original-user"), + true, + )) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Non-admin should update websocket trigger: {}", + resp.text().await? + ); + + let trigger = sqlx::query!( + "SELECT email, edited_by FROM websocket_trigger WHERE path = $1 AND workspace_id = $2", + "u/test-user-2/ws_nonadmin_update", + "test-workspace" + ) + .fetch_one(&db) + .await?; + assert_eq!( + trigger.email, "test2@windmill.dev", + "Non-admin update should overwrite websocket trigger email with their own" + ); + assert_eq!( + trigger.edited_by, "test-user-2", + "Non-admin update should overwrite websocket trigger edited_by with their own" + ); + + Ok(()) +} diff --git a/backend/tests/protection_rules.rs b/backend/tests/protection_rules.rs new file mode 100644 index 0000000000..3d71d622ed --- /dev/null +++ b/backend/tests/protection_rules.rs @@ -0,0 +1,306 @@ +//! Integration tests for workspace protection rulesets. +//! +//! Tests verify that DisableDirectDeployment protection rules correctly +//! block/allow operations based on user permissions. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::workspaces::invalidate_protection_rules_cache; + +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +fn new_script(path: &str, summary: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": summary, + "description": "", + "content": "export async function main() { return 42; }", + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }) +} + +fn new_flow(path: &str, summary: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": summary, + "description": "", + "value": { "modules": [] }, + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }) +} + +/// Comprehensive test for protection rules functionality. +/// Tests all essential cases in a single test to avoid cache interference. +#[sqlx::test(fixtures("base"))] +async fn test_protection_rules(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + invalidate_protection_rules_cache("test-workspace"); + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + // ======================================== + // 1. Without protection rule, non-admin can create scripts and flows + // ======================================== + + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN_2", + ) + .json(&new_script("u/test-user-2/script_no_rule", "No rule")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create script without rule: {}", + resp.text().await? + ); + + let resp = authed( + client().post(format!("{base}/flows/create")), + "SECRET_TOKEN_2", + ) + .json(&new_flow("u/test-user-2/flow_no_rule", "No rule")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create flow without rule: {}", + resp.text().await? + ); + + // ======================================== + // 2. Non-admin cannot create protection rules + // ======================================== + + let resp = authed( + client().post(format!("{base}/workspaces/protection_rules")), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "name": "unauthorized-rule", + "rules": ["DisableDirectDeployment"], + "bypass_users": [], + "bypass_groups": [] + })) + .send() + .await?; + assert!( + !resp.status().is_success(), + "Non-admin should not create rules: {}", + resp.status() + ); + + // ======================================== + // 3. Admin creates protection rule + // ======================================== + + let resp = authed( + client().post(format!("{base}/workspaces/protection_rules")), + "SECRET_TOKEN", + ) + .json(&json!({ + "name": "test-rule", + "rules": ["DisableDirectDeployment"], + "bypass_users": [], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should create rule: {}", + resp.text().await? + ); + + // ======================================== + // 4. With rule, non-admin is blocked from creating scripts/flows + // ======================================== + + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN_2", + ) + .json(&new_script("u/test-user-2/blocked_script", "Blocked")) + .send() + .await?; + assert!( + !resp.status().is_success(), + "Non-admin should be blocked from scripts: {}", + resp.status() + ); + let body = resp.text().await?; + assert!( + body.contains("blocked") || body.contains("Blocked"), + "Error should mention blocking: {}", + body + ); + + let resp = authed( + client().post(format!("{base}/flows/create")), + "SECRET_TOKEN_2", + ) + .json(&new_flow("u/test-user-2/blocked_flow", "Blocked")) + .send() + .await?; + assert!( + !resp.status().is_success(), + "Non-admin should be blocked from flows: {}", + resp.status() + ); + + // ======================================== + // 5. Admin bypasses protection rule + // ======================================== + + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN", + ) + .json(&new_script("u/test-user/admin_script", "Admin")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should bypass rule: {}", + resp.text().await? + ); + + // ======================================== + // 6. Update rule to bypass test-user-2 + // ======================================== + + let resp = authed( + client().post(format!("{base}/workspaces/protection_rules/test-rule")), + "SECRET_TOKEN", + ) + .json(&json!({ + "rules": ["DisableDirectDeployment"], + "bypass_users": ["test-user-2"], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Should update rule: {}", + resp.text().await? + ); + + // Invalidate cache to pick up the update + invalidate_protection_rules_cache("test-workspace"); + + // ======================================== + // 7. Bypassed user can now create + // ======================================== + + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN_2", + ) + .json(&new_script("u/test-user-2/bypassed_script", "Bypassed")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Bypassed user should create: {}", + resp.text().await? + ); + + // ======================================== + // 8. Non-bypassed user (test-user-3) is still blocked + // ======================================== + + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN_3", + ) + .json(&new_script("u/test-user-3/still_blocked", "Blocked")) + .send() + .await?; + assert!( + !resp.status().is_success(), + "Non-bypassed user should be blocked: {}", + resp.status() + ); + + // ======================================== + // 9. Delete rule + // ======================================== + + let resp = authed( + client().delete(format!("{base}/workspaces/protection_rules/test-rule")), + "SECRET_TOKEN", + ) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Should delete rule: {}", + resp.text().await? + ); + + // Invalidate cache to pick up the deletion + invalidate_protection_rules_cache("test-workspace"); + + // ======================================== + // 10. After deletion, non-admin can create again + // ======================================== + + let resp = authed( + client().post(format!("{base}/scripts/create")), + "SECRET_TOKEN_3", + ) + .json(&new_script("u/test-user-3/after_delete", "After delete")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Should create after rule deletion: {}", + resp.text().await? + ); + + // ======================================== + // 11. Verify rule list is empty + // ======================================== + + let resp = authed( + client().get(format!("{base}/workspaces/protection_rules")), + "SECRET_TOKEN", + ) + .send() + .await?; + assert_eq!(resp.status(), 200); + let rules: Vec = resp.json().await?; + assert!(rules.is_empty(), "Should have no rules after deletion"); + + Ok(()) +} diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index ab963ee531..223ec6786f 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -503,7 +503,11 @@ async fn create_flow( nf.tag, nf.dedicated_worker, nf.visible_to_runner_only.unwrap_or(false), - nf.on_behalf_of_email.and(Some(&authed.email)), + windmill_common::resolve_on_behalf_of_email( + nf.on_behalf_of_email.as_deref(), + nf.preserve_on_behalf_of.unwrap_or(false), + &authed, + ), nf.ws_error_handler_muted.unwrap_or(false), sqlx::types::Json(&nf.value) as _, schema_str, @@ -513,7 +517,7 @@ async fn create_flow( .await?; let version = sqlx::query_scalar!( - "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) + "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) VALUES ($1, $2, $3, $4::text::json, $5) RETURNING id", w_id, @@ -555,6 +559,29 @@ async fn create_flow( ), ) .await?; + if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation( + nf.on_behalf_of_email.as_deref(), + nf.preserve_on_behalf_of.unwrap_or(false), + &authed, + &authed.email, + ) { + audit_log( + &mut *tx, + &authed, + "flows.on_behalf_of", + ActionKind::Create, + &w_id, + Some(&nf.path), + Some( + [ + ("on_behalf_of", on_behalf_of.as_str()), + ("action", "create"), + ] + .into(), + ), + ) + .await?; + } let mut args: HashMap> = HashMap::new(); if let Some(dm) = nf.deployment_message { @@ -940,7 +967,11 @@ async fn update_flow( nf.tag, nf.dedicated_worker, nf.visible_to_runner_only.unwrap_or(false), - nf.on_behalf_of_email.and(Some(&authed.email)), + windmill_common::resolve_on_behalf_of_email( + nf.on_behalf_of_email.as_deref(), + nf.preserve_on_behalf_of.unwrap_or(false), + &authed, + ), nf.ws_error_handler_muted.unwrap_or(false), sqlx::types::Json(&nf.value) as _, schema_str, @@ -1103,6 +1134,29 @@ async fn update_flow( ), ) .await?; + if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation( + nf.on_behalf_of_email.as_deref(), + nf.preserve_on_behalf_of.unwrap_or(false), + &authed, + &authed.email, + ) { + audit_log( + &mut *tx, + &authed, + "flows.on_behalf_of", + ActionKind::Update, + &w_id, + Some(&nf.path), + Some( + [ + ("on_behalf_of", on_behalf_of.as_str()), + ("action", "update"), + ] + .into(), + ), + ) + .await?; + } webhook.send_message( w_id.clone(), diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 162b1f15e4..641fbfee56 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -21,6 +21,7 @@ use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::DB; use windmill_common::{ + can_preserve_on_behalf_of, db::UserDB, error::{Error, JsonResult, Result}, schedule::Schedule, @@ -30,6 +31,45 @@ use windmill_common::{ use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; use windmill_queue::schedule::push_scheduled_job; +/// Resolves the email to use for a schedule based on preservation settings. +/// When preserving, looks up the email from the provided username. +async fn resolve_email( + username: Option<&String>, + preserve_email: Option, + authed: &ApiAuthed, + db: &DB, + w_id: &str, +) -> Result { + if let Some(username) = username { + if preserve_email.unwrap_or(false) && can_preserve_on_behalf_of(authed) { + let email = sqlx::query_scalar!( + "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", + username, + w_id + ) + .fetch_optional(db) + .await?; + if let Some(email) = email { + return Ok(email); + } + } + } + Ok(authed.email.clone()) +} + +fn resolve_edited_by( + username: Option<&String>, + preserve_edited_by: Option, + authed: &ApiAuthed, +) -> String { + if let Some(username) = username { + if preserve_edited_by.unwrap_or(false) && can_preserve_on_behalf_of(authed) { + return username.clone(); + } + } + authed.username.clone() +} + pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_schedule)) @@ -75,6 +115,8 @@ pub struct NewSchedule { pub paused_until: Option>, pub cron_version: Option, pub dynamic_skip: Option, + pub email: Option, + pub preserve_email: Option, } #[derive(Serialize, Deserialize)] @@ -200,6 +242,8 @@ async fn create_schedule( validate_dynamic_skip(&mut tx, &w_id, handler_path).await?; } + let resolved_edited_by = resolve_edited_by(ns.email.as_ref(), ns.preserve_email, &authed); + let schedule = sqlx::query_as!( Schedule, r#" @@ -257,13 +301,13 @@ async fn create_schedule( ns.path, ns.schedule, ns.timezone, - authed.username, + resolved_edited_by, ns.script_path, ns.is_flow, to_json_raw_opt(ns.args.as_ref()) as Option>>, ns.enabled.unwrap_or(false), - authed.email, + resolve_email(ns.email.as_ref(), ns.preserve_email, &authed, &db, &w_id).await?, ns.on_failure, ns.on_failure_times, ns.on_failure_exact, @@ -308,6 +352,29 @@ async fn create_schedule( ), ) .await?; + if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation( + ns.email.as_deref(), + ns.preserve_email.unwrap_or(false), + &authed, + &authed.username, + ) { + audit_log( + &mut *tx, + &authed, + "schedule.on_behalf_of", + ActionKind::Create, + &w_id, + Some(&ns.path), + Some( + [ + ("on_behalf_of", on_behalf_of.as_str()), + ("action", "create"), + ] + .into(), + ), + ) + .await?; + } if ns.enabled.unwrap_or(true) { tx = push_scheduled_job(&db, tx, &schedule, Some(&authed.clone().into()), None).await? @@ -351,6 +418,9 @@ async fn edit_schedule( } clear_schedule(&mut tx, path, &w_id).await?; + + let resolved_edited_by = resolve_edited_by(es.email.as_ref(), es.preserve_email, &authed); + let schedule = sqlx::query_as!( Schedule, r#" @@ -377,7 +447,9 @@ async fn edit_schedule( workspace_id = $20, cron_version = COALESCE($21, cron_version), description = $22, - dynamic_skip = $23 + dynamic_skip = $23, + email = COALESCE($24, email), + edited_by = $25 WHERE path = $19 AND workspace_id = $20 RETURNING workspace_id, @@ -438,7 +510,9 @@ async fn edit_schedule( w_id, es.cron_version, es.description, - es.dynamic_skip + es.dynamic_skip, + Some(resolve_email(es.email.as_ref(), es.preserve_email, &authed, &db, &w_id).await?), + resolved_edited_by ) .fetch_one(&mut *tx) .await @@ -459,6 +533,29 @@ async fn edit_schedule( ), ) .await?; + if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation( + es.email.as_deref(), + es.preserve_email.unwrap_or(false), + &authed, + &authed.username, + ) { + audit_log( + &mut *tx, + &authed, + "schedule.on_behalf_of", + ActionKind::Update, + &w_id, + Some(path), + Some( + [ + ("on_behalf_of", on_behalf_of.as_str()), + ("action", "update"), + ] + .into(), + ), + ) + .await?; + } if schedule.enabled { tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; @@ -1073,6 +1170,8 @@ pub struct EditSchedule { pub paused_until: Option>, pub cron_version: Option, pub dynamic_skip: Option, + pub email: Option, + pub preserve_email: Option, } pub use windmill_queue::schedule::clear_schedule; diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index c264d95f06..d93b33670d 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -927,11 +927,11 @@ async fn create_script_internal<'c>( no_main_func.filter(|x: &bool| *x), // should be Some(true) or None codebase, has_preprocessor.filter(|x: &bool| *x), // should be Some(true) or None - if ns.on_behalf_of_email.is_some() { - Some(&authed.email) - } else { - None - }, + windmill_common::resolve_on_behalf_of_email( + ns.on_behalf_of_email.as_deref(), + ns.preserve_on_behalf_of.unwrap_or(false), + &authed, + ), validate_schema, ns.assets.as_ref().and_then(|a| serde_json::to_value(a).ok()), guarded_debounce_key, @@ -1027,6 +1027,29 @@ async fn create_script_internal<'c>( Some([("hash", hash.to_string().as_str())].into()), ) .await?; + if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation( + ns.on_behalf_of_email.as_deref(), + ns.preserve_on_behalf_of.unwrap_or(false), + &authed, + &authed.email, + ) { + audit_log( + &mut *tx, + &authed, + "scripts.on_behalf_of", + ActionKind::Update, + &w_id, + Some(&ns.path), + Some( + [ + ("on_behalf_of", on_behalf_of.as_str()), + ("action", "update"), + ] + .into(), + ), + ) + .await?; + } webhook.send_message( w_id.clone(), WebhookMessage::UpdateScript { @@ -1052,6 +1075,29 @@ async fn create_script_internal<'c>( ), ) .await?; + if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation( + ns.on_behalf_of_email.as_deref(), + ns.preserve_on_behalf_of.unwrap_or(false), + &authed, + &authed.email, + ) { + audit_log( + &mut *tx, + &authed, + "scripts.on_behalf_of", + ActionKind::Create, + &w_id, + Some(&ns.path), + Some( + [ + ("on_behalf_of", on_behalf_of.as_str()), + ("action", "create"), + ] + .into(), + ), + ) + .await?; + } webhook.send_message( w_id.clone(), WebhookMessage::CreateScript { diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 253b543b97..9b135aebc3 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -2788,6 +2788,14 @@ async fn create_workspace( .execute(&mut *tx) .await?; + sqlx::query!( + "INSERT INTO group_ + VALUES ($1, 'wm_deployers', 'Members can preserve the original author when deploying to this workspace')", + nw.id + ) + .execute(&mut *tx) + .await?; + sqlx::query!( "INSERT INTO usr_to_group VALUES ($1, 'all', $2)", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d7c47706ae..c958a4c03c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -8215,6 +8215,9 @@ paths: type: string custom_path: type: string + preserve_on_behalf_of: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it." required: - path - value @@ -8260,6 +8263,9 @@ paths: type: string custom_path: type: string + preserve_on_behalf_of: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it." required: - path - value @@ -8571,6 +8577,9 @@ paths: type: string custom_path: type: string + preserve_on_behalf_of: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it." responses: "200": description: app updated @@ -8610,6 +8619,9 @@ paths: type: string custom_path: type: string + preserve_on_behalf_of: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of value in the policy instead of overwriting it." js: type: string css: @@ -18497,6 +18509,9 @@ components: type: boolean on_behalf_of_email: type: string + preserve_on_behalf_of: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of_email value instead of overwriting it." assets: type: array items: @@ -20151,6 +20166,12 @@ components: type: string nullable: true description: Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false) + email: + type: string + description: Email of the user who the scheduled jobs run as. Used during deployment to preserve the original schedule owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path - schedule @@ -20238,6 +20259,12 @@ components: type: string nullable: true description: Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false) + email: + type: string + description: Email of the user who the scheduled jobs run as. Used during deployment to preserve the original schedule owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - schedule - timezone @@ -20594,6 +20621,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path @@ -20680,6 +20713,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path - script_path @@ -20842,6 +20881,12 @@ components: retry: description: Retry configuration for failed executions $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path @@ -20904,6 +20949,12 @@ components: retry: description: Retry configuration for failed executions $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path @@ -21073,6 +21124,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path - script_path @@ -21127,6 +21184,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path - script_path @@ -21263,6 +21326,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: "Retry configuration for failed executions." + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path - script_path @@ -21425,6 +21494,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - queue_url - aws_resource_path @@ -21471,6 +21546,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - queue_url - aws_resource_path @@ -21629,6 +21710,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path - script_path @@ -21671,6 +21758,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path - script_path @@ -21778,6 +21871,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path @@ -21831,6 +21930,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path @@ -21932,6 +22037,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path @@ -21981,6 +22092,12 @@ components: retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" description: Retry configuration for failed executions + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path - script_path @@ -22029,6 +22146,12 @@ components: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" mode: $ref: "#/components/schemas/TriggerMode" + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path @@ -22055,6 +22178,12 @@ components: $ref: "#/components/schemas/ScriptArgs" retry: $ref: "../../openflow.openapi.yaml#/components/schemas/Retry" + email: + type: string + description: Email of the user who triggered jobs run as. Used during deployment to preserve the original trigger owner. + preserve_email: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original email value instead of overwriting it." required: - path - script_path @@ -22474,6 +22603,9 @@ components: type: boolean on_behalf_of_email: type: string + preserve_on_behalf_of: + type: boolean + description: "When true and the caller is a member of the 'wm_deployers' group, preserves the original on_behalf_of_email value instead of overwriting it." required: - path diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 8874e092d8..d156dc9c88 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -39,8 +39,6 @@ use itertools::Itertools; use lazy_static::lazy_static; use magic_crypt::MagicCryptTrait; #[cfg(feature = "parquet")] -use windmill_object_store::object_store_reexports::{Attribute, Attributes}; -#[cfg(feature = "parquet")] use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{json, value::RawValue}; @@ -67,6 +65,8 @@ use windmill_common::{ workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, HUB_BASE_URL, }; +#[cfg(feature = "parquet")] +use windmill_object_store::object_store_reexports::{Attribute, Attributes}; use windmill_store::resources::get_resource_value_interpolated_internal; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; @@ -75,11 +75,7 @@ use windmill_queue::{push, PushArgs, PushArgsOwned, PushIsolationLevel}; #[cfg(feature = "parquet")] use hmac::Mac; #[cfg(feature = "parquet")] -use windmill_common::{ - jwt, - oauth2::HmacSha256, - variables::get_workspace_key, -}; +use windmill_common::{jwt, oauth2::HmacSha256, variables::get_workspace_key}; #[cfg(feature = "parquet")] use windmill_types::s3::{S3Object, S3Permission}; @@ -279,6 +275,7 @@ pub struct CreateApp { pub draft_only: Option, pub deployment_message: Option, pub custom_path: Option, + pub preserve_on_behalf_of: Option, } #[derive(Serialize, Deserialize)] @@ -289,6 +286,7 @@ pub struct EditApp { pub policy: Option, pub deployment_message: Option, pub custom_path: Option, + pub preserve_on_behalf_of: Option, } #[derive(Serialize, FromRow)] @@ -443,7 +441,9 @@ async fn get_raw_app_data( if let Some(os) = object_store { let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type); let stream = os - .get(&windmill_object_store::object_store_reexports::Path::from(path)) + .get(&windmill_object_store::object_store_reexports::Path::from( + path, + )) .await .map_err(windmill_object_store::object_store_error_to_error)? .bytes() @@ -958,7 +958,10 @@ async fn store_raw_app_file<'a>( if let Some(os) = object_store { if let Err(e) = os - .put(&windmill_object_store::object_store_reexports::Path::from(path.clone()), data.into()) + .put( + &windmill_object_store::object_store_reexports::Path::from(path.clone()), + data.into(), + ) .await { tracing::error!("Failed to put snapshot to s3 at {path}: {:?}", e); @@ -1178,8 +1181,14 @@ async fn create_app_internal<'a>( } } let mut tx = user_db.clone().begin(&authed).await?; - app.policy.on_behalf_of = Some(username_to_permissioned_as(&authed.username)); - app.policy.on_behalf_of_email = Some(authed.email.clone()); + let should_preserve = app.preserve_on_behalf_of.unwrap_or(false) + && windmill_common::can_preserve_on_behalf_of(&authed) + && app.policy.on_behalf_of.is_some(); + + if !should_preserve { + app.policy.on_behalf_of = Some(username_to_permissioned_as(&authed.username)); + app.policy.on_behalf_of_email = Some(authed.email.clone()); + } let path = app.path.clone(); if &app.path == "" { return Err(Error::BadRequest("App path cannot be empty".to_string())); @@ -1270,6 +1279,22 @@ async fn create_app_internal<'a>( None, ) .await?; + if should_preserve { + if let Some(ref obo_email) = app.policy.on_behalf_of_email { + if obo_email != &authed.email { + audit_log( + &mut *tx, + &authed, + "apps.on_behalf_of", + ActionKind::Create, + w_id, + Some(&app.path), + Some([("on_behalf_of", obo_email.as_str()), ("action", "create")].into()), + ) + .await?; + } + } + } let mut args: HashMap> = HashMap::new(); if let Some(dm) = &app.deployment_message { args.insert("deployment_message".to_string(), to_raw_value(&dm)); @@ -1599,6 +1624,7 @@ async fn update_app_internal<'a>( use sql_builder::prelude::*; let mut tx = user_db.clone().begin(&authed).await?; + let mut preserved_on_behalf_of: Option = None; let npath = if ns.policy.is_some() || ns.path.is_some() || ns.summary.is_some() @@ -1664,8 +1690,20 @@ async fn update_app_internal<'a>( } if let Some(mut npolicy) = ns.policy { - npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username)); - npolicy.on_behalf_of_email = Some(authed.email.clone()); + let should_preserve = ns.preserve_on_behalf_of.unwrap_or(false) + && windmill_common::can_preserve_on_behalf_of(&authed) + && npolicy.on_behalf_of.is_some(); + + if should_preserve { + if let Some(ref obo_email) = npolicy.on_behalf_of_email { + if obo_email != &authed.email { + preserved_on_behalf_of = Some(obo_email.clone()); + } + } + } else { + npolicy.on_behalf_of = Some(username_to_permissioned_as(&authed.username)); + npolicy.on_behalf_of_email = Some(authed.email.clone()); + } sqlb.set( "policy", quote(serde_json::to_string(&json!(npolicy)).map_err(|e| { @@ -1747,6 +1785,24 @@ async fn update_app_internal<'a>( None, ) .await?; + if let Some(on_behalf_of) = preserved_on_behalf_of { + audit_log( + &mut *tx, + &authed, + "apps.on_behalf_of", + ActionKind::Update, + w_id, + Some(&npath), + Some( + [ + ("on_behalf_of", on_behalf_of.as_str()), + ("action", "update"), + ] + .into(), + ), + ) + .await?; + } let tx = PushIsolationLevel::Transaction(tx); let mut args: HashMap> = HashMap::new(); if let Some(dm) = ns.deployment_message { diff --git a/backend/windmill-common/src/bench.rs b/backend/windmill-common/src/bench.rs index 00cb565029..b87872119b 100644 --- a/backend/windmill-common/src/bench.rs +++ b/backend/windmill-common/src/bench.rs @@ -179,13 +179,12 @@ pub async fn benchmark_verify(benchmark_jobs: i32, db: &DB) { let canceled = row.canceled.unwrap_or(0); let total = succeeded + failed + canceled; - let remaining_in_queue = sqlx::query_scalar!( - "SELECT COUNT(*) FROM v2_job_queue WHERE workspace_id = 'admins'", - ) - .fetch_one(db) - .await - .expect("benchmark verify queue query failed") - .unwrap_or(0); + let remaining_in_queue = + sqlx::query_scalar!("SELECT COUNT(*) FROM v2_job_queue WHERE workspace_id = 'admins'",) + .fetch_one(db) + .await + .expect("benchmark verify queue query failed") + .unwrap_or(0); println!("=== BENCHMARK VERIFICATION ==="); println!(" kind: {benchmark_kind}"); @@ -248,10 +247,12 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) { .execute(db) .await .unwrap_or_else(|e| panic!("failed to clean up job_perms: {e:#}")); - sqlx::query!("DELETE FROM concurrency_key WHERE key LIKE 'bench_%' OR key LIKE 'u/admin/bench_%'") - .execute(db) - .await - .unwrap_or_else(|e| panic!("failed to clean up concurrency_key: {e:#}")); + sqlx::query!( + "DELETE FROM concurrency_key WHERE key LIKE 'bench_%' OR key LIKE 'u/admin/bench_%'" + ) + .execute(db) + .await + .unwrap_or_else(|e| panic!("failed to clean up concurrency_key: {e:#}")); sqlx::query!("DELETE FROM concurrency_counter WHERE concurrency_id LIKE 'bench_%' OR concurrency_id LIKE 'u/admin/bench_%'") .execute(db) .await @@ -637,9 +638,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) { sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &noop_uuids, "admins", "deno") .execute(&mut *tx) .await.unwrap_or_else(|_e| panic!("failed to insert mixed noop queue")); - sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &noop_uuids) + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &noop_uuids + ) .execute(&mut *tx) - .await.unwrap_or_else(|_e| panic!("failed to insert mixed noop runtime")); + .await + .unwrap_or_else(|_e| panic!("failed to insert mixed noop runtime")); // 2) sequentialflow jobs if portion > 0 { @@ -661,9 +666,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) { sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sf_uuids, "admins", "flow") .execute(&mut *tx) .await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow queue")); - sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sf_uuids) + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &sf_uuids + ) .execute(&mut *tx) - .await.unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow runtime")); + .await + .unwrap_or_else(|_e| panic!("failed to insert mixed sequentialflow runtime")); sqlx::query!( "INSERT INTO v2_job_status (id, flow_status) SELECT unnest($1::uuid[]), $2", &sf_uuids, @@ -693,9 +702,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) { sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sl_uuids, "admins", "deno") .execute(&mut *tx) .await.unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs queue")); - sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sl_uuids) + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &sl_uuids + ) .execute(&mut *tx) - .await.unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs runtime")); + .await + .unwrap_or_else(|_e| panic!("failed to insert mixed scriptlogs runtime")); } // 4) concurrencylimit jobs @@ -720,9 +733,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) { sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &cl_uuids, "admins", "deno") .execute(&mut *tx) .await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit queue")); - sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &cl_uuids) + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &cl_uuids + ) .execute(&mut *tx) - .await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit runtime")); + .await + .unwrap_or_else(|_e| panic!("failed to insert mixed concurrencylimit runtime")); let cl_concurrency_id = "u/admin/bench_conclimit"; sqlx::query!( "INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING", @@ -763,9 +780,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) { sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &ck_uuids, "admins", "deno") .execute(&mut *tx) .await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey queue")); - sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &ck_uuids) + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &ck_uuids + ) .execute(&mut *tx) - .await.unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey runtime")); + .await + .unwrap_or_else(|_e| panic!("failed to insert mixed concurrencykey runtime")); let ck_concurrency_id = "bench_shared_concurrency_key"; sqlx::query!( "INSERT INTO concurrency_counter (concurrency_id, job_uuids) VALUES ($1, '{}'::jsonb) ON CONFLICT (concurrency_id) DO NOTHING", @@ -807,9 +828,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) { sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &noop_uuids, "admins", "deno") .execute(&mut *tx) .await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop queue")); - sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &noop_uuids) + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &noop_uuids + ) .execute(&mut *tx) - .await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop runtime")); + .await + .unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc noop runtime")); // 2) sequentialflow jobs if portion > 0 { @@ -831,9 +856,15 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) { sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sf_uuids, "admins", "flow") .execute(&mut *tx) .await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow queue")); - sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sf_uuids) + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &sf_uuids + ) .execute(&mut *tx) - .await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc sequentialflow runtime")); + .await + .unwrap_or_else(|_e| { + panic!("failed to insert mixed_no_cc sequentialflow runtime") + }); sqlx::query!( "INSERT INTO v2_job_status (id, flow_status) SELECT unnest($1::uuid[]), $2", &sf_uuids, @@ -863,9 +894,13 @@ pub async fn benchmark_init(benchmark_jobs: i32, db: &DB) { sqlx::query!("INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) SELECT unnest($1::uuid[]), $2, now(), $3", &sl_uuids, "admins", "deno") .execute(&mut *tx) .await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs queue")); - sqlx::query!("INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", &sl_uuids) + sqlx::query!( + "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])", + &sl_uuids + ) .execute(&mut *tx) - .await.unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs runtime")); + .await + .unwrap_or_else(|_e| panic!("failed to insert mixed_no_cc scriptlogs runtime")); } } "none" => {} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index db8b5916cf..643e08fc82 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -109,6 +109,50 @@ pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5; pub const DEFAULT_HUB_BASE_URL: &str = "https://hub.windmill.dev"; pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000; pub const SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs +pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers"; + +/// Checks if the user is allowed to preserve on_behalf_of values (admin or deployer). +pub fn can_preserve_on_behalf_of(authed: &impl db::Authable) -> bool { + authed.is_admin() || authed.groups().iter().any(|g| g == &WM_DEPLOYERS_GROUP) +} + +/// Checks if on-behalf-of preservation actually happened (the target user differs from the acting user). +/// Returns Some(target_identifier) if preservation occurred, None otherwise. +pub fn check_on_behalf_of_preservation( + on_behalf_of_identifier: Option<&str>, + preserve: bool, + authed: &impl db::Authable, + authed_identifier: &str, +) -> Option { + if preserve && can_preserve_on_behalf_of(authed) { + if let Some(id) = on_behalf_of_identifier { + if id != authed_identifier { + return Some(id.to_string()); + } + } + } + None +} + +/// Determines the on_behalf_of_email value to use when creating/updating a flow or script. +/// - If `on_behalf_of_email` is None, returns None +/// - If `preserve` is true and the user is admin or in the deployers group, returns the original value +/// - Otherwise, returns the authenticated user's email +pub fn resolve_on_behalf_of_email<'a>( + on_behalf_of_email: Option<&'a str>, + preserve: bool, + authed: &'a impl db::Authable, +) -> Option<&'a str> { + if on_behalf_of_email.is_some() { + if preserve && can_preserve_on_behalf_of(authed) { + on_behalf_of_email + } else { + Some(authed.email()) + } + } else { + None + } +} #[macro_export] macro_rules! add_time { diff --git a/backend/windmill-common/src/runtime_assets.rs b/backend/windmill-common/src/runtime_assets.rs index 817ff99bf4..ecd863f482 100644 --- a/backend/windmill-common/src/runtime_assets.rs +++ b/backend/windmill-common/src/runtime_assets.rs @@ -52,7 +52,10 @@ fn extract_assets_from_raw_value( if prefix { let s = serde_json::from_str::(value.get()).ok()?; let (kind, path) = parse_asset_syntax(&s, false)?; - assets.push(RuntimeAsset { path: path.to_string(), kind: crate::assets::asset_kind_from_parser(kind) }); + assets.push(RuntimeAsset { + path: path.to_string(), + kind: crate::assets::asset_kind_from_parser(kind), + }); } } None diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index c6a4a787ea..68f77a2303 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -49,9 +49,7 @@ pub fn extract_workspace_dependencies_annotated_refs( Some(&RE_PYTHON), runnable_path, ), - Go => { - WorkspaceDependenciesAnnotatedRefs::parse("//", "go_mod", code, None, runnable_path) - } + Go => WorkspaceDependenciesAnnotatedRefs::parse("//", "go_mod", code, None, runnable_path), Php => WorkspaceDependenciesAnnotatedRefs::parse( "//", "composer_json", @@ -67,11 +65,8 @@ pub async fn prefetch_cached_script( script: Script, db: &DB, ) -> crate::error::Result> { - let rs = runnable_settings::from_handle( - script.runnable_settings.runnable_settings_handle, - db, - ) - .await?; + let rs = runnable_settings::from_handle(script.runnable_settings.runnable_settings_handle, db) + .await?; let (debouncing_settings, concurrency_settings) = runnable_settings::prefetch_cached(&rs, db).await?; @@ -379,11 +374,8 @@ pub async fn clone_script<'c>( ))); }; - let rs = runnable_settings::from_handle( - s.runnable_settings.runnable_settings_handle, - db, - ) - .await?; + let rs = + runnable_settings::from_handle(s.runnable_settings.runnable_settings_handle, db).await?; let (debouncing_settings, concurrency_settings) = runnable_settings::prefetch_cached(&rs, db).await?; @@ -424,6 +416,7 @@ pub async fn clone_script<'c>( codebase: s.codebase, has_preprocessor: s.has_preprocessor, on_behalf_of_email: s.on_behalf_of_email, + preserve_on_behalf_of: None, assets: s.assets, }; diff --git a/backend/windmill-common/src/workspace_dependencies.rs b/backend/windmill-common/src/workspace_dependencies.rs index 6920e84a06..2ec3e26e37 100644 --- a/backend/windmill-common/src/workspace_dependencies.rs +++ b/backend/windmill-common/src/workspace_dependencies.rs @@ -384,8 +384,11 @@ impl WorkspaceDependenciesPrefetched { Box::pin(async { let r = if let Some(wdar) = - crate::scripts::extract_workspace_dependencies_annotated_refs(&language, code, runnable_path) - { + crate::scripts::extract_workspace_dependencies_annotated_refs( + &language, + code, + runnable_path, + ) { tracing::debug!(workspace_id, ?language, "found explicit annotations"); let expanded = wdar diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 578598e7c9..e8bc512011 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -138,6 +138,7 @@ fn check_no_duplicates( pub async fn insert_new_trigger_into_db( authed: &ApiAuthed, + db: &DB, tx: &mut PgConnection, w_id: &str, trigger: &TriggerData, @@ -146,6 +147,8 @@ pub async fn insert_new_trigger_into_db( require_admin(authed.is_admin, &authed.username)?; let request_type = trigger.config.request_type; + let resolved_edited_by = trigger.base.resolve_edited_by(authed); + let resolved_email = trigger.base.resolve_email(authed, db, w_id).await?; sqlx::query!( r#" @@ -196,8 +199,8 @@ pub async fn insert_new_trigger_into_db( trigger.config.authentication_method as _, trigger.config.http_method as _, trigger.config.static_asset_config as _, - &authed.username, - &authed.email, + &resolved_edited_by, + resolved_email, trigger.config.is_static_website, trigger.error_handling.error_handler_path, trigger.error_handling.error_handler_args as _, @@ -247,9 +250,16 @@ pub async fn create_many_http_triggers( let mut tx = user_db.begin(&authed).await?; for (new_http_trigger, route_path_key) in new_http_triggers.iter().zip(route_path_keys.iter()) { - insert_new_trigger_into_db(&authed, &mut tx, &w_id, new_http_trigger, route_path_key) - .await - .map_err(|err| error_wrapper(&new_http_trigger.config.route_path, err))?; + insert_new_trigger_into_db( + &authed, + &db, + &mut tx, + &w_id, + new_http_trigger, + route_path_key, + ) + .await + .map_err(|err| error_wrapper(&new_http_trigger.config.route_path, err))?; audit_log( &mut *tx, @@ -403,7 +413,7 @@ impl TriggerCrud for HttpTrigger { ) -> Result<()> { let route_path_key = check_if_route_exist(db, &trigger.config, &w_id, None).await?; - insert_new_trigger_into_db(authed, tx, w_id, &trigger, &route_path_key).await?; + insert_new_trigger_into_db(authed, db, tx, w_id, &trigger, &route_path_key).await?; increase_trigger_version(tx).await?; @@ -419,6 +429,9 @@ impl TriggerCrud for HttpTrigger { path: &str, trigger: TriggerData, ) -> Result<()> { + let resolved_edited_by = trigger.base.resolve_edited_by(authed); + let resolved_email = trigger.base.resolve_email(authed, db, workspace_id).await?; + if authed.is_admin { if trigger.config.route_path.is_empty() { return Err(Error::BadRequest("route_path is required".to_string())); @@ -478,8 +491,8 @@ impl TriggerCrud for HttpTrigger { trigger.base.mode() as _, trigger.config.http_method as _, trigger.config.static_asset_config as _, - &authed.username, - &authed.email, + &resolved_edited_by, + resolved_email, request_type as _, trigger.config.authentication_method as _, trigger.config.summary, @@ -534,8 +547,8 @@ impl TriggerCrud for HttpTrigger { trigger.base.mode() as _, trigger.config.http_method as _, trigger.config.static_asset_config as _, - &authed.username, - &authed.email, + &resolved_edited_by, + resolved_email, request_type as _, trigger.config.authentication_method as _, trigger.config.summary, diff --git a/backend/windmill-trigger-mqtt/src/handler.rs b/backend/windmill-trigger-mqtt/src/handler.rs index a68179b3e8..9704d488bf 100644 --- a/backend/windmill-trigger-mqtt/src/handler.rs +++ b/backend/windmill-trigger-mqtt/src/handler.rs @@ -66,12 +66,14 @@ impl TriggerCrud for MqttTrigger { async fn create_trigger( &self, - _db: &DB, + db: &DB, tx: &mut PgConnection, authed: &ApiAuthed, w_id: &str, trigger: TriggerData, ) -> Result<()> { + let resolved_edited_by = trigger.base.resolve_edited_by(authed); + let resolved_email = trigger.base.resolve_email(authed, db, w_id).await?; let subscribe_topics = trigger .config .subscribe_topics @@ -114,9 +116,9 @@ impl TriggerCrud for MqttTrigger { trigger.base.path, trigger.base.script_path, trigger.base.is_flow, - authed.email, + resolved_email, trigger.base.mode() as _, - authed.username, + &resolved_edited_by, trigger.error_handling.error_handler_path, trigger.error_handling.error_handler_args as _, trigger.error_handling.retry as _ @@ -129,13 +131,15 @@ impl TriggerCrud for MqttTrigger { async fn update_trigger( &self, - _db: &DB, + db: &DB, tx: &mut PgConnection, authed: &ApiAuthed, workspace_id: &str, path: &str, trigger: TriggerData, ) -> Result<()> { + let resolved_edited_by = trigger.base.resolve_edited_by(authed); + let resolved_email = trigger.base.resolve_email(authed, db, workspace_id).await?; let subscribe_topics = trigger .config .subscribe_topics @@ -179,8 +183,8 @@ impl TriggerCrud for MqttTrigger { v3_config as Option>, v5_config as Option>, trigger.base.is_flow, - authed.username, - authed.email, + &resolved_edited_by, + resolved_email, trigger.base.script_path, trigger.base.path, workspace_id, diff --git a/backend/windmill-trigger-postgres/src/handler.rs b/backend/windmill-trigger-postgres/src/handler.rs index f066533c34..2b6d620ca8 100644 --- a/backend/windmill-trigger-postgres/src/handler.rs +++ b/backend/windmill-trigger-postgres/src/handler.rs @@ -70,6 +70,8 @@ impl TriggerCrud for PostgresTrigger { w_id: &str, trigger: TriggerData, ) -> Result<()> { + let resolved_edited_by = trigger.base.resolve_edited_by(authed); + let resolved_email = trigger.base.resolve_email(authed, db, w_id).await?; let Self::TriggerConfigRequest { postgres_resource_path, publication_name, @@ -137,8 +139,8 @@ impl TriggerCrud for PostgresTrigger { trigger.base.script_path, trigger.base.is_flow, trigger.base.mode() as _, - authed.username, - authed.email, + &resolved_edited_by, + resolved_email, trigger.error_handling.error_handler_path, trigger.error_handling.error_handler_args as _, trigger.error_handling.retry as _ @@ -157,6 +159,8 @@ impl TriggerCrud for PostgresTrigger { path: &str, trigger: TriggerData, ) -> Result<()> { + let resolved_edited_by = trigger.base.resolve_edited_by(authed); + let resolved_email = trigger.base.resolve_email(authed, db, w_id).await?; let Self::TriggerConfigRequest { replication_slot_name, publication_name, @@ -235,8 +239,8 @@ impl TriggerCrud for PostgresTrigger { trigger.base.script_path, trigger.base.path, trigger.base.is_flow, - authed.username, - authed.email, + &resolved_edited_by, + resolved_email, w_id, path, trigger.error_handling.error_handler_path, diff --git a/backend/windmill-trigger-websocket/src/handler.rs b/backend/windmill-trigger-websocket/src/handler.rs index 22a31b047f..a9568512a4 100644 --- a/backend/windmill-trigger-websocket/src/handler.rs +++ b/backend/windmill-trigger-websocket/src/handler.rs @@ -72,12 +72,14 @@ impl TriggerCrud for WebsocketTrigger { async fn create_trigger( &self, - _db: &DB, + db: &DB, tx: &mut PgConnection, authed: &ApiAuthed, w_id: &str, trigger: TriggerData, ) -> Result<()> { + let resolved_edited_by = trigger.base.resolve_edited_by(authed); + let resolved_email = trigger.base.resolve_email(authed, db, w_id).await?; let filters = trigger .config .filters @@ -127,10 +129,10 @@ impl TriggerCrud for WebsocketTrigger { .config .url_runnable_args .map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap())) as _, - authed.username, + &resolved_edited_by, trigger.config.can_return_message, trigger.config.can_return_error_result, - authed.email, + resolved_email, trigger.error_handling.error_handler_path, trigger.error_handling.error_handler_args as _, trigger.error_handling.retry as _ @@ -142,13 +144,15 @@ impl TriggerCrud for WebsocketTrigger { async fn update_trigger( &self, - _db: &DB, + db: &DB, tx: &mut PgConnection, authed: &ApiAuthed, w_id: &str, path: &str, trigger: TriggerData, ) -> Result<()> { + let resolved_edited_by = trigger.base.resolve_edited_by(authed); + let resolved_email = trigger.base.resolve_email(authed, db, w_id).await?; let filters = trigger .config .filters @@ -200,8 +204,8 @@ impl TriggerCrud for WebsocketTrigger { .url_runnable_args .map(|v| SqlxJson(serde_json::value::to_raw_value(&v).unwrap())) as Option>>, - &authed.username, - &authed.email, + &resolved_edited_by, + resolved_email, trigger.config.can_return_message, trigger.config.can_return_error_result, w_id, diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 7c6d06991a..dcdb53b83f 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -416,6 +416,12 @@ async fn create_trigger( let mut tx = user_db.begin(&authed).await?; let new_path = new_trigger.base.path.clone(); + let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( + new_trigger.base.email.as_deref(), + new_trigger.base.preserve_email.unwrap_or(false), + &authed, + &authed.username, + ); handler .create_trigger(&db, &mut *tx, &authed, &workspace_id, new_trigger) @@ -431,6 +437,24 @@ async fn create_trigger( None, ) .await?; + if let Some(on_behalf_of) = on_behalf_of_info { + audit_log( + &mut *tx, + &authed, + &format!("{}_triggers.on_behalf_of", T::TRIGGER_TYPE), + ActionKind::Create, + &workspace_id, + Some(&new_path), + Some( + [ + ("on_behalf_of", on_behalf_of.as_str()), + ("action", "create"), + ] + .into(), + ), + ) + .await?; + } handle_deployment_metadata( &authed.email, @@ -510,6 +534,12 @@ async fn update_trigger( let mut tx = user_db.begin(&authed).await?; let new_path = edit_trigger.base.path.to_string(); + let on_behalf_of_info = windmill_common::check_on_behalf_of_preservation( + edit_trigger.base.email.as_deref(), + edit_trigger.base.preserve_email.unwrap_or(false), + &authed, + &authed.username, + ); handler .update_trigger(&db, &mut *tx, &authed, &workspace_id, path, edit_trigger) @@ -525,6 +555,24 @@ async fn update_trigger( None, ) .await?; + if let Some(on_behalf_of) = on_behalf_of_info { + audit_log( + &mut *tx, + &authed, + &format!("{}_triggers.on_behalf_of", T::TRIGGER_TYPE), + ActionKind::Update, + &workspace_id, + Some(&new_path), + Some( + [ + ("on_behalf_of", on_behalf_of.as_str()), + ("action", "update"), + ] + .into(), + ), + ) + .await?; + } let parent_path = if path != new_path { Some(path.to_string()) diff --git a/backend/windmill-trigger/src/types.rs b/backend/windmill-trigger/src/types.rs index 1b471ab30c..bbfdf30412 100644 --- a/backend/windmill-trigger/src/types.rs +++ b/backend/windmill-trigger/src/types.rs @@ -8,9 +8,9 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use sqlx::{types::Json as SqlxJson, FromRow}; +use sqlx::{types::Json as SqlxJson, FromRow, Pool, Postgres}; use std::{collections::HashMap, fmt::Debug}; -use windmill_common::jobs::JobTriggerKind; +use windmill_common::{db::Authable, error::Result, jobs::JobTriggerKind}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -103,6 +103,12 @@ pub struct BaseTriggerData { #[deprecated(note = "Use mode instead")] enabled: Option, // Kept for backwards compatibility, use mode instead mode: Option, + /// Optional email for deployment - when set, the trigger will run jobs as this user + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + /// If true and user is admin/wm_deployers, preserve the provided email instead of using deploying user's email + #[serde(skip_serializing_if = "Option::is_none")] + pub preserve_email: Option, } impl BaseTriggerData { @@ -116,6 +122,42 @@ impl BaseTriggerData { }, ) } + + pub async fn resolve_email( + &self, + authed: &impl Authable, + db: &Pool, + w_id: &str, + ) -> Result { + if let Some(ref username) = self.email { + if self.preserve_email.unwrap_or(false) + && windmill_common::can_preserve_on_behalf_of(authed) + { + let email = sqlx::query_scalar!( + "SELECT email FROM usr WHERE username = $1 AND workspace_id = $2", + username, + w_id + ) + .fetch_optional(db) + .await?; + if let Some(email) = email { + return Ok(email); + } + } + } + Ok(authed.email().to_string()) + } + + pub fn resolve_edited_by(&self, authed: &impl Authable) -> String { + if let Some(ref username) = self.email { + if self.preserve_email.unwrap_or(false) + && windmill_common::can_preserve_on_behalf_of(authed) + { + return username.clone(); + } + } + authed.username().to_string() + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -166,9 +208,18 @@ mod tests { #[test] fn test_trigger_mode_serialize() { - assert_eq!(serde_json::to_value(TriggerMode::Enabled).unwrap(), json!("enabled")); - assert_eq!(serde_json::to_value(TriggerMode::Disabled).unwrap(), json!("disabled")); - assert_eq!(serde_json::to_value(TriggerMode::Suspended).unwrap(), json!("suspended")); + assert_eq!( + serde_json::to_value(TriggerMode::Enabled).unwrap(), + json!("enabled") + ); + assert_eq!( + serde_json::to_value(TriggerMode::Disabled).unwrap(), + json!("disabled") + ); + assert_eq!( + serde_json::to_value(TriggerMode::Suspended).unwrap(), + json!("suspended") + ); } #[test] @@ -181,7 +232,7 @@ mod tests { #[test] fn test_trigger_mode_invalid() { - let result: Result = serde_json::from_value(json!("paused")); + let result: std::result::Result = serde_json::from_value(json!("paused")); assert!(result.is_err()); } @@ -314,11 +365,7 @@ mod tests { #[test] fn test_server_state_skip_none_fields() { - let state = ServerState { - server_id: None, - last_server_ping: None, - error: None, - }; + let state = ServerState { server_id: None, last_server_ping: None, error: None }; let json = serde_json::to_value(&state).unwrap(); assert!(!json.as_object().unwrap().contains_key("server_id")); assert!(!json.as_object().unwrap().contains_key("error")); diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index c69090a547..1449050c24 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -98,6 +98,7 @@ pub struct NewFlow { pub deployment_message: Option, pub visible_to_runner_only: Option, pub on_behalf_of_email: Option, + pub preserve_on_behalf_of: Option, pub ws_error_handler_muted: Option, } diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index 2339c65e69..bcbe0ede33 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -490,6 +490,7 @@ pub struct NewScript { pub codebase: Option, pub has_preprocessor: Option, pub on_behalf_of_email: Option, + pub preserve_on_behalf_of: Option, #[serde(skip_serializing_if = "Option::is_none")] pub assets: Option>, } diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 61da1b6b11..0b929b7e7d 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -8,18 +8,15 @@ ArrowUpRight, Building, DiffIcon, - GitFork, - Loader2 + GitFork } from 'lucide-svelte' import { Alert, Badge } from './common' import { AppService, FlowService, FolderService, - ResourceService, - ScheduleService, ScriptService, - VariableService, + UserService, WorkspaceService, type WorkspaceComparison, type WorkspaceItemDiff @@ -27,16 +24,20 @@ import Button from './common/button/Button.svelte' import DiffDrawer from './DiffDrawer.svelte' import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte' - import { getAllModules } from './flows/flowExplorer' - import { userWorkspaces, workspaceStore } from '$lib/stores' + import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' import type { Kind } from '$lib/utils_deployable' + import { deployItem, getItemValue, getOnBehalfOfEmail } from '$lib/utils_workspace_deploy' + import Tooltip from './Tooltip.svelte' + import OnBehalfOfSelector, { + needsOnBehalfOfSelection, + type OnBehalfOfChoice + } from './OnBehalfOfSelector.svelte' + import { sendUserToast } from '$lib/toast' + import { deepEqual } from 'fast-equals' + import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' - import Row from './common/table/Row.svelte' - import { sendUserToast } from '$lib/toast' - import Tooltip from './Tooltip.svelte' - import { deepEqual } from 'fast-equals' interface Props { currentWorkspaceId: string @@ -53,6 +54,11 @@ let deploying = $state(false) let hasAutoSelected = $state(false) let canDeployToParent = $state(true) + let canPreserveInParent = $state(false) + let canPreserveInCurrent = $state(false) + let canPreserveOnBehalfOf = $derived( + mergeIntoParent ? canPreserveInParent : canPreserveInCurrent + ) let selectableDiffs = $derived( comparison?.diffs.filter((diff) => { @@ -91,6 +97,11 @@ type SummaryCache = Record let summaryCache = $state({}) + // On-behalf-of tracking for flows and scripts + // Source workspace on_behalf_of emails (keyed by workspace/kind:path) + let onBehalfOfInfo = $state>({}) + let onBehalfOfChoice = $state>({}) + function getItemKey(diff: WorkspaceItemDiff): string { return `${diff.kind}:${diff.path}` } @@ -149,78 +160,68 @@ } } - async function getValue(kind: Kind, path: string, workspace: string) { - try { - if (kind == 'flow') { - const flow = await FlowService.getFlowByPath({ - workspace: workspace, - path: path - }) - getAllModules(flow.value.modules).forEach((x) => { - if (x.value.type == 'script' && x.value.hash != undefined) { - x.value.hash = undefined - } - }) - return { summary: flow.summary, description: flow.description, value: flow.value } - } else if (kind == 'script') { - const script = await ScriptService.getScriptByPath({ - workspace: workspace, - path: path - }) - return { - content: script.content, - lock: script.lock, - schema: script.schema, - summary: script.summary, - language: script.language + async function fetchOnBehalfOfInfo(diffs: WorkspaceItemDiff[]) { + const flowsAndScripts = diffs.filter((d) => ['flow', 'script', 'app'].includes(d.kind)) + for (const diff of flowsAndScripts) { + for (const workspace of [currentWorkspaceId, parentWorkspaceId]) { + const workspacedKey = getWorkspacedKey(workspace, getItemKey(diff)) + if (onBehalfOfInfo[workspacedKey] !== undefined) continue + + try { + onBehalfOfInfo[workspacedKey] = await getOnBehalfOfEmail( + diff.kind as Kind, + diff.path, + workspace + ) + } catch { + onBehalfOfInfo[workspacedKey] = undefined } - } else if (kind == 'app') { - const app = await AppService.getAppByPath({ - workspace: workspace, - path: path - }) - return app - } else if (kind == 'variable') { - const variable = await VariableService.getVariable({ - workspace: workspace, - path: path, - decryptSecret: true - }) - return variable.value - } else if (kind == 'resource') { - const resource = await ResourceService.getResource({ - workspace: workspace, - path: path - }) - return resource.value - } else if (kind == 'resource_type') { - const resource = await ResourceService.getResourceType({ - workspace: workspace, - path: path - }) - return resource.schema - } else if (kind == 'folder') { - const folder = await FolderService.getFolder({ - workspace: workspace, - name: path - }) - return { - name: folder.name - } - // } else if (kind == 'trigger') { - // if (additionalInformation?.triggers) { - // return await getTriggerValue(additionalInformation.triggers.kind, path, workspace) - // } else { - // throw new Error(`Missing trigger information`) - // } - } else { - throw new Error(`Unknown kind ${kind}`) } - } catch { - return {} } } + // Get source workspace email for an item + function getSourceEmail(itemKey: string): string | undefined { + const sourceWorkspace = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId + return onBehalfOfInfo[getWorkspacedKey(sourceWorkspace, itemKey)] + } + + // Get target workspace email for an item (existing item in destination) + function getTargetEmail(itemKey: string): string | undefined { + const targetWorkspace = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId + return onBehalfOfInfo[getWorkspacedKey(targetWorkspace, itemKey)] + } + + // Check if an item needs on_behalf_of selection (more than 1 unique option) + function itemNeedsOnBehalfOfSelection(itemKey: string, kind: string): boolean { + return needsOnBehalfOfSelection( + kind, + getSourceEmail(itemKey), + getTargetEmail(itemKey), + $userStore?.email + ) + } + + // Check if all required on_behalf_of selections are made + let hasUnselectedOnBehalfOf = $derived( + selectedItems.some((itemKey) => { + const diff = selectableDiffs.find((d) => getItemKey(d) === itemKey) + if (!diff) return false + return ( + itemNeedsOnBehalfOfSelection(itemKey, diff.kind) && onBehalfOfChoice[itemKey] === undefined + ) + }) + ) + + // Get the email to use for deployment based on user's choice + function getOnBehalfOfEmailForDeploy(itemKey: string): string | undefined { + const choice = onBehalfOfChoice[itemKey] + if (choice === 'source') return getSourceEmail(itemKey) + if (choice === 'target') return getTargetEmail(itemKey) + // 'me' or undefined = don't pass, backend will use deploying user's email + return undefined + } + let diffDrawer: DiffDrawer | undefined = $state(undefined) let isFlow = $state(true) @@ -231,8 +232,8 @@ isFlow = kind == 'flow' diffDrawer?.openDrawer() let values = await Promise.all([ - getValue(kind, path, workspaceTo), - getValue(kind, path, workspaceFrom) + getItemValue(kind, path, workspaceTo), + getItemValue(kind, path, workspaceFrom) ]) diffDrawer?.setDiff({ mode: 'simple', @@ -262,336 +263,37 @@ .filter((k) => !(deploymentStatus[k]?.status == 'deployed')) } - async function checkAlreadyExists(kind: Kind, path: string, workspace: string): Promise { - let exists: boolean - if (kind == 'flow') { - exists = await FlowService.existsFlowByPath({ - workspace: workspace, - path: path - }) - } else if (kind == 'script') { - exists = await ScriptService.existsScriptByPath({ - workspace: workspace, - path: path - }) - } else if (kind == 'app') { - exists = await AppService.existsApp({ - workspace: workspace, - path: path - }) - } else if (kind == 'variable') { - exists = await VariableService.existsVariable({ - workspace: workspace, - path: path - }) - } else if (kind == 'resource') { - exists = await ResourceService.existsResource({ - workspace: workspace, - path: path - }) - } else if (kind == 'schedule') { - exists = await ScheduleService.existsSchedule({ - workspace: workspace, - path: path - }) - } else if (kind == 'resource_type') { - exists = await ResourceService.existsResourceType({ - workspace: workspace, - path: path - }) - } else if (kind == 'folder') { - exists = await FolderService.existsFolder({ - workspace: workspace, - name: path - }) - // } else if (kind === 'trigger') { - // const triggersKind: TriggerKind[] = [ - // 'kafka', - // 'mqtt', - // 'nats', - // 'postgres', - // 'routes', - // 'schedules', - // 'sqs', - // 'websockets', - // 'gcp' - // ] - // if ( - // additionalInformation?.triggers && - // triggersKind.includes(additionalInformation.triggers.kind) - // ) { - // exists = await existsTrigger( - // { workspace: workspace, path }, - // additionalInformation.triggers.kind - // ) - // } else { - // throw new Error( - // `Unexpected triggers kind, expected one of: '${triggersKind.join(', ')}' got: ${ - // additionalInformation?.triggers?.kind - // }` - // ) - // } - } else { - throw new Error(`Unknown kind ${kind}`) - } - return exists - } - const deploymentStatus: Record< string, { status: 'loading' | 'deployed' | 'failed'; error?: string } > = $state({}) + function getWorkspacedKey(workspace: string, key: string): string { + return `${workspace}/${key}` + } + async function deploy( kind: Kind, path: string, workspaceToDeployTo: string, - workspaceFrom: string + workspaceFrom: string, + statusPath: string ) { - const statusPath = `${kind}:${path}` deploymentStatus[statusPath] = { status: 'loading' } - // await sleep(1000) - // if (Math.random() > 0.5) { - // deploymentStatus[statusPath] = {status: 'failed'} - // } else { - // deploymentStatus[statusPath] = {status: 'deployed'} - // } - // return + const result = await deployItem({ + kind, + path, + workspaceFrom, + workspaceTo: workspaceToDeployTo, + onBehalfOfEmail: getOnBehalfOfEmailForDeploy(statusPath) + }) - try { - let alreadyExists = await checkAlreadyExists(kind, path, workspaceToDeployTo) - if (kind == 'flow') { - const flow = await FlowService.getFlowByPath({ - workspace: workspaceFrom, - path: path - }) - getAllModules(flow.value.modules).forEach((x) => { - if (x.value.type == 'script' && x.value.hash != undefined) { - x.value.hash = undefined - } - }) - if (alreadyExists) { - await FlowService.updateFlow({ - workspace: workspaceToDeployTo, - path: path, - requestBody: { - ...flow - } - }) - } else { - await FlowService.createFlow({ - workspace: workspaceToDeployTo, - requestBody: { - ...flow - } - }) - } - } else if (kind == 'script') { - const script = await ScriptService.getScriptByPath({ - workspace: workspaceFrom, - path: path - }) - await ScriptService.createScript({ - workspace: workspaceToDeployTo, - requestBody: { - ...script, - lock: script.lock, - parent_hash: alreadyExists - ? ( - await ScriptService.getScriptByPath({ - workspace: workspaceToDeployTo, - path: path - }) - ).hash - : undefined - } - }) - } else if (kind == 'app') { - const app = await AppService.getAppByPath({ - workspace: workspaceFrom, - path: path - }) - if (alreadyExists) { - if (app.raw_app) { - const secret = await AppService.getPublicSecretOfLatestVersionOfApp({ - workspace: workspaceFrom, - path: app.path - }) - const js = await AppService.getRawAppData({ - secretWithExtension: `${secret}.js`, - workspace: workspaceFrom - }) - const css = await AppService.getRawAppData({ - secretWithExtension: `${secret}.css`, - workspace: workspaceFrom - }) - await AppService.updateAppRaw({ - workspace: workspaceToDeployTo, - path: path, - formData: { - app, - css, - js - } - }) - } else { - await AppService.updateApp({ - workspace: workspaceToDeployTo, - path: path, - requestBody: { - ...app - } - }) - } - } else { - if (app.raw_app) { - const secret = await AppService.getPublicSecretOfLatestVersionOfApp({ - workspace: workspaceFrom, - path: app.path - }) - const js = await AppService.getRawAppData({ - secretWithExtension: `${secret}.js`, - workspace: workspaceFrom - }) - const css = await AppService.getRawAppData({ - secretWithExtension: `${secret}.css`, - workspace: workspaceFrom - }) - await AppService.createAppRaw({ - workspace: workspaceToDeployTo, - formData: { - app, - css, - js - } - }) - } else { - await AppService.createApp({ - workspace: workspaceToDeployTo, - requestBody: { - ...app - } - }) - } - } - } else if (kind == 'variable') { - const variable = await VariableService.getVariable({ - workspace: workspaceFrom, - path: path, - decryptSecret: true - }) - if (alreadyExists) { - await VariableService.updateVariable({ - workspace: workspaceToDeployTo, - path: path, - requestBody: { - path: path, - value: variable.value ?? '', - is_secret: variable.is_secret, - description: variable.description ?? '' - }, - alreadyEncrypted: false - }) - } else { - await VariableService.createVariable({ - workspace: workspaceToDeployTo, - requestBody: { - path: path, - value: variable.value ?? '', - is_secret: variable.is_secret, - description: variable.description ?? '' - } - }) - } - } else if (kind == 'resource') { - const resource = await ResourceService.getResource({ - workspace: workspaceFrom, - path: path - }) - if (alreadyExists) { - await ResourceService.updateResource({ - workspace: workspaceToDeployTo, - path: path, - requestBody: { - path: path, - value: resource.value ?? '', - description: resource.description ?? '' - } - }) - } else { - await ResourceService.createResource({ - workspace: workspaceToDeployTo, - requestBody: { - path: path, - value: resource.value ?? '', - resource_type: resource.resource_type, - description: resource.description ?? '' - } - }) - } - } else if (kind == 'resource_type') { - const resource = await ResourceService.getResourceType({ - workspace: workspaceFrom, - path: path - }) - if (alreadyExists) { - await ResourceService.updateResourceType({ - workspace: workspaceToDeployTo, - path: path, - requestBody: { - schema: resource.schema, - description: resource.description ?? '' - } - }) - } else { - await ResourceService.createResourceType({ - workspace: workspaceToDeployTo, - requestBody: { - description: resource.description ?? '', - schema: resource.schema, - name: resource.name - } - }) - } - } else if (kind == 'folder') { - await FolderService.createFolder({ - workspace: workspaceToDeployTo, - requestBody: { - name: path - } - }) - // } else if (kind === 'trigger') { - // if (additionalInformation?.triggers) { - // const { data, createFn, updateFn } = await getTriggersDeployData( - // additionalInformation.triggers.kind, - // path, - // workspaceFrom - // ) - // if (alreadyExists) { - // await updateFn({ - // path, - // workspace: workspaceToDeployTo, - // requestBody: data - // } as any) - // } else { - // await createFn({ - // workspace: workspaceToDeployTo, - // requestBody: data - // } as any) - // } - // } else { - // throw new Error('Missing triggers kind') - // } - } else { - throw new Error(`Unknown kind ${kind}`) - } - - // allAlreadyExists[statusPath] = true + if (result.success) { deploymentStatus[statusPath] = { status: 'deployed' } - } catch (e) { - deploymentStatus[statusPath] = { status: 'failed', error: e.body || e.message } - sendUserToast(`Failed to deploy ${statusPath}: ${e.body || e.message}`) + } else { + deploymentStatus[statusPath] = { status: 'failed', error: result.error } + sendUserToast(`Failed to deploy ${statusPath}: ${result.error}`) } } @@ -643,9 +345,9 @@ } if (mergeIntoParent) { - await deploy(diff.kind, diff.path, parent, current) + await deploy(diff.kind as Kind, diff.path, parent, current, itemKey) } else { - await deploy(diff.kind, diff.path, current, parent) + await deploy(diff.kind as Kind, diff.path, current, parent, itemKey) } } deploying = false @@ -675,10 +377,33 @@ selectDefault() } - // Fetch summaries when comparison data loads + // Fetch user permissions for both workspaces + $effect(() => { + ;[currentWorkspaceId, parentWorkspaceId] + async function fetchPermissions() { + try { + const parentUser = await UserService.whoami({ workspace: parentWorkspaceId }) + canPreserveInParent = + parentUser.is_admin || parentUser.groups?.includes('wm_deployers') || false + } catch { + canPreserveInParent = false + } + try { + const currentUser = await UserService.whoami({ workspace: currentWorkspaceId }) + canPreserveInCurrent = + currentUser.is_admin || currentUser.groups?.includes('wm_deployers') || false + } catch { + canPreserveInCurrent = false + } + } + fetchPermissions() + }) + + // Fetch summaries and on_behalf_of_email when comparison data loads $effect(() => { if (comparison?.diffs) { fetchSummaries(comparison.diffs) + fetchOnBehalfOfInfo(comparison.diffs) } }) @@ -695,24 +420,57 @@ ;[selectedItems, mergeIntoParent] allowBehindChangesOverride = false }) + + // Transform diffs to deployable item format for the shared layout + let deployableItems = $derived( + (comparison?.diffs ?? []) + .filter((diff) => { + const key = getItemKey(diff) + const isSelectable = selectableDiffs.includes(diff) + const isDeployedAndIrrelevant = + deploymentStatus[key]?.status === 'deployed' && !isSelectable + return !isDeployedAndIrrelevant + }) + .map((diff) => ({ + key: getItemKey(diff), + path: diff.path, + kind: diff.kind as Kind, + diff + })) + ) -
- {#if $workspaceStore != currentWorkspaceId} - - - You are currently seeing the deployement page of workspace {currentWorkspaceInfo?.name} ({currentWorkspaceInfo?.id}) which is not your currently selected workspace. - - Click here to go home ({$workspaceStore}) - - {/if} - {#if comparison} - {@const selectedConflicts = conflictingDiffs.filter((e) => - selectedItems.includes(getItemKey(e)) - ).length} -
+{#if $workspaceStore != currentWorkspaceId} + + + You are currently seeing the deployement page of workspace {currentWorkspaceInfo?.name} ({currentWorkspaceInfo?.id}) which is not your currently selected workspace. + + Click here to go home ({$workspaceStore}) + +{/if} + +{#if comparison} + {@const selectedConflicts = conflictingDiffs.filter((e) => + selectedItems.includes(getItemKey(e)) + ).length} + + selectableDiffs.some((d) => getItemKey(d) === item.key)} + {allSelected} + onToggleItem={(item) => { + const diff = comparison?.diffs.find((d) => getItemKey(d) === item.key) + if (diff) toggleItem(diff) + }} + onSelectAll={selectAll} + onDeselectAll={deselectAll} + emptyMessage="No comparison data available" + > + {#snippet header()}
-
+ {/snippet} - {#if mergeIntoParent} - { - canDeployToParent = canDeploy - }} - /> - {/if} - {#if conflictingDiffs.length > 0} - - - - {conflictingDiffs.length} item{conflictingDiffs.length !== 1 ? 's have' : ' has'} conflicting - changes, it was modified on the original workspace while changes were made on this fork. Make - sure to resolve these before merging. - - - {/if} - {#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)} - - You have items behind '{parentWorkspaceId}'. You need to update and test your changes before - being able to deploy. - - - Override: Deploy despite {itemsWithBehindChanges.length} outdated item{itemsWithBehindChanges.length !== - 1 - ? 's' - : ''} - - - {/if} - {#if !comparison.all_ahead_items_visible || !comparison.all_behind_items_visible} - - {#if !comparison.all_ahead_items_visible && !comparison.all_behind_items_visible} - This fork is ahead and behind its parent - {:else if !comparison.all_behind_items_visible} - This fork is behind of its parent - {:else if !comparison.all_ahead_items_visible} - This fork is ahead of its parent - {/if} - and some of the changes are not visible by you. Only a user with access to the whole context - may deploy or update this fork. You can share the link to this page to someone with proper permissions - to get it deployed. - - {/if} - -
-
- Select all -
-
- -
-
- {#each comparison.diffs as diff} - {@const key = getItemKey(diff)} - {@const isSelectable = selectableDiffs.includes(diff)} - {@const isSelected = selectedItems.includes(key)} - {@const isConflict = diff.ahead > 0 && diff.behind > 0} - {@const oldSummary = mergeIntoParent - ? summaryCache[key]?.parent - : summaryCache[key]?.current} - {@const newSummary = mergeIntoParent - ? summaryCache[key]?.current - : summaryCache[key]?.parent} - {@const existsInBothWorkspaces = !( - (diff.exists_in_fork && !diff.exists_in_source) || - (!diff.exists_in_fork && diff.exists_in_source) - )} - {@const isDeployedAndIrrelevant = - deploymentStatus[key]?.status == 'deployed' && !isSelectable} - - {#if !isDeployedAndIrrelevant} - toggleItem(diff)} - path={diff.kind != 'resource' && - diff.kind != 'variable' && - diff.kind != 'resource_type' - ? diff.path - : ''} - marked={undefined} - kind={diff.kind} - canFavorite={false} - workspaceId="" - > - {#snippet customSummary()} - {#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces} - {oldSummary || diff.path} - {newSummary || diff.path} - {:else if !existsInBothWorkspaces} - {newSummary || oldSummary || diff.path} - {:else} - {newSummary || diff.path} - {/if} - {/snippet} - {#snippet actions()} - - {#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0} - New - {/if} - {#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0} - Deleted - {/if} - {#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0} - Deleted - {/if} - {#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0} - New - {/if} - {#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'} -
- {#if isConflict || existsInBothWorkspaces} - {#if diff.ahead > 0} - - - {diff.ahead} ahead - - {/if} - {#if diff.behind > 0} - - - {diff.behind} behind - - {/if} - {#if isConflict} - - - Conflict - - {/if} - {/if} -
-
- -
- {/if} - {#if deploymentStatus[key]} - {#if deploymentStatus[key].status == 'loading'} - - {:else if deploymentStatus[key].status == 'deployed'} - Deployed - {:else if deploymentStatus[key].status == 'failed'} -
- Failed - {deploymentStatus[key].error}
- {/if} - {/if} - {/snippet} -
+ {#snippet alerts()} + {#if mergeIntoParent} + { + canDeployToParent = canDeploy + }} + /> + {/if} + {#if conflictingDiffs.length > 0} + + + {conflictingDiffs.length} item{conflictingDiffs.length !== 1 ? 's have' : ' has'} conflicting + changes, it was modified on the original workspace while changes were made on this fork. + Make sure to resolve these before merging. + + + {/if} + {#if hasBehindChanges && hasAheadChanges && !(mergeIntoParent && !canDeployToParent)} + + You have items behind '{parentWorkspaceId}'. You need to update and test your changes + before being able to deploy. + + + Override: Deploy despite {itemsWithBehindChanges.length} outdated item{itemsWithBehindChanges.length !== + 1 + ? 's' + : ''} + + + {/if} + {#if !comparison.all_ahead_items_visible || !comparison.all_behind_items_visible} + + {#if !comparison.all_ahead_items_visible && !comparison.all_behind_items_visible} + This fork is ahead and behind its parent + {:else if !comparison.all_behind_items_visible} + This fork is behind of its parent + {:else if !comparison.all_ahead_items_visible} + This fork is ahead of its parent {/if} - {/each} -
-
+ and some of the changes are not visible by you. Only a user with access to the whole context + may deploy or update this fork. You can share the link to this page to someone with proper + permissions to get it deployed. + + {/if} + {/snippet} -
-
-
- - - - - + {#snippet itemSummary(item)} + {@const diff = item.diff as WorkspaceItemDiff} + {@const key = item.key} + {@const isSelectable = selectableDiffs.includes(diff)} + {@const oldSummary = mergeIntoParent ? summaryCache[key]?.parent : summaryCache[key]?.current} + {@const newSummary = mergeIntoParent ? summaryCache[key]?.current : summaryCache[key]?.parent} + {@const existsInBothWorkspaces = !( + (diff.exists_in_fork && !diff.exists_in_source) || + (!diff.exists_in_fork && diff.exists_in_source) + )} + {#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces} + {oldSummary || diff.path} + {newSummary || diff.path} + {:else if !existsInBothWorkspaces} + {newSummary || oldSummary || diff.path} + {:else} + {newSummary || diff.path} + {/if} + {/snippet} + + {#snippet itemActions(item)} + {@const diff = item.diff as WorkspaceItemDiff} + {@const key = item.key} + {@const sourceEmail = getSourceEmail(key)} + {@const targetEmail = getTargetEmail(key)} + {@const isConflict = diff.ahead > 0 && diff.behind > 0} + {@const existsInBothWorkspaces = !( + (diff.exists_in_fork && !diff.exists_in_source) || + (!diff.exists_in_fork && diff.exists_in_source) + )} + + {#if itemNeedsOnBehalfOfSelection(key, diff.kind)} + (onBehalfOfChoice[key] = choice)} + kind={diff.kind} + canPreserve={canPreserveOnBehalfOf} + /> + {/if} + + {#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0} + New + {/if} + {#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0} + Deleted + {/if} + {#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0} + Deleted + {/if} + {#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0} + New + {/if} + {#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'} +
+ {#if isConflict || existsInBothWorkspaces} + {#if diff.ahead > 0} + + + {diff.ahead} ahead + + {/if} + {#if diff.behind > 0} + + + {diff.behind} behind + + {/if} + {#if isConflict} + + + Conflict + + {/if} + {/if}
+
+ +
+ {/if} + {/snippet} + + {#snippet footer()} +
+
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible} @@ -1002,7 +710,8 @@ disabled={selectedItems.length === 0 || deploying || (hasBehindChanges && !allowBehindChangesOverride) || - (mergeIntoParent && !canDeployToParent)} + (mergeIntoParent && !canDeployToParent) || + hasUnselectedOnBehalfOf} loading={deploying} on:click={deployChanges} > @@ -1012,6 +721,16 @@ ({selectedConflicts} conflicts) {/if} + {#if hasUnselectedOnBehalfOf} + + You must set the "on behalf of" user for all items before deploying + + The "run on behalf of" field defines which user's permissions will be + applied during execution. Make sure this is set to an appropriate + user before deploying. + + + {/if} {/if} {/if} @@ -1028,11 +747,11 @@ {/if}
-
- - {:else} -
-
No comparison data available
-
- {/if} -
+ {/snippet} + + +{:else} +
+
No comparison data available
+
+{/if} diff --git a/frontend/src/lib/components/DeployWorkspace.svelte b/frontend/src/lib/components/DeployWorkspace.svelte index dd16a3e1d7..c3ef4217fa 100644 --- a/frontend/src/lib/components/DeployWorkspace.svelte +++ b/frontend/src/lib/components/DeployWorkspace.svelte @@ -1,39 +1,43 @@
@@ -668,6 +433,15 @@ > + {#if workspaceToDeployTo} + { + canDeployToWorkspace = canDeploy + }} + /> + {/if} + {#if canSeeTarget == undefined}
@@ -675,79 +449,114 @@

All related deployable items

-
- {#each dependencies ?? [] as { kind, path, include }, i} - {@const statusPath = computeStatusPath(kind, path)} -
{kind}
{path}
- { - if (dependencies?.[i]) { - dependencies[i].include = e.detail - } - }} + + true} + {allSelected} + onToggleItem={toggleItem} + onSelectAll={selectAll} + onDeselectAll={deselectAll} + emptyMessage="No deployable items found" + > + {#snippet itemActions(item)} + {@const statusPath = item.key} + {@const exists = allAlreadyExists[statusPath]} + {@const status = deploymentStatus[statusPath]} + {@const sourceEmail = sourceOnBehalfOfInfo[statusPath]} + {@const targetEmail = targetOnBehalfOfInfo[statusPath]} + + + {#if itemNeedsOnBehalfOfSelection(statusPath, item.kind)} + (onBehalfOfChoice[statusPath] = choice)} + kind={item.kind} + canPreserve={canPreserveOnBehalfOf} /> -
-
- {#if allAlreadyExists[statusPath] == false} - {#if include} - New This {kind} doesn't exist yet on the target and will be created by the deployment - {:else} - - Missing - {#if kind == 'resource_type'} - Resource types are not re-deployed by default. We strongly recommend to add - shared resource types in 'admin' workspace, which will have them be shared to - every workspace. - {:else} - This {kind} doesn't exist and is not included in the deployment. Variables and Resources - are considered to be workspace specific and are never included by default. - {/if} - - {/if} - {:else if allAlreadyExists[statusPath] == true} - New This {item.kind} doesn't exist yet on the target and will be created by the deployment - {/if}
-
- {#if deploymentStatus[statusPath]} - {#if deploymentStatus[statusPath].status == 'loading'} - - {:else if deploymentStatus[statusPath].status == 'deployed'} - Deployed - {:else if deploymentStatus[statusPath].status == 'failed'} -
- Failed - {deploymentStatus[statusPath].error}
- {/if} {:else} - + + Missing + {#if item.kind == 'resource_type'} + Resource types are not re-deployed by default. We strongly recommend to add shared + resource types in 'admin' workspace, which will have them be shared to every + workspace. + {:else} + This {item.kind} doesn't exist and is not included in the deployment. Variables and + Resources are considered to be workspace specific and are never included by default. + {/if} + + {/if} + {:else if exists === true && !status} + + {/if} + + {#if !status} + + {/if} + {/snippet} + + {#snippet footer()} +
+ {#if !hideButton} + + {/if} + {#if hasUnselectedOnBehalfOf} + + {#if kind === 'trigger'} + You must set the "edited by" user for all triggers before deploying + + The "edited by" field defines which user's permissions will be applied + when the trigger runs. Make sure this is set to an appropriate user + before deploying. + + {:else} + You must set the "on behalf of" user for all items before deploying + + The "run on behalf of" field defines which user's permissions will be + applied during execution. Make sure this is set to an appropriate user + before deploying. + + {/if} + {/if}
- {/each} -
- - {#if !hideButton} -
- {/if} + {/snippet} + {:else if canSeeTarget == 'cant-see-all-deps'}
- {#if workspaceToDeployTo} - { - canDeployToWorkspace = canDeploy - }} - /> - {/if} {#if (kind != 'trigger' && kind != undefined && initialPath != undefined) || (kind === 'trigger' && initialPath != undefined && additionalInformation?.triggers != undefined)} { + timeout = window.setTimeout(() => { lockChanges = false }, 500) } else if (event.data.type == 'error') { @@ -539,7 +539,9 @@ }), currentEditor: writable(undefined), modulesTestStates, - outputPickerOpenFns + outputPickerOpenFns, + preserveOnBehalfOf: writable(false), + savedOnBehalfOfEmail: writable(undefined) }) setContext('PropPickerContext', { flowPropPickerConfig: writable(undefined), diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index b90d91158a..95e659bdf4 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -131,6 +131,10 @@ let initialPathStore = writable(initialPath) + // For preserve_on_behalf_of feature + let preserveOnBehalfOf = writable(false) + let savedOnBehalfOfEmail = writable(savedFlow?.on_behalf_of_email) + // used for new flows for captures let fakeInitialPath = 'u/' + @@ -481,6 +485,7 @@ dedicated_worker: flow.dedicated_worker, visible_to_runner_only: flow.visible_to_runner_only, on_behalf_of_email: flow.on_behalf_of_email, + preserve_on_behalf_of: $preserveOnBehalfOf || undefined, deployment_message: deploymentMsg || undefined } }) @@ -533,6 +538,7 @@ ws_error_handler_muted: flow.ws_error_handler_muted, visible_to_runner_only: flow.visible_to_runner_only, on_behalf_of_email: flow.on_behalf_of_email, + preserve_on_behalf_of: $preserveOnBehalfOf || undefined, deployment_message: deploymentMsg || undefined } }) @@ -560,7 +566,7 @@ function saveSessionDraft() { timeout && clearTimeout(timeout) - timeout = setTimeout(() => { + timeout = window.setTimeout(() => { try { localStorage.setItem( initialPath && initialPath != '' ? `flow-${initialPath}` : 'flow', @@ -641,7 +647,9 @@ executionCount: writable(0), flowInputEditorState: flowInputEditorStateStore, modulesTestStates, - outputPickerOpenFns + outputPickerOpenFns, + preserveOnBehalfOf, + savedOnBehalfOfEmail }) // Set up NoteEditor context for note editing capabilities diff --git a/frontend/src/lib/components/GroupEditor.svelte b/frontend/src/lib/components/GroupEditor.svelte index 0bb32c8544..a8391bd8a6 100644 --- a/frontend/src/lib/components/GroupEditor.svelte +++ b/frontend/src/lib/components/GroupEditor.svelte @@ -21,6 +21,7 @@ import TextInput from './text_input/TextInput.svelte' import { Trash } from 'lucide-svelte' import PermissionHistory from './PermissionHistory.svelte' + import Alert from './common/alert/Alert.svelte' interface Props { name: string @@ -116,6 +117,13 @@
+ {#if name === 'wm_deployers'} + + Members of this group can preserve the original author (on_behalf_of / edited_by) when + deploying scripts, flows, apps, and triggers to this workspace. Without this permission, + deployed items will be reassigned to the deploying user. + + {/if}
{/if} - {#if canFavorite && kind !== 'resource' && kind !== 'variable' && kind !== 'resource_type'} + {#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow')}
@@ -120,7 +122,7 @@ {#snippet rowContent()}
- +
diff --git a/frontend/src/lib/components/common/table/RowIcon.svelte b/frontend/src/lib/components/common/table/RowIcon.svelte index 0d0e59760f..80566800ff 100644 --- a/frontend/src/lib/components/common/table/RowIcon.svelte +++ b/frontend/src/lib/components/common/table/RowIcon.svelte @@ -1,25 +1,85 @@ -
- {#if kind === 'flow'} +
+ {#if effectiveKind === 'flow'} - {:else if kind === 'app' || kind === 'raw_app'} + {:else if effectiveKind === 'app' || effectiveKind === 'raw_app'} - {:else if kind === 'script'} + {:else if effectiveKind === 'script'} - {:else if kind === 'variable'} + {:else if effectiveKind === 'variable'} - {:else if kind === 'resource'} + {:else if effectiveKind === 'resource'} - {:else if kind === 'resource_type'} -
- {:else if kind === 'folder'} + {:else if effectiveKind === 'resource_type'} +
+ {:else if effectiveKind === 'folder'} + {:else if effectiveKind === 'schedule' || effectiveKind === 'schedules'} + + {:else if effectiveKind === 'routes'} + + {:else if effectiveKind === 'websockets'} + + {:else if effectiveKind === 'postgres'} + + {:else if effectiveKind === 'kafka'} + + {:else if effectiveKind === 'nats'} + + {:else if effectiveKind === 'mqtt'} + + {:else if effectiveKind === 'sqs'} + + {:else if effectiveKind === 'gcp'} + + {:else if effectiveKind === 'trigger'} + {:else}
{/if} diff --git a/frontend/src/lib/components/flows/content/FlowSettings.svelte b/frontend/src/lib/components/flows/content/FlowSettings.svelte index 5e0e47da24..6a9c7f918a 100644 --- a/frontend/src/lib/components/flows/content/FlowSettings.svelte +++ b/frontend/src/lib/components/flows/content/FlowSettings.svelte @@ -34,8 +34,24 @@ let { noEditor, enableAi }: Props = $props() - const { flowStore, initialPathStore, previewArgs, pathStore, customUi } = - getContext('FlowEditorContext') + const { + flowStore, + initialPathStore, + previewArgs, + pathStore, + customUi, + preserveOnBehalfOf, + savedOnBehalfOfEmail + } = getContext('FlowEditorContext') + + const WM_DEPLOYERS_GROUP = 'wm_deployers' + let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) + let showPreserveToggle = $derived( + isDeployer && + flowStore.val.on_behalf_of_email && + $savedOnBehalfOfEmail && + $savedOnBehalfOfEmail !== $userStore?.email + ) function asSchema(x: any) { return x as Schema @@ -373,6 +389,7 @@ on:change={() => { if (flowStore.val.on_behalf_of_email) { flowStore.val.on_behalf_of_email = undefined + $preserveOnBehalfOf = false } else { flowStore.val.on_behalf_of_email = $userStore?.email } @@ -383,6 +400,16 @@ 'When this option is enabled, the flow will be run with the permissions of the last editor.' }} /> + {#if showPreserveToggle} + + {/if}
diff --git a/frontend/src/lib/components/flows/types.ts b/frontend/src/lib/components/flows/types.ts index eace554bf6..07409a2b92 100644 --- a/frontend/src/lib/components/flows/types.ts +++ b/frontend/src/lib/components/flows/types.ts @@ -93,6 +93,8 @@ export type FlowEditorContext = { executionCount: Writable modulesTestStates: ModulesTestStates outputPickerOpenFns: Record void> + preserveOnBehalfOf: Writable + savedOnBehalfOfEmail: Writable } export type FlowGraphAssetContext = StateStore<{ diff --git a/frontend/src/lib/utils_deployable.ts b/frontend/src/lib/utils_deployable.ts index ab7a80051b..47793e833e 100644 --- a/frontend/src/lib/utils_deployable.ts +++ b/frontend/src/lib/utils_deployable.ts @@ -93,7 +93,18 @@ export async function existsTrigger( ) } -export async function getTriggersDeployData(kind: TriggerKind, path: string, workspace: string) { +/** + * Get trigger deployment data with optional email preservation. + * @param onBehalfOfEmail - If set, the trigger will be deployed with this email and preserve_email=true. + */ +export async function getTriggersDeployData( + kind: TriggerKind, + path: string, + workspace: string, + onBehalfOfEmail?: string +) { + const preserveEmail = onBehalfOfEmail !== undefined + if (kind === 'sqs') { const sqsTrigger = await SqsTriggerService.getSqsTrigger({ workspace: workspace!, @@ -101,7 +112,7 @@ export async function getTriggersDeployData(kind: TriggerKind, path: string, wor }) return { - data: sqsTrigger, + data: { ...sqsTrigger, email: onBehalfOfEmail, preserve_email: preserveEmail }, createFn: SqsTriggerService.createSqsTrigger, updateFn: SqsTriggerService.updateSqsTrigger } @@ -112,7 +123,7 @@ export async function getTriggersDeployData(kind: TriggerKind, path: string, wor }) return { - data: kafkaTrigger, + data: { ...kafkaTrigger, email: onBehalfOfEmail, preserve_email: preserveEmail }, createFn: KafkaTriggerService.createKafkaTrigger, updateFn: KafkaTriggerService.updateKafkaTrigger } @@ -123,7 +134,7 @@ export async function getTriggersDeployData(kind: TriggerKind, path: string, wor }) return { - data: mqttTrigger, + data: { ...mqttTrigger, email: onBehalfOfEmail, preserve_email: preserveEmail }, createFn: MqttTriggerService.createMqttTrigger, updateFn: MqttTriggerService.updateMqttTrigger } @@ -134,7 +145,7 @@ export async function getTriggersDeployData(kind: TriggerKind, path: string, wor }) return { - data: natsTrigger, + data: { ...natsTrigger, email: onBehalfOfEmail, preserve_email: preserveEmail }, createFn: NatsTriggerService.createNatsTrigger, updateFn: NatsTriggerService.updateNatsTrigger } @@ -155,7 +166,9 @@ export async function getTriggersDeployData(kind: TriggerKind, path: string, wor ...gcpTrigger, delivery_config: gcpTrigger.delivery_config ?? undefined, base_endpoint: - gcpTrigger.delivery_type === 'push' ? `${window.location.origin}${base}` : undefined + gcpTrigger.delivery_type === 'push' ? `${window.location.origin}${base}` : undefined, + email: onBehalfOfEmail, + preserve_email: preserveEmail } return { @@ -170,7 +183,7 @@ export async function getTriggersDeployData(kind: TriggerKind, path: string, wor }) return { - data: postgresTrigger, + data: { ...postgresTrigger, email: onBehalfOfEmail, preserve_email: preserveEmail }, createFn: PostgresTriggerService.createPostgresTrigger, updateFn: PostgresTriggerService.updatePostgresTrigger } @@ -181,7 +194,7 @@ export async function getTriggersDeployData(kind: TriggerKind, path: string, wor }) return { - data: websocketTrigger, + data: { ...websocketTrigger, email: onBehalfOfEmail, preserve_email: preserveEmail }, createFn: WebsocketTriggerService.createWebsocketTrigger, updateFn: WebsocketTriggerService.updateWebsocketTrigger } @@ -192,7 +205,7 @@ export async function getTriggersDeployData(kind: TriggerKind, path: string, wor }) return { - data: httpTrigger, + data: { ...httpTrigger, email: onBehalfOfEmail, preserve_email: preserveEmail }, createFn: HttpTriggerService.createHttpTrigger, updateFn: HttpTriggerService.updateHttpTrigger } @@ -202,7 +215,7 @@ export async function getTriggersDeployData(kind: TriggerKind, path: string, wor path: path }) return { - data: schedulesTrigger, + data: { ...schedulesTrigger, email: onBehalfOfEmail, preserve_email: preserveEmail }, createFn: ScheduleService.createSchedule, updateFn: ScheduleService.updateSchedule } @@ -435,6 +448,49 @@ export async function getTriggerValue(kind: TriggerKind, path: string, workspace throw new Error(`Unexpected trigger kind got: ${kind}`) } +/** + * Get the email for a trigger (used for on_behalf_of during deployment). + */ +export async function getTriggerEmail( + kind: TriggerKind, + path: string, + workspace: string +): Promise { + try { + if (kind === 'sqs') { + const trigger = await SqsTriggerService.getSqsTrigger({ workspace, path }) + return trigger.edited_by + } else if (kind === 'kafka') { + const trigger = await KafkaTriggerService.getKafkaTrigger({ workspace, path }) + return trigger.edited_by + } else if (kind === 'mqtt') { + const trigger = await MqttTriggerService.getMqttTrigger({ workspace, path }) + return trigger.edited_by + } else if (kind === 'nats') { + const trigger = await NatsTriggerService.getNatsTrigger({ workspace, path }) + return trigger.edited_by + } else if (kind === 'gcp') { + const trigger = await GcpTriggerService.getGcpTrigger({ workspace, path }) + return trigger.edited_by + } else if (kind === 'postgres') { + const trigger = await PostgresTriggerService.getPostgresTrigger({ workspace, path }) + return trigger.edited_by + } else if (kind === 'websockets') { + const trigger = await WebsocketTriggerService.getWebsocketTrigger({ workspace, path }) + return trigger.edited_by + } else if (kind === 'routes') { + const trigger = await HttpTriggerService.getHttpTrigger({ workspace, path }) + return trigger.edited_by + } else if (kind === 'schedules') { + const trigger = await ScheduleService.getSchedule({ workspace, path }) + return trigger.edited_by + } + } catch { + // Trigger may not exist in the workspace + } + return undefined +} + function retrieveScriptOrFlowKind(path: string, is_flow: boolean): { kind: Kind; path: string } { return { kind: is_flow ? 'flow' : 'script', diff --git a/frontend/src/lib/utils_workspace_deploy.ts b/frontend/src/lib/utils_workspace_deploy.ts new file mode 100644 index 0000000000..cddf7d0d60 --- /dev/null +++ b/frontend/src/lib/utils_workspace_deploy.ts @@ -0,0 +1,476 @@ +import { + AppService, + FlowService, + FolderService, + ResourceService, + ScheduleService, + ScriptService, + VariableService +} from '$lib/gen' +import { getAllModules } from './components/flows/flowExplorer' +import { + existsTrigger, + getTriggersDeployData, + getTriggerEmail, + getTriggerValue, + type AdditionalInformation, + type Kind +} from '$lib/utils_deployable' +import type { TriggerKind } from './components/triggers' + +export interface DeployItemParams { + kind: Kind + path: string + workspaceFrom: string + workspaceTo: string + additionalInformation?: AdditionalInformation + /** The email to use for on_behalf_of. If set, preserve_on_behalf_of will be true. If undefined, deploying user's email is used. */ + onBehalfOfEmail?: string +} + +export interface DeployResult { + success: boolean + error?: string +} + +/** + * Deploy an item from one workspace to another. + * Handles all item kinds: flow, script, app, variable, resource, resource_type, folder, trigger. + */ +export async function deployItem(params: DeployItemParams): Promise { + const { kind, path, workspaceFrom, workspaceTo, additionalInformation, onBehalfOfEmail } = params + // When onBehalfOfEmail is set, we preserve the on_behalf_of setting with the specified email + const preserveOnBehalfOf = onBehalfOfEmail !== undefined + + try { + const alreadyExists = await checkItemExists(kind, path, workspaceTo, additionalInformation) + + if (kind === 'flow') { + const flow = await FlowService.getFlowByPath({ + workspace: workspaceFrom, + path: path + }) + getAllModules(flow.value.modules).forEach((x) => { + if (x.value.type === 'script' && x.value.hash != undefined) { + x.value.hash = undefined + } + }) + if (alreadyExists) { + await FlowService.updateFlow({ + workspace: workspaceTo, + path: path, + requestBody: { + ...flow, + preserve_on_behalf_of: preserveOnBehalfOf, + on_behalf_of_email: onBehalfOfEmail + } + }) + } else { + await FlowService.createFlow({ + workspace: workspaceTo, + requestBody: { + ...flow, + preserve_on_behalf_of: preserveOnBehalfOf, + on_behalf_of_email: onBehalfOfEmail + } + }) + } + } else if (kind === 'script') { + const script = await ScriptService.getScriptByPath({ + workspace: workspaceFrom, + path: path + }) + await ScriptService.createScript({ + workspace: workspaceTo, + requestBody: { + ...script, + lock: script.lock, + parent_hash: alreadyExists + ? ( + await ScriptService.getScriptByPath({ + workspace: workspaceTo, + path: path + }) + ).hash + : undefined, + preserve_on_behalf_of: preserveOnBehalfOf, + on_behalf_of_email: onBehalfOfEmail + } + }) + } else if (kind === 'app') { + const app = await AppService.getAppByPath({ + workspace: workspaceFrom, + path: path + }) + if (alreadyExists) { + if (app.raw_app) { + const secret = await AppService.getPublicSecretOfLatestVersionOfApp({ + workspace: workspaceFrom, + path: app.path + }) + const js = await AppService.getRawAppData({ + secretWithExtension: `${secret}.js`, + workspace: workspaceFrom + }) + const css = await AppService.getRawAppData({ + secretWithExtension: `${secret}.css`, + workspace: workspaceFrom + }) + await AppService.updateAppRaw({ + workspace: workspaceTo, + path: path, + formData: { + app: { ...app, preserve_on_behalf_of: preserveOnBehalfOf }, + css, + js + } + }) + } else { + await AppService.updateApp({ + workspace: workspaceTo, + path: path, + requestBody: { + ...app, + preserve_on_behalf_of: preserveOnBehalfOf + } + }) + } + } else { + if (app.raw_app) { + const secret = await AppService.getPublicSecretOfLatestVersionOfApp({ + workspace: workspaceFrom, + path: app.path + }) + const js = await AppService.getRawAppData({ + secretWithExtension: `${secret}.js`, + workspace: workspaceFrom + }) + const css = await AppService.getRawAppData({ + secretWithExtension: `${secret}.css`, + workspace: workspaceFrom + }) + await AppService.createAppRaw({ + workspace: workspaceTo, + formData: { + app: { ...app, preserve_on_behalf_of: preserveOnBehalfOf }, + css, + js + } + }) + } else { + await AppService.createApp({ + workspace: workspaceTo, + requestBody: { + ...app, + preserve_on_behalf_of: preserveOnBehalfOf + } + }) + } + } + } else if (kind === 'variable') { + const variable = await VariableService.getVariable({ + workspace: workspaceFrom, + path: path, + decryptSecret: true + }) + if (alreadyExists) { + await VariableService.updateVariable({ + workspace: workspaceTo, + path: path, + requestBody: { + path: path, + value: variable.value ?? '', + is_secret: variable.is_secret, + description: variable.description ?? '' + }, + alreadyEncrypted: false + }) + } else { + await VariableService.createVariable({ + workspace: workspaceTo, + requestBody: { + path: path, + value: variable.value ?? '', + is_secret: variable.is_secret, + description: variable.description ?? '' + } + }) + } + } else if (kind === 'resource') { + const resource = await ResourceService.getResource({ + workspace: workspaceFrom, + path: path + }) + if (alreadyExists) { + await ResourceService.updateResource({ + workspace: workspaceTo, + path: path, + requestBody: { + path: path, + value: resource.value ?? '', + description: resource.description ?? '' + } + }) + } else { + await ResourceService.createResource({ + workspace: workspaceTo, + requestBody: { + path: path, + value: resource.value ?? '', + resource_type: resource.resource_type, + description: resource.description ?? '' + } + }) + } + } else if (kind === 'resource_type') { + const resource = await ResourceService.getResourceType({ + workspace: workspaceFrom, + path: path + }) + if (alreadyExists) { + await ResourceService.updateResourceType({ + workspace: workspaceTo, + path: path, + requestBody: { + schema: resource.schema, + description: resource.description ?? '' + } + }) + } else { + await ResourceService.createResourceType({ + workspace: workspaceTo, + requestBody: { + description: resource.description ?? '', + schema: resource.schema, + name: resource.name + } + }) + } + } else if (kind === 'folder') { + await FolderService.createFolder({ + workspace: workspaceTo, + requestBody: { + name: path + } + }) + } else if (kind === 'trigger') { + if (additionalInformation?.triggers) { + const { data, createFn, updateFn } = await getTriggersDeployData( + additionalInformation.triggers.kind, + path, + workspaceFrom, + onBehalfOfEmail + ) + if (alreadyExists) { + await updateFn({ + path, + workspace: workspaceTo, + requestBody: data + } as any) + } else { + await createFn({ + workspace: workspaceTo, + requestBody: data + } as any) + } + } else { + throw new Error('Missing triggers kind') + } + } else { + throw new Error(`Unknown kind ${kind}`) + } + + return { success: true } + } catch (e: any) { + return { success: false, error: e.body || e.message } + } +} + +/** + * Check if an item already exists in the target workspace. + */ +export async function checkItemExists( + kind: Kind, + path: string, + workspace: string, + additionalInformation?: AdditionalInformation +): Promise { + if (kind === 'flow') { + return await FlowService.existsFlowByPath({ + workspace: workspace, + path: path + }) + } else if (kind === 'script') { + return await ScriptService.existsScriptByPath({ + workspace: workspace, + path: path + }) + } else if (kind === 'app') { + return await AppService.existsApp({ + workspace: workspace, + path: path + }) + } else if (kind === 'variable') { + return await VariableService.existsVariable({ + workspace: workspace, + path: path + }) + } else if (kind === 'resource') { + return await ResourceService.existsResource({ + workspace: workspace, + path: path + }) + } else if (kind === 'schedule') { + return await ScheduleService.existsSchedule({ + workspace: workspace, + path: path + }) + } else if (kind === 'resource_type') { + return await ResourceService.existsResourceType({ + workspace: workspace, + path: path + }) + } else if (kind === 'folder') { + return await FolderService.existsFolder({ + workspace: workspace, + name: path + }) + } else if (kind === 'trigger') { + const triggersKind: TriggerKind[] = [ + 'kafka', + 'mqtt', + 'nats', + 'postgres', + 'routes', + 'schedules', + 'sqs', + 'websockets', + 'gcp' + ] + if ( + additionalInformation?.triggers && + triggersKind.includes(additionalInformation.triggers.kind) + ) { + return await existsTrigger( + { workspace: workspace, path }, + additionalInformation.triggers.kind + ) + } else { + throw new Error( + `Unexpected triggers kind, expected one of: '${triggersKind.join(', ')}' got: ${ + additionalInformation?.triggers?.kind + }` + ) + } + } else { + throw new Error(`Unknown kind ${kind}`) + } +} + +/** + * Get the value of an item for diff comparison. + */ +export async function getItemValue( + kind: Kind, + path: string, + workspace: string, + additionalInformation?: AdditionalInformation +): Promise { + try { + if (kind === 'flow') { + const flow = await FlowService.getFlowByPath({ + workspace: workspace, + path: path + }) + getAllModules(flow.value.modules).forEach((x) => { + if (x.value.type === 'script' && x.value.hash != undefined) { + x.value.hash = undefined + } + }) + return { summary: flow.summary, description: flow.description, value: flow.value } + } else if (kind === 'script') { + const script = await ScriptService.getScriptByPath({ + workspace: workspace, + path: path + }) + return { + content: script.content, + lock: script.lock, + schema: script.schema, + summary: script.summary, + language: script.language + } + } else if (kind === 'app') { + const app = await AppService.getAppByPath({ + workspace: workspace, + path: path + }) + return app + } else if (kind === 'variable') { + const variable = await VariableService.getVariable({ + workspace: workspace, + path: path, + decryptSecret: true + }) + return variable.value + } else if (kind === 'resource') { + const resource = await ResourceService.getResource({ + workspace: workspace, + path: path + }) + return resource.value + } else if (kind === 'resource_type') { + const resource = await ResourceService.getResourceType({ + workspace: workspace, + path: path + }) + return resource.schema + } else if (kind === 'folder') { + const folder = await FolderService.getFolder({ + workspace: workspace, + name: path + }) + return { + name: folder.name + } + } else if (kind === 'trigger') { + if (additionalInformation?.triggers) { + return await getTriggerValue(additionalInformation.triggers.kind, path, workspace) + } else { + throw new Error(`Missing trigger information`) + } + } else { + throw new Error(`Unknown kind ${kind}`) + } + } catch { + return {} + } +} + +/** + * Get the on_behalf_of_email for a flow, script, app, or trigger (including schedule). + */ +export async function getOnBehalfOfEmail( + kind: Kind, + path: string, + workspace: string, + additionalInformation?: AdditionalInformation +): Promise { + try { + if (kind === 'flow') { + const flow = await FlowService.getFlowByPath({ workspace, path }) + return flow.on_behalf_of_email + } else if (kind === 'script') { + const script = await ScriptService.getScriptByPath({ workspace, path }) + return script.on_behalf_of_email + } else if (kind === 'app') { + const app = await AppService.getAppByPath({ workspace, path }) + return app.policy.on_behalf_of_email + } else if (kind === 'trigger' && additionalInformation?.triggers) { + return await getTriggerEmail(additionalInformation.triggers.kind, path, workspace) + } + } catch { + // Item may not exist in the workspace + } + return undefined +} diff --git a/frontend/src/routes/flows/dev/+page.svelte b/frontend/src/routes/flows/dev/+page.svelte index 80516e237e..8e043bf758 100644 --- a/frontend/src/routes/flows/dev/+page.svelte +++ b/frontend/src/routes/flows/dev/+page.svelte @@ -113,7 +113,9 @@ }), currentEditor: writable(undefined), modulesTestStates: new ModulesTestStates(), - outputPickerOpenFns: {} + outputPickerOpenFns: {}, + preserveOnBehalfOf: writable(false), + savedOnBehalfOfEmail: writable(undefined) }) setContext('PropPickerContext', { flowPropPickerConfig: writable(undefined),