From 0c54cdb75287189373b737f64762b8a843fdf72e Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Sun, 6 Sep 2026 02:16:34 +0200 Subject: [PATCH] fix(datatables): the clone stamp is server-owned, and a kept original never carries one --- ...9d6b5e01e21890ef53ccb600c2f3506d84a3.json} | 4 +- backend/ee-repo-ref.txt | 2 +- .../tests/datatable_tenants.rs | 85 +++++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 19 ++++- 4 files changed, 106 insertions(+), 4 deletions(-) rename backend/.sqlx/{query-7637d311391ab08865b10f69d048583f43c8157abf675358cfd84ff056925fc4.json => query-77841f6a74d13f92b095a5fdeca59d6b5e01e21890ef53ccb600c2f3506d84a3.json} (50%) diff --git a/backend/.sqlx/query-7637d311391ab08865b10f69d048583f43c8157abf675358cfd84ff056925fc4.json b/backend/.sqlx/query-77841f6a74d13f92b095a5fdeca59d6b5e01e21890ef53ccb600c2f3506d84a3.json similarity index 50% rename from backend/.sqlx/query-7637d311391ab08865b10f69d048583f43c8157abf675358cfd84ff056925fc4.json rename to backend/.sqlx/query-77841f6a74d13f92b095a5fdeca59d6b5e01e21890ef53ccb600c2f3506d84a3.json index f9cd9c9577..bc09acefed 100644 --- a/backend/.sqlx/query-7637d311391ab08865b10f69d048583f43c8157abf675358cfd84ff056925fc4.json +++ b/backend/.sqlx/query-77841f6a74d13f92b095a5fdeca59d6b5e01e21890ef53ccb600c2f3506d84a3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, '{datatables}', (\n SELECT COALESCE(jsonb_object_agg(\n key,\n CASE WHEN key = ANY($2) THEN value - 'permissions' ELSE value END\n ), '{}'::jsonb)\n FROM jsonb_each(datatable->'datatables')\n WHERE key = ANY($2)\n OR COALESCE((value->'permissions'->>'enabled')::boolean, false) = false\n ))\n WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'", + "query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, '{datatables}', (\n SELECT COALESCE(jsonb_object_agg(\n key,\n CASE WHEN key = ANY($2) THEN value - 'permissions' ELSE value - 'forked_from' END\n ), '{}'::jsonb)\n FROM jsonb_each(datatable->'datatables')\n WHERE key = ANY($2)\n OR COALESCE((value->'permissions'->>'enabled')::boolean, false) = false\n ))\n WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "7637d311391ab08865b10f69d048583f43c8157abf675358cfd84ff056925fc4" + "hash": "77841f6a74d13f92b095a5fdeca59d6b5e01e21890ef53ccb600c2f3506d84a3" } diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index e528d2e98b..6935b5a17d 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -7765fc05fdb287f0c258f51c7029b1abc3627b6c +f89f0cd16ec24f7beb146ba8373d688e1fe126e8 diff --git a/backend/windmill-api-integration-tests/tests/datatable_tenants.rs b/backend/windmill-api-integration-tests/tests/datatable_tenants.rs index d51709a61c..71b63b67cc 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_tenants.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_tenants.rs @@ -218,6 +218,8 @@ async fn enabling_permissions_is_refused_while_a_fork_exists( ) .execute(&db) .await?; + // Past the refusal the preview fails on the resource, which this test does + // not have; the refusal is what is pinned. let resp = authed(client().post(&byo), "SECRET_TOKEN") .json(&body) .send() @@ -404,3 +406,86 @@ async fn a_rename_leaves_no_datatable_under_the_old_id(db: Pool) -> an Ok(()) } + +/// A fork that keeps the original copies the parent's entry verbatim, `forked_from` +/// included when the parent's own entry is a clone (a detached dev workspace keeps +/// its clones). The stamp means "cloned into this workspace's own database", and +/// the opt-in trusts it, so a copy must not carry one. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn a_kept_original_does_not_inherit_the_clone_stamp( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + sqlx::query( + r#"UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = 'test-workspace'"#, + ) + .bind(json!({ + "datatables": { + "byo": { + "database": { "resource_type": "postgresql", "resource_path": "u/test-user/pg" }, + "forked_from": { "schema": {} } + } + } + })) + .execute(&db) + .await?; + + let resp = authed( + client().post(format!("{ws}/workspaces/create_fork")), + "SECRET_TOKEN", + ) + .json(&json!({ "id": "wm-fork-kept", "name": "kept" })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let copy: serde_json::Value = sqlx::query_scalar( + "SELECT datatable->'datatables'->'byo' FROM workspace_settings WHERE workspace_id = 'wm-fork-kept'", + ) + .fetch_one(&db) + .await?; + assert_eq!(copy["database"]["resource_path"], json!("u/test-user/pg")); + assert!(copy.get("forked_from").is_none(), "{copy}"); + + let resp = authed( + client().post(format!("{ws}/workspaces/datatable_permissions/byo/preview")), + "SECRET_TOKEN", + ) + .json(&json!({ "enabled": true, "roles": [] })) + .send() + .await?; + assert_eq!(resp.status(), 400); + let text = resp.text().await?; + assert!(text.contains("wm-fork-kept (data table 'byo')"), "{text}"); + + // The form cannot stamp the copy either. + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/wm-fork-kept/workspaces/edit_datatable_config" + )), + "SECRET_TOKEN", + ) + .json(&json!({ + "settings": { "datatables": { + "byo": { + "database": { "resource_type": "postgresql", "resource_path": "u/test-user/pg" }, + "forked_from": { "schema": {} } + } + }} + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let copy: serde_json::Value = sqlx::query_scalar( + "SELECT datatable->'datatables'->'byo' FROM workspace_settings WHERE workspace_id = 'wm-fork-kept'", + ) + .fetch_one(&db) + .await?; + assert!(copy.get("forked_from").is_none(), "{copy}"); + + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 0d86278224..86f1221cbf 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3863,6 +3863,19 @@ async fn edit_datatable_config( // Same for permissions, owned by the datatable_permissions endpoints. let old = old_datatables.get(lookup); dt.permissions = old.and_then(|old| old.permissions.clone()); + // `forked_from` is stamped by the fork clone and read by the permissions + // opt-in as "this entry has a database of its own": the form may update the + // schema snapshot inside it, never add or remove the stamp. + dt.forked_from = match ( + old.and_then(|old| old.forked_from.as_ref()), + dt.forked_from.take(), + ) { + (None, _) => None, + (Some(_), Some(new)) => Some(new), + (Some(old), None) => Some(windmill_common::workspaces::DataTableForkedFrom { + schema: old.schema.clone(), + }), + }; // The roles live in the database this data table points at: their logins // were created there and every grant they hold is recorded there. Carried // onto another database they authenticate against a cluster that never @@ -8371,6 +8384,10 @@ async fn create_workspace_fork( // would never reach the copy, and the fork would keep running as the role it named. So // a permissioned data table is not shared into a fork at all; the fork can fork it, or // go without it. + // + // A copy that was not forked here also loses any `forked_from` it inherited: the stamp + // means "cloned into this workspace's own database", which is what the permissions + // opt-in reads it as, and a copy of the parent's clone points where the parent points. let forked_datatable_names: Vec = nw .forked_datatables .iter() @@ -8381,7 +8398,7 @@ async fn create_workspace_fork( SET datatable = jsonb_set(datatable, '{datatables}', ( SELECT COALESCE(jsonb_object_agg( key, - CASE WHEN key = ANY($2) THEN value - 'permissions' ELSE value END + CASE WHEN key = ANY($2) THEN value - 'permissions' ELSE value - 'forked_from' END ), '{}'::jsonb) FROM jsonb_each(datatable->'datatables') WHERE key = ANY($2)