Files
windmill/backend/windmill-api-workspaces/src/datatable_permissions.rs
T
Diego ImbertandClaude Opus 5 67091eec69 fix(datatables): validate a rename against the save it describes, and re-check under the locks
Three from the round, all about deciding on state that could already have moved.

A permission save resolved the data table and checked it was instance-backed before taking any
lock, then wrote under one. A config save committing in between could move the table onto a
PostgreSQL resource — recreating exactly what the transition guard refuses — or rename it, in
which case the write targeted a key that no longer existed and reported success having changed
nothing. It now re-resolves and re-checks on the locked state.

Rename validation checked that the source existed before and the target existed after, which
still accepts `main -> decoy` against a save that keeps both: every fork of `main` then follows
onto a different data table, silently, because it keeps resolving. The rule is now the actual
old-to-new key transition — a source may only survive if another rename took its name, and a
target may only pre-exist if another rename freed it. That also stops two sources sharing one
target, and it admits a swap, which the previous guard refused: `datatables` is keyed by name, so
a swap cannot be done one save at a time, and refusing it was a regression against main. The
pointer cascade now runs in two passes through a temporary name, the way the migration cascade
one layer down already handles the same shape, so `A -> B` with `B -> C` moves each pointer once
from what it named before the save.

The tenant mutators say what they are for: they write an access decision for any workspace named,
with an arbitrary mutation, and exist for the transaction that frees or renames a principal.
Editing a decision on purpose belongs in the permissions endpoint.

Carried in the same change: the stranded-fork list is a field rather than a phrase to grep out of
a success string; the pointer cascade matches with `EXISTS` instead of a `LIKE` over the whole
document, so a workspace whose pointers name something else is not rewritten to a byte-identical
value under an exclusive lock; and `InstanceDatatableRole` drops the serde derives left over from
the JSON document, one of which would emit `pwd`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
2026-09-08 17:40:19 +02:00

529 lines
21 KiB
Rust

/*
* 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.
*/
//! Who may connect to a data table as which role.
//!
//! The decision lives on the data table entry of the workspace that governs it, which is not
//! necessarily the workspace asking: a fork's entry points at its parent's, and everything here
//! resolves through that pointer first. Nothing in this module runs SQL against the data table —
//! 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::{
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_common::DB;
pub(crate) fn routes() -> Router {
Router::new()
.route(
"/datatable_permissions/{datatable_name}",
get(get_datatable_permissions).post(set_datatable_permissions),
)
.route(
"/datatable_usable_roles/{datatable_name}",
get(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.
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
)))
}
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
)));
}
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}");
}
}
}
}
// A live replication stream holds a connection it opened under the old decision. Bouncing the
// rows makes every listener reconnect and re-authorize.
restart_streams_reaching(&db, &governing).await?;
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)
})
}
/// Make every Postgres trigger and capture reading this data table reconnect, so a revoked tenant
/// stops streaming rather than living on inside an already-open replication connection.
pub(crate) async fn restart_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
// filtering on the governing workspace alone would leave its stream running on the connection
// it already opened under the old decision — which is the one window this function exists to
// close.
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)));
for (w_id, name) in reached {
let reference = format!("datatable://{name}");
let prefix = format!("{reference}?%");
sqlx::query!(
"UPDATE postgres_trigger SET server_id = NULL, last_server_ping = NULL
WHERE workspace_id = $1
AND (postgres_resource_path = $2 OR postgres_resource_path LIKE $3)",
&w_id,
&reference,
&prefix,
)
.execute(db)
.await?;
// A capture keeps the reference inside its `trigger_config` blob rather than in a column
// of its own, and only a postgres capture has one there at all.
sqlx::query!(
"UPDATE capture_config SET server_id = NULL, last_server_ping = NULL
WHERE workspace_id = $1 AND trigger_kind = 'postgres'
AND (trigger_config->>'postgres_resource_path' = $2
OR trigger_config->>'postgres_resource_path' LIKE $3)",
&w_id,
&reference,
&prefix,
)
.execute(db)
.await?;
}
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())
},
}))
}