diff --git a/backend/.sqlx/query-aab1b338605f4fb9dd0d19e78d32849ccc783d295de38508d20ec30f99f518ca.json b/backend/.sqlx/query-34a6b159360d652fac09662cedab4a490be9db7e331874cbfa7b1b93e50af586.json similarity index 78% rename from backend/.sqlx/query-aab1b338605f4fb9dd0d19e78d32849ccc783d295de38508d20ec30f99f518ca.json rename to backend/.sqlx/query-34a6b159360d652fac09662cedab4a490be9db7e331874cbfa7b1b93e50af586.json index 4e9fb9bd7f..a2e8106c05 100644 --- a/backend/.sqlx/query-aab1b338605f4fb9dd0d19e78d32849ccc783d295de38508d20ec30f99f518ca.json +++ b/backend/.sqlx/query-34a6b159360d652fac09662cedab4a490be9db7e331874cbfa7b1b93e50af586.json @@ -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" } diff --git a/backend/windmill-api-integration-tests/tests/datatable_tenants.rs b/backend/windmill-api-integration-tests/tests/datatable_tenants.rs index 106f402aaf..d51709a61c 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_tenants.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_tenants.rs @@ -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'") diff --git a/backend/windmill-api-workspaces/src/datatable_permissions.rs b/backend/windmill-api-workspaces/src/datatable_permissions.rs index caa3a5edbd..8e1fe431d9 100644 --- a/backend/windmill-api-workspaces/src/datatable_permissions.rs +++ b/backend/windmill-api-workspaces/src/datatable_permissions.rs @@ -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, diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index b417f8cc3f..0d86278224 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -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, diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 4a41eaf29e..64108568fe 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -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 { if w_id.starts_with(WM_FORK_PREFIX) { return Ok(true);