diff --git a/backend/.sqlx/query-01b0ebb2d85a8e59b04dd42275d14e04b4a13774d442a8edb6c9cc642140cf45.json b/backend/.sqlx/query-01b0ebb2d85a8e59b04dd42275d14e04b4a13774d442a8edb6c9cc642140cf45.json new file mode 100644 index 0000000000..836a4477f9 --- /dev/null +++ b/backend/.sqlx/query-01b0ebb2d85a8e59b04dd42275d14e04b4a13774d442a8edb6c9cc642140cf45.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COALESCE((datatable->'datatables'->$2->'permissions'->>'enabled')::boolean, false)\n FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "coalesce", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "01b0ebb2d85a8e59b04dd42275d14e04b4a13774d442a8edb6c9cc642140cf45" +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 459bf39aa0..c597f5ba63 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -12,10 +12,10 @@ use windmill_api_auth::{ }; use windmill_api_users::users::WorkspaceInvite; use windmill_common::email_oss::send_email_if_possible; -use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace; use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME}; use windmill_common::webhook::WebhookShared; use windmill_common::{BASE_URL, DB}; +use windmill_dep_map::lock_hash::record_lock_hashes_for_workspace; use axum::{ extract::{Extension, Path, Query}, @@ -3094,6 +3094,42 @@ pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Re } /// Same, for the `datatable://` / `$res:` form the import endpoints take. +/// Refuse to clone a data table whose role permissions are enabled. +/// +/// A clone lands in a brand-new database where none of the roles exist, and the +/// fork's copy of the config is stripped of its permissions — so every member of +/// the fork resolves to the copy's own owner connection and reads, in full, the +/// data the roles existed to divide. Reproducing the roles in the copy is a +/// separate piece of work; until it exists, a fork shares the original, which +/// keeps the parent's restrictions, or goes without. +pub(crate) async fn refuse_clone_of_permissioned_datatable( + db: &DB, + w_id: &str, + source: &str, +) -> Result<()> { + let Some(name) = source.strip_prefix("datatable://") else { + return Ok(()); + }; + let enabled = sqlx::query_scalar!( + "SELECT COALESCE((datatable->'datatables'->$2->'permissions'->>'enabled')::boolean, false) + FROM workspace_settings WHERE workspace_id = $1", + w_id, + name, + ) + .fetch_optional(db) + .await? + .flatten() + .unwrap_or(false); + if enabled { + return Err(Error::BadRequest(format!( + "Data table '{name}' has role permissions enabled and cannot be cloned into a fork: \ + the copy cannot carry its roles, so it would be readable in full by every member of \ + the fork. Keep the original instead — the fork shares it with the same restrictions." + ))); + } + Ok(()) +} + async fn is_instance_datatable_source(db: &DB, w_id: &str, source: &str) -> Result { match source.strip_prefix("datatable://") { Some(name) => is_instance_datatable(db, w_id, name).await, @@ -3420,6 +3456,7 @@ async fn create_pg_database( Json(req): Json, ) -> Result { windmill_common::validate_dbname(&req.target_dbname)?; + refuse_clone_of_permissioned_datatable(&db, &w_id, &req.source).await?; // Non-superadmin: restrict dbname to wm_fork_ prefix if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { @@ -3511,6 +3548,13 @@ async fn import_pg_database( } } + // Only the fork clone flow overrides the target database name; a plain + // database-to-database import is an admin moving data between databases they + // already reach, and lands nowhere that strips permissions. + if req.target_dbname_override.is_some() { + refuse_clone_of_permissioned_datatable(&db, &w_id, &req.source).await?; + } + let schema_only = req.fork_behavior == DataTableForkBehavior::SchemaOnly; let source_pg = resolve_pg_source_for_copy(&db, &user_db, &authed, &w_id, &req.source).await?; let mut target_pg = @@ -7610,6 +7654,10 @@ async fn apply_forked_datatable( fdt: &ForkedDatatableInfo, ) -> Result<()> { windmill_common::validate_dbname(&fdt.new_dbname)?; + // The clone endpoints refuse this too; this is the one a caller cannot go + // around, since it is what wires the fork's config to the copied database. + refuse_clone_of_permissioned_datatable(db, parent_w_id, &format!("datatable://{}", fdt.name)) + .await?; if !fdt.new_dbname.starts_with("wm_fork_") { return Err(Error::BadRequest(format!( "Forked datatable database name '{}' must start with 'wm_fork_'", diff --git a/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte b/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte index 06a9123460..9abe4855f4 100644 --- a/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte +++ b/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte @@ -226,9 +226,18 @@ ? 'Not shared (permissions enabled)' : 'Keep original' }, - { value: 'schema_only', label: 'Clone schema only' }, - ...(!isCloudHosted() && $userStore?.is_admin - ? [{ value: 'schema_and_data', label: 'Clone schema and data' }] + // A clone cannot carry the data table's roles, and the fork's copy + // is stripped of its permissions — so the copy would be readable in + // full by every member of the fork. The backend refuses it; not + // offering it is what keeps the two in step. Where the check could + // not answer, the safe reading is "permissioned". + ...(dt.permissioned === false + ? [ + { value: 'schema_only', label: 'Clone schema only' }, + ...(!isCloudHosted() && $userStore?.is_admin + ? [{ value: 'schema_and_data', label: 'Clone schema and data' }] + : []) + ] : []) ]} />