Merge branch 'datatable-roles-redesign' into datatable-roles-redesign-part-2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BRoYE5ZeAVvrDYdfhDAYXb
This commit is contained in:
Diego Imbert
2026-09-16 23:40:04 +02:00
co-authored by Claude Opus 5
7 changed files with 157 additions and 52 deletions
+1 -1
View File
@@ -1 +1 @@
6f26308c67acf9fcc45773b373aa30a2593b665c
85e15fc93b238159fc90acfc8427d29e219b88ca
@@ -1009,6 +1009,68 @@ async fn an_entry_without_roles_cannot_newly_reach_a_database_under_roles(
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn an_alias_saved_elsewhere_waits_for_roles_going_on_for_its_database(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query(
r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, '{datatables,other}',
'{"database": {"resource_type": "instance", "resource_path": "dt_other"}}')
WHERE workspace_id = 'test-workspace'"#,
)
.execute(&db)
.await?;
// Roles going on for `dt_other`, not committed yet: it holds only its own workspace's settings
// row, so an alias saved from another workspace that looked for roles now would miss them.
let enabling = {
let mut tx = db.begin().await?;
windmill_common::datatable_roles::lock_instance_databases_governance(
&mut *tx,
["dt_other"],
)
.await?;
sqlx::query(
r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable,
'{datatables,other,permissions}',
'{"default_role": "admin", "roles": {"admin": {"tenants": ["*"]}}}')
WHERE workspace_id = 'test-workspace'"#,
)
.execute(&mut *tx)
.await?;
tx
};
let server = ApiServer::start(db.clone()).await?;
let url = format!(
"http://localhost:{}/api/w/wm-fork-dt/workspaces/edit_datatable_config",
server.addr.port()
);
let save = tokio::spawn(
authed(client().post(&url), "SECRET_TOKEN")
.json(&json!({ "settings": { "datatables": {
"direct": { "database": { "resource_type": "instance", "resource_path": "dt_other" } }
} } }))
.send(),
);
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
assert!(
!save.is_finished(),
"an alias was saved while roles were going on for its database"
);
enabling.commit().await?;
let resp = save.await??;
let status = resp.status();
let body = resp.text().await?;
assert!(
status == 400 && body.contains("which a data table under roles uses"),
"the alias reached the database whose roles went on while it waited ({status}): {body}"
);
Ok(())
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
const ENTERPRISE_REFUSAL: &str = "Data table roles are a Windmill Enterprise Edition feature";
@@ -1098,22 +1160,25 @@ async fn without_the_enterprise_edition_a_data_table_under_roles_is_refused_a_co
assert!(err.to_string().contains(ENTERPRISE_REFUSAL), "{err}");
}
// Not under roles, it resolves as it always has; naming a role on it is refused.
// Not under roles, it resolves as it always has, including when `admin` is named — which every
// migration does; naming any other role on it is refused.
sqlx::query(
"UPDATE workspace_settings SET datatable = datatable #- '{datatables,main,permissions}'
WHERE workspace_id = 'test-workspace'",
)
.execute(&db)
.await?;
let resolved = get_datatable_resource_from_db(
&db,
"test-workspace",
"main",
None,
DatatableAccess::NoIdentity,
)
.await?;
assert_eq!(resolved["dbname"], "dt_main", "{resolved}");
for role in [None, Some("admin")] {
let resolved = get_datatable_resource_from_db(
&db,
"test-workspace",
"main",
role,
DatatableAccess::NoIdentity,
)
.await?;
assert_eq!(resolved["dbname"], "dt_main", "{resolved}");
}
let err = get_datatable_resource_from_db(
&db,
"test-workspace",
@@ -174,6 +174,10 @@ async fn datatable_database_arg(
// default role — which is what `ensure_migration_role_allowed` gated it as, and which is the
// only role a DDL statement can be expected to succeed under. A migration that does declare a
// role overrides this: the annotation wins over the reference.
//
// A legacy name containing `?` cannot be migrated through this reference: the appended query
// makes it neither an exact name nor a parseable one. Accepted on purpose, since such names can
// no longer be created and none are expected to carry migrations.
Ok(to_raw_value(&format!(
"datatable://{datatable_name}?role={ADMIN_DATATABLE_ROLE}"
)))
@@ -3892,50 +3892,64 @@ async fn edit_datatable_config(
// entry through a declared rename alone, and a settings sync never declares one, so an entry
// without roles that newly points at such a database — a name added, or an existing one
// repointed — would answer everyone there as `admin`. That holds whichever workspace governs it.
let governed_elsewhere: Vec<String> = sqlx::query_scalar(
"SELECT DISTINCT dt.value->'database'->>'resource_path' FROM workspace_settings ws
CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt
WHERE ws.workspace_id <> $1 AND dt.value ? 'permissions'
AND dt.value->'database'->>'resource_type' = 'instance'",
let newly_pointed: Vec<(&String, &str)> = new_config
.settings
.datatables
.iter()
.filter(|(_, dt)| dt.permissions.is_none())
.filter_map(|(name, dt)| {
let db = dt
.database
.as_ref()
.filter(|d| d.resource_type == DataTableCatalogResourceType::Instance)?;
let lookup = rename_src
.get(name.as_str())
.copied()
.unwrap_or(name.as_str());
let repointed = old_datatables
.get(lookup)
.and_then(|old| old.database.as_ref())
.is_none_or(|old_db| {
old_db.resource_type != db.resource_type
|| old_db.resource_path != db.resource_path
});
repointed.then_some((name, db.resource_path.as_str()))
})
.collect();
// Another workspace turning roles on for the same database holds only its own settings row, so
// without this the scan below could read past its uncommitted write.
windmill_common::datatable_roles::lock_instance_databases_governance(
&mut *tx,
newly_pointed.iter().map(|(_, dbname)| *dbname),
)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
for (name, dt) in new_config.settings.datatables.iter() {
if dt.permissions.is_some() {
continue;
}
let Some(db) = dt
.database
.as_ref()
.filter(|d| d.resource_type == DataTableCatalogResourceType::Instance)
else {
continue;
};
let lookup = rename_src
.get(name.as_str())
.copied()
.unwrap_or(name.as_str());
let repointed = old_datatables
.get(lookup)
.and_then(|old| old.database.as_ref())
.is_none_or(|old_db| {
old_db.resource_type != db.resource_type || old_db.resource_path != db.resource_path
});
let governed_elsewhere: Vec<String> = if newly_pointed.is_empty() {
vec![]
} else {
sqlx::query_scalar(
"SELECT DISTINCT dt.value->'database'->>'resource_path' FROM workspace_settings ws
CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt
WHERE ws.workspace_id <> $1 AND dt.value ? 'permissions'
AND dt.value->'database'->>'resource_type' = 'instance'",
)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?
};
for (name, dbname) in newly_pointed {
let governed_here = old_datatables.values().any(|old| {
old.permissions.is_some()
&& old.database.as_ref().is_some_and(|d| {
d.resource_type == DataTableCatalogResourceType::Instance
&& d.resource_path == db.resource_path
&& d.resource_path == dbname
})
});
if repointed && (governed_here || governed_elsewhere.contains(&db.resource_path)) {
if governed_here || governed_elsewhere.iter().any(|g| g == dbname) {
return Err(Error::BadRequest(format!(
"Data table '{name}' would point at database '{}', which a data table under roles \
uses, without carrying those roles: everyone reaching '{name}' would connect there \
as `admin`. Rename the data table under roles from the data table settings, which \
carries its roles, or turn its roles off first.",
db.resource_path
"Data table '{name}' would point at database '{dbname}', which a data table under \
roles uses, without carrying those roles: everyone reaching '{name}' would connect \
there as `admin`. Rename the data table under roles from the data table settings, \
which carries its roles, or turn its roles off first."
)));
}
}
@@ -140,6 +140,25 @@ pub async fn lock_datatable_streams(conn: &mut sqlx::PgConnection, exclusive: bo
Ok(())
}
/// Whether an instance database is reached only through entries under roles is decided by two
/// writes that lock different workspaces' settings rows: turning roles on for one entry, and a
/// settings save pointing an entry without roles at the database. Each holds this for every
/// database it decides on, so neither reads past the other's uncommitted write. Held for the
/// transaction; the names are locked in sorted order so two holders cannot deadlock.
pub async fn lock_instance_databases_governance<'a>(
conn: &mut sqlx::PgConnection,
dbnames: impl IntoIterator<Item = &'a str>,
) -> Result<()> {
let dbnames: std::collections::BTreeSet<&str> = dbnames.into_iter().collect();
for dbname in dbnames {
sqlx::query("SELECT pg_advisory_xact_lock(hashtext('datatable_instance_database:' || $1))")
.bind(dbname)
.execute(&mut *conn)
.await?;
}
Ok(())
}
/// Disclosure: returns every role's stored Postgres password in plaintext. Any server path that
/// has to resolve or name a role may call it — including handlers open to a workspace member, who
/// need the names — but callers MUST NOT let `pwd` reach a response, a log line, an audit record
@@ -163,8 +163,8 @@ mod ce {
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.
/// A data table not under roles, asked for no role or for `admin`, 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,
@@ -174,7 +174,9 @@ mod ce {
_context: &str,
) -> Result<()> {
let governing = resolve_governing_datatable(db, w_id, name).await?;
if governing.datatable.permissions.is_none() && role.is_none() {
if governing.datatable.permissions.is_none()
&& role.is_none_or(|r| r == crate::datatable_roles::ADMIN_DATATABLE_ROLE)
{
Ok(())
} else {
Err(unavailable())
+4 -3
View File
@@ -1664,9 +1664,10 @@ pub async fn get_datatable_resource_from_db(
) -> Result<serde_json::Value> {
let governing = resolve_governing_datatable(db, w_id, name).await?;
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() {
// Not under roles and asked for none, or for `admin` by name: the `admin` connection, as before
// roles existed, in every edition. Anything else is a role decision. Every migration names
// `admin` explicitly, so an edition without roles must not treat that as one.
if governing.datatable.permissions.is_none() && role.is_none_or(|r| r == ADMIN_DATATABLE_ROLE) {
return Ok(db_resource);
}
crate::datatable_roles_oss::resolve_datatable_role_connection(