From 7ae3335f035d48ccf1c5bd940baf7a85f8905adc Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 15 Sep 2026 12:47:46 +0200 Subject: [PATCH] feat(datatables): move the data table permissions endpoints to the enterprise edition The permissions read, save and usable-roles handlers move to windmill-ee-private; the routes stay registered and, without the enterprise edition, answer that data table roles are an Enterprise Edition feature. ensure_governs_datatable and ensure_reaches_datatable keep their paths: the first refuses, the second passes a data table not under roles. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb --- .../src/datatable_permissions.rs | 537 +----------------- .../src/datatable_permissions_oss.rs | 73 +++ backend/windmill-api-workspaces/src/lib.rs | 4 + 3 files changed, 90 insertions(+), 524 deletions(-) create mode 100644 backend/windmill-api-workspaces/src/datatable_permissions_oss.rs diff --git a/backend/windmill-api-workspaces/src/datatable_permissions.rs b/backend/windmill-api-workspaces/src/datatable_permissions.rs index f774448d64..5cb1f3c1c3 100644 --- a/backend/windmill-api-workspaces/src/datatable_permissions.rs +++ b/backend/windmill-api-workspaces/src/datatable_permissions.rs @@ -14,125 +14,40 @@ //! a save is tenant lists and a default, and the Postgres roles themselves are the instance //! catalog's business. -use std::collections::BTreeMap; +use axum::{routing::get, Router}; -use axum::{ - extract::{Extension, Path}, - routing::get, - Json, Router, -}; -use serde::{Deserialize, Serialize}; - -use windmill_api_auth::{require_super_admin, ApiAuthed}; -use windmill_audit::audit_oss::audit_log; -use windmill_audit::ActionKind; -use windmill_common::datatable_roles::{read_role_catalog, ADMIN_DATATABLE_ROLE}; -use windmill_common::error::{Error, JsonResult, Result}; -use windmill_common::workspaces::{ - can_use_datatable_role_in_governing_workspace, resolve_governing_datatable, - DataTableCatalogResourceType, DataTablePermissions, DataTableRoleTenants, DatatableAccess, - GoverningDatatable, DATATABLE_TENANT_WILDCARD, -}; +use windmill_api_auth::ApiAuthed; +use windmill_common::error::Result; +use windmill_common::workspaces::GoverningDatatable; use windmill_common::DB; +use crate::datatable_permissions_oss as roles; + pub(crate) fn routes() -> Router { Router::new() .route( "/datatable_permissions/{datatable_name}", - get(get_datatable_permissions).post(set_datatable_permissions), + get(roles::get_datatable_permissions).post(roles::set_datatable_permissions), ) .route( "/datatable_usable_roles/{datatable_name}", - get(list_usable_datatable_roles), + get(roles::list_usable_datatable_roles), ) } -/// One row of the permissions drawer: an instance role (or the reserved `admin`) and who may -/// connect as it here. -#[derive(Serialize, Deserialize, Debug)] -pub struct DatatableRoleTenantsInfo { - /// The instance catalog id, or `admin`. - pub id: String, - /// The role's current name, for display. Absent when the catalog no longer has the id — a - /// role deleted out from under this data table, which the drawer shows so it can be removed. - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - pub tenants: Vec, -} - -#[derive(Serialize)] -struct DatatablePermissionsInfo { - /// Whether this data table can be put under roles at all — only one on the instance database - /// can, since a role is a login on that cluster. - supported: bool, - /// Whether the data table is under roles at all. - permissioned: bool, - default_role: String, - roles: Vec, - /// The workspace whose entry this is, when it is not the one asking. - #[serde(skip_serializing_if = "Option::is_none")] - governing_workspace_id: Option, - /// Whether this caller may save. False from a fork, and for a non-admin. - editable: bool, - /// Every instance role the instance defines, to pick from. - available_roles: Vec, - /// Other workspaces whose own entry reaches the same database without being governed by this - /// one — a legacy fork's copy, or a second entry a superadmin pointed here. They keep their own - /// access, so a save here does not reach them. - #[serde(skip_serializing_if = "Vec::is_empty")] - ungoverned_reachers: Vec, -} - -#[derive(Serialize)] -struct AvailableRole { - id: String, - name: String, - enabled: bool, -} - -#[derive(Serialize)] -struct UngovernedReacher { - workspace_id: String, - datatable: String, -} - -#[derive(Deserialize)] -pub struct SetDatatablePermissions { - /// False clears the block: the data table goes back to everyone connecting as `admin`. - pub permissioned: bool, - #[serde(default)] - pub default_role: Option, - #[serde(default)] - pub roles: Vec, -} - -#[derive(Serialize)] -struct UsableDatatableRoles { - permissioned: bool, - /// Names, not ids: this is what a caller writes in `-- role `. - roles: Vec, - default_role: String, -} - /// Administering a data table — its permissions, its migrations that declare no role, its exports /// — is for the admins of the workspace that governs it. A fork can use the data table; it never /// administers it. +// The gate for whatever administers a data table under roles, which the routes of this module alone +// do not always reach. +#[allow(dead_code)] pub(crate) async fn ensure_governs_datatable( db: &DB, authed: &ApiAuthed, w_id: &str, governing: &GoverningDatatable, ) -> Result<()> { - if governing.workspace_id == w_id && authed.is_admin { - return Ok(()); - } - if require_super_admin(db, authed).await.is_ok() { - return Ok(()); - } - Err(Error::NotAuthorized(format!( - "Data table '{}' is governed by workspace '{}'; this is for its admins.", - governing.name, governing.workspace_id - ))) + roles::ensure_governs_datatable(db, authed, w_id, governing).await } /// Refuse a caller that no tenant of this data table covers. @@ -148,431 +63,5 @@ pub(crate) async fn ensure_reaches_datatable( datatable_name: &str, authed: &ApiAuthed, ) -> Result<()> { - let governing = resolve_governing_datatable(db, w_id, datatable_name).await?; - let Some(permissions) = governing.datatable.permissions.as_ref() else { - return Ok(()); - }; - let catalog = read_role_catalog(db).await?; - let access = DatatableAccess::Authed(authed.to_authed_ref()); - for (id, tenants) in &permissions.roles { - // A role the instance no longer defines, or has disabled, cannot be connected as, so being - // tenanted into it is not reach. - if id != ADMIN_DATATABLE_ROLE && !catalog.get(id).is_some_and(|r| r.enabled) { - continue; - } - if can_use_datatable_role_in_governing_workspace( - db, - &governing.workspace_id, - w_id, - tenants, - &access, - ) - .await? - { - return Ok(()); - } - } - Err(Error::NotAuthorized(format!( - "Not allowed to use data table '{datatable_name}': no role of it covers you" - ))) -} - -fn validate_tenant(tenant: &str) -> Result<()> { - if tenant == DATATABLE_TENANT_WILDCARD { - return Ok(()); - } - match tenant.split_once('/') { - Some(("u" | "g" | "f", rest)) if !rest.is_empty() => Ok(()), - _ => Err(Error::BadRequest(format!( - "Invalid tenant '{tenant}': expected 'u/', 'g/', 'f/' or '*'" - ))), - } -} - -/// Entries in other workspaces that reach the same instance database without pointing at this one. -/// -/// They exist by construction and are allowed: a fork created before data table roles holds a -/// literal copy of its parent's entry, and a superadmin can point a second workspace at any -/// instance database. Each governs its own access, so a save here leaves them untouched — which is -/// exactly why they are worth naming at the moment someone turns roles on. -async fn ungoverned_reachers( - db: &DB, - governing: &GoverningDatatable, -) -> Result> { - let Some(database) = governing.datatable.database.as_ref() else { - return Ok(vec![]); - }; - if database.resource_type != DataTableCatalogResourceType::Instance { - return Ok(vec![]); - } - let rows = sqlx::query!( - r#" - SELECT ws.workspace_id AS "workspace_id!", dt.key AS "datatable!" - FROM workspace_settings ws - JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false - CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt - WHERE ws.workspace_id <> $1 - AND dt.value->'database'->>'resource_type' = 'instance' - AND dt.value->'database'->>'resource_path' = $2 - ORDER BY ws.workspace_id, dt.key - "#, - &governing.workspace_id, - &database.resource_path, - ) - .fetch_all(db) - .await?; - Ok(rows - .into_iter() - .map(|r| UngovernedReacher { workspace_id: r.workspace_id, datatable: r.datatable }) - .collect()) -} - -async fn get_datatable_permissions( - authed: ApiAuthed, - Extension(db): Extension, - Path((w_id, datatable_name)): Path<(String, String)>, -) -> JsonResult { - let governing = resolve_governing_datatable(&db, &w_id, &datatable_name).await?; - let catalog = read_role_catalog(&db).await?; - let editable = ensure_governs_datatable(&db, &authed, &w_id, &governing) - .await - .is_ok(); - - let permissions = governing.datatable.permissions.as_ref(); - let roles = permissions - .map(|p| { - p.roles - .iter() - .map(|(id, tenants)| DatatableRoleTenantsInfo { - id: id.clone(), - name: if id == ADMIN_DATATABLE_ROLE { - Some(ADMIN_DATATABLE_ROLE.to_string()) - } else { - catalog.get(id).map(|r| r.name.clone()) - }, - // Tenants name users, groups and folders of the governing workspace, so they - // are for the people who set them. Someone reading from a fork gets the shape - // of the decision, not the parent's membership; what they may use themselves - // is what `datatable_usable_roles` answers. - tenants: if editable { - tenants.tenants.clone() - } else { - vec![] - }, - }) - .collect() - }) - .unwrap_or_default(); - - Ok(Json(DatatablePermissionsInfo { - supported: governing.is_instance(), - permissioned: permissions.is_some(), - default_role: permissions - .map(|p| p.default_role().to_string()) - .unwrap_or_else(|| ADMIN_DATATABLE_ROLE.to_string()), - roles, - governing_workspace_id: (governing.workspace_id != w_id) - .then(|| governing.workspace_id.clone()), - editable, - // The instance's role names are only of use to someone who can pick from them, and - // enumerating them is the first step of anything that wants to name one it shouldn't. - available_roles: if editable { - catalog - .iter() - .map(|(id, role)| AvailableRole { - id: id.clone(), - name: role.name.clone(), - enabled: role.enabled, - }) - .collect() - } else { - vec![] - }, - ungoverned_reachers: if editable { - ungoverned_reachers(&db, &governing).await? - } else { - vec![] - }, - })) -} - -async fn set_datatable_permissions( - authed: ApiAuthed, - Extension(db): Extension, - Path((w_id, datatable_name)): Path<(String, String)>, - Json(req): Json, -) -> Result { - let governing = resolve_governing_datatable(&db, &w_id, &datatable_name).await?; - ensure_governs_datatable(&db, &authed, &w_id, &governing).await?; - - // A data table role is a login on Windmill's own cluster; a resource-backed data table dials a - // host the workspace admin chose, so it has no business naming one. - if req.permissioned && !governing.is_instance() { - return Err(Error::BadRequest(format!( - "Data table '{}' is backed by a Postgres resource. Data table roles are logins on the \ - Windmill instance's own Postgres, so only a data table on the instance database can \ - use them.", - governing.name - ))); - } - - // One transaction for the whole save, holding both locks the decision depends on: the role - // catalog, so a role cannot be deleted between validating an id and writing it back, and the - // workspace settings row, so a concurrent settings save cannot carry a stale copy of this - // block forward over what is written here. - let mut tx = db.begin().await?; - windmill_common::datatable_roles::lock_role_catalog(&mut tx).await?; - sqlx::query!( - "SELECT 1 AS one FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", - &governing.workspace_id - ) - .fetch_optional(&mut *tx) - .await?; - - // Everything above was decided on a read taken before the locks. A settings save committing in - // between could have moved this data table onto a PostgreSQL resource — recreating the exact - // state the transition guard refuses — or renamed it, in which case the write below would - // target a key that no longer exists and report success having changed nothing. Re-resolve and - // re-check on the locked state; the earlier pass stays because it is what refuses without - // taking locks at all. - let governing = resolve_governing_datatable(&db, &w_id, &datatable_name).await?; - ensure_governs_datatable(&db, &authed, &w_id, &governing).await?; - if req.permissioned && !governing.is_instance() { - return Err(Error::BadRequest(format!( - "Data table '{}' is backed by a Postgres resource. Data table roles are logins on the \ - Windmill instance's own Postgres, so only a data table on the instance database can \ - use them.", - governing.name - ))); - } - - // Turning roles on is refused while a replication stream reads this data table. One already - // under roles cannot have any: the listener refuses to open a stream on it. - if req.permissioned && governing.datatable.permissions.is_none() { - windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, true).await?; - ensure_no_streams_reaching(&db, &governing).await?; - } - - let permissions = if req.permissioned { - let catalog = windmill_common::datatable_roles::read_role_catalog_tx(&mut tx).await?; - let mut roles: BTreeMap = BTreeMap::new(); - for role in req.roles { - if role.id != ADMIN_DATATABLE_ROLE && !catalog.contains_key(&role.id) { - return Err(Error::BadRequest(format!( - "'{}' is not a data table role of this instance", - role.name.unwrap_or(role.id) - ))); - } - for tenant in &role.tenants { - validate_tenant(tenant)?; - } - roles.insert(role.id, DataTableRoleTenants { tenants: role.tenants }); - } - // `admin` is always a row: it is the connection every object in the database is owned by, - // and a save that dropped it would leave the data table with no way back in. - roles.entry(ADMIN_DATATABLE_ROLE.to_string()).or_default(); - - let default_role = req - .default_role - .unwrap_or_else(|| ADMIN_DATATABLE_ROLE.to_string()); - if !roles.contains_key(&default_role) { - return Err(Error::BadRequest(format!( - "The default role '{default_role}' is not among the data table's roles" - ))); - } - Some(DataTablePermissions { default_role: Some(default_role), roles }) - } else { - None - }; - - let value = match &permissions { - Some(p) => serde_json::to_value(p).map_err(|e| Error::internal_err(e.to_string()))?, - None => serde_json::Value::Null, - }; - // Written straight onto the governing workspace's entry rather than through the settings form, - // which deliberately carries this block across untouched. - sqlx::query!( - r#"UPDATE workspace_settings - SET datatable = CASE WHEN $3::jsonb = 'null'::jsonb - THEN datatable #- ARRAY['datatables', $2, 'permissions'] - ELSE jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3::jsonb) - END - WHERE workspace_id = $1"#, - &governing.workspace_id, - &governing.name, - value, - ) - .execute(&mut *tx) - .await?; - - audit_log( - &mut *tx, - &authed, - "workspaces.set_datatable_permissions", - ActionKind::Update, - &governing.workspace_id, - Some(&authed.email), - Some([("datatable", governing.name.as_str())].into()), - ) - .await?; - tx.commit().await?; - - // An instance database provisioned before data table roles existed has neither the grant - // options the admin connection needs to delegate privileges, nor a CONNECT grant for any role - // — so a role would be refused at login however its tenants read. Repair it here, at the one - // moment someone is deciding this data table's roles. Best-effort: neither is worth failing a - // tenant edit over, and both converge again on the next save. - // - // Runs after the commit: it opens its own connections to other databases, which has no place - // inside a transaction holding two locks. - if permissions.is_some() { - if let Some(database) = governing.datatable.database.as_ref() { - if database.resource_type == DataTableCatalogResourceType::Instance { - let dbname = &database.resource_path; - if let Err(e) = - windmill_common::ensure_instance_db_grant_options_unchecked(&db, dbname).await - { - tracing::warn!("Could not refresh grant options on '{dbname}': {e}"); - } - if let Err(e) = - windmill_common::datatable_roles::converge_connect_grants(&db, dbname).await - { - tracing::warn!("Could not refresh CONNECT grants on '{dbname}': {e}"); - } - } - } - } - - windmill_common::feature_usage::log_feature_usage( - "datatable", - "roles_toggled", - if permissions.is_some() { "on" } else { "off" }, - ); - - Ok(if permissions.is_some() { - format!("Updated the roles of data table '{}'", governing.name) - } else { - format!("Data table '{}' is no longer under roles", governing.name) - }) -} - -/// Refuse to put a data table under roles while a Postgres trigger or capture streams it. A -/// replication stream reads every row whatever the roles grant, so a data table carries one or the -/// other; the listener side refuses a data table already under roles. -async fn ensure_no_streams_reaching(db: &DB, governing: &GoverningDatatable) -> Result<()> { - // Every workspace holding an entry that resolves here, under the name it calls it: the - // governing one, plus each fork pointing at it. A fork's trigger names its own local entry, so - // looking in the governing workspace alone would miss every stream a fork opened. - let mut reached = vec![(governing.workspace_id.clone(), governing.name.clone())]; - let pointers = sqlx::query!( - r#"SELECT ws.workspace_id AS "workspace_id!", dt.key AS "datatable!" - FROM workspace_settings ws - CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt - WHERE dt.value->'reference'->>'workspace_id' = $1 - AND dt.value->'reference'->>'datatable' = $2"#, - &governing.workspace_id, - &governing.name, - ) - .fetch_all(db) - .await?; - reached.extend(pointers.into_iter().map(|r| (r.workspace_id, r.datatable))); - - let mut streams = Vec::new(); - for (w_id, name) in reached { - let reference = format!("datatable://{name}"); - let with_query = format!("{reference}?"); - // A suspended trigger keeps its listener, and a capture streams while its client pings. A - // listener also outlives its trigger being disabled, or its capture's client going quiet, - // until its next heartbeat notices; one that pinged within the 15 seconds a server holds a - // listener for may still be dispatching. - streams.extend( - sqlx::query_scalar::<_, String>( - r#"SELECT workspace_id || '/' || path FROM postgres_trigger - WHERE workspace_id = $1 - AND (mode <> 'disabled'::TRIGGER_MODE - OR last_server_ping > now() - interval '15 seconds') - AND (postgres_resource_path = $2 OR starts_with(postgres_resource_path, $3)) - UNION ALL - SELECT workspace_id || '/' || path || ' (capture)' FROM capture_config - WHERE workspace_id = $1 AND trigger_kind = 'postgres' - AND (last_client_ping > now() - interval '10 seconds' - OR last_server_ping > now() - interval '15 seconds') - AND (trigger_config->>'postgres_resource_path' = $2 - OR starts_with(trigger_config->>'postgres_resource_path', $3))"#, - ) - .bind(&w_id) - .bind(&reference) - .bind(&with_query) - .fetch_all(db) - .await?, - ); - } - if !streams.is_empty() { - return Err(Error::BadRequest(format!( - "Data table '{}' cannot be put under roles while a Postgres trigger or capture streams \ - it: a replication stream reads every row whatever the roles grant. Disable them, then \ - allow their listeners up to 15 seconds to stop: {}", - governing.name, - streams.join(", ") - ))); - } - Ok(()) -} - -/// The roles this caller may connect as, by name, plus the one they get without asking. Drives the -/// role pickers; an empty list means the data table is not under roles. -async fn list_usable_datatable_roles( - authed: ApiAuthed, - Extension(db): Extension, - Path((w_id, datatable_name)): Path<(String, String)>, -) -> JsonResult { - let governing = resolve_governing_datatable(&db, &w_id, &datatable_name).await?; - let Some(permissions) = governing.datatable.permissions.as_ref() else { - return Ok(Json(UsableDatatableRoles { - permissioned: false, - roles: vec![], - default_role: ADMIN_DATATABLE_ROLE.to_string(), - })); - }; - let catalog = read_role_catalog(&db).await?; - let access = DatatableAccess::Authed(authed.to_authed_ref()); - - let mut roles = Vec::new(); - for (id, tenants) in &permissions.roles { - let name = if id == ADMIN_DATATABLE_ROLE { - ADMIN_DATATABLE_ROLE.to_string() - } else { - match catalog.get(id).filter(|r| r.enabled) { - Some(role) => role.name.clone(), - // Deleted or disabled instance-side: it cannot be connected as, so it is not - // offered, even to someone the tenants cover. - None => continue, - } - }; - if can_use_datatable_role_in_governing_workspace( - &db, - &governing.workspace_id, - &w_id, - tenants, - &access, - ) - .await? - { - roles.push(name); - } - } - - let default_role = permissions.default_role(); - Ok(Json(UsableDatatableRoles { - permissioned: true, - roles, - default_role: if default_role == ADMIN_DATATABLE_ROLE { - ADMIN_DATATABLE_ROLE.to_string() - } else { - catalog - .get(default_role) - .map(|r| r.name.clone()) - .unwrap_or_else(|| default_role.to_string()) - }, - })) + roles::ensure_reaches_datatable(db, w_id, datatable_name, authed).await } diff --git a/backend/windmill-api-workspaces/src/datatable_permissions_oss.rs b/backend/windmill-api-workspaces/src/datatable_permissions_oss.rs new file mode 100644 index 0000000000..f4d8c6a7ad --- /dev/null +++ b/backend/windmill-api-workspaces/src/datatable_permissions_oss.rs @@ -0,0 +1,73 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Where the data table permissions endpoints and their gates come from: the enterprise +//! implementation, or a refusal. Roles are an Enterprise Edition feature; see +//! `windmill_common::datatable_roles_oss`. + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) use crate::datatable_permissions_ee::{ + ensure_governs_datatable, ensure_reaches_datatable, get_datatable_permissions, + list_usable_datatable_roles, set_datatable_permissions, +}; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) use ce::*; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +mod ce { + use windmill_api_auth::ApiAuthed; + use windmill_common::{ + datatable_roles_oss::datatable_roles_unavailable as unavailable, + error::Result, + workspaces::{resolve_governing_datatable, GoverningDatatable}, + DB, + }; + + /// Nobody administers a data table's roles without them. + #[allow(dead_code)] + pub(crate) async fn ensure_governs_datatable( + _db: &DB, + _authed: &ApiAuthed, + _w_id: &str, + _governing: &GoverningDatatable, + ) -> Result<()> { + Err(unavailable()) + } + + /// A data table not under roles is reached as it was before roles existed. One under roles is + /// refused: no role of it can be connected as. + pub(crate) async fn ensure_reaches_datatable( + db: &DB, + w_id: &str, + datatable_name: &str, + _authed: &ApiAuthed, + ) -> Result<()> { + let governing = resolve_governing_datatable(db, w_id, datatable_name).await?; + if governing.datatable.permissions.is_none() { + Ok(()) + } else { + Err(unavailable()) + } + } + + // The routes stay registered so the API has one shape; each answers after authentication, + // before anything is read. + + pub(crate) async fn get_datatable_permissions(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } + + pub(crate) async fn set_datatable_permissions(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } + + pub(crate) async fn list_usable_datatable_roles(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } +} diff --git a/backend/windmill-api-workspaces/src/lib.rs b/backend/windmill-api-workspaces/src/lib.rs index ac3f492ff0..c1eb6c1ffc 100644 --- a/backend/windmill-api-workspaces/src/lib.rs +++ b/backend/windmill-api-workspaces/src/lib.rs @@ -3,6 +3,7 @@ pub mod ai_session_backups; pub mod data_metrics; pub mod datatable_migrations; pub mod datatable_permissions; +pub mod datatable_permissions_oss; pub mod deployment_requests; pub mod workspaces; pub mod workspaces_extra; @@ -10,3 +11,6 @@ pub mod workspaces_oss; #[cfg(feature = "private")] pub mod workspaces_ee; + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub mod datatable_permissions_ee;