fix(datatables): the clone stamp is server-owned, and a kept original never carries one

This commit is contained in:
Diego Imbert
2026-09-06 02:16:34 +02:00
parent 01567c2d30
commit 0c54cdb752
4 changed files with 106 additions and 4 deletions
@@ -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"
}
+1 -1
View File
@@ -1 +1 @@
7765fc05fdb287f0c258f51c7029b1abc3627b6c
f89f0cd16ec24f7beb146ba8373d688e1fe126e8
@@ -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<Postgres>) -> 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<Postgres>,
) -> 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(())
}
@@ -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<String> = 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)