fix(datatables): a resource-backed clone is not a copy, and the two helpers state their contract

This commit is contained in:
Diego Imbert
2026-09-06 01:28:56 +02:00
parent 73628dea4b
commit 01567c2d30
5 changed files with 43 additions and 6 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"name!\", w.deleted AS \"deleted!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id,\n jsonb_each(ws.datatable->'datatables') dt\n WHERE ws.workspace_id = ANY($1)\n AND dt.value->'database' = $2\n ORDER BY ws.workspace_id, dt.key",
"query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"name!\", w.deleted AS \"deleted!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id,\n jsonb_each(ws.datatable->'datatables') dt\n WHERE ws.workspace_id = ANY($1)\n AND dt.value->'database' = $2\n AND dt.value->'forked_from' IS NULL\n ORDER BY ws.workspace_id, dt.key",
"describe": {
"columns": [
{
@@ -31,5 +31,5 @@
false
]
},
"hash": "aab1b338605f4fb9dd0d19e78d32849ccc783d295de38508d20ec30f99f518ca"
"hash": "34a6b159360d652fac09662cedab4a490be9db7e331874cbfa7b1b93e50af586"
}
@@ -125,7 +125,8 @@ async fn enabling_permissions_is_refused_while_a_fork_exists(
)
.bind(json!({
"datatables": {
"main": { "database": { "resource_type": "instance", "resource_path": "dt_main" } }
"main": { "database": { "resource_type": "instance", "resource_path": "dt_main" } },
"byo": { "database": { "resource_type": "postgresql", "resource_path": "u/test-user/pg" } }
}
}))
.execute(&db)
@@ -141,7 +142,8 @@ async fn enabling_permissions_is_refused_while_a_fork_exists(
)
.bind(json!({
"datatables": {
"main": { "database": { "resource_type": "instance", "resource_path": "dt_main" } }
"main": { "database": { "resource_type": "instance", "resource_path": "dt_main" } },
"byo": { "database": { "resource_type": "postgresql", "resource_path": "u/test-user/pg" } }
}
}))
.execute(&db)
@@ -199,6 +201,30 @@ async fn enabling_permissions_is_refused_while_a_fork_exists(
.execute(&db)
.await?;
// A resource-backed copy keeps the parent's pointer when cloned — the cloned
// resource is what changes — so `forked_from` is what tells the two apart.
let byo = format!("{ws}/workspaces/datatable_permissions/byo/preview");
let resp = authed(client().post(&byo), "SECRET_TOKEN")
.json(&body)
.send()
.await?;
assert_eq!(resp.status(), 400);
let text = resp.text().await?;
assert!(text.contains("wm-fork-t (data table 'byo')"), "{text}");
sqlx::query(
r#"UPDATE workspace_settings
SET datatable = jsonb_set(datatable, '{datatables,byo,forked_from}', '{"schema": {}}')
WHERE workspace_id = 'wm-fork-t'"#,
)
.execute(&db)
.await?;
let resp = authed(client().post(&byo), "SECRET_TOKEN")
.json(&body)
.send()
.await?;
let text = resp.text().await?;
assert!(!text.contains("cannot be enabled"), "{text}");
// Archiving keeps the fork's members and its copy, so it still counts; a fork
// whose copy is a clone of its own does not.
sqlx::query("UPDATE workspace SET deleted = true WHERE id = 'wm-fork-t'")
@@ -788,7 +788,10 @@ async fn refuse_enabling_permissions_over_shared_access(
};
let forks = windmill_common::workspaces::list_fork_descendants(db, w_id).await?;
if !forks.is_empty() {
// A clone points at a database of its own and does not count.
// A clone (`forked_from`) points at a database of its own and does not
// count. It is not told apart by the pointer alone: cloning a
// resource-backed data table rewrites the cloned resource, not the path
// the entry names, so the pointer still equals the parent's.
let copies = sqlx::query!(
r#"SELECT ws.workspace_id AS "workspace_id!", dt.key AS "name!", w.deleted AS "deleted!"
FROM workspace_settings ws
@@ -796,6 +799,7 @@ async fn refuse_enabling_permissions_over_shared_access(
jsonb_each(ws.datatable->'datatables') dt
WHERE ws.workspace_id = ANY($1)
AND dt.value->'database' = $2
AND dt.value->'forked_from' IS NULL
ORDER BY ws.workspace_id, dt.key"#,
&forks[..],
database,
@@ -3146,6 +3146,10 @@ pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Re
/// data the roles existed to divide. Reproducing the roles in the copy is a
/// separate piece of work; until it exists, a fork goes without the data table
/// (the fork creation leaves a permissioned one out of the fork's config).
///
/// Authorization: performs none. It reads whether `w_id`'s data table is
/// permissioned, for any `w_id` it is handed, so callers MUST already have
/// authorized the caller for that workspace.
pub(crate) async fn refuse_clone_of_permissioned_datatable(
db: &DB,
w_id: &str,
@@ -2166,7 +2166,10 @@ async fn is_workspace_owner(
/// `parent_workspace_id`, but a `wm-fork-` workspace can outlive its parent (the FK is
/// `ON DELETE SET NULL`), so also treat the prefix as fork-ness — otherwise an orphaned fork would
/// lose owner-self-delete. Used to gate owner-self-delete, which is permitted for forks/dev
/// workspaces but requires superadmin otherwise.
/// workspaces but requires superadmin otherwise, and to refuse a data table opt-in from a fork.
///
/// Authorization: performs none. It reads the lineage of any `w_id` it is handed, so callers
/// MUST already have authorized the caller for that workspace.
pub(crate) async fn workspace_is_fork(db: &DB, w_id: &str) -> Result<bool> {
if w_id.starts_with(WM_FORK_PREFIX) {
return Ok(true);