mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
a7bac22c13
commit
1986ec2535
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user