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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
This commit is contained in:
Diego Imbert
2026-09-17 10:01:15 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 7ae3335f03
commit 11ba3cd477
2 changed files with 51 additions and 255 deletions
@@ -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<String> {
Err(unavailable())
}
pub(crate) async fn create_datatable_role(_authed: ApiAuthed) -> Result<String> {
Err(unavailable())
}
pub(crate) async fn update_datatable_role(_authed: ApiAuthed) -> Result<String> {
Err(unavailable())
}
pub(crate) async fn delete_datatable_role(_authed: ApiAuthed) -> Result<String> {
Err(unavailable())
}
}
+7 -255
View File
@@ -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",
@@ -2591,256 +2596,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<String>,
/// `LOGIN` / `NOLOGIN`. Grants and ownership survive either way.
#[serde(default)]
enabled: Option<bool>,
}
fn datatable_role_infos(
catalog: &windmill_common::datatable_roles::DatatableRoleCatalog,
) -> Vec<DatatableRoleInfo> {
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<DB>,
) -> JsonResult<Vec<DatatableRoleInfo>> {
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<DB>,
Json(req): Json<CreateDatatableRole>,
) -> JsonResult<DatatableRoleInfo> {
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<DB>,
Path(id): Path<String>,
Json(req): Json<UpdateDatatableRole>,
) -> JsonResult<DatatableRoleInfo> {
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<DB>,
Path(id): Path<String>,
) -> 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}"
);
}
}
}