From 64c58c824fcefe00f15405b7e3877eb566a3ffa2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 13 Apr 2026 13:10:37 -0400 Subject: [PATCH] feat: add deploy restriction rule and fork review requests (#8804) * feat: add deploy restriction rule and fork review requests Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt for fork review requests Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address PR review comments on fork review requests Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: rename fork review requests to deployment requests Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref.txt for deployment request rename Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: inline deployment request panel into deploy layout Co-Authored-By: Claude Opus 4.6 (1M context) * fix: place Request deployment button to the left of Deploy Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: inline fork triggers into main deploy list Co-Authored-By: Claude Opus 4.6 (1M context) * refactor: open real trigger detail drawer for inline fork triggers Co-Authored-By: Claude Opus 4.6 (1M context) * feat: email notifications for merge completion and reply pings Co-Authored-By: Claude Opus 4.6 (1M context) * fix: update deployment_request + protection_rule tables on workspace id rename Co-Authored-By: Claude Opus 4.6 (1M context) * chore: update ee-repo-ref to 972893c3870e4c4a70a35748abed282d88904805 This commit updates the EE repository reference after PR #528 was merged in windmill-ee-private. Previous ee-repo-ref: 5684d1c17d930b17849c1e5d7577891e64682d45 New ee-repo-ref: 972893c3870e4c4a70a35748abed282d88904805 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...fc3377a9e6e8a616f0aa90ddd49eff4a8e670.json | 25 + ...a999325a4921dde8039f96712fd4106cae2ed.json | 34 + ...0a7962fd4e1d96e06a5162d248da21566ccbe.json | 16 + ...1a43ecf2b3d70c459cfd7622e5ca82eb20eb0.json | 15 + ...3583fea35a4653f73c0840584f21af5de2739.json | 28 + ...0eeeea12d49c38963b611988635a68faf9b0c.json | 24 + ...b2ffbd6b7330dff606c2f6910951baed551ef.json | 29 + ...c7497ef63412ce30b634e43eb2c46c78c82ad.json | 14 + ...ac03d555a56d79c8aa302c893009b5cfdc80d.json | 16 + ...040034e83e9ee6079de140eebc70bc41c14f0.json | 15 + ...83fda7a128e714de9380a1be13096ab945602.json | 22 + ...961e2af3f749d66e9e07fc84667bbb685a452.json | 14 + ...99fb6de9c13f8d02195a425b3fea2cc71c7c5.json | 12 + ...e67eeebe7332d941baa5e4d210588ee2d9d55.json | 15 + ...cb3945a2368d03644fce1cd3bb35faaa18460.json | 70 ++ ...689090528db9f77ad355278a563bf24da72c3.json | 53 ++ ...4ced0a03d482d7891a61f37dfedfd8fdc66db.json | 35 + ...c8c27267e3fc19e92725257a487a58e34e7ae.json | 22 + ...a6c84a8d743b2914ea5b1f3fd93f97f56f2b5.json | 41 + ...97d17dd9035854126a3f76a86b613e3975d92.json | 14 + ...cc7328b149072cd86bea4b6a1e1c09aabe999.json | 41 + ...0a5d6a1b20cc9067b83c2a3fb2d1e536f5a6a.json | 31 + ...78543a91d018048a1ba921dcd0da406c3bbf6.json | 30 + ...b4a9888d3db2d5edfa44f87585a4d80cec461.json | 29 + ...742043a39726dedfcfdda5aab1629264aab8c.json | 23 + backend/ee-repo-ref.txt | 2 +- ...13045437_fork_deployment_requests.down.sql | 3 + ...0413045437_fork_deployment_requests.up.sql | 70 ++ .../fixtures/fork_deployment_requests.sql | 63 ++ .../fixtures/restrict_deploy_to_deployers.sql | 42 + backend/tests/fork_deployment_requests.rs | 356 +++++++++ backend/tests/protection_rules.rs | 121 ++- backend/windmill-api-flows/src/flows.rs | 14 +- backend/windmill-api-groups/src/folders.rs | 11 +- backend/windmill-api-scripts/src/scripts.rs | 20 +- .../src/deployment_requests.rs | 732 ++++++++++++++++++ backend/windmill-api-workspaces/src/lib.rs | 1 + .../src/workspaces_extra.rs | 25 + backend/windmill-api/openapi.yaml | 254 ++++++ backend/windmill-api/src/apps.rs | 17 +- backend/windmill-api/src/lib.rs | 4 + .../src/deployment_requests_oss.rs | 50 ++ backend/windmill-common/src/lib.rs | 3 + backend/windmill-common/src/workspaces.rs | 44 ++ backend/windmill-store/src/resources.rs | 26 +- backend/windmill-store/src/variables.rs | 14 +- .../lib/components/CompareWorkspaces.svelte | 590 ++++++++------ .../DeploymentRequestPanel.svelte | 356 +++++++++ .../workspaceSettings/RulesetEditor.svelte | 29 +- 49 files changed, 3188 insertions(+), 327 deletions(-) create mode 100644 backend/.sqlx/query-0ed4ccab404d5487b87bf2c04abfc3377a9e6e8a616f0aa90ddd49eff4a8e670.json create mode 100644 backend/.sqlx/query-1e284b9415a6a5e2485e69cf704a999325a4921dde8039f96712fd4106cae2ed.json create mode 100644 backend/.sqlx/query-25bef98423d075337bc6cdcd88b0a7962fd4e1d96e06a5162d248da21566ccbe.json create mode 100644 backend/.sqlx/query-2fe88c8d79ca5dd4b8f806b2fb11a43ecf2b3d70c459cfd7622e5ca82eb20eb0.json create mode 100644 backend/.sqlx/query-2ff6b0d240b88fd5654bb46f9913583fea35a4653f73c0840584f21af5de2739.json create mode 100644 backend/.sqlx/query-330b4697892fcb85741ba13e6620eeeea12d49c38963b611988635a68faf9b0c.json create mode 100644 backend/.sqlx/query-35cb70e555469fea3a65e199f72b2ffbd6b7330dff606c2f6910951baed551ef.json create mode 100644 backend/.sqlx/query-4943323bbcf93bd6b080f946f3fc7497ef63412ce30b634e43eb2c46c78c82ad.json create mode 100644 backend/.sqlx/query-4af18a140d2eceb07b450077f00ac03d555a56d79c8aa302c893009b5cfdc80d.json create mode 100644 backend/.sqlx/query-508fd3937cc0cad51852a797deb040034e83e9ee6079de140eebc70bc41c14f0.json create mode 100644 backend/.sqlx/query-575598f84c6ee7c5b5d961d8fc083fda7a128e714de9380a1be13096ab945602.json create mode 100644 backend/.sqlx/query-5fc750b6e52b7fab11876240206961e2af3f749d66e9e07fc84667bbb685a452.json create mode 100644 backend/.sqlx/query-6272b3b073d26bd11063c1ab00199fb6de9c13f8d02195a425b3fea2cc71c7c5.json create mode 100644 backend/.sqlx/query-6edb794d2005c233e621ffd7b0ae67eeebe7332d941baa5e4d210588ee2d9d55.json create mode 100644 backend/.sqlx/query-6f01ccf15df0e73b465dd0fbe3fcb3945a2368d03644fce1cd3bb35faaa18460.json create mode 100644 backend/.sqlx/query-709593e036492fefa7be7ed6e44689090528db9f77ad355278a563bf24da72c3.json create mode 100644 backend/.sqlx/query-71d8af5759b4c6af0aa760911094ced0a03d482d7891a61f37dfedfd8fdc66db.json create mode 100644 backend/.sqlx/query-826621e07af88f94592143def15c8c27267e3fc19e92725257a487a58e34e7ae.json create mode 100644 backend/.sqlx/query-894210b1db20ecf08a57851d845a6c84a8d743b2914ea5b1f3fd93f97f56f2b5.json create mode 100644 backend/.sqlx/query-8e4bcfa5e7ccec5d26b5602ed8697d17dd9035854126a3f76a86b613e3975d92.json create mode 100644 backend/.sqlx/query-950f109a4cd8d8666f9d3945e41cc7328b149072cd86bea4b6a1e1c09aabe999.json create mode 100644 backend/.sqlx/query-a9fe245eee63eab4f7c3cbf1cdd0a5d6a1b20cc9067b83c2a3fb2d1e536f5a6a.json create mode 100644 backend/.sqlx/query-b3e9e8c625cf73251e70207d67d78543a91d018048a1ba921dcd0da406c3bbf6.json create mode 100644 backend/.sqlx/query-bccd4d5e772f4967f282d6c014bb4a9888d3db2d5edfa44f87585a4d80cec461.json create mode 100644 backend/.sqlx/query-e7947f973f5542775026edcc03e742043a39726dedfcfdda5aab1629264aab8c.json create mode 100644 backend/migrations/20260413045437_fork_deployment_requests.down.sql create mode 100644 backend/migrations/20260413045437_fork_deployment_requests.up.sql create mode 100644 backend/tests/fixtures/fork_deployment_requests.sql create mode 100644 backend/tests/fixtures/restrict_deploy_to_deployers.sql create mode 100644 backend/tests/fork_deployment_requests.rs create mode 100644 backend/windmill-api-workspaces/src/deployment_requests.rs create mode 100644 backend/windmill-common/src/deployment_requests_oss.rs create mode 100644 frontend/src/lib/components/deploymentRequest/DeploymentRequestPanel.svelte diff --git a/backend/.sqlx/query-0ed4ccab404d5487b87bf2c04abfc3377a9e6e8a616f0aa90ddd49eff4a8e670.json b/backend/.sqlx/query-0ed4ccab404d5487b87bf2c04abfc3377a9e6e8a616f0aa90ddd49eff4a8e670.json new file mode 100644 index 0000000000..4b387c9a27 --- /dev/null +++ b/backend/.sqlx/query-0ed4ccab404d5487b87bf2c04abfc3377a9e6e8a616f0aa90ddd49eff4a8e670.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT 1 FROM workspace_diff\n WHERE source_workspace_id = $1\n AND fork_workspace_id = $2\n AND kind = $3\n AND path = $4\n ) as \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0ed4ccab404d5487b87bf2c04abfc3377a9e6e8a616f0aa90ddd49eff4a8e670" +} diff --git a/backend/.sqlx/query-1e284b9415a6a5e2485e69cf704a999325a4921dde8039f96712fd4106cae2ed.json b/backend/.sqlx/query-1e284b9415a6a5e2485e69cf704a999325a4921dde8039f96712fd4106cae2ed.json new file mode 100644 index 0000000000..b3ca1eeb98 --- /dev/null +++ b/backend/.sqlx/query-1e284b9415a6a5e2485e69cf704a999325a4921dde8039f96712fd4106cae2ed.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO workspace_fork_deployment_request_comment\n (request_id, parent_id, author, author_email, body, anchor_kind, anchor_path)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Varchar", + "Varchar", + "Text", + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "1e284b9415a6a5e2485e69cf704a999325a4921dde8039f96712fd4106cae2ed" +} diff --git a/backend/.sqlx/query-25bef98423d075337bc6cdcd88b0a7962fd4e1d96e06a5162d248da21566ccbe.json b/backend/.sqlx/query-25bef98423d075337bc6cdcd88b0a7962fd4e1d96e06a5162d248da21566ccbe.json new file mode 100644 index 0000000000..b16eed6baa --- /dev/null +++ b/backend/.sqlx/query-25bef98423d075337bc6cdcd88b0a7962fd4e1d96e06a5162d248da21566ccbe.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE workspace_fork_deployment_request_comment c\n SET obsolete = true\n FROM workspace_fork_deployment_request r\n WHERE c.request_id = r.id\n AND r.fork_workspace_id = $1\n AND r.closed_at IS NULL\n AND c.anchor_kind = $2\n AND c.anchor_path = $3\n AND c.obsolete = false\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "25bef98423d075337bc6cdcd88b0a7962fd4e1d96e06a5162d248da21566ccbe" +} diff --git a/backend/.sqlx/query-2fe88c8d79ca5dd4b8f806b2fb11a43ecf2b3d70c459cfd7622e5ca82eb20eb0.json b/backend/.sqlx/query-2fe88c8d79ca5dd4b8f806b2fb11a43ecf2b3d70c459cfd7622e5ca82eb20eb0.json new file mode 100644 index 0000000000..ba9377a1f1 --- /dev/null +++ b/backend/.sqlx/query-2fe88c8d79ca5dd4b8f806b2fb11a43ecf2b3d70c459cfd7622e5ca82eb20eb0.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_fork_deployment_request SET fork_workspace_id = $1 WHERE fork_workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2fe88c8d79ca5dd4b8f806b2fb11a43ecf2b3d70c459cfd7622e5ca82eb20eb0" +} diff --git a/backend/.sqlx/query-2ff6b0d240b88fd5654bb46f9913583fea35a4653f73c0840584f21af5de2739.json b/backend/.sqlx/query-2ff6b0d240b88fd5654bb46f9913583fea35a4653f73c0840584f21af5de2739.json new file mode 100644 index 0000000000..68850d97b5 --- /dev/null +++ b/backend/.sqlx/query-2ff6b0d240b88fd5654bb46f9913583fea35a4653f73c0840584f21af5de2739.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT username, email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1 ORDER BY username", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "2ff6b0d240b88fd5654bb46f9913583fea35a4653f73c0840584f21af5de2739" +} diff --git a/backend/.sqlx/query-330b4697892fcb85741ba13e6620eeeea12d49c38963b611988635a68faf9b0c.json b/backend/.sqlx/query-330b4697892fcb85741ba13e6620eeeea12d49c38963b611988635a68faf9b0c.json new file mode 100644 index 0000000000..185cf81905 --- /dev/null +++ b/backend/.sqlx/query-330b4697892fcb85741ba13e6620eeeea12d49c38963b611988635a68faf9b0c.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(\n SELECT 1\n FROM usr u\n WHERE u.workspace_id = $1\n AND u.email = $2\n AND u.disabled = false\n AND (\n u.is_admin = true\n OR EXISTS (\n SELECT 1 FROM usr_to_group g\n WHERE g.workspace_id = $1\n AND g.group_ = $3\n AND g.usr = u.username\n )\n )\n ) as \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "330b4697892fcb85741ba13e6620eeeea12d49c38963b611988635a68faf9b0c" +} diff --git a/backend/.sqlx/query-35cb70e555469fea3a65e199f72b2ffbd6b7330dff606c2f6910951baed551ef.json b/backend/.sqlx/query-35cb70e555469fea3a65e199f72b2ffbd6b7330dff606c2f6910951baed551ef.json new file mode 100644 index 0000000000..7b0744a8fa --- /dev/null +++ b/backend/.sqlx/query-35cb70e555469fea3a65e199f72b2ffbd6b7330dff606c2f6910951baed551ef.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT parent_id, author_email FROM workspace_fork_deployment_request_comment WHERE id = $1 AND request_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "parent_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "author_email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "35cb70e555469fea3a65e199f72b2ffbd6b7330dff606c2f6910951baed551ef" +} diff --git a/backend/.sqlx/query-4943323bbcf93bd6b080f946f3fc7497ef63412ce30b634e43eb2c46c78c82ad.json b/backend/.sqlx/query-4943323bbcf93bd6b080f946f3fc7497ef63412ce30b634e43eb2c46c78c82ad.json new file mode 100644 index 0000000000..d737b61d20 --- /dev/null +++ b/backend/.sqlx/query-4943323bbcf93bd6b080f946f3fc7497ef63412ce30b634e43eb2c46c78c82ad.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_fork_deployment_request SET closed_at = now(), closed_reason = 'cancelled' WHERE id = $1 AND closed_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "4943323bbcf93bd6b080f946f3fc7497ef63412ce30b634e43eb2c46c78c82ad" +} diff --git a/backend/.sqlx/query-4af18a140d2eceb07b450077f00ac03d555a56d79c8aa302c893009b5cfdc80d.json b/backend/.sqlx/query-4af18a140d2eceb07b450077f00ac03d555a56d79c8aa302c893009b5cfdc80d.json new file mode 100644 index 0000000000..e4de0d11e5 --- /dev/null +++ b/backend/.sqlx/query-4af18a140d2eceb07b450077f00ac03d555a56d79c8aa302c893009b5cfdc80d.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO workspace_fork_deployment_request_assignee (request_id, username, email)\n SELECT $1, u, e\n FROM UNNEST($2::text[], $3::text[]) AS t(u, e)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "TextArray", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "4af18a140d2eceb07b450077f00ac03d555a56d79c8aa302c893009b5cfdc80d" +} diff --git a/backend/.sqlx/query-508fd3937cc0cad51852a797deb040034e83e9ee6079de140eebc70bc41c14f0.json b/backend/.sqlx/query-508fd3937cc0cad51852a797deb040034e83e9ee6079de140eebc70bc41c14f0.json new file mode 100644 index 0000000000..802f28fc15 --- /dev/null +++ b/backend/.sqlx/query-508fd3937cc0cad51852a797deb040034e83e9ee6079de140eebc70bc41c14f0.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_fork_deployment_request SET source_workspace_id = $1 WHERE source_workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "508fd3937cc0cad51852a797deb040034e83e9ee6079de140eebc70bc41c14f0" +} diff --git a/backend/.sqlx/query-575598f84c6ee7c5b5d961d8fc083fda7a128e714de9380a1be13096ab945602.json b/backend/.sqlx/query-575598f84c6ee7c5b5d961d8fc083fda7a128e714de9380a1be13096ab945602.json new file mode 100644 index 0000000000..1b6ef0494b --- /dev/null +++ b/backend/.sqlx/query-575598f84c6ee7c5b5d961d8fc083fda7a128e714de9380a1be13096ab945602.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "575598f84c6ee7c5b5d961d8fc083fda7a128e714de9380a1be13096ab945602" +} diff --git a/backend/.sqlx/query-5fc750b6e52b7fab11876240206961e2af3f749d66e9e07fc84667bbb685a452.json b/backend/.sqlx/query-5fc750b6e52b7fab11876240206961e2af3f749d66e9e07fc84667bbb685a452.json new file mode 100644 index 0000000000..bb93d50408 --- /dev/null +++ b/backend/.sqlx/query-5fc750b6e52b7fab11876240206961e2af3f749d66e9e07fc84667bbb685a452.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_fork_deployment_request SET closed_at = now(), closed_reason = 'merged' WHERE id = $1 AND closed_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "5fc750b6e52b7fab11876240206961e2af3f749d66e9e07fc84667bbb685a452" +} diff --git a/backend/.sqlx/query-6272b3b073d26bd11063c1ab00199fb6de9c13f8d02195a425b3fea2cc71c7c5.json b/backend/.sqlx/query-6272b3b073d26bd11063c1ab00199fb6de9c13f8d02195a425b3fea2cc71c7c5.json new file mode 100644 index 0000000000..1523d3286c --- /dev/null +++ b/backend/.sqlx/query-6272b3b073d26bd11063c1ab00199fb6de9c13f8d02195a425b3fea2cc71c7c5.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE workspace_fork_deployment_request_comment c\n SET obsolete = true\n FROM workspace_fork_deployment_request r\n WHERE c.request_id = r.id\n AND r.fork_workspace_id = 'fork-ws'\n AND r.closed_at IS NULL\n AND c.anchor_kind = 'script'\n AND c.anchor_path = 'f/shared/script1'\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "6272b3b073d26bd11063c1ab00199fb6de9c13f8d02195a425b3fea2cc71c7c5" +} diff --git a/backend/.sqlx/query-6edb794d2005c233e621ffd7b0ae67eeebe7332d941baa5e4d210588ee2d9d55.json b/backend/.sqlx/query-6edb794d2005c233e621ffd7b0ae67eeebe7332d941baa5e4d210588ee2d9d55.json new file mode 100644 index 0000000000..134ad177d9 --- /dev/null +++ b/backend/.sqlx/query-6edb794d2005c233e621ffd7b0ae67eeebe7332d941baa5e4d210588ee2d9d55.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "6edb794d2005c233e621ffd7b0ae67eeebe7332d941baa5e4d210588ee2d9d55" +} diff --git a/backend/.sqlx/query-6f01ccf15df0e73b465dd0fbe3fcb3945a2368d03644fce1cd3bb35faaa18460.json b/backend/.sqlx/query-6f01ccf15df0e73b465dd0fbe3fcb3945a2368d03644fce1cd3bb35faaa18460.json new file mode 100644 index 0000000000..8a3ce22cec --- /dev/null +++ b/backend/.sqlx/query-6f01ccf15df0e73b465dd0fbe3fcb3945a2368d03644fce1cd3bb35faaa18460.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, parent_id, author, author_email, body,\n anchor_kind, anchor_path, obsolete, created_at\n FROM workspace_fork_deployment_request_comment\n WHERE request_id = $1\n ORDER BY created_at ASC, id ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "parent_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "author", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "author_email", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "body", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "anchor_kind", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "anchor_path", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "obsolete", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + true, + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "6f01ccf15df0e73b465dd0fbe3fcb3945a2368d03644fce1cd3bb35faaa18460" +} diff --git a/backend/.sqlx/query-709593e036492fefa7be7ed6e44689090528db9f77ad355278a563bf24da72c3.json b/backend/.sqlx/query-709593e036492fefa7be7ed6e44689090528db9f77ad355278a563bf24da72c3.json new file mode 100644 index 0000000000..472651c241 --- /dev/null +++ b/backend/.sqlx/query-709593e036492fefa7be7ed6e44689090528db9f77ad355278a563bf24da72c3.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, source_workspace_id, fork_workspace_id, requested_by,\n requested_by_email, requested_at\n FROM workspace_fork_deployment_request\n WHERE source_workspace_id = $1\n AND fork_workspace_id = $2\n AND closed_at IS NULL\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "source_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "fork_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "requested_by", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "requested_by_email", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "requested_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "709593e036492fefa7be7ed6e44689090528db9f77ad355278a563bf24da72c3" +} diff --git a/backend/.sqlx/query-71d8af5759b4c6af0aa760911094ced0a03d482d7891a61f37dfedfd8fdc66db.json b/backend/.sqlx/query-71d8af5759b4c6af0aa760911094ced0a03d482d7891a61f37dfedfd8fdc66db.json new file mode 100644 index 0000000000..5c673fff5c --- /dev/null +++ b/backend/.sqlx/query-71d8af5759b4c6af0aa760911094ced0a03d482d7891a61f37dfedfd8fdc66db.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT u.username, u.email, u.is_admin\n FROM usr u\n WHERE u.workspace_id = $1\n AND u.disabled = false\n AND (\n u.is_admin = true\n OR EXISTS (\n SELECT 1 FROM usr_to_group g\n WHERE g.workspace_id = $1\n AND g.group_ = $2\n AND g.usr = u.username\n )\n )\n ORDER BY u.username\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_admin", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "71d8af5759b4c6af0aa760911094ced0a03d482d7891a61f37dfedfd8fdc66db" +} diff --git a/backend/.sqlx/query-826621e07af88f94592143def15c8c27267e3fc19e92725257a487a58e34e7ae.json b/backend/.sqlx/query-826621e07af88f94592143def15c8c27267e3fc19e92725257a487a58e34e7ae.json new file mode 100644 index 0000000000..cecbda0ba8 --- /dev/null +++ b/backend/.sqlx/query-826621e07af88f94592143def15c8c27267e3fc19e92725257a487a58e34e7ae.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) as \"c!\" FROM workspace_fork_deployment_request_comment WHERE request_id = $1 AND obsolete = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "c!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "826621e07af88f94592143def15c8c27267e3fc19e92725257a487a58e34e7ae" +} diff --git a/backend/.sqlx/query-894210b1db20ecf08a57851d845a6c84a8d743b2914ea5b1f3fd93f97f56f2b5.json b/backend/.sqlx/query-894210b1db20ecf08a57851d845a6c84a8d743b2914ea5b1f3fd93f97f56f2b5.json new file mode 100644 index 0000000000..05a96da318 --- /dev/null +++ b/backend/.sqlx/query-894210b1db20ecf08a57851d845a6c84a8d743b2914ea5b1f3fd93f97f56f2b5.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT source_workspace_id, requested_by, requested_by_email, closed_at FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "source_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "requested_by", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "requested_by_email", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "closed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "894210b1db20ecf08a57851d845a6c84a8d743b2914ea5b1f3fd93f97f56f2b5" +} diff --git a/backend/.sqlx/query-8e4bcfa5e7ccec5d26b5602ed8697d17dd9035854126a3f76a86b613e3975d92.json b/backend/.sqlx/query-8e4bcfa5e7ccec5d26b5602ed8697d17dd9035854126a3f76a86b613e3975d92.json new file mode 100644 index 0000000000..5c6334096e --- /dev/null +++ b/backend/.sqlx/query-8e4bcfa5e7ccec5d26b5602ed8697d17dd9035854126a3f76a86b613e3975d92.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_fork_deployment_request_comment SET obsolete = true WHERE request_id = $1 AND obsolete = false", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "8e4bcfa5e7ccec5d26b5602ed8697d17dd9035854126a3f76a86b613e3975d92" +} diff --git a/backend/.sqlx/query-950f109a4cd8d8666f9d3945e41cc7328b149072cd86bea4b6a1e1c09aabe999.json b/backend/.sqlx/query-950f109a4cd8d8666f9d3945e41cc7328b149072cd86bea4b6a1e1c09aabe999.json new file mode 100644 index 0000000000..cf2095d178 --- /dev/null +++ b/backend/.sqlx/query-950f109a4cd8d8666f9d3945e41cc7328b149072cd86bea4b6a1e1c09aabe999.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, source_workspace_id, closed_at, requested_by_email FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "source_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "closed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "requested_by_email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false + ] + }, + "hash": "950f109a4cd8d8666f9d3945e41cc7328b149072cd86bea4b6a1e1c09aabe999" +} diff --git a/backend/.sqlx/query-a9fe245eee63eab4f7c3cbf1cdd0a5d6a1b20cc9067b83c2a3fb2d1e536f5a6a.json b/backend/.sqlx/query-a9fe245eee63eab4f7c3cbf1cdd0a5d6a1b20cc9067b83c2a3fb2d1e536f5a6a.json new file mode 100644 index 0000000000..ee76016b27 --- /dev/null +++ b/backend/.sqlx/query-a9fe245eee63eab4f7c3cbf1cdd0a5d6a1b20cc9067b83c2a3fb2d1e536f5a6a.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO workspace_fork_deployment_request\n (source_workspace_id, fork_workspace_id, requested_by, requested_by_email)\n VALUES ($1, $2, $3, $4)\n RETURNING id, requested_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "requested_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a9fe245eee63eab4f7c3cbf1cdd0a5d6a1b20cc9067b83c2a3fb2d1e536f5a6a" +} diff --git a/backend/.sqlx/query-b3e9e8c625cf73251e70207d67d78543a91d018048a1ba921dcd0da406c3bbf6.json b/backend/.sqlx/query-b3e9e8c625cf73251e70207d67d78543a91d018048a1ba921dcd0da406c3bbf6.json new file mode 100644 index 0000000000..2de5db6ea5 --- /dev/null +++ b/backend/.sqlx/query-b3e9e8c625cf73251e70207d67d78543a91d018048a1ba921dcd0da406c3bbf6.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.username, u.email\n FROM usr u\n WHERE u.workspace_id = $1\n AND u.disabled = false\n AND u.username = ANY($2::text[])\n AND (\n u.is_admin = true\n OR EXISTS (\n SELECT 1 FROM usr_to_group g\n WHERE g.workspace_id = $1\n AND g.group_ = $3\n AND g.usr = u.username\n )\n )\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b3e9e8c625cf73251e70207d67d78543a91d018048a1ba921dcd0da406c3bbf6" +} diff --git a/backend/.sqlx/query-bccd4d5e772f4967f282d6c014bb4a9888d3db2d5edfa44f87585a4d80cec461.json b/backend/.sqlx/query-bccd4d5e772f4967f282d6c014bb4a9888d3db2d5edfa44f87585a4d80cec461.json new file mode 100644 index 0000000000..aa60515999 --- /dev/null +++ b/backend/.sqlx/query-bccd4d5e772f4967f282d6c014bb4a9888d3db2d5edfa44f87585a4d80cec461.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT requested_by, closed_at FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "requested_by", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "closed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "bccd4d5e772f4967f282d6c014bb4a9888d3db2d5edfa44f87585a4d80cec461" +} diff --git a/backend/.sqlx/query-e7947f973f5542775026edcc03e742043a39726dedfcfdda5aab1629264aab8c.json b/backend/.sqlx/query-e7947f973f5542775026edcc03e742043a39726dedfcfdda5aab1629264aab8c.json new file mode 100644 index 0000000000..7aacaf1b06 --- /dev/null +++ b/backend/.sqlx/query-e7947f973f5542775026edcc03e742043a39726dedfcfdda5aab1629264aab8c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE workspace_fork_deployment_request\n SET closed_at = now(),\n closed_reason = 'merged'\n WHERE source_workspace_id = $1\n AND fork_workspace_id = $2\n AND closed_at IS NULL\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e7947f973f5542775026edcc03e742043a39726dedfcfdda5aab1629264aab8c" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index a77dab77e5..5085261744 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -5f912375340225876a8c1740c3301f39cd6cbd6d +972893c3870e4c4a70a35748abed282d88904805 diff --git a/backend/migrations/20260413045437_fork_deployment_requests.down.sql b/backend/migrations/20260413045437_fork_deployment_requests.down.sql new file mode 100644 index 0000000000..cbcc7839d2 --- /dev/null +++ b/backend/migrations/20260413045437_fork_deployment_requests.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS workspace_fork_deployment_request_comment; +DROP TABLE IF EXISTS workspace_fork_deployment_request_assignee; +DROP TABLE IF EXISTS workspace_fork_deployment_request; diff --git a/backend/migrations/20260413045437_fork_deployment_requests.up.sql b/backend/migrations/20260413045437_fork_deployment_requests.up.sql new file mode 100644 index 0000000000..00c2f6cdd0 --- /dev/null +++ b/backend/migrations/20260413045437_fork_deployment_requests.up.sql @@ -0,0 +1,70 @@ +-- Fork deployment requests: a lightweight request + comments layer on top +-- of the workspace fork/merge flow. +-- +-- When a fork author can't deploy to the parent workspace (because the +-- RestrictDeployToDeployers rule is active and they aren't admin / wm_deployers), +-- they open a deployment request naming one or more eligible deployers as +-- assignees. Assignees comment, the chosen deployer performs the merge, +-- and the request auto-closes on success. There can only be one open +-- request per (source, fork) pair — enforced by the partial unique index. + +CREATE TABLE workspace_fork_deployment_request ( + id BIGSERIAL PRIMARY KEY, + source_workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + fork_workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + requested_by VARCHAR(255) NOT NULL, + requested_by_email VARCHAR(255) NOT NULL, + requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), + closed_at TIMESTAMPTZ, + closed_reason VARCHAR(20) +); + +-- Only one open deployment request per (source, fork). Closed requests +-- accumulate freely for history. +CREATE UNIQUE INDEX workspace_fork_deployment_request_open_unique + ON workspace_fork_deployment_request (source_workspace_id, fork_workspace_id) + WHERE closed_at IS NULL; + +CREATE INDEX workspace_fork_deployment_request_fork_idx + ON workspace_fork_deployment_request (fork_workspace_id, closed_at); + +-- Assignees for a deployment request — the users asked to merge. Username +-- + email are snapshotted at request creation so notification dispatch +-- doesn't depend on the user's current workspace membership. +CREATE TABLE workspace_fork_deployment_request_assignee ( + request_id BIGINT NOT NULL REFERENCES workspace_fork_deployment_request(id) ON DELETE CASCADE, + username VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + PRIMARY KEY (request_id, username) +); + +-- Comments on a deployment request. `parent_id` threads replies under a +-- top-level comment (2 levels max, enforced in application code). +-- `anchor_kind`/`anchor_path` pin a comment to a specific diff row; both +-- NULL = general comment about the whole fork. Anchored comments flip to +-- obsolete when the underlying item is updated in the fork, and all +-- comments on a request are marked obsolete on successful merge. +CREATE TABLE workspace_fork_deployment_request_comment ( + id BIGSERIAL PRIMARY KEY, + request_id BIGINT NOT NULL REFERENCES workspace_fork_deployment_request(id) ON DELETE CASCADE, + parent_id BIGINT REFERENCES workspace_fork_deployment_request_comment(id) ON DELETE CASCADE, + author VARCHAR(255) NOT NULL, + author_email VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + anchor_kind VARCHAR(50), + anchor_path VARCHAR(255), + obsolete BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT anchor_kind_path_both_or_neither + CHECK ((anchor_kind IS NULL) = (anchor_path IS NULL)) +); + +CREATE INDEX workspace_fork_deployment_request_comment_request_idx + ON workspace_fork_deployment_request_comment (request_id); + +-- Anchored-comment lookup: find all open-request comments pinned to a +-- specific (kind, path) — used when we mark comments obsolete after an +-- item in the fork is updated. +CREATE INDEX workspace_fork_deployment_request_comment_anchor_idx + ON workspace_fork_deployment_request_comment (request_id, anchor_kind, anchor_path) + WHERE anchor_kind IS NOT NULL; diff --git a/backend/tests/fixtures/fork_deployment_requests.sql b/backend/tests/fixtures/fork_deployment_requests.sql new file mode 100644 index 0000000000..877dbe3f5c --- /dev/null +++ b/backend/tests/fixtures/fork_deployment_requests.sql @@ -0,0 +1,63 @@ +-- Fixture for fork deployment request integration tests. +-- Two workspaces: 'parent-ws' (source) and 'fork-ws' (fork, with +-- parent_workspace_id=parent-ws). wm_deployers exists in the parent with +-- one member. Users get distinct emails/usernames/tokens to avoid +-- collisions with rows inserted by migrations. + +INSERT INTO workspace (id, name, owner) VALUES + ('parent-ws', 'Parent WS', 'fdr-admin'); +INSERT INTO workspace (id, name, owner, parent_workspace_id) VALUES + ('fork-ws', 'Fork WS', 'fdr-admin', 'parent-ws'); + +INSERT INTO workspace_key (workspace_id, kind, key) VALUES + ('parent-ws', 'cloud', 'parent-key'), + ('fork-ws', 'cloud', 'fork-key'); + +INSERT INTO workspace_settings (workspace_id) VALUES + ('parent-ws'), + ('fork-ws'); + +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('parent-ws', 'all', 'All users', '{}'), + ('parent-ws', 'wm_deployers', 'Deployers', '{}'), + ('fork-ws', 'all', 'All users', '{}'), + ('fork-ws', 'wm_deployers', 'Deployers', '{}'); + +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username) + VALUES ('fdr-admin@windmill.dev', 'x', 'password', true, true, 'FDR Admin', 'fdr-admin'); +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('fdr-deployer@windmill.dev', 'x', 'password', false, true, 'FDR Deployer'); +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('fdr-owner@windmill.dev', 'x', 'password', false, true, 'FDR Owner'); +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('fdr-random@windmill.dev', 'x', 'password', false, true, 'FDR Random'); + +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('parent-ws', 'fdr-admin@windmill.dev', 'fdr-admin', true, 'Admin'), + ('parent-ws', 'fdr-deployer@windmill.dev', 'fdr-deployer', false, 'User'), + ('parent-ws', 'fdr-random@windmill.dev', 'fdr-random', false, 'User'), + ('fork-ws', 'fdr-admin@windmill.dev', 'fdr-admin', true, 'Admin'), + ('fork-ws', 'fdr-owner@windmill.dev', 'fdr-owner', false, 'User'), + ('fork-ws', 'fdr-deployer@windmill.dev', 'fdr-deployer', false, 'User'), + ('fork-ws', 'fdr-random@windmill.dev', 'fdr-random', false, 'User'); + +INSERT INTO usr_to_group(workspace_id, group_, usr) VALUES + ('parent-ws', 'wm_deployers', 'fdr-deployer'); + +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('FDR_ADMIN_TOKEN'::bytea), 'hex'), 'FDR_ADMIN_', 'FDR_ADMIN_TOKEN', 'fdr-admin@windmill.dev', 't', true); +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('FDR_DEPLOYER_TOKEN'::bytea), 'hex'), 'FDR_DEPLOY', 'FDR_DEPLOYER_TOKEN', 'fdr-deployer@windmill.dev', 't', false); +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('FDR_OWNER_TOKEN'::bytea), 'hex'), 'FDR_OWNER_', 'FDR_OWNER_TOKEN', 'fdr-owner@windmill.dev', 't', false); +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('FDR_RANDOM_TOKEN'::bytea), 'hex'), 'FDR_RANDOM', 'FDR_RANDOM_TOKEN', 'fdr-random@windmill.dev', 't', false); + +GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; +GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; + +-- Seed a workspace_diff row so anchored comments can validate against an +-- existing (kind, path) tuple for this (parent, fork) pair. +INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes) +VALUES ('parent-ws', 'fork-ws', 'f/shared/script1', 'script', 1, 0, true); diff --git a/backend/tests/fixtures/restrict_deploy_to_deployers.sql b/backend/tests/fixtures/restrict_deploy_to_deployers.sql new file mode 100644 index 0000000000..46464807db --- /dev/null +++ b/backend/tests/fixtures/restrict_deploy_to_deployers.sql @@ -0,0 +1,42 @@ +-- Fixture for the RestrictDeployToDeployers protection rule test. +-- Uses a dedicated workspace id + dedicated token prefixes so the process- +-- global AUTH_CACHE and PROTECTION_RULES_CACHE lazy_statics don't race with +-- the parallel test_protection_rules test (which also uses test-workspace). + +INSERT INTO workspace (id, name, owner) VALUES + ('rdd-ws', 'Restrict Deploy WS', 'rdd-admin'); + +INSERT INTO workspace_key (workspace_id, kind, key) VALUES + ('rdd-ws', 'cloud', 'rdd-key'); + +INSERT INTO workspace_settings (workspace_id) VALUES + ('rdd-ws'); + +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('rdd-ws', 'all', 'All users', '{}'), + ('rdd-ws', 'wm_deployers', 'Deployers', '{}'); + +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username) + VALUES ('rdd-admin@windmill.dev', 'x', 'password', true, true, 'RDD Admin', 'rdd-admin'); +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('rdd-deployer@windmill.dev', 'x', 'password', false, true, 'RDD Deployer'); +INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) + VALUES ('rdd-user@windmill.dev', 'x', 'password', false, true, 'RDD User'); + +INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES + ('rdd-ws', 'rdd-admin@windmill.dev', 'rdd-admin', true, 'Admin'), + ('rdd-ws', 'rdd-deployer@windmill.dev', 'rdd-deployer', false, 'User'), + ('rdd-ws', 'rdd-user@windmill.dev', 'rdd-user', false, 'User'); + +INSERT INTO usr_to_group(workspace_id, group_, usr) VALUES + ('rdd-ws', 'wm_deployers', 'rdd-deployer'); + +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('RDD_ADMIN_TOKEN'::bytea), 'hex'), 'RDD_ADMIN_', 'RDD_ADMIN_TOKEN', 'rdd-admin@windmill.dev', 't', true); +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('RDD_DEPLOYER_TOKEN'::bytea), 'hex'), 'RDD_DEPLOY', 'RDD_DEPLOYER_TOKEN', 'rdd-deployer@windmill.dev', 't', false); +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('RDD_USER_TOKEN'::bytea), 'hex'), 'RDD_USER_T', 'RDD_USER_TOKEN', 'rdd-user@windmill.dev', 't', false); + +GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; +GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; diff --git a/backend/tests/fork_deployment_requests.rs b/backend/tests/fork_deployment_requests.rs new file mode 100644 index 0000000000..6b89799d58 --- /dev/null +++ b/backend/tests/fork_deployment_requests.rs @@ -0,0 +1,356 @@ +//! Integration tests for fork deployment requests + comments. +//! +//! Covers: one-open-per-fork constraint, assignee ACL validation, anchor +//! obsolescence when an item in the fork changes, merge-close lifecycle, +//! replies, reply-to-reply rejection, and cancel ACL. + +use serde_json::{json, Value}; +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)) +} + +#[sqlx::test(fixtures("fork_deployment_requests"))] +async fn test_fork_deployment_request_lifecycle(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let fork_base = format!("http://localhost:{port}/api/w/fork-ws"); + + // ---- 1. eligible_deployers returns admin + deployer, not random ---- + let resp = authed( + client().get(format!("{fork_base}/deployment_request/eligible_deployers")), + "FDR_OWNER_TOKEN", + ) + .send() + .await?; + assert_eq!(resp.status(), 200); + let deployers: Vec = resp.json().await?; + let usernames: Vec<&str> = deployers + .iter() + .filter_map(|d| d.get("username").and_then(|v| v.as_str())) + .collect(); + assert!(usernames.contains(&"fdr-admin")); + assert!(usernames.contains(&"fdr-deployer")); + assert!(!usernames.contains(&"fdr-random")); + + // ---- 2. createDeploymentRequest with valid assignees succeeds ---- + let resp = authed( + client().post(format!("{fork_base}/deployment_request")), + "FDR_OWNER_TOKEN", + ) + .json(&json!({ "assignees": ["fdr-admin", "fdr-deployer"] })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "create: {}", + resp.text().await.unwrap_or_default() + ); + let created: Value = resp.json().await?; + let request_id = created.get("id").and_then(|v| v.as_i64()).unwrap(); + assert_eq!( + created.get("requested_by").and_then(|v| v.as_str()), + Some("fdr-owner") + ); + assert_eq!( + created + .get("assignees") + .and_then(|v| v.as_array()) + .map(|a| a.len()), + Some(2) + ); + + // ---- 3. second create fails with 409 because one-open constraint ---- + let resp = authed( + client().post(format!("{fork_base}/deployment_request")), + "FDR_OWNER_TOKEN", + ) + .json(&json!({ "assignees": ["fdr-admin"] })) + .send() + .await?; + assert_eq!( + resp.status(), + 409, + "second create should return 409 Conflict: {}", + resp.status() + ); + + // ---- 4. getOpenRequest returns the request ---- + let resp = authed( + client().get(format!("{fork_base}/deployment_request/open")), + "FDR_RANDOM_TOKEN", + ) + .send() + .await?; + assert_eq!(resp.status(), 200); + let open: Value = resp.json().await?; + assert_eq!(open.get("id").and_then(|v| v.as_i64()), Some(request_id)); + + // ---- 5. random user posts a general comment (anyone with fork access) ---- + let resp = authed( + client().post(format!( + "{fork_base}/deployment_request/{request_id}/comment" + )), + "FDR_RANDOM_TOKEN", + ) + .json(&json!({ "body": "Looks good overall" })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "general comment: {}", + resp.text().await.unwrap_or_default() + ); + let top_comment: Value = resp.json().await?; + let top_id = top_comment.get("id").and_then(|v| v.as_i64()).unwrap(); + + // ---- 6. admin posts an anchored comment ---- + let resp = authed( + client().post(format!( + "{fork_base}/deployment_request/{request_id}/comment" + )), + "FDR_ADMIN_TOKEN", + ) + .json(&json!({ + "body": "Can you revisit this script?", + "anchor_kind": "script", + "anchor_path": "f/shared/script1" + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "anchored comment: {}", + resp.text().await.unwrap_or_default() + ); + let anchored: Value = resp.json().await?; + let anchored_id = anchored.get("id").and_then(|v| v.as_i64()).unwrap(); + assert_eq!( + anchored.get("anchor_kind").and_then(|v| v.as_str()), + Some("script") + ); + assert_eq!( + anchored.get("obsolete").and_then(|v| v.as_bool()), + Some(false) + ); + + // ---- 7. reply to top-level comment ---- + let resp = authed( + client().post(format!( + "{fork_base}/deployment_request/{request_id}/comment" + )), + "FDR_OWNER_TOKEN", + ) + .json(&json!({ "body": "Thanks!", "parent_id": top_id })) + .send() + .await?; + assert_eq!(resp.status(), 200); + let reply: Value = resp.json().await?; + let reply_id = reply.get("id").and_then(|v| v.as_i64()).unwrap(); + assert_eq!( + reply.get("parent_id").and_then(|v| v.as_i64()), + Some(top_id) + ); + + // ---- 7b. reply-to-reply is rejected (2-level max) ---- + let resp = authed( + client().post(format!( + "{fork_base}/deployment_request/{request_id}/comment" + )), + "FDR_OWNER_TOKEN", + ) + .json(&json!({ "body": "nested!", "parent_id": reply_id })) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "reply-to-reply should be rejected: {}", + resp.status() + ); + + // ---- 8. anchored comment becomes obsolete when item changes in fork ---- + // The CE stub of `mark_anchor_obsolete` is a no-op (the EE module fills + // it in). Simulate the EE effect directly so the CE test can verify + // the GET response renders obsolete comments correctly. + sqlx::query!( + r#" + UPDATE workspace_fork_deployment_request_comment c + SET obsolete = true + FROM workspace_fork_deployment_request r + WHERE c.request_id = r.id + AND r.fork_workspace_id = 'fork-ws' + AND r.closed_at IS NULL + AND c.anchor_kind = 'script' + AND c.anchor_path = 'f/shared/script1' + "# + ) + .execute(&db) + .await?; + let resp = authed( + client().get(format!("{fork_base}/deployment_request/open")), + "FDR_OWNER_TOKEN", + ) + .send() + .await?; + let open: Value = resp.json().await?; + let comments = open.get("comments").and_then(|v| v.as_array()).unwrap(); + let anchored_after = comments + .iter() + .find(|c| c.get("id").and_then(|v| v.as_i64()) == Some(anchored_id)) + .unwrap(); + assert_eq!( + anchored_after.get("obsolete").and_then(|v| v.as_bool()), + Some(true), + "anchored comment should be obsolete" + ); + let top_after = comments + .iter() + .find(|c| c.get("id").and_then(|v| v.as_i64()) == Some(top_id)) + .unwrap(); + assert_eq!( + top_after.get("obsolete").and_then(|v| v.as_bool()), + Some(false), + "general comment should NOT be obsolete" + ); + + // ---- 9. non-requester non-admin cannot cancel ---- + let resp = authed( + client().post(format!( + "{fork_base}/deployment_request/{request_id}/cancel" + )), + "FDR_RANDOM_TOKEN", + ) + .send() + .await?; + assert!( + !resp.status().is_success(), + "random cancel should fail: {}", + resp.status() + ); + + // ---- 10. requester can cancel ---- + let resp = authed( + client().post(format!( + "{fork_base}/deployment_request/{request_id}/cancel" + )), + "FDR_OWNER_TOKEN", + ) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "cancel: {}", + resp.text().await.unwrap_or_default() + ); + + // ---- 11. after cancel a new request can be opened ---- + let resp = authed( + client().post(format!("{fork_base}/deployment_request")), + "FDR_OWNER_TOKEN", + ) + .json(&json!({ "assignees": ["fdr-admin"] })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "re-create: {}", + resp.text().await.unwrap_or_default() + ); + let new_req: Value = resp.json().await?; + let new_req_id = new_req.get("id").and_then(|v| v.as_i64()).unwrap(); + + // ---- 12. ineligible assignee rejected ---- + authed( + client().post(format!( + "{fork_base}/deployment_request/{new_req_id}/cancel" + )), + "FDR_OWNER_TOKEN", + ) + .send() + .await?; + let resp = authed( + client().post(format!("{fork_base}/deployment_request")), + "FDR_OWNER_TOKEN", + ) + .json(&json!({ "assignees": ["fdr-random"] })) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "ineligible assignee should fail: {}", + resp.status() + ); + + // ---- 13. merge-close: create a request, close as merged, comments flip ---- + let resp = authed( + client().post(format!("{fork_base}/deployment_request")), + "FDR_OWNER_TOKEN", + ) + .json(&json!({ "assignees": ["fdr-admin"] })) + .send() + .await?; + let final_req: Value = resp.json().await?; + let final_id = final_req.get("id").and_then(|v| v.as_i64()).unwrap(); + + authed( + client().post(format!("{fork_base}/deployment_request/{final_id}/comment")), + "FDR_ADMIN_TOKEN", + ) + .json(&json!({ "body": "lgtm" })) + .send() + .await?; + + let resp = authed( + client().post(format!( + "{fork_base}/deployment_request/{final_id}/close_merged" + )), + "FDR_ADMIN_TOKEN", + ) + .send() + .await?; + assert_eq!(resp.status(), 200); + + // After close_merged, getOpenRequest returns null. + let resp = authed( + client().get(format!("{fork_base}/deployment_request/open")), + "FDR_OWNER_TOKEN", + ) + .send() + .await?; + let after_merge = resp.text().await?; + assert!( + after_merge == "null" || after_merge == "", + "open should be null after merge-close, got: {}", + after_merge + ); + + // And every comment on the closed request is now obsolete. + let obsolete_count: i64 = sqlx::query_scalar!( + "SELECT COUNT(*) as \"c!\" FROM workspace_fork_deployment_request_comment WHERE request_id = $1 AND obsolete = false", + final_id, + ) + .fetch_one(&db) + .await?; + assert_eq!( + obsolete_count, 0, + "all comments on merged request should be obsolete" + ); + + Ok(()) +} diff --git a/backend/tests/protection_rules.rs b/backend/tests/protection_rules.rs index 3d71d622ed..69873bf158 100644 --- a/backend/tests/protection_rules.rs +++ b/backend/tests/protection_rules.rs @@ -1,7 +1,7 @@ //! Integration tests for workspace protection rulesets. //! -//! Tests verify that DisableDirectDeployment protection rules correctly -//! block/allow operations based on user permissions. +//! Tests verify that DisableDirectDeployment and RestrictDeployToDeployers +//! protection rules correctly block/allow operations based on user permissions. use serde_json::json; use sqlx::{Pool, Postgres}; @@ -304,3 +304,120 @@ async fn test_protection_rules(db: Pool) -> anyhow::Result<()> { Ok(()) } + +/// Test the `RestrictDeployToDeployers` rule. +/// +/// Admins and members of the `wm_deployers` group can deploy, everyone else +/// is blocked. Uses a dedicated workspace + token prefixes so it doesn't +/// race against `test_protection_rules` on the shared PROTECTION_RULES_CACHE +/// and AUTH_CACHE lazy_statics. +#[sqlx::test(fixtures("restrict_deploy_to_deployers"))] +async fn test_restrict_deploy_to_deployers(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + invalidate_protection_rules_cache("rdd-ws"); + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/rdd-ws"); + + // Admin creates the rule. + let resp = authed( + client().post(format!("{base}/workspaces/protection_rules")), + "RDD_ADMIN_TOKEN", + ) + .json(&json!({ + "name": "deployers-only", + "rules": ["RestrictDeployToDeployers"], + "bypass_users": [], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "Admin should create rule: {}", + resp.text().await? + ); + + // Admin can still deploy. + let resp = authed( + client().post(format!("{base}/scripts/create")), + "RDD_ADMIN_TOKEN", + ) + .json(&new_script("u/rdd-admin/admin_deploys", "admin")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "Admin should deploy: {}", + resp.text().await? + ); + + // wm_deployers member can deploy. + let resp = authed( + client().post(format!("{base}/scripts/create")), + "RDD_DEPLOYER_TOKEN", + ) + .json(&new_script("u/rdd-deployer/deployer_deploys", "deployer")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "wm_deployers member should deploy: {}", + resp.text().await? + ); + + // Regular non-deployer is blocked. + let resp = authed( + client().post(format!("{base}/scripts/create")), + "RDD_USER_TOKEN", + ) + .json(&new_script("u/rdd-user/blocked", "blocked")) + .send() + .await?; + assert!( + !resp.status().is_success(), + "non-deployer should be blocked: {}", + resp.status() + ); + let body = resp.text().await?; + assert!( + body.contains("wm_deployers"), + "Error should mention wm_deployers: {}", + body + ); + + // Extend the rule with the user as a bypass_user — they can then deploy. + let resp = authed( + client().post(format!("{base}/workspaces/protection_rules/deployers-only")), + "RDD_ADMIN_TOKEN", + ) + .json(&json!({ + "rules": ["RestrictDeployToDeployers"], + "bypass_users": ["rdd-user"], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "Should update rule"); + invalidate_protection_rules_cache("rdd-ws"); + + let resp = authed( + client().post(format!("{base}/scripts/create")), + "RDD_USER_TOKEN", + ) + .json(&new_script("u/rdd-user/bypassed", "bypassed")) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "bypass_users should allow deploy: {}", + resp.text().await? + ); + + Ok(()) +} diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index cecfda9a52..92ff7f5fa3 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -18,7 +18,7 @@ use windmill_api_auth::{ auth::{list_tokens_internal, TruncatedTokenWithEmail}, check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed, }; -use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}; +use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use windmill_common::{ utils::{WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, @@ -439,9 +439,8 @@ async fn create_flow( } check_scopes(&authed, || format!("flows:write:{}", nf.path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -933,9 +932,8 @@ async fn update_flow( let flow_path = flow_path.to_path(); check_scopes(&authed, || format!("flows:write:{}", flow_path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1564,9 +1562,8 @@ async fn archive_flow_by_path( ) -> Result { let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1705,9 +1702,8 @@ async fn delete_flow_by_path( ) -> Result { let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs index 2a87fc38de..fa23a21df6 100644 --- a/backend/windmill-api-groups/src/folders.rs +++ b/backend/windmill-api-groups/src/folders.rs @@ -28,7 +28,7 @@ use windmill_common::{ use windmill_common::{ error::Error, webhook::{WebhookMessage, WebhookShared}, - workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, + workspaces::{check_deploy_rules, RuleCheckResult}, }; use serde::{Deserialize, Serialize}; @@ -224,9 +224,8 @@ async fn create_folder( Path(w_id): Path, Json(ng): Json, ) -> Result { - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -384,9 +383,8 @@ async fn update_folder( ) -> Result { use sql_builder::prelude::*; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -709,9 +707,8 @@ async fn delete_folder( Extension(webhook): Extension, Path((w_id, name)): Path<(String, String)>, ) -> Result { - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 079bdacccf..5f2560fdbc 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -14,7 +14,7 @@ use windmill_api_auth::{ use windmill_common::{ utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, webhook::{WebhookMessage, WebhookShared}, - workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, + workspaces::{check_deploy_rules, RuleCheckResult}, DB, }; use windmill_queue::schedule::clear_schedule; @@ -573,9 +573,8 @@ async fn create_script( Path(w_id): Path, Json(ns): Json, ) -> Result<(StatusCode, String)> { - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -2143,9 +2142,8 @@ async fn archive_script_by_path( } let path = path.to_path(); check_scopes(&authed, || format!("scripts:write:{}", path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -2222,9 +2220,8 @@ async fn archive_script_by_hash( "Operators cannot archive scripts for security reasons".to_string(), )); } - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -2282,9 +2279,8 @@ async fn delete_script_by_hash( Path((w_id, hash)): Path<(String, ScriptHash)>, ) -> JsonResult> { require_admin(authed.is_admin, &authed.username)?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -2354,9 +2350,8 @@ async fn delete_script_by_path( )); } - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -2518,9 +2513,8 @@ async fn delete_scripts_bulk( )); } - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, diff --git a/backend/windmill-api-workspaces/src/deployment_requests.rs b/backend/windmill-api-workspaces/src/deployment_requests.rs new file mode 100644 index 0000000000..9701852ea2 --- /dev/null +++ b/backend/windmill-api-workspaces/src/deployment_requests.rs @@ -0,0 +1,732 @@ +//! Fork deployment requests + comments. +//! +//! A deployment request is a one-per-open ask from a fork author to one or +//! more assignees (admins or wm_deployers in the parent workspace) to merge +//! the fork into its parent. Assignees can leave general or anchored +//! comments; anyone with fork access can reply. Anchored comments become +//! obsolete when the underlying item changes in the fork, and the whole +//! request auto-closes on successful merge. +//! +//! Email dispatch happens via `send_email_if_possible`, which is a no-op on +//! OSS builds. + +use axum::{ + extract::{Extension, Path}, + http::StatusCode, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use windmill_api_auth::ApiAuthed; +use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; +use windmill_audit::ActionKind; +use windmill_common::email_oss::send_email_if_possible; +use windmill_common::{ + error::{Error, JsonResult, Result}, + BASE_URL, DB, WM_DEPLOYERS_GROUP, +}; + +pub fn workspaced_service() -> Router { + Router::new() + .route("/eligible_deployers", get(list_eligible_deployers)) + .route("/open", get(get_open_deployment_request)) + .route("/", post(create_deployment_request)) + .route("/{id}/cancel", post(cancel_deployment_request)) + .route("/{id}/close_merged", post(close_deployment_request_merged)) + .route("/{id}/comment", post(create_deployment_request_comment)) +} + +// ---- DTOs --------------------------------------------------------------- + +#[derive(Serialize)] +struct EligibleDeployer { + username: String, + email: String, + is_admin: bool, +} + +#[derive(Serialize)] +struct DeploymentRequest { + id: i64, + source_workspace_id: String, + fork_workspace_id: String, + requested_by: String, + requested_by_email: String, + requested_at: chrono::DateTime, + assignees: Vec, + comments: Vec, +} + +#[derive(Serialize)] +struct DeploymentRequestAssignee { + username: String, + email: String, +} + +#[derive(Serialize)] +struct DeploymentRequestComment { + id: i64, + parent_id: Option, + author: String, + author_email: String, + body: String, + anchor_kind: Option, + anchor_path: Option, + obsolete: bool, + created_at: chrono::DateTime, +} + +#[derive(Deserialize)] +struct CreateDeploymentRequestBody { + assignees: Vec, +} + +#[derive(Deserialize)] +struct CreateDeploymentRequestCommentBody { + body: String, + #[serde(default)] + anchor_kind: Option, + #[serde(default)] + anchor_path: Option, + #[serde(default)] + parent_id: Option, +} + +// ---- Endpoints ---------------------------------------------------------- + +/// List users eligible to be assignees on a deployment request: admins plus +/// members of the wm_deployers group in the *parent* workspace. The `{w_id}` +/// path parameter is the fork workspace; we resolve its parent and query +/// there. +/// +/// Note: any member of the fork workspace can call this and see parent +/// admin/deployer usernames + emails. Intentional for the request UX — the +/// fork author has to pick an assignee somehow — but documented here as a +/// design choice for the shared-fork privacy model. +async fn list_eligible_deployers( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult> { + let parent = parent_of_fork(&db, &w_id).await?; + + let rows = sqlx::query!( + r#" + SELECT DISTINCT u.username, u.email, u.is_admin + FROM usr u + WHERE u.workspace_id = $1 + AND u.disabled = false + AND ( + u.is_admin = true + OR EXISTS ( + SELECT 1 FROM usr_to_group g + WHERE g.workspace_id = $1 + AND g.group_ = $2 + AND g.usr = u.username + ) + ) + ORDER BY u.username + "#, + &parent, + WM_DEPLOYERS_GROUP, + ) + .fetch_all(&db) + .await?; + + Ok(Json( + rows.into_iter() + .map(|r| EligibleDeployer { + username: r.username, + email: r.email, + is_admin: r.is_admin, + }) + .collect(), + )) +} + +/// Fetch the open deployment request (if any) for the fork's parent. +/// `{w_id}` is the fork workspace. +async fn get_open_deployment_request( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult> { + let parent = parent_of_fork(&db, &w_id).await?; + + let req = sqlx::query!( + r#" + SELECT id, source_workspace_id, fork_workspace_id, requested_by, + requested_by_email, requested_at + FROM workspace_fork_deployment_request + WHERE source_workspace_id = $1 + AND fork_workspace_id = $2 + AND closed_at IS NULL + LIMIT 1 + "#, + parent, + &w_id, + ) + .fetch_optional(&db) + .await?; + + let Some(req) = req else { + return Ok(Json(None)); + }; + + let assignees = sqlx::query!( + "SELECT username, email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1 ORDER BY username", + req.id, + ) + .fetch_all(&db) + .await? + .into_iter() + .map(|r| DeploymentRequestAssignee { username: r.username, email: r.email }) + .collect(); + + let comments = sqlx::query!( + r#" + SELECT id, parent_id, author, author_email, body, + anchor_kind, anchor_path, obsolete, created_at + FROM workspace_fork_deployment_request_comment + WHERE request_id = $1 + ORDER BY created_at ASC, id ASC + "#, + req.id, + ) + .fetch_all(&db) + .await? + .into_iter() + .map(|r| DeploymentRequestComment { + id: r.id, + parent_id: r.parent_id, + author: r.author, + author_email: r.author_email, + body: r.body, + anchor_kind: r.anchor_kind, + anchor_path: r.anchor_path, + obsolete: r.obsolete, + created_at: r.created_at, + }) + .collect(); + + Ok(Json(Some(DeploymentRequest { + id: req.id, + source_workspace_id: req.source_workspace_id, + fork_workspace_id: req.fork_workspace_id, + requested_by: req.requested_by, + requested_by_email: req.requested_by_email, + requested_at: req.requested_at, + assignees, + comments, + }))) +} + +/// Open a new deployment request targeting a list of assignees in the +/// parent workspace. At most one open request exists per (parent, fork) +/// pair; the DB partial unique index enforces this and we surface a 409 on +/// conflict. +async fn create_deployment_request( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(body): Json, +) -> Result> { + // Dedupe assignees so `["alice","alice"]` isn't falsely rejected: the + // `= ANY($...)` lookup returns each match once regardless of input + // duplicates, so a direct `.len()` comparison would over-count. + let unique_assignees: Vec = body + .assignees + .iter() + .collect::>() + .into_iter() + .cloned() + .collect(); + + if unique_assignees.is_empty() { + return Err(Error::BadRequest( + "At least one assignee is required".to_string(), + )); + } + + let parent = parent_of_fork(&db, &w_id).await?; + + // Validate every requested assignee is admin or wm_deployers in parent. + let assignee_rows = sqlx::query!( + r#" + SELECT u.username, u.email + FROM usr u + WHERE u.workspace_id = $1 + AND u.disabled = false + AND u.username = ANY($2::text[]) + AND ( + u.is_admin = true + OR EXISTS ( + SELECT 1 FROM usr_to_group g + WHERE g.workspace_id = $1 + AND g.group_ = $3 + AND g.usr = u.username + ) + ) + "#, + &parent, + &unique_assignees, + WM_DEPLOYERS_GROUP, + ) + .fetch_all(&db) + .await?; + + if assignee_rows.len() != unique_assignees.len() { + return Err(Error::BadRequest( + "All assignees must be admins or members of wm_deployers in the parent workspace" + .to_string(), + )); + } + + let mut tx = db.begin().await?; + + let request_id_row = sqlx::query!( + r#" + INSERT INTO workspace_fork_deployment_request + (source_workspace_id, fork_workspace_id, requested_by, requested_by_email) + VALUES ($1, $2, $3, $4) + RETURNING id, requested_at + "#, + parent, + &w_id, + AuditAuthorable::username(&authed), + &authed.email, + ) + .fetch_one(&mut *tx) + .await + .map_err(|e| match &e { + sqlx::Error::Database(db_err) if db_err.is_unique_violation() => Error::Generic( + StatusCode::CONFLICT, + "A deployment request is already open for this fork; cancel it first".to_string(), + ), + _ => Error::from(e), + })?; + + let request_id = request_id_row.id; + + let assignee_usernames: Vec = + assignee_rows.iter().map(|r| r.username.clone()).collect(); + let assignee_emails: Vec = assignee_rows.iter().map(|r| r.email.clone()).collect(); + sqlx::query!( + r#" + INSERT INTO workspace_fork_deployment_request_assignee (request_id, username, email) + SELECT $1, u, e + FROM UNNEST($2::text[], $3::text[]) AS t(u, e) + "#, + request_id, + &assignee_usernames, + &assignee_emails, + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "fork_deployment_request.create", + ActionKind::Create, + &w_id, + Some(&request_id.to_string()), + None, + ) + .await?; + + tx.commit().await?; + + // Send a deployment-request email to each assignee. + let base_url = BASE_URL.read().await.clone(); + let subject = format!( + "[Windmill] @{} requested a deployment on fork {w_id}", + authed.username + ); + let body_text = format!( + "@{} asked you to deploy the fork {w_id} → {parent}.\n\nOpen the compare view: {base_url}/?workspace={w_id}", + authed.username + ); + for r in &assignee_rows { + if r.email != authed.email { + send_email_if_possible(&subject, &body_text, &r.email); + } + } + + let assignees: Vec = assignee_rows + .into_iter() + .map(|r| DeploymentRequestAssignee { username: r.username, email: r.email }) + .collect(); + + Ok(Json(DeploymentRequest { + id: request_id, + source_workspace_id: parent, + fork_workspace_id: w_id, + requested_by: authed.username.clone(), + requested_by_email: authed.email.clone(), + requested_at: request_id_row.requested_at, + assignees, + comments: vec![], + })) +} + +/// Cancel an open deployment request. Only the original requester or an +/// admin may cancel — anyone else gets 403. +async fn cancel_deployment_request( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, i64)>, +) -> Result { + let row = sqlx::query!( + "SELECT requested_by, closed_at FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2", + id, + &w_id, + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| Error::NotFound(format!("deployment request {id} not found")))?; + + if row.closed_at.is_some() { + return Err(Error::BadRequest( + "Deployment request is already closed".to_string(), + )); + } + + if !authed.is_admin && row.requested_by != authed.username { + return Err(Error::NotAuthorized( + "Only the requester or an admin can cancel this request".to_string(), + )); + } + + let mut tx = db.begin().await?; + let rows_affected = sqlx::query!( + "UPDATE workspace_fork_deployment_request SET closed_at = now(), closed_reason = 'cancelled' WHERE id = $1 AND closed_at IS NULL", + id, + ) + .execute(&mut *tx) + .await? + .rows_affected(); + + if rows_affected == 0 { + // Lost the race to another cancel / close_merged call. + return Ok("already-closed".to_string()); + } + + audit_log( + &mut *tx, + &authed, + "fork_deployment_request.cancel", + ActionKind::Update, + &w_id, + Some(&id.to_string()), + None, + ) + .await?; + tx.commit().await?; + + // Notify assignees that the request was cancelled. + let assignees = sqlx::query!( + "SELECT email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1", + id, + ) + .fetch_all(&db) + .await?; + let subject = format!("[Windmill] Deployment request on fork {w_id} cancelled"); + let body_text = format!( + "@{} cancelled the open deployment request on fork {w_id}.", + authed.username + ); + for r in assignees { + if r.email != authed.email { + send_email_if_possible(&subject, &body_text, &r.email); + } + } + + Ok("ok".to_string()) +} + +/// Called by the UI after a successful merge loop. Closes the open +/// deployment request for this fork and marks every comment obsolete. Only +/// admins and members of wm_deployers *in the parent workspace* may close — +/// same set that can actually merge. `authed.groups` here reflects the fork +/// workspace's groups, so we query the parent explicitly. +async fn close_deployment_request_merged( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, i64)>, +) -> Result { + let row = sqlx::query!( + "SELECT source_workspace_id, requested_by, requested_by_email, closed_at FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2", + id, + &w_id, + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| Error::NotFound(format!("deployment request {id} not found")))?; + + // Check deployer membership against the parent workspace, not the fork. + let is_parent_deployer = sqlx::query_scalar!( + r#" + SELECT EXISTS( + SELECT 1 + FROM usr u + WHERE u.workspace_id = $1 + AND u.email = $2 + AND u.disabled = false + AND ( + u.is_admin = true + OR EXISTS ( + SELECT 1 FROM usr_to_group g + WHERE g.workspace_id = $1 + AND g.group_ = $3 + AND g.usr = u.username + ) + ) + ) as "exists!" + "#, + row.source_workspace_id, + &authed.email, + WM_DEPLOYERS_GROUP, + ) + .fetch_one(&db) + .await?; + + if !authed.is_admin && !is_parent_deployer { + return Err(Error::NotAuthorized( + "Only admins or members of wm_deployers in the parent workspace can close a deployment request as merged" + .to_string(), + )); + } + + let mut tx = db.begin().await?; + let rows_affected = sqlx::query!( + "UPDATE workspace_fork_deployment_request SET closed_at = now(), closed_reason = 'merged' WHERE id = $1 AND closed_at IS NULL", + id, + ) + .execute(&mut *tx) + .await? + .rows_affected(); + + if rows_affected == 0 { + return Ok("already-closed".to_string()); + } + + sqlx::query!( + "UPDATE workspace_fork_deployment_request_comment SET obsolete = true WHERE request_id = $1 AND obsolete = false", + id, + ) + .execute(&mut *tx) + .await?; + audit_log( + &mut *tx, + &authed, + "fork_deployment_request.close_merged", + ActionKind::Update, + &w_id, + Some(&id.to_string()), + None, + ) + .await?; + tx.commit().await?; + + // Notify the requester + every assignee that the request was merged. + // Skip the merger themselves to avoid self-ping. + let assignee_emails: Vec = sqlx::query_scalar!( + "SELECT email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1", + id, + ) + .fetch_all(&db) + .await?; + let mut recipients: BTreeSet = assignee_emails.into_iter().collect(); + recipients.insert(row.requested_by_email); + recipients.remove(&authed.email); + let subject = format!("[Windmill] Deployment request on fork {w_id} merged"); + let base_url = BASE_URL.read().await.clone(); + let body_text = format!( + "@{} merged the deployment request from @{} on fork {w_id}.\n\n{base_url}/?workspace={w_id}", + authed.username, row.requested_by + ); + for email in recipients { + send_email_if_possible(&subject, &body_text, &email); + } + + Ok("ok".to_string()) +} + +/// Append a comment to an open deployment request. Can be a top-level +/// comment or a reply (set `parent_id`, must target a top-level comment), +/// and can be general or anchored to a diff row. Anyone with access to the +/// fork workspace can comment; ACL is enforced by the bearer-token → +/// workspace membership check higher up. +async fn create_deployment_request_comment( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, id)): Path<(String, i64)>, + Json(body): Json, +) -> JsonResult { + if body.body.trim().is_empty() { + return Err(Error::BadRequest("Comment body is empty".to_string())); + } + if body.anchor_kind.is_some() != body.anchor_path.is_some() { + return Err(Error::BadRequest( + "anchor_kind and anchor_path must both be set or both omitted".to_string(), + )); + } + + let req = sqlx::query!( + "SELECT id, source_workspace_id, closed_at, requested_by_email FROM workspace_fork_deployment_request WHERE id = $1 AND fork_workspace_id = $2", + id, + &w_id, + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| Error::NotFound(format!("deployment request {id} not found")))?; + + if req.closed_at.is_some() { + return Err(Error::BadRequest( + "Cannot comment on a closed deployment request".to_string(), + )); + } + + // If anchor is set, validate the (kind, path) exists on the workspace_diff + // for this (source, fork) pair. This prevents comments against phantom + // rows. + if let (Some(kind), Some(path)) = (body.anchor_kind.as_ref(), body.anchor_path.as_ref()) { + let exists: bool = sqlx::query_scalar!( + r#" + SELECT EXISTS( + SELECT 1 FROM workspace_diff + WHERE source_workspace_id = $1 + AND fork_workspace_id = $2 + AND kind = $3 + AND path = $4 + ) as "exists!" + "#, + req.source_workspace_id, + &w_id, + kind, + path, + ) + .fetch_one(&db) + .await?; + if !exists { + return Err(Error::BadRequest(format!( + "No diff row exists for {kind}:{path} in this fork", + ))); + } + } + + // If this is a reply, look up the parent comment — must exist on the + // same request, must itself be top-level. The parent's author_email is + // captured here so we can include them in the notification recipients. + let parent_author_email: Option = if let Some(parent_id) = body.parent_id { + let parent_row = sqlx::query!( + "SELECT parent_id, author_email FROM workspace_fork_deployment_request_comment WHERE id = $1 AND request_id = $2", + parent_id, + id, + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| { + Error::BadRequest("parent_id does not belong to this request".to_string()) + })?; + if parent_row.parent_id.is_some() { + return Err(Error::BadRequest( + "Replies can only target top-level comments".to_string(), + )); + } + Some(parent_row.author_email) + } else { + None + }; + + let mut tx = db.begin().await?; + let row = sqlx::query!( + r#" + INSERT INTO workspace_fork_deployment_request_comment + (request_id, parent_id, author, author_email, body, anchor_kind, anchor_path) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, created_at + "#, + id, + body.parent_id, + AuditAuthorable::username(&authed), + &authed.email, + &body.body, + body.anchor_kind, + body.anchor_path, + ) + .fetch_one(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "fork_deployment_request.comment", + ActionKind::Create, + &w_id, + Some(&row.id.to_string()), + None, + ) + .await?; + tx.commit().await?; + + // Recipient set: requester + every assignee + (for replies) the + // parent-comment author, minus the comment author. + let assignee_emails: Vec = sqlx::query_scalar!( + "SELECT email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1", + id, + ) + .fetch_all(&db) + .await?; + + let mut recipients: BTreeSet = assignee_emails.into_iter().collect(); + recipients.insert(req.requested_by_email.clone()); + if let Some(e) = parent_author_email { + recipients.insert(e); + } + recipients.remove(&authed.email); + + let subject = format!( + "[Windmill] New comment on deployment request for fork {w_id} by @{}", + authed.username + ); + let base_url = BASE_URL.read().await.clone(); + let body_text = format!( + "@{} commented on the deployment request for fork {w_id}:\n\n{}\n\n{base_url}/?workspace={w_id}", + authed.username, body.body + ); + for email in recipients { + send_email_if_possible(&subject, &body_text, &email); + } + + Ok(Json(DeploymentRequestComment { + id: row.id, + parent_id: body.parent_id, + author: authed.username.clone(), + author_email: authed.email.clone(), + body: body.body, + anchor_kind: body.anchor_kind, + anchor_path: body.anchor_path, + obsolete: false, + created_at: row.created_at, + })) +} + +// ---- helpers ------------------------------------------------------------ + +async fn parent_of_fork(db: &DB, w_id: &str) -> Result { + sqlx::query_scalar!( + "SELECT parent_workspace_id FROM workspace WHERE id = $1", + w_id, + ) + .fetch_optional(db) + .await? + .flatten() + .ok_or_else(|| { + Error::BadRequest(format!( + "workspace {w_id} is not a fork (no parent_workspace_id)" + )) + }) +} diff --git a/backend/windmill-api-workspaces/src/lib.rs b/backend/windmill-api-workspaces/src/lib.rs index 7c12ebd3a0..b3e9853de9 100644 --- a/backend/windmill-api-workspaces/src/lib.rs +++ b/backend/windmill-api-workspaces/src/lib.rs @@ -1,3 +1,4 @@ +pub mod deployment_requests; pub mod workspaces; pub mod workspaces_extra; pub mod workspaces_oss; diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 195b6dc89f..11f3a42e1b 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -331,6 +331,31 @@ pub(crate) async fn change_workspace_id( .execute(&mut *tx) .await?; + info!("Updating workspace_fork_deployment_request table"); + sqlx::query!( + "UPDATE workspace_fork_deployment_request SET source_workspace_id = $1 WHERE source_workspace_id = $2", + &rw.new_id, + &old_id + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "UPDATE workspace_fork_deployment_request SET fork_workspace_id = $1 WHERE fork_workspace_id = $2", + &rw.new_id, + &old_id + ) + .execute(&mut *tx) + .await?; + + info!("Updating workspace_protection_rule table"); + sqlx::query!( + "UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2", + &rw.new_id, + &old_id + ) + .execute(&mut *tx) + .await?; + info!("Updating workspace_integrations table"); sqlx::query!( "UPDATE workspace_integrations SET workspace_id = $1 WHERE workspace_id = $2", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5936fb290b..c00e2602ea 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -5314,6 +5314,168 @@ paths: type: string "404": description: protection rule not found + + /w/{workspace}/deployment_request/eligible_deployers: + get: + summary: list users eligible to be deployment request assignees + description: Returns admins and members of wm_deployers in the parent workspace of the fork at `{workspace}`. + operationId: listDeploymentRequestEligibleDeployers + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: list of eligible deployers + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/DeploymentRequestEligibleDeployer" + + /w/{workspace}/deployment_request/open: + get: + summary: get the currently open deployment request for this fork + operationId: getOpenDeploymentRequest + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: the open request or null if none exists + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/DeploymentRequest" + - type: "null" + + /w/{workspace}/deployment_request: + post: + summary: create a new deployment request + operationId: createDeploymentRequest + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - assignees + properties: + assignees: + type: array + items: + type: string + description: Usernames in the parent workspace. Must be admin or wm_deployers. + responses: + "200": + description: request created + content: + application/json: + schema: + $ref: "#/components/schemas/DeploymentRequest" + "400": + description: invalid assignees + "409": + description: a deployment request is already open for this fork + + /w/{workspace}/deployment_request/{id}/cancel: + post: + summary: cancel an open deployment request + operationId: cancelDeploymentRequest + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: cancelled + content: + text/plain: + schema: + type: string + + /w/{workspace}/deployment_request/{id}/close_merged: + post: + summary: close a deployment request after a successful merge + description: Called by the UI after the deploy loop completes. Closes the request and marks every comment obsolete. Idempotent. + operationId: closeDeploymentRequestMerged + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: closed + content: + text/plain: + schema: + type: string + + /w/{workspace}/deployment_request/{id}/comment: + post: + summary: append a comment to an open deployment request + operationId: createDeploymentRequestComment + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + schema: + type: integer + format: int64 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - body + properties: + body: + type: string + parent_id: + type: integer + format: int64 + nullable: true + anchor_kind: + type: string + nullable: true + anchor_path: + type: string + nullable: true + responses: + "200": + description: comment created + content: + application/json: + schema: + $ref: "#/components/schemas/DeploymentRequestComment" + "400": + description: invalid input or request closed + /w/{workspace}/workspaces/log_chat: post: summary: log AI chat message @@ -26493,6 +26655,7 @@ components: enum: - DisableDirectDeployment - DisableWorkspaceForking + - RestrictDeployToDeployers RuleBypasserGroups: type: array description: Groups that can bypass this ruleset @@ -26503,6 +26666,97 @@ components: description: Users that can bypass this ruleset items: type: string + DeploymentRequestEligibleDeployer: + type: object + required: + - username + - email + - is_admin + properties: + username: + type: string + email: + type: string + is_admin: + type: boolean + DeploymentRequestAssignee: + type: object + required: + - username + - email + properties: + username: + type: string + email: + type: string + DeploymentRequestComment: + type: object + required: + - id + - author + - author_email + - body + - obsolete + - created_at + properties: + id: + type: integer + format: int64 + parent_id: + type: integer + format: int64 + nullable: true + author: + type: string + author_email: + type: string + body: + type: string + anchor_kind: + type: string + nullable: true + anchor_path: + type: string + nullable: true + obsolete: + type: boolean + created_at: + type: string + format: date-time + DeploymentRequest: + type: object + required: + - id + - source_workspace_id + - fork_workspace_id + - requested_by + - requested_by_email + - requested_at + - assignees + - comments + properties: + id: + type: integer + format: int64 + source_workspace_id: + type: string + fork_workspace_id: + type: string + requested_by: + type: string + requested_by_email: + type: string + requested_at: + type: string + format: date-time + assignees: + type: array + items: + $ref: "#/components/schemas/DeploymentRequestAssignee" + comments: + type: array + items: + $ref: "#/components/schemas/DeploymentRequestComment" QuotaInfo: type: object properties: diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 5a9f065e05..662df5dbc4 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -62,7 +62,7 @@ use windmill_common::{ }, variables::{build_crypt, build_crypt_with_key_suffix, encrypt}, worker::{to_raw_value, CLOUD_HOSTED}, - workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}, + workspaces::{check_deploy_rules, RuleCheckResult}, HUB_BASE_URL, }; #[cfg(feature = "parquet")] @@ -1108,9 +1108,8 @@ async fn create_app_raw<'a>( )); } - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1181,9 +1180,8 @@ async fn create_app( let path = app.path.clone(); check_scopes(&authed, || format!("apps:write:{}", &path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1472,9 +1470,8 @@ async fn delete_app( )); } - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1634,9 +1631,8 @@ async fn update_app( let path = path.to_path(); check_scopes(&authed, || format!("apps:write:{}", path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1678,9 +1674,8 @@ async fn update_app_raw<'a>( )); } - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index b61cc526d7..9b02f23cce 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -620,6 +620,10 @@ pub async fn run_server( .nest("/volumes", volumes_oss::workspaced_service()) .nest("/workers", windmill_api_workers::workspaced_service()) .nest("/workspaces", workspaces::workspaced_service()) + .nest( + "/deployment_request", + windmill_api_workspaces::deployment_requests::workspaced_service(), + ) .nest("/oidc", oidc_oss::workspaced_service()) .nest("/openapi", { #[cfg(feature = "http_trigger")] diff --git a/backend/windmill-common/src/deployment_requests_oss.rs b/backend/windmill-common/src/deployment_requests_oss.rs new file mode 100644 index 0000000000..35e6791ded --- /dev/null +++ b/backend/windmill-common/src/deployment_requests_oss.rs @@ -0,0 +1,50 @@ +//! OSS stubs for the fork deployment requests feature. The EE build +//! overrides these via `deployment_requests_ee.rs` when the `private` +//! feature is active. +//! +//! These helpers are called from fork item-change hooks in EE code (see +//! `windmill-git-sync/src/git_sync_ee.rs::tally_deployed_object_changes`). +//! The stub exists so code paths that wire the hook compile on OSS even +//! though the obsolescence behavior itself is EE-only. +//! +//! On OSS the call is a no-op, so anchored-comment obsolescence after an +//! item change only actually fires when compiled with `--features private`. +//! The `fork_deployment_requests` integration test simulates the EE effect +//! via a raw SQL UPDATE to keep the lifecycle test meaningful on CE builds. + +#[cfg(feature = "private")] +#[allow(unused)] +pub use crate::deployment_requests_ee::*; + +#[cfg(not(feature = "private"))] +use crate::{db::DB, error::Result}; + +/// Mark every anchored comment on the currently open deployment request +/// for the given fork workspace that pins to `(anchor_kind, anchor_path)` +/// as obsolete. +/// +/// OSS stub: no-op. The EE build writes to +/// `workspace_fork_deployment_request_comment`. +#[cfg(not(feature = "private"))] +pub async fn mark_anchor_obsolete( + _db: &DB, + _fork_workspace_id: &str, + _anchor_kind: &str, + _anchor_path: &str, +) -> Result<()> { + Ok(()) +} + +/// Close the currently open deployment request for `(source_workspace_id, +/// fork_workspace_id)` with `closed_reason = 'merged'` and mark all its +/// comments obsolete. Called from the frontend deploy success handler. +/// +/// OSS stub: no-op. +#[cfg(not(feature = "private"))] +pub async fn close_open_request_on_merge( + _db: &DB, + _source_workspace_id: &str, + _fork_workspace_id: &str, +) -> Result<()> { + Ok(()) +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 8ac739cebb..6f71090124 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -48,6 +48,9 @@ mod db_entra_ee; mod db_iam_ee; pub mod db_params; #[cfg(feature = "private")] +pub mod deployment_requests_ee; +pub mod deployment_requests_oss; +#[cfg(feature = "private")] pub mod ee; pub mod ee_oss; #[cfg(feature = "private")] diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index ed51728bdd..5909c481fa 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -67,6 +67,7 @@ bitflags::bitflags! { pub struct ProtectionRules: i32 { const DISABLE_DIRECT_DEPLOYMENT = 1 << 0; const DISABLE_WORKSPACE_FORKING = 1 << 1; + const RESTRICT_DEPLOY_TO_DEPLOYERS = 1 << 2; } } @@ -76,6 +77,7 @@ sqlx_bitflags!(ProtectionRules => i32); pub enum ProtectionRuleKind { DisableDirectDeployment, DisableWorkspaceForking, + RestrictDeployToDeployers, } impl ProtectionRuleKind { @@ -87,6 +89,9 @@ impl ProtectionRuleKind { ProtectionRuleKind::DisableWorkspaceForking => { ProtectionRules::DISABLE_WORKSPACE_FORKING } + ProtectionRuleKind::RestrictDeployToDeployers => { + ProtectionRules::RESTRICT_DEPLOY_TO_DEPLOYERS + } } } @@ -96,6 +101,9 @@ impl ProtectionRuleKind { "Cannot directly deploy in this workspace. Fork or Pull request required." } ProtectionRuleKind::DisableWorkspaceForking => "Forking this workspace is forbidden", + ProtectionRuleKind::RestrictDeployToDeployers => { + "Only workspace admins and members of wm_deployers can deploy to this workspace" + } } } } @@ -365,6 +373,14 @@ pub async fn check_user_against_rule( return Ok(RuleCheckResult::Allowed); } + // wm_deployers members implicitly satisfy RestrictDeployToDeployers, + // regardless of per-ruleset bypass configuration. + if matches!(rule, ProtectionRuleKind::RestrictDeployToDeployers) + && user_groups.iter().any(|g| g == crate::WM_DEPLOYERS_GROUP) + { + return Ok(RuleCheckResult::Allowed); + } + let rulesets = get_protection_rules(workspace_id, db).await?; for ruleset in rulesets.iter() { @@ -389,6 +405,34 @@ pub async fn check_user_against_rule( Ok(RuleCheckResult::Allowed) } +/// Check all deploy-gating protection rules at once. +/// +/// Evaluates `DisableDirectDeployment` first (so its message wins when both +/// rules would block), then `RestrictDeployToDeployers`. Returns the first +/// `Blocked` result, or `Allowed` if neither rule blocks. +/// +/// Use this at every item create/update endpoint that participates in the +/// deploy/merge flow so a single call enforces both rules consistently. +pub async fn check_deploy_rules( + workspace_id: &str, + username: &str, + user_groups: &[String], + is_admin: bool, + db: &DB, +) -> Result { + for rule in [ + ProtectionRuleKind::DisableDirectDeployment, + ProtectionRuleKind::RestrictDeployToDeployers, + ] { + let res = check_user_against_rule(workspace_id, &rule, username, user_groups, is_admin, db) + .await?; + if matches!(res, RuleCheckResult::Blocked(_)) { + return Ok(res); + } + } + Ok(RuleCheckResult::Allowed) +} + #[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq)] #[serde(rename_all = "snake_case")] pub enum DataTableForkBehavior { diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 1a716fff7f..9f9ea7b22b 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -14,7 +14,7 @@ use windmill_api_auth::{ Tokened, }; use windmill_common::db::DB; -use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}; +use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use crate::secret_backend_ext::rename_vault_secret; use crate::var_resource_cache::{cache_resource, get_cached_resource}; @@ -770,9 +770,8 @@ async fn create_resource( Json(resource): Json, ) -> Result<(StatusCode, String)> { check_scopes(&authed, || format!("resources:write:{}", resource.path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -893,9 +892,8 @@ async fn delete_resource( let path = path.to_path(); check_scopes(&authed, || format!("resources:write:{}", path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1094,9 +1092,8 @@ async fn delete_resources_bulk( check_scopes(&authed, || format!("resources:write:{}", path))?; } - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1201,9 +1198,8 @@ async fn update_resource( let path = path.to_path(); check_scopes(&authed, || format!("resources:write:{}", path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1394,9 +1390,8 @@ async fn update_resource_value( ) -> Result { let path = path.to_path(); check_scopes(&authed, || format!("resources:write:{}", path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1583,9 +1578,8 @@ async fn create_resource_type( Path(w_id): Path, Json(resource_type): Json, ) -> Result<(StatusCode, String)> { - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1691,9 +1685,8 @@ async fn delete_resource_type( Path((w_id, name)): Path<(String, String)>, ) -> Result { require_admin(authed.is_admin, &authed.username)?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -1757,9 +1750,8 @@ async fn update_resource_type( Json(ns): Json, ) -> Result { use sql_builder::prelude::*; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 31160f84fa..1e28c1e19e 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -8,7 +8,7 @@ use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed}; use windmill_common::db::DB; -use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}; +use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use crate::secret_backend_ext::{ delete_secret_from_backend, get_secret_value, is_vault_stored_value, rename_vault_secret, @@ -391,9 +391,8 @@ async fn create_variable( Json(variable): Json, ) -> Result<(StatusCode, String)> { check_scopes(&authed, || format!("variables:write:{}", variable.path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -504,9 +503,8 @@ async fn delete_variable( let path = path.to_path(); check_scopes(&authed, || format!("variables:write:{}", path))?; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -652,9 +650,8 @@ async fn delete_variables_bulk( check_scopes(&authed, || format!("variables:write:{}", path))?; } - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, @@ -799,9 +796,8 @@ async fn update_variable( ) -> Result { use sql_builder::prelude::*; - if let RuleCheckResult::Blocked(msg) = check_user_against_rule( + if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, - &ProtectionRuleKind::DisableDirectDeployment, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 0b62cea9d7..3ace24d1fb 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -10,12 +10,13 @@ CircleCheck, CircleX, DiffIcon, + Eye, FileJson, FlaskConical, GitFork, Loader2, Trash2, - Upload + UserPlus } from 'lucide-svelte' import type { CiTestResult } from '$lib/gen' import { Alert, Badge } from './common' @@ -41,10 +42,18 @@ } from '$lib/gen' import Button from './common/button/Button.svelte' import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' - import Row from './common/table/Row.svelte' import DiffDrawer from './DiffDrawer.svelte' - import DeployWorkspaceDrawer from './DeployWorkspaceDrawer.svelte' import ParentWorkspaceProtectionAlert from './ParentWorkspaceProtectionAlert.svelte' + import ScheduleEditor from './triggers/schedules/ScheduleEditor.svelte' + import RouteEditor from './triggers/http/RouteEditor.svelte' + import WebsocketTriggerEditor from './triggers/websocket/WebsocketTriggerEditor.svelte' + import KafkaTriggerEditor from './triggers/kafka/KafkaTriggerEditor.svelte' + import PostgresTriggerEditor from './triggers/postgres/PostgresTriggerEditor.svelte' + import NatsTriggerEditor from './triggers/nats/NatsTriggerEditor.svelte' + import MqttTriggerEditor from './triggers/mqtt/MqttTriggerEditor.svelte' + import SqsTriggerEditor from './triggers/sqs/SqsTriggerEditor.svelte' + import GcpTriggerEditor from './triggers/gcp/GcpTriggerEditor.svelte' + import EmailTriggerEditor from './triggers/email/EmailTriggerEditor.svelte' import { userWorkspaces, workspaceStore } from '$lib/stores' import type { Kind } from '$lib/utils_deployable' @@ -64,6 +73,8 @@ import { sendUserToast } from '$lib/toast' import { deepEqual } from 'fast-equals' import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte' + import DeploymentRequestPanel from './deploymentRequest/DeploymentRequestPanel.svelte' + import { userStore } from '$lib/stores' import type { TriggerKind } from './triggers' import { triggerDisplayNamesMap, triggerKindToTriggerType } from './triggers/utils' import { getEmailAddress, getEmailDomain } from './triggers/email/utils' @@ -280,7 +291,12 @@ } } - let allSelected = $derived(selectedItems.length == selectableDiffs.length) + // All *diff* items selected. Trigger items are opt-in and don't count + // toward "all selected" — see item merge below in deployableItems. + let allSelected = $derived( + selectableDiffs.length > 0 && + selectableDiffs.every((d) => selectedItems.includes(getItemKey(d))) + ) async function selectAll() { selectedItems = selectableDiffs @@ -313,13 +329,14 @@ path: string, workspaceToDeployTo: string, workspaceFrom: string, - statusPath: string + statusKey: string, + trigger?: ForkTrigger ) { - deploymentStatus[statusPath] = { status: 'loading' } + deploymentStatus[statusKey] = { status: 'loading' } // Check if the item was deleted in the source workspace. // If so, archive/delete it in the target workspace instead of copying. - const diff = comparison?.diffs.find((d) => getItemKey(d) === statusPath) + const diff = comparison?.diffs.find((d) => getItemKey(d) === statusKey) const itemDeletedInSource = diff ? mergeIntoParent ? diff.exists_in_fork === false @@ -329,21 +346,30 @@ let result: DeployResult if (itemDeletedInSource) { result = await deleteItemInWorkspace(kind, path, workspaceToDeployTo) + } else if (trigger) { + result = await deployItem({ + kind: 'trigger', + path, + workspaceFrom, + workspaceTo: workspaceToDeployTo, + additionalInformation: { triggers: { kind: trigger.triggerKind } }, + onBehalfOf: getOnBehalfOfForDeploy(statusKey, 'trigger') + }) } else { result = await deployItem({ kind, path, workspaceFrom, workspaceTo: workspaceToDeployTo, - onBehalfOf: getOnBehalfOfForDeploy(statusPath, kind) + onBehalfOf: getOnBehalfOfForDeploy(statusKey, kind) }) } if (result.success) { - deploymentStatus[statusPath] = { status: 'deployed' } + deploymentStatus[statusKey] = { status: 'deployed' } } else { - deploymentStatus[statusPath] = { status: 'failed', error: result.error } - sendUserToast(`Failed to deploy ${statusPath}: ${result.error}`) + deploymentStatus[statusKey] = { status: 'failed', error: result.error } + sendUserToast(`Failed to deploy ${statusKey}: ${result.error}`) } } @@ -393,26 +419,46 @@ if (!aIsFolder && bIsFolder) return 1 return 0 }) + let anyFailed = false for (const itemKey of sortedItems) { - const diff = selectableDiffs.find((d) => itemKey == getItemKey(d)) + const deployable = deployableItems.find((d) => d.key === itemKey) - if (!diff) { + if (!deployable) { sendUserToast(`Undeployable item: ${itemKey}`, true) continue } - if (mergeIntoParent) { - await deploy(diff.kind as Kind, diff.path, parent, current, itemKey) - } else { - await deploy(diff.kind as Kind, diff.path, current, parent, itemKey) + const to = mergeIntoParent ? parent : current + const from = mergeIntoParent ? current : parent + await deploy(deployable.kind, deployable.path, to, from, itemKey, deployable.trigger) + if (deploymentStatus[itemKey]?.status === 'failed') { + anyFailed = true } } deploying = false deselectAll() + + // If every selected item deployed cleanly and the direction was + // merge-into-parent, close any open deployment request for this fork. + if (!anyFailed && mergeIntoParent) { + try { + const open = await WorkspaceService.getOpenDeploymentRequest({ + workspace: currentWorkspaceId + }) + if (open) { + await WorkspaceService.closeDeploymentRequestMerged({ + workspace: currentWorkspaceId, + id: open.id + }) + deploymentRequestPanel?.refresh() + } + } catch (e) { + console.error('Failed to close open deployment request after merge', e) + } + } } - function toggleItem(diff: WorkspaceItemDiff) { - const key = getItemKey(diff) + function toggleKey(key: string) { if (selectedItems.includes(key)) { selectedItems = selectedItems.filter((i) => i !== key) } else { @@ -478,9 +524,15 @@ allowBehindChangesOverride = false }) - // Transform diffs to deployable item format for the shared layout - let deployableItems = $derived( - (comparison?.diffs ?? []) + function getTriggerKey(trigger: ForkTrigger): string { + return `trigger:${trigger.triggerKind}:${trigger.path}` + } + + // Transform diffs + fork triggers to deployable item format for the + // shared layout. Triggers render as inline rows alongside diff items; + // they carry no ahead/behind info and are selectable à la carte. + let deployableItems = $derived.by(() => { + const diffItems = (comparison?.diffs ?? []) .filter((diff) => { const key = getItemKey(diff) const isSelectable = selectableDiffs.includes(diff) @@ -492,9 +544,23 @@ key: getItemKey(diff), path: diff.path, kind: diff.kind as Kind, - diff + diff, + trigger: undefined as ForkTrigger | undefined })) - ) + const triggerItems = forkTriggers + .filter((t) => { + const key = getTriggerKey(t) + return deploymentStatus[key]?.status !== 'deployed' + }) + .map((trigger) => ({ + key: getTriggerKey(trigger), + path: trigger.path, + kind: 'trigger' as Kind, + diff: undefined as WorkspaceItemDiff | undefined, + trigger + })) + return [...diffItems, ...triggerItems] + }) // --- Fork Triggers --- @@ -559,9 +625,58 @@ }) let forkTriggers = $state([]) - let loadingTriggers = $state(true) - let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined) let triggerToDelete = $state(undefined) + let deploymentRequestPanel: DeploymentRequestPanel | undefined = $state(undefined) + let hasOpenDeploymentRequest = $state(false) + + // Trigger detail drawer refs — one per trigger kind. Each is lazy-mounted + // on first openEdit() call, so having them all sit here is cheap. + let scheduleEditor: ScheduleEditor | undefined = $state() + let routeEditor: RouteEditor | undefined = $state() + let websocketEditor: WebsocketTriggerEditor | undefined = $state() + let kafkaEditor: KafkaTriggerEditor | undefined = $state() + let postgresEditor: PostgresTriggerEditor | undefined = $state() + let natsEditor: NatsTriggerEditor | undefined = $state() + let mqttEditor: MqttTriggerEditor | undefined = $state() + let sqsEditor: SqsTriggerEditor | undefined = $state() + let gcpEditor: GcpTriggerEditor | undefined = $state() + let emailEditor: EmailTriggerEditor | undefined = $state() + + function openTriggerDetails(trigger: ForkTrigger) { + const isFlow = trigger.isFlow + switch (trigger.triggerKind) { + case 'schedules': + scheduleEditor?.openEdit(trigger.path, isFlow) + break + case 'routes': + routeEditor?.openEdit(trigger.path, isFlow) + break + case 'websockets': + websocketEditor?.openEdit(trigger.path, isFlow) + break + case 'kafka': + kafkaEditor?.openEdit(trigger.path, isFlow) + break + case 'postgres': + postgresEditor?.openEdit(trigger.path, isFlow) + break + case 'nats': + natsEditor?.openEdit(trigger.path, isFlow) + break + case 'mqtt': + mqttEditor?.openEdit(trigger.path, isFlow) + break + case 'sqs': + sqsEditor?.openEdit(trigger.path, isFlow) + break + case 'gcp': + gcpEditor?.openEdit(trigger.path, isFlow) + break + case 'emails': + emailEditor?.openEdit(trigger.path, isFlow) + break + } + } /** Deployable trigger kinds and their list+delete services */ const triggerServices = { @@ -702,7 +817,6 @@ let emailDomain = $state(undefined) async function fetchAllTriggers() { - loadingTriggers = true try { emailDomain = await getEmailDomain() const entries = Object.values(triggerServices) @@ -716,8 +830,6 @@ } catch (e) { console.error('Failed to fetch fork triggers:', e) forkTriggers = [] - } finally { - loadingTriggers = false } } @@ -751,24 +863,6 @@ return triggerType ? triggerDisplayNamesMap[triggerType] : triggerKind } - const triggerKindToPagePath: Record = { - schedules: '/schedules', - routes: '/routes', - websockets: '/websocket_triggers', - kafka: '/kafka_triggers', - postgres: '/postgres_triggers', - nats: '/nats_triggers', - mqtt: '/mqtt_triggers', - sqs: '/sqs_triggers', - gcp: '/gcp_triggers', - emails: '/email_triggers' - } - - function getTriggerHref(triggerKind: TriggerKind): string | undefined { - const pagePath = triggerKindToPagePath[triggerKind] - return pagePath ? `${base}${pagePath}` : undefined - } - // Fetch triggers when workspace is available $effect(() => { if (currentWorkspaceId) { @@ -798,12 +892,10 @@ items={deployableItems} {selectedItems} {deploymentStatus} - selectablePredicate={(item) => selectableDiffs.some((d) => getItemKey(d) === item.key)} + selectablePredicate={(item) => + item.trigger != null || selectableDiffs.some((d) => getItemKey(d) === item.key)} {allSelected} - onToggleItem={(item) => { - const diff = comparison?.diffs.find((d) => getItemKey(d) === item.key) - if (diff) toggleItem(diff) - }} + onToggleItem={(item) => toggleKey(item.key)} onSelectAll={selectAll} onDeselectAll={deselectAll} emptyMessage="No comparison data available" @@ -975,126 +1067,163 @@ {/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} + {#if item.trigger} + {@const t = item.trigger as ForkTrigger} + {getTriggerDisplayName(t.triggerKind)} + + {t.scriptPath} {:else} - {newSummary || diff.path} + {@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} {/if} {/snippet} {#snippet itemActions(item)} - {@const diff = item.diff as WorkspaceItemDiff} - {@const key = item.key} - {@const targetOnBehalfOf = getTargetOnBehalfOf(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 - if (details) customOnBehalfOf[key] = details - }} - kind={diff.kind} - canPreserve={canPreserveOnBehalfOf} - customValue={customOnBehalfOf[key]?.permissionedAs} - /> - {/if} - {#if diff.kind === 'raw_app'} - Raw - {/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} - {@const ciStatus = getCiTestStatus(diff)} - {#if ciStatus === 'pass'} - CI pass - {:else if ciStatus === 'fail'} - CI fail - {:else if ciStatus === 'running'} - CI - {/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 item.trigger} + {@const t = item.trigger as ForkTrigger} + {@const key = item.key} + Fork-only + {#if t.isFlow} + flow + {/if} + {#if t.extraLabel} + ({t.extraLabel}) + {/if} + {#if t.enabled != null} + + {t.enabled ? 'Enabled' : 'Disabled'} + + {/if} + {#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'} -
+ + {/if} + {:else} + {@const diff = item.diff as WorkspaceItemDiff} + {@const key = item.key} + {@const targetOnBehalfOf = getTargetOnBehalfOf(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 + if (details) customOnBehalfOf[key] = details + }} + kind={diff.kind} + canPreserve={canPreserveOnBehalfOf} + customValue={customOnBehalfOf[key]?.permissionedAs} + /> + {/if} + {#if diff.kind === 'raw_app'} + Raw + {/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} + {@const ciStatus = getCiTestStatus(diff)} + {#if ciStatus === 'pass'} + CI pass + {:else if ciStatus === 'fail'} + CI fail + {:else if ciStatus === 'running'} + CI + {/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} {/snippet} @@ -1104,8 +1233,18 @@
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible} - {#if !(mergeIntoParent && !canDeployToParent)} +
+ {#if mergeIntoParent && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()} + + {/if}
+ {#if !(mergeIntoParent && !canDeployToParent) && 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} @@ -1148,89 +1287,17 @@
{/snippet} - - -
-
-

Triggers created in this fork

- {#if !loadingTriggers} - {forkTriggers.length} trigger{forkTriggers.length !== 1 ? 's' : ''} - {/if} -
- - - When forking a workspace, triggers are not forked to avoid unnecessary executions or - collisions. If you created this triggers with the intention of deploying them to the parent - workspace, you can do so here. Otherwise it is recommended to delete them or disable them. - - - {#if loadingTriggers} -
- - Loading triggers... -
- {:else if forkTriggers.length === 0} -
- No triggers in this fork workspace. -
- {:else} -
- {#each forkTriggers as trigger (trigger.triggerKind + ':' + trigger.path)} - - {#snippet customSummary()} - {getTriggerDisplayName(trigger.triggerKind)} - - {trigger.scriptPath} - {#if trigger.isFlow} - flow - {/if} - {#if trigger.extraLabel} - ({trigger.extraLabel}) - {/if} - {/snippet} - {#snippet actions()} - {#if trigger.enabled != null} - - {trigger.enabled ? 'Enabled' : 'Disabled'} - - {/if} - - - {/snippet} - - {/each} -
- {/if} + { + hasOpenDeploymentRequest = open + }} + />
@@ -1238,8 +1305,19 @@
- + + + + + + + + + + + + + import { onMount, untrack } from 'svelte' + import { Badge, Button } from '$lib/components/common' + import Select from '$lib/components/select/Select.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import { safeSelectItems } from '$lib/components/select/utils.svelte' + import { + WorkspaceService, + type DeploymentRequest, + type DeploymentRequestComment, + type DeploymentRequestEligibleDeployer + } from '$lib/gen' + import { sendUserToast } from '$lib/toast' + import { Send, Plus, X, ArchiveX } from 'lucide-svelte' + + interface Props { + forkWorkspaceId: string + parentWorkspaceId: string + currentUsername: string + isAdmin: boolean + onStateChange?: (hasOpenRequest: boolean) => void + } + + let { forkWorkspaceId, parentWorkspaceId, currentUsername, isAdmin, onStateChange }: Props = + $props() + + let request = $state(null) + let loading = $state(true) + let eligibleDeployers = $state([]) + let showRequestDialog = $state(false) + let selectedAssignees = $state([]) + let assigneeToAdd = $state(undefined) + let newComment = $state('') + let posting = $state(false) + let replyParentId = $state(undefined) + let replyBody = $state('') + + let canCancel = $derived(request != null && (isAdmin || request.requested_by === currentUsername)) + + async function load() { + loading = true + try { + request = await WorkspaceService.getOpenDeploymentRequest({ workspace: forkWorkspaceId }) + } catch (e: any) { + console.error('Failed to load open deployment request', e) + request = null + } finally { + loading = false + onStateChange?.(request != null) + } + } + + async function loadEligibleDeployers() { + try { + eligibleDeployers = await WorkspaceService.listDeploymentRequestEligibleDeployers({ + workspace: forkWorkspaceId + }) + } catch (e: any) { + console.error('Failed to load eligible deployers', e) + eligibleDeployers = [] + } + } + + onMount(() => { + load() + loadEligibleDeployers() + }) + + $effect(() => { + if (assigneeToAdd && !selectedAssignees.includes(assigneeToAdd)) { + const v = assigneeToAdd + untrack(() => { + selectedAssignees = [...selectedAssignees, v] + assigneeToAdd = undefined + }) + } + }) + + async function createRequest() { + if (selectedAssignees.length === 0) { + sendUserToast('Pick at least one assignee', true) + return + } + posting = true + try { + request = await WorkspaceService.createDeploymentRequest({ + workspace: forkWorkspaceId, + requestBody: { assignees: selectedAssignees } + }) + sendUserToast(`Deployment request sent to ${selectedAssignees.length} assignee(s)`) + showRequestDialog = false + selectedAssignees = [] + onStateChange?.(true) + } catch (e: any) { + sendUserToast(`Failed to create deployment request: ${e.body || e.message}`, true) + } finally { + posting = false + } + } + + async function cancelRequest() { + if (!request) return + if (!confirm('Cancel the open deployment request? Assignees will be notified.')) return + try { + await WorkspaceService.cancelDeploymentRequest({ + workspace: forkWorkspaceId, + id: request.id + }) + sendUserToast('Deployment request cancelled') + request = null + onStateChange?.(false) + } catch (e: any) { + sendUserToast(`Failed to cancel: ${e.body || e.message}`, true) + } + } + + async function postComment(body: string, parentId?: number) { + if (!request || !body.trim()) return + posting = true + try { + const created = await WorkspaceService.createDeploymentRequestComment({ + workspace: forkWorkspaceId, + id: request.id, + requestBody: { body, parent_id: parentId } + }) + request = { + ...request, + comments: [...request.comments, created] + } + if (parentId == null) { + newComment = '' + } else { + replyBody = '' + replyParentId = undefined + } + } catch (e: any) { + sendUserToast(`Failed to post comment: ${e.body || e.message}`, true) + } finally { + posting = false + } + } + + let topLevel = $derived(request?.comments.filter((c) => c.parent_id == null) ?? []) + function repliesOf(parent: DeploymentRequestComment): DeploymentRequestComment[] { + if (!request) return [] + return request.comments.filter((c) => c.parent_id === parent.id) + } + + function formatTimestamp(iso: string): string { + return new Date(iso).toLocaleString() + } + + // Has content to render inline in the deploy panel: dialog open, + // request open, or still loading. + let hasContent = $derived(loading || showRequestDialog || request != null) + + export function refresh() { + load() + } + + export function openRequestDialog() { + showRequestDialog = true + } + + export function isDialogOpen(): boolean { + return showRequestDialog + } + + export function hasOpenRequest(): boolean { + return request != null + } + + +{#if hasContent} +
+ {#if loading} +
Loading deployment request…
+ {:else if !request} + +
+
+ Pick one or more assignees. They must be admins or members of wm_deployers + in {parentWorkspaceId}. +
+