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) <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 c7269e975f
commit 7ae3335f03
3 changed files with 90 additions and 524 deletions
@@ -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<String>,
pub tenants: Vec<String>,
}
#[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<DatatableRoleTenantsInfo>,
/// The workspace whose entry this is, when it is not the one asking.
#[serde(skip_serializing_if = "Option::is_none")]
governing_workspace_id: Option<String>,
/// 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<AvailableRole>,
/// 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<UngovernedReacher>,
}
#[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<String>,
#[serde(default)]
pub roles: Vec<DatatableRoleTenantsInfo>,
}
#[derive(Serialize)]
struct UsableDatatableRoles {
permissioned: bool,
/// Names, not ids: this is what a caller writes in `-- role <name>`.
roles: Vec<String>,
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/<user>', 'g/<group>', 'f/<folder>' 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<Vec<UngovernedReacher>> {
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<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> JsonResult<DatatablePermissionsInfo> {
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<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
Json(req): Json<SetDatatablePermissions>,
) -> Result<String> {
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<String, DataTableRoleTenants> = 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<DB>,
Path((w_id, datatable_name)): Path<(String, String)>,
) -> JsonResult<UsableDatatableRoles> {
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
}
@@ -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<String> {
Err(unavailable())
}
pub(crate) async fn set_datatable_permissions(_authed: ApiAuthed) -> Result<String> {
Err(unavailable())
}
pub(crate) async fn list_usable_datatable_roles(_authed: ApiAuthed) -> Result<String> {
Err(unavailable())
}
}
@@ -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;