mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-25 16:03:47 +00:00
feat(backend): scope drafts to the owning user's email
Add a nullable `email` column to the `draft` table and replace the
(workspace_id, path, typ) primary key with a unique index over
(workspace_id, path, typ, COALESCE(email, '')) so each user gets at most
one draft per path while the legacy/NULL bucket stays unique too. The
column is nullable so pre-migration drafts are preserved.
All draft reads/writes are now user-scoped:
- create_draft stamps email = authed.email and upserts per-user
- delete_draft and the deploy-time draft cleanup only touch the
caller's own draft
- get_{script,flow,app}_by_path_with_draft and the list endpoints only
surface the caller's own draft (draft.email = authed.email)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
89a7a37776
commit
a672f125bd
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND email = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3e3f58826066ba9082353f68067ea771407f5e535313ed9afcce260b3678aed5"
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO draft\n (workspace_id, path, value, typ, email)\n VALUES ($1, $2, $3::text::json, $4, $5)\n ON CONFLICT (workspace_id, path, typ, COALESCE(email, ''))\n DO UPDATE SET value = EXCLUDED.value, created_at = now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4b674c99ee913808afef889658690c699f1cdb6aff9f04fba65f8b2c324fe25d"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app' AND email = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c73d312c68396ffbb736dc8cb0a3610d6954fb29408011b280668416853cf3ed"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3 AND email = $4",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "draft_type",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"script",
|
||||
"flow",
|
||||
"app"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f67ccafc38046db751897dfdef653c510cff7238dc5015cf9c2e5c654f18c906"
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND email = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "fc7e4714d3ba0089982a83d65c8f713fe057434f1a775e39d906ef28c7fafdd9"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
DROP INDEX IF EXISTS draft_workspace_id_path_typ_email_key;
|
||||
|
||||
-- Restoring the old (workspace_id, path, typ) primary key requires uniqueness
|
||||
-- on those three columns. Per-user drafts created while this migration was
|
||||
-- applied may have introduced duplicates, so collapse them first (keep an
|
||||
-- arbitrary surviving row per key) before re-adding the constraint.
|
||||
DELETE FROM draft a
|
||||
USING draft b
|
||||
WHERE a.ctid < b.ctid
|
||||
AND a.workspace_id = b.workspace_id
|
||||
AND a.path = b.path
|
||||
AND a.typ = b.typ;
|
||||
|
||||
ALTER TABLE draft ADD PRIMARY KEY (workspace_id, path, typ);
|
||||
ALTER TABLE draft DROP COLUMN email;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Drafts become user-scoped: each draft now records the email of the user that
|
||||
-- owns it. The column is nullable so that legacy drafts (and rows transferred
|
||||
-- out of `draft_only` in a later migration) are preserved with a NULL owner
|
||||
-- rather than being lost.
|
||||
ALTER TABLE draft ADD COLUMN email VARCHAR(255);
|
||||
|
||||
-- The old primary key (workspace_id, path, typ) allowed a single draft per
|
||||
-- path. Now that drafts are per-user we need at most one draft per
|
||||
-- (workspace_id, path, typ, owner). We use COALESCE(email, '') so the NULL
|
||||
-- (legacy) bucket collapses to a single key as well — this keeps the
|
||||
-- constraint working on every supported Postgres version (unlike
|
||||
-- `UNIQUE NULLS NOT DISTINCT`, which requires PG15+).
|
||||
ALTER TABLE draft DROP CONSTRAINT draft_pkey;
|
||||
CREATE UNIQUE INDEX draft_workspace_id_path_typ_email_key
|
||||
ON draft (workspace_id, path, typ, COALESCE(email, ''));
|
||||
@@ -162,7 +162,8 @@ async fn list_flows(
|
||||
.left()
|
||||
.join("draft")
|
||||
.on(
|
||||
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow'"
|
||||
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow' AND draft.email = ?"
|
||||
.bind(&authed.email)
|
||||
)
|
||||
.left()
|
||||
.join("flow_version fv")
|
||||
@@ -562,9 +563,10 @@ async fn create_flow(
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !nf.skip_draft_deletion.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND email = $3",
|
||||
nf.path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1165,9 +1167,10 @@ async fn update_flow(
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !nf.skip_draft_deletion.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' AND email = $3",
|
||||
flow_path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1537,6 +1540,7 @@ async fn get_flow_by_path_w_draft(
|
||||
ON flow.path = draft.path
|
||||
AND draft.workspace_id = $2
|
||||
AND draft.typ = 'flow'
|
||||
AND draft.email = $3
|
||||
LEFT JOIN flow_version
|
||||
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
|
||||
WHERE flow.path = $1
|
||||
@@ -1544,6 +1548,7 @@ async fn get_flow_by_path_w_draft(
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.bind(&authed.email)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -331,7 +331,8 @@ async fn list_scripts(
|
||||
.left()
|
||||
.join("draft")
|
||||
.on(
|
||||
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script'"
|
||||
"draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'script' AND draft.email = ?"
|
||||
.bind(&authed.email)
|
||||
)
|
||||
.order_desc("favorite.path IS NOT NULL")
|
||||
.order_by("created_at", lq.order_desc.unwrap_or(true))
|
||||
@@ -1365,9 +1366,10 @@ async fn create_script_internal<'c>(
|
||||
if let Some(ref p_path) = p_path_opt {
|
||||
if !skip_draft_deletion {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND email = $3",
|
||||
p_path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1452,9 +1454,10 @@ async fn create_script_internal<'c>(
|
||||
}
|
||||
} else if !skip_draft_deletion {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' AND email = $3",
|
||||
ns.path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1834,12 +1837,13 @@ async fn get_script_by_path_w_draft(
|
||||
|
||||
let script_o = sqlx::query_as::<_, ScriptWDraft<ScriptRunnableSettingsHandle>>(
|
||||
"SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, draft.created_at as draft_created_at, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, delete_after_secs, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s, labels FROM script LEFT JOIN draft ON
|
||||
script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script'
|
||||
script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' AND draft.email = $3
|
||||
WHERE script.path = $1 AND script.workspace_id = $2
|
||||
ORDER BY script.created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(w_id)
|
||||
.bind(&w_id)
|
||||
.bind(&authed.email)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
@@ -400,7 +400,8 @@ async fn list_apps(
|
||||
.left()
|
||||
.join("draft")
|
||||
.on(
|
||||
"draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app'"
|
||||
"draft.path = app.path AND draft.workspace_id = app.workspace_id AND draft.typ = 'app' AND draft.email = ?"
|
||||
.bind(&authed.email)
|
||||
)
|
||||
.order_desc("favorite.path IS NOT NULL")
|
||||
.order_by("app_version.created_at", true)
|
||||
@@ -678,12 +679,14 @@ async fn get_app_w_draft(
|
||||
ON app.path = draft.path
|
||||
AND draft.workspace_id = $2
|
||||
AND draft.typ = 'app'
|
||||
AND draft.email = $3
|
||||
WHERE app.path = $1
|
||||
AND app.workspace_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(path.to_owned())
|
||||
.bind(&w_id)
|
||||
.bind(&authed.email)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -1352,9 +1355,10 @@ async fn create_app_internal<'a>(
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !app.skip_draft_deletion.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app' AND email = $3",
|
||||
&app.path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1961,9 +1965,10 @@ async fn update_app_internal<'a>(
|
||||
// path instead of wiping it as part of the deploy.
|
||||
if !ns.skip_draft_deletion.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app' AND email = $3",
|
||||
path,
|
||||
&w_id
|
||||
&w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -77,15 +77,16 @@ async fn create_draft(
|
||||
|
||||
sqlx::query!(
|
||||
"INSERT INTO draft
|
||||
(workspace_id, path, value, typ)
|
||||
VALUES ($1, $2, $3::text::json, $4)
|
||||
ON CONFLICT (workspace_id, path, typ)
|
||||
(workspace_id, path, value, typ, email)
|
||||
VALUES ($1, $2, $3::text::json, $4, $5)
|
||||
ON CONFLICT (workspace_id, path, typ, COALESCE(email, ''))
|
||||
DO UPDATE SET value = EXCLUDED.value, created_at = now()",
|
||||
&w_id,
|
||||
draft.path,
|
||||
//to preserve key orders
|
||||
serde_json::to_string(&draft.value).unwrap(),
|
||||
draft.typ as DraftType,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -103,10 +104,11 @@ async fn delete_draft(
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3",
|
||||
"DELETE FROM draft WHERE path = $1 AND typ = $2 AND workspace_id = $3 AND email = $4",
|
||||
path.to_path(),
|
||||
kind as DraftType,
|
||||
w_id
|
||||
w_id,
|
||||
&authed.email,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
Reference in New Issue
Block a user