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) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt for fork review requests

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

* fix: address PR review comments on fork review requests

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

* refactor: rename fork review requests to deployment requests

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

* chore: update ee-repo-ref.txt for deployment request rename

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

* refactor: inline deployment request panel into deploy layout

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

* fix: place Request deployment button to the left of Deploy

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

* refactor: inline fork triggers into main deploy list

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

* refactor: open real trigger detail drawer for inline fork triggers

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

* feat: email notifications for merge completion and reply pings

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

* fix: update deployment_request + protection_rule tables on workspace id rename

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

* 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) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-04-13 13:10:37 -04:00
committed by GitHub
parent b3ef4bc26c
commit 64c58c824f
49 changed files with 3188 additions and 327 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+1 -1
View File
@@ -1 +1 @@
5f912375340225876a8c1740c3301f39cd6cbd6d
972893c3870e4c4a70a35748abed282d88904805
@@ -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;
@@ -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;
+63
View File
@@ -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);
+42
View File
@@ -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;
+356
View File
@@ -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<Postgres>) -> 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<Value> = 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(())
}
+119 -2
View File
@@ -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<Postgres>) -> 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<Postgres>) -> 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(())
}
+5 -9
View File
@@ -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<String> {
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<String> {
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,
+4 -7
View File
@@ -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<String>,
Json(ng): Json<NewFolder>,
) -> Result<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,
@@ -384,9 +383,8 @@ async fn update_folder(
) -> Result<String> {
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<WebhookShared>,
Path((w_id, name)): Path<(String, String)>,
) -> Result<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,
+7 -13
View File
@@ -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<String>,
Json(ns): Json<NewScript>,
) -> 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<Script<ScriptRunnableSettingsInline>> {
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,
@@ -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<chrono::Utc>,
assignees: Vec<DeploymentRequestAssignee>,
comments: Vec<DeploymentRequestComment>,
}
#[derive(Serialize)]
struct DeploymentRequestAssignee {
username: String,
email: String,
}
#[derive(Serialize)]
struct DeploymentRequestComment {
id: i64,
parent_id: Option<i64>,
author: String,
author_email: String,
body: String,
anchor_kind: Option<String>,
anchor_path: Option<String>,
obsolete: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Deserialize)]
struct CreateDeploymentRequestBody {
assignees: Vec<String>,
}
#[derive(Deserialize)]
struct CreateDeploymentRequestCommentBody {
body: String,
#[serde(default)]
anchor_kind: Option<String>,
#[serde(default)]
anchor_path: Option<String>,
#[serde(default)]
parent_id: Option<i64>,
}
// ---- 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<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<EligibleDeployer>> {
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<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Option<DeploymentRequest>> {
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<DB>,
Path(w_id): Path<String>,
Json(body): Json<CreateDeploymentRequestBody>,
) -> Result<Json<DeploymentRequest>> {
// 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<String> = body
.assignees
.iter()
.collect::<BTreeSet<&String>>()
.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<String> =
assignee_rows.iter().map(|r| r.username.clone()).collect();
let assignee_emails: Vec<String> = 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<DeploymentRequestAssignee> = 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<DB>,
Path((w_id, id)): Path<(String, i64)>,
) -> Result<String> {
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<DB>,
Path((w_id, id)): Path<(String, i64)>,
) -> Result<String> {
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<String> = sqlx::query_scalar!(
"SELECT email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1",
id,
)
.fetch_all(&db)
.await?;
let mut recipients: BTreeSet<String> = 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<DB>,
Path((w_id, id)): Path<(String, i64)>,
Json(body): Json<CreateDeploymentRequestCommentBody>,
) -> JsonResult<DeploymentRequestComment> {
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<String> = 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<String> = sqlx::query_scalar!(
"SELECT email FROM workspace_fork_deployment_request_assignee WHERE request_id = $1",
id,
)
.fetch_all(&db)
.await?;
let mut recipients: BTreeSet<String> = 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<String> {
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)"
))
})
}
@@ -1,3 +1,4 @@
pub mod deployment_requests;
pub mod workspaces;
pub mod workspaces_extra;
pub mod workspaces_oss;
@@ -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",
+254
View File
@@ -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:
+6 -11
View File
@@ -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,
+4
View File
@@ -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")]
@@ -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(())
}
+3
View File
@@ -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")]
+44
View File
@@ -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<RuleCheckResult> {
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 {
+9 -17
View File
@@ -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<CreateResource>,
) -> 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<String> {
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<String>,
Json(resource_type): Json<CreateResourceType>,
) -> 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<String> {
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<EditResourceType>,
) -> Result<String> {
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,
+5 -9
View File
@@ -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<CreateVariable>,
) -> 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<String> {
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,
@@ -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<ForkTrigger[]>([])
let loadingTriggers = $state(true)
let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state(undefined)
let triggerToDelete = $state<ForkTrigger | undefined>(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<string | undefined>(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<string, string> = {
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}
<span class="line-through text-secondary">{oldSummary || diff.path}</span>
{newSummary || diff.path}
{:else if !existsInBothWorkspaces}
{newSummary || oldSummary || diff.path}
{#if item.trigger}
{@const t = item.trigger as ForkTrigger}
<span class="text-emphasis">{getTriggerDisplayName(t.triggerKind)}</span>
<span class="text-secondary mx-1">&rarr;</span>
<span class="text-secondary">{t.scriptPath}</span>
{: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}
<span class="line-through text-secondary">{oldSummary || diff.path}</span>
{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)
)}
<!-- On-behalf-of selector -->
{#if itemNeedsOnBehalfOfSelection(key, diff.kind)}
<OnBehalfOfSelector
targetWorkspace={deployTargetWorkspace}
targetValue={targetOnBehalfOf}
selected={onBehalfOfChoice[key]}
onSelect={(choice, details) => {
onBehalfOfChoice[key] = choice
if (details) customOnBehalfOf[key] = details
}}
kind={diff.kind}
canPreserve={canPreserveOnBehalfOf}
customValue={customOnBehalfOf[key]?.permissionedAs}
/>
{/if}
{#if diff.kind === 'raw_app'}
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
<!-- Status badges -->
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0}
<Badge
title="This item was newly created in the parent workspace '{parentWorkspaceId}'"
color="indigo"
size="xs">New</Badge
>
{/if}
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0}
<Badge title="This item was deleted in '{currentWorkspaceId}'" color="red" size="xs"
>Deleted</Badge
>
{/if}
{#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0}
<Badge
title="This item was deleted in the parent workspace '{parentWorkspaceId}'"
color="red"
size="xs">Deleted</Badge
>
{/if}
{#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0}
<Badge
title="This item was newly created in '{currentWorkspaceId}'"
color="indigo"
size="xs">New</Badge
>
{/if}
{@const ciStatus = getCiTestStatus(diff)}
{#if ciStatus === 'pass'}
<Badge color="green" size="xs"><CircleCheck size={10} class="mr-0.5" />CI pass</Badge>
{:else if ciStatus === 'fail'}
<Badge color="red" size="xs"><CircleX size={10} class="mr-0.5" />CI fail</Badge>
{:else if ciStatus === 'running'}
<Badge color="yellow" size="xs"
><Loader2 size={10} class="mr-0.5 animate-spin" />CI</Badge
>
{/if}
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
<div class="flex items-center gap-2">
{#if isConflict || existsInBothWorkspaces}
{#if diff.ahead > 0}
<Badge color="green" size="xs">
<ArrowUpRight class="w-3 h-3 inline" />
{diff.ahead} ahead
</Badge>
{/if}
{#if diff.behind > 0}
<Badge color="blue" size="xs">
<ArrowDownRight class="w-3 h-3 inline" />
{diff.behind} behind
</Badge>
{/if}
{#if isConflict}
<Badge color="orange" size="xs">
<AlertTriangle class="w-3 h-3 inline" />
Conflict
</Badge>
{/if}
{/if}
</div>
<div class:invisible={!existsInBothWorkspaces}>
{#if item.trigger}
{@const t = item.trigger as ForkTrigger}
{@const key = item.key}
<Badge color="indigo" size="xs">Fork-only</Badge>
{#if t.isFlow}
<Badge color="blue" size="xs">flow</Badge>
{/if}
{#if t.extraLabel}
<span class="text-tertiary text-xs">({t.extraLabel})</span>
{/if}
{#if t.enabled != null}
<Badge color={t.enabled ? 'green' : 'gray'} size="xs">
{t.enabled ? 'Enabled' : 'Disabled'}
</Badge>
{/if}
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
<Button
size="xs"
variant="subtle"
onclick={() => showDiff(diff.kind as Kind, diff.path)}
startIcon={{ icon: Eye }}
onclick={() => openTriggerDetails(t)}
>
<DiffIcon class="w-3 h-3" />
Show diff
Details
</Button>
</div>
<Button size="xs" variant="subtle" color="red" onclick={() => deleteTrigger(t)}>
<Trash2 size={12} />
</Button>
{/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)
)}
<!-- On-behalf-of selector -->
{#if itemNeedsOnBehalfOfSelection(key, diff.kind)}
<OnBehalfOfSelector
targetWorkspace={deployTargetWorkspace}
targetValue={targetOnBehalfOf}
selected={onBehalfOfChoice[key]}
onSelect={(choice, details) => {
onBehalfOfChoice[key] = choice
if (details) customOnBehalfOf[key] = details
}}
kind={diff.kind}
canPreserve={canPreserveOnBehalfOf}
customValue={customOnBehalfOf[key]?.permissionedAs}
/>
{/if}
{#if diff.kind === 'raw_app'}
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{/if}
<!-- Status badges -->
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0}
<Badge
title="This item was newly created in the parent workspace '{parentWorkspaceId}'"
color="indigo"
size="xs">New</Badge
>
{/if}
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0}
<Badge title="This item was deleted in '{currentWorkspaceId}'" color="red" size="xs"
>Deleted</Badge
>
{/if}
{#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0}
<Badge
title="This item was deleted in the parent workspace '{parentWorkspaceId}'"
color="red"
size="xs">Deleted</Badge
>
{/if}
{#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0}
<Badge
title="This item was newly created in '{currentWorkspaceId}'"
color="indigo"
size="xs">New</Badge
>
{/if}
{@const ciStatus = getCiTestStatus(diff)}
{#if ciStatus === 'pass'}
<Badge color="green" size="xs"><CircleCheck size={10} class="mr-0.5" />CI pass</Badge>
{:else if ciStatus === 'fail'}
<Badge color="red" size="xs"><CircleX size={10} class="mr-0.5" />CI fail</Badge>
{:else if ciStatus === 'running'}
<Badge color="yellow" size="xs"
><Loader2 size={10} class="mr-0.5 animate-spin" />CI</Badge
>
{/if}
{#if !deploymentStatus[key] || deploymentStatus[key].status != 'deployed'}
<div class="flex items-center gap-2">
{#if isConflict || existsInBothWorkspaces}
{#if diff.ahead > 0}
<Badge color="green" size="xs">
<ArrowUpRight class="w-3 h-3 inline" />
{diff.ahead} ahead
</Badge>
{/if}
{#if diff.behind > 0}
<Badge color="blue" size="xs">
<ArrowDownRight class="w-3 h-3 inline" />
{diff.behind} behind
</Badge>
{/if}
{#if isConflict}
<Badge color="orange" size="xs">
<AlertTriangle class="w-3 h-3 inline" />
Conflict
</Badge>
{/if}
{/if}
</div>
<div class:invisible={!existsInBothWorkspaces}>
<Button
size="xs"
variant="subtle"
onclick={() => showDiff(diff.kind as Kind, diff.path)}
>
<DiffIcon class="w-3 h-3" />
Show diff
</Button>
</div>
{/if}
{/if}
{/snippet}
@@ -1104,8 +1233,18 @@
<div class="flex flex-col items-end gap-2">
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
{#if !(mergeIntoParent && !canDeployToParent)}
<div class="flex items-center gap-2">
{#if mergeIntoParent && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()}
<Button
variant="default"
startIcon={{ icon: UserPlus }}
on:click={() => deploymentRequestPanel?.openRequestDialog()}
>
Request deployment
</Button>
{/if}
<Button
variant="accent"
disabled={selectedItems.length === 0 ||
deploying ||
(hasBehindChanges && !allowBehindChangesOverride) ||
@@ -1120,16 +1259,16 @@
({selectedConflicts} conflicts)
{/if}
</Button>
{#if hasUnselectedOnBehalfOf}
<span class="text-xs text-yellow-600">
You must set the "on behalf of" user for all items before deploying
<Tooltip class="text-yellow-600">
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.
</Tooltip>
</span>
{/if}
</div>
{#if !(mergeIntoParent && !canDeployToParent) && hasUnselectedOnBehalfOf}
<span class="text-xs text-yellow-600">
You must set the "on behalf of" user for all items before deploying
<Tooltip class="text-yellow-600">
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.
</Tooltip>
</span>
{/if}
{/if}
@@ -1148,89 +1287,17 @@
</div>
{/snippet}
</WorkspaceDeployLayout>
</div>
<!-- Fork Triggers Section -->
<div class="bg-surface-tertiary p-4 rounded-md border">
<div class="flex items-center gap-2 mb-2">
<h3 class="text-sm font-semibold">Triggers created in this fork</h3>
{#if !loadingTriggers}
<Badge color="indigo" size="xs"
>{forkTriggers.length} trigger{forkTriggers.length !== 1 ? 's' : ''}</Badge
>
{/if}
</div>
<Alert title="Deploy and/or delete these triggers" type="info" class="mb-2">
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.
</Alert>
{#if loadingTriggers}
<div class="flex items-center gap-2 text-secondary text-sm p-4">
<Loader2 class="animate-spin w-4 h-4" />
Loading triggers...
</div>
{:else if forkTriggers.length === 0}
<div class="text-secondary text-sm p-4 border rounded-md bg-surface-tertiary">
No triggers in this fork workspace.
</div>
{:else}
<div class="border rounded-md bg-surface-tertiary">
{#each forkTriggers as trigger (trigger.triggerKind + ':' + trigger.path)}
<Row
kind="trigger"
triggerKind={trigger.triggerKind}
path={trigger.path}
href={getTriggerHref(trigger.triggerKind)}
marked={undefined}
isSelectable={false}
canFavorite={false}
workspaceId={currentWorkspaceId}
>
{#snippet customSummary()}
<span>{getTriggerDisplayName(trigger.triggerKind)}</span>
<span class="text-secondary mx-1">&rarr;</span>
<span class="text-secondary">{trigger.scriptPath}</span>
{#if trigger.isFlow}
<Badge color="blue" size="xs">flow</Badge>
{/if}
{#if trigger.extraLabel}
<span class="text-tertiary text-xs">({trigger.extraLabel})</span>
{/if}
{/snippet}
{#snippet actions()}
{#if trigger.enabled != null}
<Badge color={trigger.enabled ? 'green' : 'gray'} size="xs">
{trigger.enabled ? 'Enabled' : 'Disabled'}
</Badge>
{/if}
<Button
size="xs"
variant="subtle"
onclick={() => {
deploymentDrawer?.openDrawer(trigger.path, 'trigger', {
triggers: { kind: trigger.triggerKind }
})
}}
>
<Upload size={12} />
Deploy
</Button>
<Button
size="xs"
variant="subtle"
color="red"
onclick={() => deleteTrigger(trigger)}
>
<Trash2 size={12} />
</Button>
{/snippet}
</Row>
{/each}
</div>
{/if}
<DeploymentRequestPanel
bind:this={deploymentRequestPanel}
forkWorkspaceId={currentWorkspaceId}
{parentWorkspaceId}
currentUsername={$userStore?.username ?? ''}
isAdmin={$userStore?.is_admin ?? false}
onStateChange={(open) => {
hasOpenDeploymentRequest = open
}}
/>
</div>
<div class="bg-surface-tertiary p-4 rounded-md border">
@@ -1238,8 +1305,19 @@
</div>
</div>
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
<DiffDrawer bind:this={diffDrawer} {isFlow} />
<ScheduleEditor bind:this={scheduleEditor} />
<RouteEditor bind:this={routeEditor} />
<WebsocketTriggerEditor bind:this={websocketEditor} />
<KafkaTriggerEditor bind:this={kafkaEditor} />
<PostgresTriggerEditor bind:this={postgresEditor} />
<NatsTriggerEditor bind:this={natsEditor} />
<MqttTriggerEditor bind:this={mqttEditor} />
<SqsTriggerEditor bind:this={sqsEditor} />
<GcpTriggerEditor bind:this={gcpEditor} />
<EmailTriggerEditor bind:this={emailEditor} />
<ConfirmationModal
title="Delete trigger"
confirmationText="Delete"
@@ -0,0 +1,356 @@
<script lang="ts">
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<DeploymentRequest | null>(null)
let loading = $state(true)
let eligibleDeployers = $state<DeploymentRequestEligibleDeployer[]>([])
let showRequestDialog = $state(false)
let selectedAssignees = $state<string[]>([])
let assigneeToAdd = $state<string | undefined>(undefined)
let newComment = $state('')
let posting = $state(false)
let replyParentId = $state<number | undefined>(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
}
</script>
{#if hasContent}
<div class="flex flex-col gap-2 mt-3 pt-3 border-t">
{#if loading}
<div class="text-secondary text-xs">Loading deployment request…</div>
{:else if !request}
<!-- showRequestDialog == true -->
<div class="flex flex-col gap-2 border rounded-md p-3 bg-surface">
<div class="text-xs text-secondary">
Pick one or more assignees. They must be admins or members of <code>wm_deployers</code>
in <b>{parentWorkspaceId}</b>.
</div>
<Select
bind:value={assigneeToAdd}
items={safeSelectItems(
eligibleDeployers
.filter((d) => !selectedAssignees.includes(d.username))
.map((d) => ({
label: `${d.username} <${d.email}>`,
value: d.username
}))
)}
placeholder="Add assignee…"
/>
{#if selectedAssignees.length > 0}
<div class="flex flex-wrap gap-1">
{#each selectedAssignees as a (a)}
<Badge color="blue">
{a}
<button
type="button"
class="ml-1 hover:text-red-600"
onclick={() => {
selectedAssignees = selectedAssignees.filter((s) => s !== a)
}}
>
<X size={12} />
</button>
</Badge>
{/each}
</div>
{/if}
<div class="flex gap-2">
<Button
variant="accent"
size="xs"
startIcon={{ icon: Send }}
disabled={posting || selectedAssignees.length === 0}
onclick={createRequest}
>
Send request
</Button>
<Button
variant="default"
size="xs"
onclick={() => {
showRequestDialog = false
selectedAssignees = []
}}
>
Cancel
</Button>
</div>
</div>
{:else}
<div class="flex items-center justify-between flex-wrap gap-2 text-xs">
<div class="flex items-center gap-2 flex-wrap">
<Badge color="green" size="xs">Deployment request open</Badge>
<span class="text-secondary">by</span>
<b>{request.requested_by}</b>
<span class="text-secondary">at {formatTimestamp(request.requested_at)}</span>
</div>
{#if canCancel}
<Button
variant="default"
size="xs"
startIcon={{ icon: ArchiveX }}
onclick={cancelRequest}
>
Cancel request
</Button>
{/if}
</div>
<div class="flex items-center gap-1 flex-wrap text-xs">
<span class="text-secondary">Assignees:</span>
{#each request.assignees as a (a.username)}
<Badge color="indigo" size="xs">{a.username}</Badge>
{/each}
</div>
<div class="border rounded-md bg-surface p-3 flex flex-col gap-3">
{#if topLevel.length === 0}
<div class="text-secondary text-xs">No comments yet. Be the first to leave feedback.</div>
{/if}
{#each topLevel as c (c.id)}
<div class="border rounded-md p-2 flex flex-col gap-1" class:opacity-60={c.obsolete}>
<div class="flex items-center justify-between gap-2 text-xs">
<div class="flex items-center gap-2">
<b>{c.author}</b>
{#if c.anchor_kind && c.anchor_path}
<Badge color="blue" size="xs">
{c.anchor_kind}:{c.anchor_path}
</Badge>
{/if}
{#if c.obsolete}
<Badge color="gray" size="xs">obsolete</Badge>
{/if}
</div>
<span class="text-tertiary text-2xs">{formatTimestamp(c.created_at)}</span>
</div>
<div class="text-sm whitespace-pre-wrap">{c.body}</div>
{#each repliesOf(c) as r (r.id)}
<div
class="ml-4 border-l pl-2 flex flex-col gap-1"
class:opacity-60={r.obsolete || c.obsolete}
>
<div class="flex items-center justify-between gap-2 text-xs">
<b>{r.author}</b>
<span class="text-tertiary text-2xs">{formatTimestamp(r.created_at)}</span>
</div>
<div class="text-sm whitespace-pre-wrap">{r.body}</div>
</div>
{/each}
{#if replyParentId === c.id}
<div class="ml-4 flex flex-col gap-1 mt-1">
<TextInput bind:value={replyBody} inputProps={{ placeholder: 'Write a reply…' }} />
<div class="flex gap-2">
<Button
size="xs"
variant="accent"
disabled={posting || !replyBody.trim()}
onclick={() => postComment(replyBody, c.id)}
>
Reply
</Button>
<Button
size="xs"
variant="default"
onclick={() => {
replyParentId = undefined
replyBody = ''
}}
>
Cancel
</Button>
</div>
</div>
{:else}
<button
class="text-xs text-tertiary hover:text-secondary mt-1 self-start"
onclick={() => {
replyParentId = c.id
replyBody = ''
}}
>
Reply
</button>
{/if}
</div>
{/each}
<div class="flex flex-col gap-2 border-t pt-2">
<TextInput
bind:value={newComment}
inputProps={{ placeholder: 'Leave a general comment…' }}
/>
<div class="self-end">
<Button
size="xs"
variant="accent"
startIcon={{ icon: Plus }}
disabled={posting || !newComment.trim()}
onclick={() => postComment(newComment)}
>
Comment
</Button>
</div>
</div>
</div>
{/if}
</div>
{/if}
@@ -36,6 +36,7 @@
let name = $state(untrack(() => rule)?.name ?? '')
let disableDirectDeployment = $state(hasRule('DisableDirectDeployment'))
let disableFork = $state(hasRule('DisableWorkspaceForking'))
let restrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers'))
let selectedGroups = $state<string[]>(
untrack(() => rule)?.bypass_groups?.map((g) => g.replace('g/', '')) ?? []
)
@@ -47,6 +48,7 @@
let initialName = $state(untrack(() => rule)?.name ?? '')
let initialDisableDirectDeployment = $state(hasRule('DisableDirectDeployment'))
let initialDisableFork = $state(hasRule('DisableWorkspaceForking'))
let initialRestrictDeployToDeployers = $state(hasRule('RestrictDeployToDeployers'))
let initialSelectedGroups = $state<string[]>(
untrack(() => rule)?.bypass_groups
? untrack(() => rule)!.bypass_groups.map((g) => g.replace('g/', ''))
@@ -112,11 +114,13 @@
? name.trim() !== '' ||
disableDirectDeployment ||
disableFork ||
restrictDeployToDeployers ||
selectedGroups.length > 0 ||
selectedUsers.length > 0
: name !== initialName ||
disableDirectDeployment !== initialDisableDirectDeployment ||
disableFork !== initialDisableFork ||
restrictDeployToDeployers !== initialRestrictDeployToDeployers ||
JSON.stringify([...selectedGroups].sort()) !==
JSON.stringify([...initialSelectedGroups].sort()) ||
JSON.stringify([...selectedUsers].sort()) !==
@@ -153,7 +157,10 @@
name,
rules: [
...(disableDirectDeployment ? ['DisableDirectDeployment' as ProtectionRuleKind] : []),
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : [])
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []),
...(restrictDeployToDeployers
? ['RestrictDeployToDeployers' as ProtectionRuleKind]
: [])
],
bypass_groups: selectedGroups,
bypass_users: selectedUsers
@@ -178,7 +185,10 @@
requestBody: {
rules: [
...(disableDirectDeployment ? ['DisableDirectDeployment' as ProtectionRuleKind] : []),
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : [])
...(disableFork ? ['DisableWorkspaceForking' as ProtectionRuleKind] : []),
...(restrictDeployToDeployers
? ['RestrictDeployToDeployers' as ProtectionRuleKind]
: [])
],
bypass_groups: selectedGroups,
bypass_users: selectedUsers
@@ -191,6 +201,7 @@
initialName = name
initialDisableDirectDeployment = disableDirectDeployment
initialDisableFork = disableFork
initialRestrictDeployToDeployers = restrictDeployToDeployers
initialSelectedGroups = clone(selectedGroups)
initialSelectedUsers = clone(selectedUsers)
@@ -310,6 +321,20 @@
/>
<div class="text-xs text-secondary ml-6">Users cannot create forks of this workspace.</div>
</div>
<!-- Restrict deploy to deployers -->
<div class="flex flex-col gap-2">
<Toggle
bind:checked={restrictDeployToDeployers}
options={{
right: 'Restrict deployment to wm_deployers'
}}
/>
<div class="text-xs text-secondary ml-6">
Only workspace admins and members of <code>wm_deployers</code> can deploy to this workspace.
Non-deployers can still fork, browse, and request a review.
</div>
</div>
</div>
</Section>