From edef1e0d7469716cedebe1f26c486c43834020c6 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 role catalog endpoints to the enterprise edition The superadmin list, create, update and delete handlers move to windmill-ee-private. The routes stay registered and, without the enterprise edition, refuse after authentication. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb --- .../src/datatable_roles_oss.rs | 44 +++ backend/windmill-api-settings/src/lib.rs | 262 +----------------- 2 files changed, 51 insertions(+), 255 deletions(-) create mode 100644 backend/windmill-api-settings/src/datatable_roles_oss.rs diff --git a/backend/windmill-api-settings/src/datatable_roles_oss.rs b/backend/windmill-api-settings/src/datatable_roles_oss.rs new file mode 100644 index 0000000000..885d13ee9d --- /dev/null +++ b/backend/windmill-api-settings/src/datatable_roles_oss.rs @@ -0,0 +1,44 @@ +/* + * 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 role catalog endpoints 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_roles_ee::{ + create_datatable_role, delete_datatable_role, list_datatable_roles, update_datatable_role, +}; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) use ce::*; + +// The routes stay registered so the API has one shape; each answers after authentication, before +// anything is read. +#[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, + }; + + pub(crate) async fn list_datatable_roles(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } + + pub(crate) async fn create_datatable_role(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } + + pub(crate) async fn update_datatable_role(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } + + pub(crate) async fn delete_datatable_role(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 3a5c00ed8d..5e0961b45e 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -17,6 +17,9 @@ mod audit_logs_s3; mod audit_logs_s3_backfill; #[cfg(feature = "parquet")] mod background_task; +#[cfg(all(feature = "private", feature = "enterprise"))] +mod datatable_roles_ee; +mod datatable_roles_oss; #[cfg(feature = "private")] mod ee; pub mod ee_oss; @@ -153,11 +156,13 @@ pub fn global_service() -> Router { ) .route( "/datatable_roles", - get(list_datatable_roles).post(create_datatable_role), + get(datatable_roles_oss::list_datatable_roles) + .post(datatable_roles_oss::create_datatable_role), ) .route( "/datatable_roles/{id}", - post(update_datatable_role).delete(delete_datatable_role), + post(datatable_roles_oss::update_datatable_role) + .delete(datatable_roles_oss::delete_datatable_role), ) .route( "/refresh_custom_instance_user_pwd", @@ -2567,256 +2572,3 @@ mod object_storage_test_hardening { } } } - -// --------------------------------------------------------------------------- -// Data table roles -// --------------------------------------------------------------------------- - -use windmill_audit::audit_oss::audit_log; -use windmill_audit::ActionKind; - -/// One catalog entry as the settings UI sees it. The password never leaves the instance: it is a -/// Postgres credential Windmill mints and hands only to a resolved connection. -#[derive(Serialize)] -struct DatatableRoleInfo { - id: String, - name: String, - enabled: bool, -} - -#[derive(Deserialize)] -struct CreateDatatableRole { - name: String, -} - -#[derive(Deserialize)] -struct UpdateDatatableRole { - /// A rename. Absent leaves the name alone. - #[serde(default)] - name: Option, - /// `LOGIN` / `NOLOGIN`. Grants and ownership survive either way. - #[serde(default)] - enabled: Option, -} - -fn datatable_role_infos( - catalog: &windmill_common::datatable_roles::DatatableRoleCatalog, -) -> Vec { - catalog - .iter() - .map(|(id, role)| DatatableRoleInfo { - id: id.clone(), - name: role.name.clone(), - enabled: role.enabled, - }) - .collect() -} - -async fn list_datatable_roles( - authed: ApiAuthed, - Extension(db): Extension, -) -> JsonResult> { - require_super_admin(&db, &authed).await?; - let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?; - Ok(Json(datatable_role_infos(&catalog))) -} - -/// Create the Postgres role first, then record it. The cluster is the source of truth: a catalog -/// entry naming a role that does not exist would resolve to a login nothing can authenticate as. -async fn create_datatable_role( - authed: ApiAuthed, - Extension(db): Extension, - Json(req): Json, -) -> JsonResult { - require_super_admin(&db, &authed).await?; - windmill_common::datatable_roles::validate_role_name(&req.name)?; - - // The cluster DDL and the row that records it are one transaction under one lock, so a - // half-done create cannot leave a live login the catalog does not know about. - let mut tx = db.begin().await?; - windmill_common::datatable_roles::lock_role_catalog(&mut tx).await?; - let mut catalog = windmill_common::datatable_roles::read_role_catalog_tx(&mut tx).await?; - if catalog.values().any(|r| r.name == req.name) { - return Err(error::Error::BadRequest(format!( - "A data table role named '{}' already exists", - req.name - ))); - } - - let id = windmill_common::utils::rd_string(12); - let pwd = uuid::Uuid::new_v4().to_string(); - windmill_common::datatable_roles::create_instance_role(&mut tx, &req.name, &pwd).await?; - - let entry = windmill_common::datatable_roles::InstanceDatatableRole { - name: req.name.clone(), - enabled: true, - pwd: Some(pwd), - }; - windmill_common::datatable_roles::insert_role_catalog_entry(&mut tx, &id, &entry).await?; - catalog.insert(id.clone(), entry); - tx.commit().await?; - converge_connect_grants_everywhere(&db, &catalog).await; - windmill_common::feature_usage::log_feature_usage("datatable", "role_created", ""); - - audit_log( - &db, - &authed, - "settings.create_datatable_role", - ActionKind::Create, - "global", - Some(&authed.email), - Some([("name", req.name.as_str())].into()), - ) - .await?; - - Ok(Json(DatatableRoleInfo { - id, - name: req.name, - enabled: true, - })) -} - -async fn update_datatable_role( - authed: ApiAuthed, - Extension(db): Extension, - Path(id): Path, - Json(req): Json, -) -> JsonResult { - require_super_admin(&db, &authed).await?; - let mut tx = db.begin().await?; - windmill_common::datatable_roles::lock_role_catalog(&mut tx).await?; - let mut catalog = windmill_common::datatable_roles::read_role_catalog_tx(&mut tx).await?; - let role = catalog - .get(&id) - .cloned() - .ok_or_else(|| error::Error::NotFound(format!("No data table role with id '{id}'")))?; - - let mut updated = role.clone(); - if let Some(name) = req.name.filter(|n| n != &role.name) { - windmill_common::datatable_roles::validate_role_name(&name)?; - if catalog.values().any(|r| r.name == name) { - return Err(error::Error::BadRequest(format!( - "A data table role named '{name}' already exists" - ))); - } - // RENAME discards an md5-hashed password, so the role gets a fresh one in the same - // statement and the catalog records it. Tenants name the id, so nothing else moves. - let pwd = uuid::Uuid::new_v4().to_string(); - windmill_common::datatable_roles::rename_instance_role(&mut tx, &role.name, &name, &pwd) - .await?; - updated.name = name; - updated.pwd = Some(pwd); - } - if let Some(enabled) = req.enabled.filter(|e| *e != role.enabled) { - windmill_common::datatable_roles::set_instance_role_login(&mut tx, &updated.name, enabled) - .await?; - updated.enabled = enabled; - } - - windmill_common::datatable_roles::update_role_catalog_entry(&mut tx, &id, &updated).await?; - catalog.insert(id.clone(), updated.clone()); - tx.commit().await?; - converge_connect_grants_everywhere(&db, &catalog).await; - - audit_log( - &db, - &authed, - "settings.update_datatable_role", - ActionKind::Update, - "global", - Some(&authed.email), - Some([("name", updated.name.as_str())].into()), - ) - .await?; - - Ok(Json(DatatableRoleInfo { - id, - name: updated.name, - enabled: updated.enabled, - })) -} - -/// Disable the role in its own commit, then drop the Postgres role, then forget it, then strip it -/// from every workspace that tenanted it. -/// -/// Dropping before forgetting is what makes the catalog trustworthy: the drop refuses while any -/// instance database is unreachable, so a failure leaves the entry in place to retry rather than a -/// live Postgres login nothing names. -async fn delete_datatable_role( - authed: ApiAuthed, - Extension(db): Extension, - Path(id): Path, -) -> JsonResult<()> { - require_super_admin(&db, &authed).await?; - let find = |catalog: &windmill_common::datatable_roles::DatatableRoleCatalog| { - catalog - .get(&id) - .cloned() - .ok_or_else(|| error::Error::NotFound(format!("No data table role with id '{id}'"))) - }; - - let mut tx = db.begin().await?; - windmill_common::datatable_roles::lock_role_catalog(&mut tx).await?; - let mut role = find(&windmill_common::datatable_roles::read_role_catalog_tx(&mut tx).await?)?; - if role.enabled { - windmill_common::datatable_roles::set_instance_role_login(&mut tx, &role.name, false) - .await?; - role.enabled = false; - windmill_common::datatable_roles::update_role_catalog_entry(&mut tx, &id, &role).await?; - } - tx.commit().await?; - - let mut tx = db.begin().await?; - windmill_common::datatable_roles::lock_role_catalog(&mut tx).await?; - let role = find(&windmill_common::datatable_roles::read_role_catalog_tx(&mut tx).await?)?; - if role.enabled { - return Err(error::Error::BadRequest(format!( - "Data table role '{}' was re-enabled while being deleted", - role.name - ))); - } - - windmill_common::datatable_roles::drop_instance_role(&db, &mut tx, &role.name).await?; - windmill_common::datatable_roles::delete_role_catalog_entry(&mut tx, &id).await?; - windmill_common::workspaces::forget_datatable_role_everywhere(&mut tx, &id).await?; - tx.commit().await?; - - audit_log( - &db, - &authed, - "settings.delete_datatable_role", - ActionKind::Delete, - "global", - Some(&authed.email), - Some([("name", role.name.as_str())].into()), - ) - .await?; - - Ok(Json(())) -} - -/// Best-effort `CONNECT` convergence over the instance database registry. A database that is -/// unreachable right now is repaired the next time one of its data tables is administered, so a -/// role creation is not held hostage by an unrelated database being down. -async fn converge_connect_grants_everywhere( - db: &DB, - catalog: &windmill_common::datatable_roles::DatatableRoleCatalog, -) { - let dbnames = match windmill_common::datatable_roles::registered_instance_databases(db).await { - Ok(dbnames) => dbnames, - Err(e) => { - tracing::warn!("Could not list instance databases to grant CONNECT: {e}"); - return; - } - }; - for dbname in dbnames { - if let Err(e) = - windmill_common::datatable_roles::converge_connect_grants_with(db, &dbname, catalog) - .await - { - tracing::warn!( - "Could not converge CONNECT grants on instance database '{dbname}': {e}" - ); - } - } -}