fix(datatables): make the concurrency test pin the handlers, and the contracts describe what is enforced

The concurrency test reimplemented the read-modify-write inline, so deleting the lock from all
three handlers left it green — it pinned Postgres, not the code it was written for. It now
drives `create_datatable_role` twice concurrently and asserts the catalog kept both names.
Checked the way the last one should have been: removing the lock from the handler makes it
fail with "wmtest_a_… is a live cluster login the catalog forgot".

The contracts added last commit were stricter than this PR's own callers, which is worse than
none — the next reader sees a rule already broken and learns to ignore it.
`read_role_catalog` said superadmin-only while two of its four callers are open to any
workspace member, and `converge_connect_grants` said superadmin while
`set_datatable_permissions` reaches it as a workspace admin. Both were fine on substance: the
rule that actually holds is about the credential never reaching a response, log, audit record
or export, not about who may call. They now say that. `read_datatable_entry` gets the same
treatment rather than the one the earlier message claimed for it: it is the primitive every
resolution goes through, so it is deliberately open, and what must not escape is `permissions`
— it names the governing workspace's users, groups and folders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
This commit is contained in:
Diego Imbert
2026-09-16 15:14:28 +02:00
co-authored by Claude Opus 5
parent 715d8a0e6d
commit 1d9ee09b31
4 changed files with 58 additions and 44 deletions
@@ -331,41 +331,51 @@ async fn a_caller_with_no_identity_reaches_a_permissioned_data_table_not_at_all(
}
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn concurrent_role_catalog_writes_do_not_lose_an_entry(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::datatable_roles::{
lock_role_catalog, read_role_catalog_tx, InstanceDatatableRole,
};
async fn concurrent_role_creations_both_survive(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// The catalog is one JSON document, so every mutation is read-modify-write. Without the lock
// two concurrent creates read the same snapshot and the second write drops the first — leaving
// the role it dropped as a live cluster login nobody recorded. No DDL here: the losable step is
// the catalog write, and that is what this pins.
let insert = |id: &'static str| {
let db = db.clone();
async move {
let mut tx = db.begin().await?;
lock_role_catalog(&mut tx).await?;
let mut catalog = read_role_catalog_tx(&mut tx).await?;
// Widen the window the lock has to cover, so an unlocked version fails reliably rather
// than occasionally.
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
catalog.insert(
id.to_string(),
InstanceDatatableRole { name: id.to_string(), enabled: true, pwd: None },
);
windmill_common::datatable_roles::write_role_catalog(&mut tx, &catalog).await?;
tx.commit().await?;
Ok::<_, anyhow::Error>(())
}
};
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let (a, b) = tokio::join!(insert("first"), insert("second"));
a?;
b?;
// Postgres roles are cluster-wide and this cluster is shared with every other test database,
// so the names have to be unique to this run.
let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string();
let names = [format!("wmtest_a_{suffix}"), format!("wmtest_b_{suffix}")];
// The catalog is one JSON document, so create is read-modify-write. Unserialized, both of
// these read the same snapshot, both `CREATE ROLE` succeeds, and the second write drops the
// first entry — leaving a live cluster login nobody recorded.
let create = |name: String| async move {
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/settings/datatable_roles"
)),
"SECRET_TOKEN",
)
.json(&json!({ "name": name }))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
Ok::<_, anyhow::Error>((status, body))
};
let (a, b) = tokio::join!(create(names[0].clone()), create(names[1].clone()));
let (a, b) = (a?, b?);
assert_eq!(a.0, 200, "{}", a.1);
assert_eq!(b.0, 200, "{}", b.1);
let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?;
assert!(catalog.contains_key("first"), "lost 'first': {catalog:?}");
assert!(catalog.contains_key("second"), "lost 'second': {catalog:?}");
let recorded: Vec<&str> = catalog.values().map(|r| r.name.as_str()).collect();
for name in &names {
assert!(
recorded.contains(&name.as_str()),
"{name} is a live cluster login the catalog forgot: {recorded:?}"
);
}
for name in &names {
sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\""))
.execute(&db)
.await?;
}
Ok(())
}
@@ -3,7 +3,9 @@
-- the pointer exists for.
INSERT INTO global_settings (name, value) VALUES
('custom_instance_pg_databases', '{"user_pwd": "pw", "databases": {"dt_main": {}}}'::jsonb),
-- Empty registry: role provisioning grants CONNECT on every database named here, and the
-- data table's `dt_main` is a name in workspace settings, not a database that exists.
('custom_instance_pg_databases', '{"user_pwd": "pw", "databases": {}}'::jsonb),
-- The role catalog has its own row: it holds generated credentials and must stay out of the
-- operator-facing config the neighbouring row belongs to.
('datatable_roles', '{"role1": {"name": "analytics", "enabled": true, "pwd": "pw"}}'::jsonb)
@@ -129,9 +129,11 @@ pub async fn lock_role_catalog(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>) -
Ok(())
}
/// Authorization: returns every role's stored Postgres password in plaintext. Callers MUST
/// restrict this to superadmin or internal server paths, and MUST NOT put what it returns into a
/// response, a log line or an audit record.
/// 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
/// 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 value = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = $1",
@@ -143,7 +145,7 @@ pub async fn read_role_catalog(db: &DB) -> Result<DatatableRoleCatalog> {
}
/// As [`read_role_catalog`], reading inside the caller's transaction so the value is the one
/// [`lock_role_catalog`] is protecting. Same authorization contract.
/// [`lock_role_catalog`] is protecting. Same disclosure contract.
pub async fn read_role_catalog_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<DatatableRoleCatalog> {
@@ -228,14 +230,14 @@ pub async fn registered_instance_databases(db: &DB) -> Result<Vec<String>> {
/// provisioned before a role existed is repaired rather than left silently unreachable.
///
/// Authorization: rewrites a database's ACL with the server's own credentials and checks nothing.
/// Callers MUST restrict this to superadmin or internal server paths.
/// 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
}
/// As [`converge_connect_grants`], with a catalog the caller already read. Same authorization
/// contract: it rewrites a database's ACL with the server's own credentials and checks nothing.
/// As [`converge_connect_grants`], with a catalog the caller already read. Same contract.
pub async fn converge_connect_grants_with(
db: &DB,
dbname: &str,
+4 -4
View File
@@ -1427,10 +1427,10 @@ fn datatable_not_found_error(name: &str, datatables: Option<&serde_json::Value>)
/// Read one workspace's data table entry, without following a pointer.
///
/// Authorization: reads a workspace's stored configuration by id and checks nothing — not that the
/// caller belongs to that workspace, nor that they may see the data table. Callers MUST have
/// authorized access to `w_id` already, and MUST NOT return the entry to a caller from another
/// workspace: it names the database and, on a governing entry, who may reach it as what.
/// Disclosure: this is the primitive [`resolve_governing_datatable`] calls on every path, so it is
/// deliberately open to anything that has to resolve a data table, including for a workspace the
/// caller does not belong to. What it returns is not: callers MUST NOT put `permissions` into a
/// response, an export or a log — it names the governing workspace's users, groups and folders.
pub async fn read_datatable_entry(db: &DB, w_id: &str, name: &str) -> Result<DataTable> {
let datatables = sqlx::query_scalar!(
r#"