feat(datatables): move data table role catalog and resolution to the enterprise edition

Roles are an Enterprise Edition feature. The catalog, the Postgres logins,
CONNECT convergence, tenant evaluation and the role half of connection
resolution move to windmill-ee-private. Every public function keeps its path
and signature and forwards through datatable_roles_oss, which re-exports the
enterprise implementation or, without it, refuses.

Without the enterprise edition a data table under roles, or a caller naming a
role, is refused a connection rather than resolved as admin, and the reach and
admin-access checks refuse one under roles. A data table not under roles
resolves as before in every edition, and an instance database keeps the
CONNECT grants it was created with. The catalog lock, the stream lock, the
tenant cascades and the permissions stripping stay in OSS: they only restrict.

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-15 12:47:46 +02:00
co-authored by Claude Opus 5
parent baba5e19ee
commit 236c99e483
5 changed files with 255 additions and 462 deletions
+1 -1
View File
@@ -1 +1 @@
332893020ea80cd1e740e1ff4231eb5be3bbc048
e6e5dc7d20bb7143231a2bede7f53c430aeaa2c1
+16 -185
View File
@@ -102,16 +102,13 @@ pub fn validate_role_name(name: &str) -> Result<()> {
Ok(())
}
/// SAFETY: every caller must have run [`validate_role_name`] first — the charset it enforces is
/// what makes this quoting sufficient.
fn quote_ident(name: &str) -> String {
/// A double-quoted Postgres identifier. Doubling `"` is Postgres's own escaping inside one, so this
/// quotes any name — schema, table or role. Role names are validated as well
/// ([`validate_role_name`]) because they also travel unquoted, in `-- role <name>` and `?role=`.
pub fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
fn quote_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
/// Serialize the mutations that are not already serialized by the row itself.
///
/// A create is an insert and a delete is a delete, which Postgres orders for us — the unique index
@@ -149,18 +146,7 @@ pub async fn lock_datatable_streams(conn: &mut sqlx::PgConnection, exclusive: bo
/// or an export. Nothing about who may call it: the credential is the whole risk, and `Debug` is
/// hand-written to redact it for the same reason.
pub async fn read_role_catalog(db: &DB) -> Result<DatatableRoleCatalog> {
let rows = sqlx::query!("SELECT id, name, enabled, pwd FROM datatable_role")
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(|r| {
(
r.id,
InstanceDatatableRole { name: r.name, enabled: r.enabled, pwd: r.pwd },
)
})
.collect())
crate::datatable_roles_oss::read_role_catalog(db).await
}
/// As [`read_role_catalog`], reading inside the caller's transaction so the value is the one
@@ -168,18 +154,7 @@ pub async fn read_role_catalog(db: &DB) -> Result<DatatableRoleCatalog> {
pub async fn read_role_catalog_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<DatatableRoleCatalog> {
let rows = sqlx::query!("SELECT id, name, enabled, pwd FROM datatable_role")
.fetch_all(&mut **tx)
.await?;
Ok(rows
.into_iter()
.map(|r| {
(
r.id,
InstanceDatatableRole { name: r.name, enabled: r.enabled, pwd: r.pwd },
)
})
.collect())
crate::datatable_roles_oss::read_role_catalog_tx(tx).await
}
/// Record a role, in the caller's transaction so it commits with the `CREATE ROLE` it describes.
@@ -191,16 +166,7 @@ pub async fn insert_role_catalog_entry(
id: &str,
role: &InstanceDatatableRole,
) -> Result<()> {
sqlx::query!(
"INSERT INTO datatable_role (id, name, enabled, pwd) VALUES ($1, $2, $3, $4)",
id,
role.name,
role.enabled,
role.pwd,
)
.execute(&mut **tx)
.await?;
Ok(())
crate::datatable_roles_oss::insert_role_catalog_entry(tx, id, role).await
}
/// Update a role's recorded name, login flag and password. Same contract as
@@ -210,16 +176,7 @@ pub async fn update_role_catalog_entry(
id: &str,
role: &InstanceDatatableRole,
) -> Result<()> {
sqlx::query!(
"UPDATE datatable_role SET name = $2, enabled = $3, pwd = $4 WHERE id = $1",
id,
role.name,
role.enabled,
role.pwd,
)
.execute(&mut **tx)
.await?;
Ok(())
crate::datatable_roles_oss::update_role_catalog_entry(tx, id, role).await
}
/// Forget a role. Same contract as [`insert_role_catalog_entry`]; run it in the transaction that
@@ -228,10 +185,7 @@ pub async fn delete_role_catalog_entry(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: &str,
) -> Result<()> {
sqlx::query!("DELETE FROM datatable_role WHERE id = $1", id)
.execute(&mut **tx)
.await?;
Ok(())
crate::datatable_roles_oss::delete_role_catalog_entry(tx, id).await
}
/// Resolve the role a caller named to its catalog id. A disabled role is an error rather than a
@@ -261,13 +215,7 @@ pub fn role_id_by_name<'a>(catalog: &'a DatatableRoleCatalog, name: &str) -> Res
/// Every instance database the registry knows about. Role provisioning has to reach all of them:
/// a role that cannot `CONNECT` to a database is refused by Postgres before any grant matters.
pub async fn registered_instance_databases(db: &DB) -> Result<Vec<String>> {
let names = sqlx::query_scalar!(
"SELECT jsonb_object_keys(value->'databases') FROM global_settings
WHERE name = 'custom_instance_pg_databases'"
)
.fetch_all(db)
.await?;
Ok(names.into_iter().flatten().collect())
crate::datatable_roles_oss::registered_instance_databases(db).await
}
/// `CONNECT` on `dbname` for every enabled role, and none for `PUBLIC`. Run at role creation, at
@@ -278,8 +226,7 @@ pub async fn registered_instance_databases(db: &DB) -> Result<Vec<String>> {
/// Callers MUST have authorized administration of `dbname` — superadmin, or an admin of the
/// workspace governing a data table on it.
pub async fn converge_connect_grants(db: &DB, dbname: &str) -> Result<()> {
let catalog = read_role_catalog(db).await?;
converge_connect_grants_with(db, dbname, &catalog).await
crate::datatable_roles_oss::converge_connect_grants(db, dbname).await
}
/// As [`converge_connect_grants`], with a catalog the caller already read. Same contract.
@@ -288,18 +235,7 @@ pub async fn converge_connect_grants_with(
dbname: &str,
catalog: &DatatableRoleCatalog,
) -> Result<()> {
crate::validate_dbname(dbname)?;
let quoted_db = quote_ident(dbname);
let mut sql = format!("REVOKE CONNECT ON DATABASE {quoted_db} FROM PUBLIC;\n");
for role in catalog.values().filter(|r| r.enabled) {
validate_role_name(&role.name)?;
sql.push_str(&format!(
"GRANT CONNECT ON DATABASE {quoted_db} TO {};\n",
quote_ident(&role.name)
));
}
sqlx::raw_sql(&sql).execute(db).await?;
Ok(())
crate::datatable_roles_oss::converge_connect_grants_with(db, dbname, catalog).await
}
/// `CREATE ROLE <name> LOGIN PASSWORD ...; GRANT <name> TO custom_instance_user`, and `CONNECT` on
@@ -313,36 +249,7 @@ pub async fn create_instance_role(
name: &str,
password: &str,
) -> Result<()> {
validate_role_name(name)?;
let exists = sqlx::query_scalar!(
"SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)",
name
)
.fetch_one(&mut **tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"A Postgres role named '{name}' already exists on this cluster"
)));
}
let quoted = quote_ident(name);
// One statement per call rather than a batch: `raw_sql` takes the simple protocol, which is
// only needed for genuinely multi-statement SQL, and its future is not `Send` — which an axum
// handler holding this transaction requires.
sqlx::query(&format!(
"CREATE ROLE {quoted} LOGIN PASSWORD {}",
quote_literal(password)
))
.execute(&mut **tx)
.await?;
sqlx::query(&format!(
"GRANT {quoted} TO {}",
quote_ident(CUSTOM_INSTANCE_USER)
))
.execute(&mut **tx)
.await?;
Ok(())
crate::datatable_roles_oss::create_instance_role(tx, name, password).await
}
/// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin
@@ -352,15 +259,7 @@ pub async fn set_instance_role_login(
name: &str,
enabled: bool,
) -> Result<()> {
validate_role_name(name)?;
sqlx::query(&format!(
"ALTER ROLE {} {}",
quote_ident(name),
if enabled { "LOGIN" } else { "NOLOGIN" }
))
.execute(&mut **tx)
.await?;
Ok(())
crate::datatable_roles_oss::set_instance_role_login(tx, name, enabled).await
}
/// A rename discards an md5-hashed password, so the caller has to hand over a fresh one.
@@ -373,35 +272,7 @@ pub async fn rename_instance_role(
to: &str,
password: &str,
) -> Result<()> {
validate_role_name(from)?;
validate_role_name(to)?;
let taken = sqlx::query_scalar!(
"SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)",
to
)
.fetch_one(&mut **tx)
.await?
.unwrap_or(false);
if taken {
return Err(Error::BadRequest(format!(
"A Postgres role named '{to}' already exists on this cluster"
)));
}
sqlx::query(&format!(
"ALTER ROLE {} RENAME TO {}",
quote_ident(from),
quote_ident(to)
))
.execute(&mut **tx)
.await?;
sqlx::query(&format!(
"ALTER ROLE {} PASSWORD {}",
quote_ident(to),
quote_literal(password)
))
.execute(&mut **tx)
.await?;
Ok(())
crate::datatable_roles_oss::rename_instance_role(tx, from, to, password).await
}
/// A role owning anything in any database blocks its own `DROP ROLE`, and both its objects and the
@@ -427,47 +298,7 @@ pub async fn drop_instance_role(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
name: &str,
) -> Result<()> {
validate_role_name(name)?;
let quoted = quote_ident(name);
let reassign = format!(
"REASSIGN OWNED BY {quoted} TO {};\nDROP OWNED BY {quoted};",
quote_ident(CUSTOM_INSTANCE_USER)
);
let base = crate::PgDatabase::parse_uri(&crate::get_database_url().await?.as_str().await)?;
for dbname in registered_instance_databases(db).await? {
let creds = crate::PgDatabase { dbname: dbname.clone(), ..base.clone() };
let (client, connection) = creds.connect(Some(db)).await.map_err(|e| {
Error::BadRequest(format!(
"Cannot delete role '{name}': instance database '{dbname}' is unreachable ({e}). \
Objects it owns there would be orphaned."
))
})?;
let join_handle = tokio::spawn(async move { connection.await });
let result = client.batch_execute(&reassign).await;
drop(client);
crate::shutdown_pg_connection(join_handle).await?;
result.map_err(|e| {
Error::internal_err(format!(
"Reassigning what role '{name}' owns in '{dbname}': {}",
crate::error::pg_error_message(&e)
))
})?;
}
sqlx::query(&format!(
"REASSIGN OWNED BY {quoted} TO {}",
quote_ident(CUSTOM_INSTANCE_USER)
))
.execute(&mut **tx)
.await?;
sqlx::query(&format!("DROP OWNED BY {quoted}"))
.execute(&mut **tx)
.await?;
sqlx::query(&format!("DROP ROLE {quoted}"))
.execute(&mut **tx)
.await?;
Ok(())
crate::datatable_roles_oss::drop_instance_role(db, tx, name).await
}
#[cfg(test)]
@@ -0,0 +1,206 @@
/*
* 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 data table roles come from: the enterprise implementation, or a refusal.
//!
//! Roles are an Enterprise Edition feature. An edition without them creates, grants and connects
//! as none, and a data table saved under roles — by an enterprise build, before a downgrade — is
//! refused rather than resolved as `admin`. A data table not under roles, asked for no role,
//! resolves as it always has. `private` alone is not that edition: community builds carry it.
use crate::error::Error;
/// What every roles path answers without the Enterprise Edition.
pub fn datatable_roles_unavailable() -> Error {
Error::BadRequest("Data table roles are a Windmill Enterprise Edition feature".to_string())
}
#[cfg(all(feature = "private", feature = "enterprise"))]
pub(crate) use crate::datatable_roles_ee::{
can_use_datatable_role, can_use_datatable_role_in_governing_workspace, converge_connect_grants,
converge_connect_grants_with, create_instance_role, delete_role_catalog_entry,
drop_instance_role, ensure_can_use_datatable_role, ensure_datatable_admin_access,
ensure_instance_db_grant_options_unchecked, forget_datatable_role_everywhere,
insert_role_catalog_entry, read_role_catalog, read_role_catalog_tx,
registered_instance_databases, rename_instance_role, resolve_datatable_role_connection,
set_instance_role_login, update_role_catalog_entry,
};
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub(crate) use ce::*;
#[cfg(not(all(feature = "private", feature = "enterprise")))]
mod ce {
use super::datatable_roles_unavailable as unavailable;
use crate::{
datatable_roles::{DatatableRoleCatalog, InstanceDatatableRole},
db::AuthedRef,
error::Result,
workspaces::{
resolve_governing_datatable, DataTableRoleTenants, DatatableAccess, GoverningDatatable,
},
DB,
};
type Tx<'a> = sqlx::Transaction<'a, sqlx::Postgres>;
pub(crate) async fn read_role_catalog(_db: &DB) -> Result<DatatableRoleCatalog> {
Err(unavailable())
}
pub(crate) async fn read_role_catalog_tx(_tx: &mut Tx<'_>) -> Result<DatatableRoleCatalog> {
Err(unavailable())
}
pub(crate) async fn insert_role_catalog_entry(
_tx: &mut Tx<'_>,
_id: &str,
_role: &InstanceDatatableRole,
) -> Result<()> {
Err(unavailable())
}
pub(crate) async fn update_role_catalog_entry(
_tx: &mut Tx<'_>,
_id: &str,
_role: &InstanceDatatableRole,
) -> Result<()> {
Err(unavailable())
}
pub(crate) async fn delete_role_catalog_entry(_tx: &mut Tx<'_>, _id: &str) -> Result<()> {
Err(unavailable())
}
pub(crate) async fn registered_instance_databases(_db: &DB) -> Result<Vec<String>> {
Err(unavailable())
}
/// Nothing to converge: with no roles to admit, an instance database keeps the `CONNECT`
/// grants it was created with, `PUBLIC`'s included, as it did before roles existed.
pub(crate) async fn converge_connect_grants(_db: &DB, _dbname: &str) -> Result<()> {
Ok(())
}
/// As [`converge_connect_grants`].
pub(crate) async fn converge_connect_grants_with(
_db: &DB,
_dbname: &str,
_catalog: &DatatableRoleCatalog,
) -> Result<()> {
Ok(())
}
pub(crate) async fn create_instance_role(
_tx: &mut Tx<'_>,
_name: &str,
_password: &str,
) -> Result<()> {
Err(unavailable())
}
pub(crate) async fn set_instance_role_login(
_tx: &mut Tx<'_>,
_name: &str,
_enabled: bool,
) -> Result<()> {
Err(unavailable())
}
pub(crate) async fn rename_instance_role(
_tx: &mut Tx<'_>,
_from: &str,
_to: &str,
_password: &str,
) -> Result<()> {
Err(unavailable())
}
pub(crate) async fn drop_instance_role(_db: &DB, _tx: &mut Tx<'_>, _name: &str) -> Result<()> {
Err(unavailable())
}
pub(crate) async fn ensure_instance_db_grant_options_unchecked(
_db: &DB,
_dbname: &str,
) -> Result<()> {
Err(unavailable())
}
/// No tenant list covers anyone: there is no role to connect as.
pub(crate) fn can_use_datatable_role(
_tenants: &DataTableRoleTenants,
_authed: &AuthedRef<'_>,
) -> bool {
false
}
pub(crate) async fn can_use_datatable_role_in_governing_workspace(
_db: &DB,
_governing_w_id: &str,
_w_id: &str,
_tenants: &DataTableRoleTenants,
_access: &DatatableAccess<'_>,
) -> Result<bool> {
Err(unavailable())
}
/// Reached only for a data table under roles or a caller naming a role: both are refused.
pub(crate) async fn resolve_datatable_role_connection(
_db: &DB,
_w_id: &str,
_name: &str,
_governing: &GoverningDatatable,
_db_resource: serde_json::Value,
_role: Option<&str>,
_access: DatatableAccess<'_>,
) -> Result<serde_json::Value> {
Err(unavailable())
}
/// A data table not under roles, asked for no role, is not a role decision and passes, as it
/// did before roles existed. Anything else is refused.
pub(crate) async fn ensure_can_use_datatable_role(
db: &DB,
w_id: &str,
name: &str,
role: Option<&str>,
_access: &DatatableAccess<'_>,
_context: &str,
) -> Result<()> {
let governing = resolve_governing_datatable(db, w_id, name).await?;
if governing.datatable.permissions.is_none() && role.is_none() {
Ok(())
} else {
Err(unavailable())
}
}
/// A data table not under roles is the `admin` connection for anyone who reaches it, as before
/// roles existed. One under roles is refused.
pub(crate) async fn ensure_datatable_admin_access(
db: &DB,
w_id: &str,
name: &str,
_access: &DatatableAccess<'_>,
) -> Result<()> {
let governing = resolve_governing_datatable(db, w_id, name).await?;
if governing.datatable.permissions.is_none() {
Ok(())
} else {
Err(unavailable())
}
}
pub(crate) async fn forget_datatable_role_everywhere(
_tx: &mut Tx<'_>,
_role_id: &str,
) -> Result<()> {
Err(unavailable())
}
}
+9 -18
View File
@@ -38,6 +38,9 @@ pub mod cache;
pub mod client;
pub mod data_metrics;
pub mod datatable_roles;
#[cfg(all(feature = "private", feature = "enterprise"))]
mod datatable_roles_ee;
pub mod datatable_roles_oss;
pub mod db;
#[cfg(all(feature = "enterprise", feature = "private"))]
mod db_entra_ee;
@@ -1504,7 +1507,7 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu
/// and it is the one that hands privileges to data table roles. Postgres refuses to let a role pass
/// on a privilege it does not itself hold with grant option, so without these an admin could own
/// the database and still be unable to grant `SELECT` on it to `analytics`.
fn instance_db_grants(dbname: &str) -> String {
pub(crate) fn instance_db_grants(dbname: &str) -> String {
format!(
"GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION;
GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION;
@@ -1526,23 +1529,11 @@ fn instance_db_grants(dbname: &str) -> String {
/// Authorization: reaches an instance database with the server's own credentials and checks
/// nothing. Callers MUST have authorized administration of `dbname` — superadmin, or an admin of
/// the workspace governing a data table on it.
pub async fn ensure_instance_db_grant_options_unchecked(db: &DB, dbname: &str) -> error::Result<()> {
let dbname = dbname.trim();
validate_dbname(dbname)?;
let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
let creds = PgDatabase { dbname: dbname.to_string(), ..wmill_pg_creds };
let (client, connection) = creds.connect(Some(db)).await?;
let join_handle = tokio::spawn(async move { connection.await });
let result = client.batch_execute(&instance_db_grants(dbname)).await;
drop(client);
shutdown_pg_connection(join_handle).await?;
result.map_err(|e| {
error::Error::internal_err(format!(
"Failed to grant permissions on '{}': {}",
dbname,
crate::error::pg_error_message(&e)
))
})
pub async fn ensure_instance_db_grant_options_unchecked(
db: &DB,
dbname: &str,
) -> error::Result<()> {
crate::datatable_roles_oss::ensure_instance_db_grant_options_unchecked(db, dbname).await
}
/// Create a custom instance database: CREATE DATABASE, grant permissions, register in global_settings.
+23 -258
View File
@@ -1614,18 +1614,7 @@ pub fn can_use_datatable_role(
tenants: &DataTableRoleTenants,
authed: &crate::db::AuthedRef<'_>,
) -> bool {
*authed.is_admin
|| tenants.tenants.iter().any(|tenant| {
if tenant == DATATABLE_TENANT_WILDCARD {
return true;
}
match tenant.split_once('/') {
Some(("u", user)) => authed.username == user,
Some(("g", group)) => authed.groups.iter().any(|g| g == group),
Some(("f", folder)) => authed.folders.iter().any(|(f, _, _)| f == folder),
_ => false,
}
})
crate::datatable_roles_oss::can_use_datatable_role(tenants, authed)
}
/// Evaluate a tenant list **as a member of the governing workspace**, whoever is calling.
@@ -1642,101 +1631,14 @@ pub async fn can_use_datatable_role_in_governing_workspace(
tenants: &DataTableRoleTenants,
access: &DatatableAccess<'_>,
) -> Result<bool> {
let (permissioned_as, email): (String, String) = match access {
DatatableAccess::Unchecked => return Ok(true),
DatatableAccess::NoIdentity => return Ok(false),
DatatableAccess::Authed(authed) => {
if w_id == governing_w_id {
return Ok(can_use_datatable_role(tenants, authed));
}
(format!("u/{}", authed.username), authed.email.to_string())
}
DatatableAccess::PermissionedAs { permissioned_as, email } => {
(permissioned_as.to_string(), email.to_string())
}
DatatableAccess::Job(job_id) => {
let job = sqlx::query!(
"SELECT permissioned_as, permissioned_as_email FROM v2_job
WHERE id = $1 AND workspace_id = $2",
job_id,
w_id,
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("job {job_id} not found in {w_id}")))?;
(job.permissioned_as, job.permissioned_as_email)
}
};
if w_id == governing_w_id {
let authed =
crate::auth::fetch_authed_from_permissioned_as(&permissioned_as, &email, w_id, db)
.await?;
return Ok(can_use_datatable_role(tenants, &authed.to_authed_ref()));
}
if crate::auth::is_super_admin_email(db, &email).await? {
return Ok(true);
}
if !permissioned_as.starts_with("u/") {
return Ok(false);
}
let Some(username) = sqlx::query_scalar!(
"SELECT username FROM usr WHERE workspace_id = $1 AND email = $2 AND disabled = false",
governing_w_id,
&email
)
.fetch_optional(db)
.await?
else {
return Ok(false);
};
let authed = crate::auth::fetch_authed_from_permissioned_as(
&format!("u/{username}"),
&email,
governing_w_id,
crate::datatable_roles_oss::can_use_datatable_role_in_governing_workspace(
db,
governing_w_id,
w_id,
tenants,
access,
)
.await?;
Ok(can_use_datatable_role(tenants, &authed.to_authed_ref()))
}
/// Which tenant list a caller's role selection lands on. `Ok(None)` means the data table is
/// unpermissioned and resolves through its own `admin` connection, as it did before roles existed.
///
/// `role` is the **name** a caller wrote (`-- role analytics`); it is mapped to the catalog id the
/// tenant lists are keyed by here, so a rename moves nothing.
fn datatable_role_entry<'a>(
permissions: Option<&'a DataTablePermissions>,
catalog: &crate::datatable_roles::DatatableRoleCatalog,
name: &str,
role: Option<&str>,
) -> Result<Option<(String, &'a DataTableRoleTenants)>> {
let Some(permissions) = permissions else {
return match role {
Some(role) if role != ADMIN_DATATABLE_ROLE => Err(Error::BadRequest(format!(
"Cannot use role '{role}': data table '{name}' is not under roles. \
Put it under roles in its permissions drawer first."
))),
_ => Ok(None),
};
};
let role_id = match role {
None => permissions.default_role().to_string(),
Some(ADMIN_DATATABLE_ROLE) => ADMIN_DATATABLE_ROLE.to_string(),
Some(role) => crate::datatable_roles::role_id_by_name(catalog, role)?.to_string(),
};
let tenants = permissions.roles.get(&role_id).ok_or_else(|| {
let display = role.map(str::to_string).unwrap_or_else(|| {
catalog
.get(&role_id)
.map(|r| r.name.clone())
.unwrap_or_else(|| role_id.clone())
});
Error::NotFound(format!(
"Role '{display}' is not among the roles of data table '{name}'"
))
})?;
Ok(Some((role_id, tenants)))
.await
}
/// Resolve a data table to connection credentials for one identity.
@@ -1755,83 +1657,22 @@ pub async fn get_datatable_resource_from_db(
access: DatatableAccess<'_>,
) -> Result<serde_json::Value> {
let governing = resolve_governing_datatable(db, w_id, name).await?;
let mut db_resource = resolve_datatable_connection_unchecked(db, &governing, false).await?;
// A data table role is a login on Windmill's own cluster, so it is only meaningful against an
// instance database. Substituting its password into a resource-backed connection would hand a
// real cluster credential to whatever host that resource names — which a workspace admin
// chooses. Refused rather than ignored: an entry that reached this state was never a shape the
// permissions endpoint accepts, so silently resolving it as admin would hide a broken record.
if !governing.is_instance() {
return match (governing.datatable.permissions.as_ref(), role) {
(None, None | Some(ADMIN_DATATABLE_ROLE)) => Ok(db_resource),
_ => Err(Error::BadRequest(format!(
"Data table '{name}' is backed by a Postgres resource, which cannot be put under \
data table roles"
))),
};
}
let catalog = crate::datatable_roles::read_role_catalog(db).await?;
let Some((role_id, tenants)) = datatable_role_entry(
governing.datatable.permissions.as_ref(),
&catalog,
name,
role,
)?
else {
let db_resource = resolve_datatable_connection_unchecked(db, &governing, false).await?;
// Not under roles and asked for none: the `admin` connection, as before roles existed, in
// every edition. Anything else is a role decision.
if governing.datatable.permissions.is_none() && role.is_none() {
return Ok(db_resource);
};
// Only for a data table actually under roles: whether people name a role or ride the default
// is what says if the `-- role` annotation is carrying its weight.
crate::feature_usage::log_feature_usage(
"datatable",
"role_connection",
if role.is_some() { "named" } else { "default" },
);
if !can_use_datatable_role_in_governing_workspace(
}
crate::datatable_roles_oss::resolve_datatable_role_connection(
db,
&governing.workspace_id,
w_id,
tenants,
&access,
name,
&governing,
db_resource,
role,
access,
)
.await?
{
let display = catalog
.get(&role_id)
.map(|r| r.name.as_str())
.unwrap_or(role_id.as_str());
return Err(Error::NotAuthorized(format!(
"Not allowed to use role '{display}' of data table '{name}'"
)));
}
if role_id == ADMIN_DATATABLE_ROLE {
return Ok(db_resource);
}
let entry = catalog.get(&role_id).ok_or_else(|| {
Error::NotFound(format!(
"Data table '{name}' names a role that no longer exists on this instance"
))
})?;
if !entry.enabled {
return Err(Error::BadRequest(format!(
"Data table role '{}' is disabled on this instance",
entry.name
)));
}
let pwd = entry.pwd.as_ref().ok_or_else(|| {
Error::internal_err(format!(
"Data table role '{}' has no stored credential; recreate it in instance settings",
entry.name
))
})?;
db_resource["user"] = serde_json::Value::String(entry.name.clone());
db_resource["password"] = serde_json::Value::String(pwd.clone());
Ok(db_resource)
.await
}
/// Would the chokepoint accept this identity connecting as this role? Answers without resolving
@@ -1848,38 +1689,8 @@ pub async fn ensure_can_use_datatable_role(
access: &DatatableAccess<'_>,
context: &str,
) -> Result<()> {
let governing = resolve_governing_datatable(db, w_id, name).await?;
if !governing.is_instance() {
return Ok(());
}
let catalog = crate::datatable_roles::read_role_catalog(db).await?;
let Some((role_id, tenants)) = datatable_role_entry(
governing.datatable.permissions.as_ref(),
&catalog,
name,
role,
)?
else {
return Ok(());
};
if can_use_datatable_role_in_governing_workspace(
db,
&governing.workspace_id,
w_id,
tenants,
access,
)
.await?
{
return Ok(());
}
let display = catalog
.get(&role_id)
.map(|r| r.name.as_str())
.unwrap_or(role_id.as_str());
Err(Error::NotAuthorized(format!(
"{context} runs as role '{display}' of data table '{name}', which you are not allowed to use"
)))
crate::datatable_roles_oss::ensure_can_use_datatable_role(db, w_id, name, role, access, context)
.await
}
/// Gate the operations that see the whole database whatever the roles grant: a migration that
@@ -1892,35 +1703,7 @@ pub async fn ensure_datatable_admin_access(
name: &str,
access: &DatatableAccess<'_>,
) -> Result<()> {
let governing = resolve_governing_datatable(db, w_id, name).await?;
if !governing.is_instance() {
return Ok(());
}
let Some(permissions) = governing.datatable.permissions.as_ref() else {
return Ok(());
};
let admin = permissions
.roles
.get(ADMIN_DATATABLE_ROLE)
.cloned()
.unwrap_or_default();
if can_use_datatable_role_in_governing_workspace(
db,
&governing.workspace_id,
w_id,
&admin,
access,
)
.await?
{
Ok(())
} else {
Err(Error::NotAuthorized(format!(
"Data table '{name}' is under roles; this reaches the whole database, so it is for \
the admins of workspace '{}', which governs it.",
governing.workspace_id
)))
}
crate::datatable_roles_oss::ensure_datatable_admin_access(db, w_id, name, access).await
}
/// Rewrite the `permissions` of every data table entry of one workspace, in the caller's
@@ -2056,25 +1839,7 @@ pub async fn forget_datatable_role_everywhere(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
role_id: &str,
) -> Result<()> {
let workspaces = sqlx::query_scalar!(
"SELECT workspace_id FROM workspace_settings WHERE datatable::text LIKE $1",
format!("%{}%", role_id)
)
.fetch_all(&mut **tx)
.await?;
for w_id in workspaces {
update_datatable_permissions_in_workspace(tx, &w_id, |permissions| {
let mut touched = permissions.roles.remove(role_id).is_some();
if permissions.default_role.as_deref() == Some(role_id) {
permissions.default_role = Some(ADMIN_DATATABLE_ROLE.to_string());
touched = true;
}
touched
})
.await?;
}
Ok(())
crate::datatable_roles_oss::forget_datatable_role_everywhere(tx, role_id).await
}
/// Drop the `permissions` block from a `workspace_settings.datatable` value before it leaves the