feat: add the viewer's roles to unnamed data table connections (#11709)

* feat: connect to a data table with every role of the job's viewer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: key a cached result by the run's recorded viewer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: pin the per-viewer cache key and fix a stale doc sentence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: length-prefix the viewer in the result cache key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: drop data table role set logins unused for 30 days

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin the ee ref with the set login sweep fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin the ee ref with the sweep retry fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: give the cache key test its own migrated database

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: add the viewer's roles to a data table connection that names none

The default role of a data table under roles is now a floor: a connection
naming no role also holds the roles its viewer is a tenant of, unless the
default is admin. This replaces the explicit as=viewer option, which is
removed from the reference parser, the executors and both clients. The roles
drawer warns while the default is admin and confirms before removing the
default role.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: let a data table have no default role

A connection naming no role then holds its viewer's roles only and is
refused without any. The roles drawer offers the choice, states what an
unnamed connection gets for the current settings, says who gets admin while
it is the default, and notes who owns a table created over several roles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: start a data table's roles with admin open to everyone

Turning roles on from the drawer seeded admin with no tenant, which refused
every non-admin member's job as soon as it was saved. It now starts open to
everyone, so the toggle changes nothing until the roles are edited, and the
drawer warns for as long as everyone can connect as admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 40ab35596e6fcb82a4dfc56e186befb807f8e2af

This commit updates the EE repository reference after PR #873 was merged in windmill-ee-private.

Previous ee-repo-ref: a5def184a29fffab8ed42c70c5a7445b2b3cbe98

New ee-repo-ref: 40ab35596e6fcb82a4dfc56e186befb807f8e2af

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-10-13 09:01:21 +00:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent b57fd3e29a
commit a298ed3fc1
26 changed files with 612 additions and 28 deletions
+3
View File
@@ -33,6 +33,9 @@ Open-source platform for internal tools, workflows, API integrations, background
administers behind `external_instance` data tables and Ducklake catalogs: its invariants (one
lifecycle lock, managed-object markers, the setup gate, per-cluster roles, fork copy ownership)
and how to run one locally
- **Data table viewer roles**: `docs/datatable-viewer-roles.md`, on what a connection naming no
role holds: the default role as a floor, where the viewer comes from, why an admin's roles are
not the viewer's, the per-role-set logins and the order their rows and logins are written in
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are
enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option`
+1 -1
View File
@@ -1 +1 @@
1e538620a9b2f08b044997d032e71d8720ecbd5c
40ab35596e6fcb82a4dfc56e186befb807f8e2af
@@ -0,0 +1 @@
DROP TABLE IF EXISTS datatable_role_set;
@@ -0,0 +1,21 @@
-- The logins behind a data table connection made as several roles at once: one Postgres login per
-- distinct set of data table roles, a member of each. Which roles a login holds is its
-- memberships on the cluster, and its name is derived from their catalog ids, so a row only has to
-- carry the credential.
--
-- A row is written after its login exists and removed before the login is dropped, so a row always
-- names a login that can authenticate with `pwd`.
CREATE TABLE datatable_role_set (
cluster VARCHAR(20) NOT NULL CHECK (cluster IN ('instance', 'external_instance')),
name VARCHAR(63) NOT NULL,
-- Generated by Windmill, never entered by anyone, and never leaves the server.
pwd TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Recorded at most once a day. A login unused for long enough is dropped, and made again if
-- its set of roles is asked for later.
last_used_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (cluster, name)
);
GRANT ALL ON datatable_role_set TO windmill_user;
GRANT ALL ON datatable_role_set TO windmill_admin;
+19
View File
@@ -4449,6 +4449,24 @@ pub async fn monitor_db(
}
};
// Drop the data table role set logins nobody has connected with for a month. Every ~40 min,
// spawned for the same reason as the credential maintenance above: it opens a connection per
// managed database for each login it drops.
let datatable_role_set_sweep_f = async {
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(240) {
if let Some(db) = conn.as_sql() {
let db = db.clone();
tokio::spawn(async move {
if let Err(e) =
windmill_common::datatable_roles::sweep_unused_role_set_logins(&db).await
{
tracing::error!("Error sweeping unused data table role set logins: {e}");
}
});
}
}
};
// Re-encrypt the legacy-format secret variables once new values are written in AES-256-GCM.
// Every ~5 min until one pass has gone through every workspace; spawned so a large instance
// does not hold up the join below.
@@ -4538,6 +4556,7 @@ pub async fn monitor_db(
cleanup_scheduled_job_deletions_f,
git_auto_pull_f,
git_credential_maintenance_f,
datatable_role_set_sweep_f,
ai_session_retention_f,
legacy_secret_upgrade_f,
pipeline_freshness_watchdog_f,
@@ -1847,3 +1847,222 @@ async fn cleanup_waits_out_a_save_racing_it_for_an_instance_database(
assert_eq!(users, vec!["test-workspace".to_string()]);
Ok(())
}
#[cfg(all(feature = "private", feature = "enterprise"))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use windmill_common::workspaces::{get_datatable_resource_from_db, DatatableAccess};
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string();
let names = [
format!("wmtest_floor_{suffix}"),
format!("wmtest_emea_{suffix}"),
format!("wmtest_amer_{suffix}"),
];
let roles_url = format!("http://localhost:{port}/api/settings/datatable_roles");
const MEMBER_LOGINS: &str = "SELECT m.rolname::text FROM pg_auth_members am
JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member
WHERE r.rolname = $1 AND m.rolname LIKE 'wm\\_set\\_%'";
// The job is the app publisher's in every case: only the viewer the server recorded differs.
let connect = |role: Option<&'static str>, viewer: Option<&'static str>| {
get_datatable_resource_from_db(
&db,
"test-workspace",
"main",
role,
DatatableAccess::PermissionedAs {
permissioned_as: "u/test-user",
email: "test@windmill.dev",
end_user_email: viewer,
},
)
};
let set_permissions = |default_role: String, roles: Value| {
sqlx::query(
"UPDATE workspace_settings
SET datatable = jsonb_set(datatable, '{datatables,main,permissions}', $1)
WHERE workspace_id = 'test-workspace'",
)
.bind(json!({ "default_role": default_role, "roles": roles }))
.execute(&db)
};
let outcome = async {
let mut ids = vec![];
for name in &names {
let created: Value = authed(client().post(&roles_url), "SECRET_TOKEN")
.json(&json!({ "name": name }))
.send()
.await?
.error_for_status()?
.json()
.await?;
ids.push(created["id"].as_str().unwrap().to_string());
}
// `test-user-2` is named by both regional roles, one of them through a group, and by
// `admin`; `test-user-3` by one; the workspace admin `test-user` by neither.
let roles = json!({
"admin": {"tenants": ["u/test-user-2"]},
(ids[0].as_str()): {"tenants": ["*"]},
(ids[1].as_str()): {"tenants": ["u/test-user-2", "u/test-user-3"]},
(ids[2].as_str()): {"tenants": ["g/analysts"]},
});
set_permissions(ids[0].clone(), roles.clone()).await?;
sqlx::query(
"INSERT INTO usr_to_group (workspace_id, usr, group_)
VALUES ('test-workspace', 'test-user-2', 'analysts')",
)
.execute(&db)
.await?;
let member_of = |login: String| async {
let mut roles: Vec<String> = sqlx::query_scalar(
"SELECT r.rolname::text FROM pg_auth_members am
JOIN pg_roles r ON r.oid = am.roleid JOIN pg_roles m ON m.oid = am.member
WHERE m.rolname = $1",
)
.bind(login)
.fetch_all(&db)
.await?;
roles.sort();
Ok::<_, anyhow::Error>(roles)
};
let all = connect(None, Some("test2@windmill.dev")).await?;
let login = all["user"].as_str().unwrap().to_string();
assert!(login.starts_with("wm_set_"), "{login}");
let mut expected = names.to_vec();
expected.sort();
assert_eq!(
member_of(login.clone()).await?,
expected,
"the login holds the default role and the viewer's, and not `admin`"
);
assert_eq!(connect(None, Some("test2@windmill.dev")).await?, all);
let two = connect(None, Some("test3@windmill.dev")).await?;
let mut expected = names[..2].to_vec();
expected.sort();
assert_eq!(
member_of(two["user"].as_str().unwrap().to_string()).await?,
expected
);
// An admin may use every role, the one owning the tables included. As a viewer they add
// what names them, which here is nothing; nor does a job with no viewer recorded.
for viewer in [Some("test@windmill.dev"), None] {
assert_eq!(connect(None, viewer).await?["user"], names[0].as_str());
}
// A named role is that role alone, whoever is viewing.
let named: &'static str = Box::leak(names[1].clone().into_boxed_str());
assert_eq!(
connect(Some(named), Some("test2@windmill.dev")).await?["user"],
names[1].as_str()
);
// A default of `admin` is the connection it always was: the viewer's roles are not added.
set_permissions("admin".to_string(), roles.clone()).await?;
let admin = connect(None, Some("test2@windmill.dev")).await?;
assert_eq!(admin, connect(Some("admin"), None).await?);
assert!(!admin["user"].as_str().unwrap().starts_with("wm_"));
// With no default role the viewer's roles are all there is, the one open to everyone
// included, and a connection left with none is refused rather than resolved as anything.
set_permissions("$none".to_string(), roles.clone()).await?;
assert_eq!(
connect(None, Some("test3@windmill.dev")).await?,
two,
"no default changes nothing for a viewer the roles name"
);
assert!(connect(None, None).await.is_err());
// The same holds for a default the job's own identity is not a tenant of.
let mut closed = roles.clone();
closed[ids[0].as_str()] = json!({"tenants": []});
set_permissions(ids[0].clone(), closed).await?;
let as_member = |viewer: Option<&'static str>| {
get_datatable_resource_from_db(
&db,
"test-workspace",
"main",
None,
DatatableAccess::PermissionedAs {
permissioned_as: "u/test-user-3",
email: "test3@windmill.dev",
end_user_email: viewer,
},
)
};
assert_eq!(
as_member(Some("test3@windmill.dev")).await?["user"],
names[1].as_str()
);
assert!(as_member(None).await.is_err());
set_permissions(ids[0].clone(), roles).await?;
// A set login nobody connects with any more is dropped, a recent one is kept, and a
// dropped one is made again for the next viewer who needs it.
let exists = || async {
sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)",
)
.bind(&login)
.fetch_one(&db)
.await
};
windmill_common::datatable_roles::sweep_unused_role_set_logins(&db).await?;
assert!(exists().await?, "a set login in use was swept");
sqlx::query("UPDATE datatable_role_set SET last_used_at = now() - interval '31 days'")
.execute(&db)
.await?;
windmill_common::datatable_roles::sweep_unused_role_set_logins(&db).await?;
assert!(!exists().await?, "an unused set login was kept");
assert_eq!(
connect(None, Some("test2@windmill.dev")).await?["user"],
login.as_str()
);
// A disabled role must not stay reachable through a login that is a member of it.
authed(
client().post(format!("{roles_url}/{}", ids[2])),
"SECRET_TOKEN",
)
.json(&json!({ "enabled": false }))
.send()
.await?
.error_for_status()?;
let can_login: bool =
sqlx::query_scalar("SELECT rolcanlogin FROM pg_roles WHERE rolname = $1")
.bind(&login)
.fetch_one(&db)
.await?;
assert!(!can_login, "{login} still logs in with a disabled role");
assert_eq!(
connect(None, Some("test2@windmill.dev")).await?["user"],
two["user"]
);
Ok::<_, anyhow::Error>(())
}
.await;
// Roles are cluster-wide, so they outlive this test's throwaway database.
for name in &names {
let logins: Vec<String> = sqlx::query_scalar(MEMBER_LOGINS)
.bind(name)
.fetch_all(&db)
.await
.unwrap_or_default();
for login in logins {
let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{login}\""))
.execute(&db)
.await;
}
let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\""))
.execute(&db)
.await;
}
outcome
}
+4
View File
@@ -5568,6 +5568,9 @@ paths:
type: boolean
default_role:
type: string
description: >-
The id of one of `roles`, `admin` (the default), or `$none` for no default
role: a connection that names no role then holds its viewer's roles only.
roles:
type: array
items:
@@ -34782,6 +34785,7 @@ components:
type: boolean
default_role:
type: string
description: A role id, `admin`, or `$none` when the data table has no default role.
roles:
type: array
items:
+10 -3
View File
@@ -8982,6 +8982,7 @@ async fn run_preview_script(
check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?;
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let (uuid, tx) = push(
&db,
tx,
@@ -9029,7 +9030,7 @@ async fn run_preview_script(
None,
Some(&authed.clone().into()),
false,
None,
end_user_email,
authed.trigger_or_fallback(None),
None,
scope_ceiling.as_deref(),
@@ -9068,6 +9069,7 @@ async fn run_inline_preview_script(
if let Some(job_id) = job_id {
register_potential_assets_on_inline_execution(job_id, &w_id, &preview);
}
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let utils = get_worker_internal_server_inline_utils()?;
let result = utils.run_inline_preview_script.as_ref()(RunInlinePreviewScriptFnParams {
content: preview.content,
@@ -9078,6 +9080,7 @@ async fn run_inline_preview_script(
created_by: authed.display_username().to_string(),
permissioned_as: username_to_permissioned_as(&authed.username),
permissioned_as_email: authed.email.clone(),
end_user_email,
lang: preview.language,
job_dir: "".to_string(),
worker_name: "".to_string(),
@@ -9217,6 +9220,7 @@ async fn run_inline_script_inner(
.to_string(),
));
}
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let utils = get_worker_internal_server_inline_utils()?;
let authed_owned: windmill_common::db::Authed = authed.clone().into();
let result = utils.run_inline_script.as_ref()(RunInlineScriptFnParams {
@@ -9228,6 +9232,7 @@ async fn run_inline_script_inner(
created_by: authed.display_username().to_string(),
permissioned_as: username_to_permissioned_as(&authed.username),
permissioned_as_email: authed.email.clone(),
end_user_email,
job_dir: "".to_string(),
worker_name: "".to_string(),
worker_dir: "".to_string(),
@@ -9404,6 +9409,7 @@ async fn run_bundle_preview_script(
// tracing::info!("is_tar 1: {is_tar}");
// hmap.insert("")
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let (uuid, ntx) = push(
&db,
ltx,
@@ -9447,7 +9453,7 @@ async fn run_bundle_preview_script(
None,
Some(&authed.clone().into()),
false,
None,
end_user_email,
authed.trigger_or_fallback(None),
None,
scope_ceiling.as_deref(),
@@ -10151,6 +10157,7 @@ async fn run_preview_flow_job(
.await?;
let scope_ceiling = windmill_api_auth::caller_scope_ceiling(&db, &authed).await?;
let end_user_email = run_end_user_email(&db, &w_id, &authed, None).await?;
let (uuid, mut tx) = push(
&db,
tx,
@@ -10182,7 +10189,7 @@ async fn run_preview_flow_job(
None,
Some(&authed.clone().into()),
false,
None,
end_user_email,
authed.trigger_or_fallback(None),
None,
scope_ceiling.as_deref(),
@@ -72,6 +72,11 @@ impl DatatableRoleCluster {
/// created, renamed or dropped.
pub const ADMIN_DATATABLE_ROLE: &str = "admin";
/// Stands in a data table's `default_role` for "none": a connection that names no role then holds
/// its viewer's roles only, and is refused without any. `$` is outside both the role-name charset
/// and the catalog ids, and a server predating it refuses it as a role the data table lacks.
pub const NO_DEFAULT_DATATABLE_ROLE: &str = "$none";
/// The login the admin connection uses, and the role every created role is granted to — that
/// membership is what later lets it `ALTER ... OWNER TO` a role and drop it.
pub const CUSTOM_INSTANCE_USER: &str = "custom_instance_user";
@@ -121,8 +126,13 @@ fn is_reserved_role_name(name: &str) -> bool {
|| lower.starts_with("pg_")
|| lower.starts_with("windmill_")
|| lower.starts_with("custom_instance_")
|| lower.starts_with(ROLE_SET_LOGIN_PREFIX)
}
/// Prefix of the logins Windmill creates for a connection made as several roles at once. Reserved,
/// so no data table role can be created under, renamed to or taken over at such a name.
pub const ROLE_SET_LOGIN_PREFIX: &str = "wm_set_";
/// The charset is what makes every downstream interpolation safe: the name reaches Postgres as a
/// quoted identifier, a `-- role <name>` annotation, and a `?role=` query parameter.
pub fn validate_role_name(name: &str) -> Result<()> {
@@ -414,6 +424,13 @@ pub async fn drop_datatable_role(
crate::datatable_roles_oss::drop_datatable_role(db, tx, cluster, name).await
}
/// Drop the logins made for a connection as several roles at once that nobody has connected with
/// for a month, on every cluster. They are made again on demand, so this only bounds how many
/// exist. Safe to call from any server at any time: it serializes on the role catalog lock.
pub async fn sweep_unused_role_set_logins(db: &DB) -> Result<()> {
crate::datatable_roles_oss::sweep_unused_role_set_logins(db).await
}
#[cfg(test)]
mod tests {
use super::*;
@@ -29,7 +29,8 @@ pub(crate) use crate::datatable_roles_ee::{
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_datatable_role, resolve_datatable_role_connection,
role_cluster, set_datatable_role_login, update_role_catalog_entry,
role_cluster, set_datatable_role_login, sweep_unused_role_set_logins,
update_role_catalog_entry,
};
#[cfg(not(all(feature = "private", feature = "enterprise")))]
@@ -235,6 +236,11 @@ mod ce {
}
}
/// No roles, so no set login to sweep.
pub(crate) async fn sweep_unused_role_set_logins(_db: &DB) -> Result<()> {
Ok(())
}
pub(crate) async fn forget_datatable_role_everywhere(
_tx: &mut Tx<'_>,
_role_id: &str,
+4
View File
@@ -474,6 +474,8 @@ pub struct RunInlinePreviewScriptFnParams {
pub created_by: String,
pub permissioned_as: String,
pub permissioned_as_email: String,
/// The caller's recorded end user: `job_perms.end_user_email` of the calling job.
pub end_user_email: Option<String>,
pub base_internal_url: String,
pub worker_name: String,
pub conn: crate::worker::Connection,
@@ -496,6 +498,8 @@ pub struct RunInlineScriptFnParams {
pub created_by: String,
pub permissioned_as: String,
pub permissioned_as_email: String,
/// The caller's recorded end user: `job_perms.end_user_email` of the calling job.
pub end_user_email: Option<String>,
pub base_internal_url: String,
pub worker_name: String,
pub conn: crate::worker::Connection,
+15 -3
View File
@@ -1564,7 +1564,8 @@ pub struct DataTableReference {
/// The access decision for one data table: which role a caller gets, and who may ask for each.
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct DataTablePermissions {
/// A role id from the instance catalog, or `admin`. Absent = `admin`.
/// A role id from the instance catalog, `admin`, or
/// [`crate::datatable_roles::NO_DEFAULT_DATATABLE_ROLE`]. Absent = `admin`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_role: Option<String>,
/// Keyed by instance role id, plus the reserved `admin` key. A role absent from this map
@@ -2161,9 +2162,13 @@ pub enum DatatableAccess<'a> {
PermissionedAs {
permissioned_as: &'a str,
email: &'a str,
/// The job's viewer: MUST be what the server recorded in `job_perms.end_user_email`,
/// never an argument or anything else the job or its caller can set.
end_user_email: Option<&'a str>,
},
/// A job identified by id; its owner is read from `v2_job`. For agent workers and anything
/// else that authenticates as infrastructure rather than as the job's user.
/// A job identified by id; its owner is read from `v2_job` and its viewer from `job_perms`.
/// For agent workers and anything else that authenticates as infrastructure rather than as
/// the job's user.
Job(uuid::Uuid),
/// No identity established. Unpermissioned data tables resolve as before; permissioned ones
/// are refused, so a caller predating this feature fails closed.
@@ -2211,6 +2216,13 @@ pub async fn can_use_datatable_role_in_governing_workspace(
///
/// The resolved role **logs in as itself**. Never `SET ROLE`: a script could `RESET ROLE` its way
/// back to admin.
///
/// A default role other than `admin` is a floor rather than the whole connection: a resolution
/// naming no role holds it when the job's own identity may use it, plus the roles whose tenant
/// lists name the job's viewer, as one login that is a member of each. With no role at all it is
/// refused. Being an admin adds none of the viewer's and `admin` is never among them, so a viewer
/// cannot be handed the connection that owns the tables and bypasses row-level security. A named
/// role is that role alone.
pub async fn get_datatable_resource_from_db(
db: &DB,
w_id: &str,
+63
View File
@@ -1726,6 +1726,23 @@ pub async fn cached_result_path(
hasher.update(b"wac_step:");
hasher.update(step_key.as_bytes());
}
// What a run returns can depend on who it ran for (`WM_END_USER_EMAIL`, a data table used
// as the viewer), so a result cached for one viewer is never served to another. Read from
// `job_perms`: a flow job re-fetched between steps does not carry it.
let end_user: Option<Option<String>> = sqlx::query_scalar(
"SELECT end_user_email FROM job_perms WHERE job_id = $1 AND workspace_id = $2",
)
.bind(job.id)
.bind(&job.workspace_id)
.fetch_optional(db)
.await?;
// Length-prefixed: the arguments follow undelimited, and the end of one viewer's address
// must not read as the start of another run's first argument name.
if let Some(end_user) = end_user.flatten() {
hasher.update(b"end_user:");
hasher.update((end_user.len() as u64).to_le_bytes());
hasher.update(end_user.as_bytes());
}
hash_args(
db,
client,
@@ -2475,6 +2492,52 @@ mod tests {
}
}
// A cache hit returns before the job runs, so the key is all that keeps a result computed
// for one viewer (a data table used as the viewer, `WM_END_USER_EMAIL`) from another.
#[sqlx::test(migrations = "../migrations")]
async fn a_cached_result_is_keyed_by_the_recorded_viewer(pool: sqlx::PgPool) {
let client = test_client();
// The last two differ only in where the viewer's address ends and the first argument
// name starts.
let mut paths = vec![];
for (viewer, arg) in [
(Some("a@windmill.dev"), "mode"),
(Some("a@windmill.dev"), "mode"),
(Some("b@windmill.dev"), "mode"),
(None, "mode"),
(Some("a@windmill.co"), "mode"),
(Some("a@windmill.com"), "ode"),
] {
let args = HashMap::from([(arg.to_string(), to_raw_value(&"all"))]);
let job = MiniPulledJob {
id: uuid::Uuid::new_v4(),
args: Some(sqlx::types::Json(args)),
..test_job()
};
sqlx::query(
"INSERT INTO job_perms (job_id, email, username, is_admin, is_operator,
workspace_id, groups, folders, end_user_email)
VALUES ($1, 'publisher@windmill.dev', 'publisher', false, false, 'test', '{}',
'{}', $2)",
)
.bind(job.id)
.bind(viewer)
.execute(&pool)
.await
.unwrap();
paths.push(cached_result_path(&pool, &client, &job, None).await.unwrap());
}
assert_eq!(
paths[0], paths[1],
"one viewer shares their own cached result"
);
assert_ne!(paths[0], paths[2], "two viewers share a cached result");
assert_ne!(paths[0], paths[3]);
assert_ne!(paths[2], paths[3]);
assert_ne!(paths[4], paths[5], "an address runs into the argument names");
}
#[tokio::test]
async fn test_transform_array_over_1000_passthrough() {
let db_url = std::env::var("DATABASE_URL")
@@ -2758,6 +2758,7 @@ async fn transform_attach_datatable(
DatatableAccess::PermissionedAs {
permissioned_as: &job.permissioned_as,
email: &job.permissioned_as_email,
end_user_email: job.permissioned_as_end_user_email.as_deref(),
},
)
.await?
@@ -1229,6 +1229,7 @@ pub async fn do_postgresql(
DatatableAccess::PermissionedAs {
permissioned_as: &job.permissioned_as,
email: &job.permissioned_as_email,
end_user_email: job.permissioned_as_end_user_email.as_deref(),
},
)
.await?
+4 -2
View File
@@ -7253,7 +7253,7 @@ pub fn init_worker_internal_server_inline_utils(
base_internal_url,
killpill_rx: Arc::new(killpill_rx),
run_inline_preview_script: Arc::new(|params| {
let job = MiniPulledJob::new_inline(
let mut job = MiniPulledJob::new_inline(
params.workspace_id,
params.args,
params.created_by,
@@ -7265,6 +7265,7 @@ pub fn init_worker_internal_server_inline_utils(
"inline_preview".to_string(),
Some(params.lang),
);
job.permissioned_as_end_user_email = params.end_user_email;
Box::pin(async move {
let mut mem_peak: i32 = -1;
let mut canceled_by: Option<CanceledBy> = None;
@@ -7314,7 +7315,7 @@ pub fn init_worker_internal_server_inline_utils(
let content_info =
get_script_content_by_hash(&script_hash, &params.workspace_id, &params.conn)
.await?;
let job = MiniPulledJob::new_inline(
let mut job = MiniPulledJob::new_inline(
params.workspace_id,
params.args,
params.created_by,
@@ -7326,6 +7327,7 @@ pub fn init_worker_internal_server_inline_utils(
"inline_run".to_string(),
content_info.language,
);
job.permissioned_as_end_user_email = params.end_user_email;
let mut mem_peak: i32 = -1;
let mut canceled_by: Option<CanceledBy> = None;
let mut column_order: Option<Vec<String>> = None;
+2 -1
View File
@@ -6557,7 +6557,7 @@ data:
- \`<datatable>/<table>\` — Specific table in public schema
- \`<datatable>/<schema>:<table>\` — Table in specific schema
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role.
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role, if it has one, plus the roles of whoever is using the app unless that default is \`admin\`.
## SQL Migrations (sql_to_apply/)
@@ -6863,6 +6863,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer
3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.
4. **Qualify table names**: an unqualified name means the \`public\` schema, so write every other table as \`schema.table\`, in table creation and queries alike. The app's \`data\` config sets the default datatable and schema its tables go in; use them consistently across runnables.
5. **Pass the role**: when the app's \`data.roles\` gives a data table a role, every \`wmill.datatable\` call on it passes that role (\`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python). The role only reaches what it was granted, so a query outside it fails with \`permission denied\`.
6. **Per-viewer rows**: a data table with no entry in \`data.roles\` is called with no role. Unless its default role is \`admin\`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no \`data.roles\` entry.
\`wmill datatable list\` lists the workspace's data tables. Create or change tables with a migration in \`sql_to_apply/\` (see "SQL Migrations" above), then add them to \`data.tables\` in \`raw_app.yaml\`.
+93
View File
@@ -0,0 +1,93 @@
# Data table connections and the viewer's roles
A data table under roles (Enterprise Edition) resolves a connection that names a role to that
role alone. A connection that names none is resolved from the data table's default role:
| Default role | A connection naming no role holds |
|---|---|
| `admin` (until changed) | `admin`, as before viewer roles existed |
| another role | that role when the job's own identity may use it, plus **every role the job's viewer is a tenant of** |
| none (`NO_DEFAULT_DATATABLE_ROLE`) | the viewer's roles only |
A connection left with no role at all is refused. Postgres row-level security can then answer
per viewer from a backend runnable that runs as the app's publisher, with nothing written in the
runnable.
Policies test membership, which is true for a role's own login too, so one policy serves a named
connection and an unnamed one:
```sql
CREATE POLICY emea ON orders FOR SELECT
USING (region = 'emea' AND pg_has_role(current_user, 'sales_emea', 'member'));
```
A policy names its role as text, so renaming the role breaks it.
## Code
| Where | What |
|---|---|
| `windmill-common/src/workspaces.rs` | `DatatableAccess`, `get_datatable_resource_from_db` |
| `windmill-common/src/datatable_roles_ee.rs` | `resolve_datatable_role_connection`, `viewer_role_ids`, `ensure_role_set_login`, `retire_role_set_logins_of`, `sweep_unused_role_set_logins` |
| `windmill-api-jobs/src/execution.rs` | `run_end_user_email`, where a run's viewer is recorded |
| `frontend/.../workspaceSettings/DataTablePermissionsButton.svelte` | the roles drawer, which warns while the default is `admin` |
## What holds it together
- **A default of `admin` is not a floor.** `admin` owns the tables and bypasses row-level
security, so nothing could be added to it, and it is the default until someone changes it.
Such a data table resolves exactly as it did before viewer roles existed. Nothing ever sets a
default back to `admin`: removing or deleting the default role leaves none.
- **The default role is tenant-checked against the job's own identity**, the publisher for a
publisher-mode app, and held only when that passes. The viewer's roles are added either way.
- **No role at all is a refusal, never an empty answer.** There is no login that would read
nothing: what a role sees is up to each table's grants and policies. A job without a recorded
viewer (anonymous app viewer, schedule, trigger, a tool job started by an AI agent step), or
whose viewer is not a member of the governing workspace, therefore needs a default it may use
or a named role.
- **A set login owns what it creates.** Postgres gives a new object to `current_user`, and
default privileges go by the creating role, so a table created over a connection that holds
several roles belongs to that set's login: no other role or viewer reaches it, and it passes to
`custom_instance_user` when the login is swept. Unnamed connections are for rows. To create an
object, name a role or run `SET ROLE <role>` first, which a set login may do for any role it
holds. Migrations are not affected: one that declares no role runs as `admin`.
- **The viewer is `job_perms.end_user_email`, and nothing else.** The server records it when the
run starts: the session behind an app request, the caller of a direct run or preview, and for a
job started with another job's token, that job's viewer. No argument reaches it.
- **A cached result belongs to one viewer.** `cached_result_path` hashes the run's recorded viewer
into the key, since a cache hit returns before any connection is resolved. Runs with no viewer
recorded keep sharing one entry.
- **Only a tenant list naming the viewer counts.** A workspace admin may *use* every role,
`admin` included. The viewer's roles are those whose tenants name them (user, group, folder,
`*`), and never `admin`.
- **One role is that role's login. Several are a login per distinct set.** `wm_set_<hash>` is a
member of exactly those roles, with `INHERIT`; the hash is over their catalog ids, so a rename
moves nothing and a change in someone's groups resolves them to another login instead of
editing one. Its privileges are its memberships, so `SET ROLE` only narrows and `RESET ROLE`
comes back to it. The prefix is reserved in `validate_role_name`.
- **Resolving stays cheap where no viewer could add anything.** The viewer is only looked up when
some role other than the default and `admin` has tenants on that data table.
- **A `datatable_role_set` row with a password names a login that can authenticate with it.** The
row is written after the login exists and removed, in its own commit, before the login is
dropped or made `NOLOGIN`. A login without a row is taken back with a new password on next use,
and so is one whose row has an empty password: that is what a sweep leaves when it could
neither drop a login nor let it back in, so the next sweep still finds it.
- **Set logins are bounded by use, not by the number of role combinations.** One exists only for
a set some viewer actually connected with, so there are at most as many as (viewer, data table)
pairs. `last_used_at` is recorded at most once a day, and `sweep_unused_role_set_logins`, run
from the monitor loop, drops those unused for 30 days; a dropped one is made again on demand.
The sweep goes by this instance's rows, never by the cluster's `wm_set_*` roles: a cluster can
serve several Windmill instances.
- **Disabling or dropping a role retires every set login holding it.** `NOLOGIN` on a role does
not reach a login that is a member of it.
- **A worker or server that predates this connects as the default role alone**, so a mixed fleet
shows a viewer fewer rows, never more. It refuses a data table with no default role, whose
marker it reads as a role the data table lacks.
## Trust
A job that names no role runs its author's code with the viewer's reach into the data table, the
same trust as an app in viewer execution mode or a script someone else wrote and you run. Whoever
can deploy a publisher-mode app, or a script that others run, can therefore read through the
non-`admin` roles of the people who use it. Naming a role in the code is how an author pins the
connection to one role instead.
+10 -2
View File
@@ -45,7 +45,12 @@
import { favoriteManager } from './sidebar/FavoriteMenu.svelte'
import DatatableRoleBadge from './DatatableRoleBadge.svelte'
import type { AclTarget, Asset, DataTableTables } from '$lib/gen'
import { ADMIN_DATATABLE_ROLE, datatableNameTakesRole, type DatatableRowAction } from './dbTypes'
import {
ADMIN_DATATABLE_ROLE,
datatableNameTakesRole,
namedDefaultRole,
type DatatableRowAction
} from './dbTypes'
import TextInput from './text_input/TextInput.svelte'
import Checkbox from './common/checkbox/Checkbox.svelte'
import DbSchemaDiagram from './dbdiagram/DbSchemaDiagram.svelte'
@@ -417,7 +422,10 @@
if (!entry?.permissioned || !datatableNameTakesRole(datatable)) return undefined
const roles = entry.usable_roles
if (roles.length === 0) return { kind: 'none' }
const role = (datatable === currentDatatable ? currentRole : undefined) ?? entry.default_role
const role =
(datatable === currentDatatable ? currentRole : undefined) ??
namedDefaultRole(entry.default_role) ??
roles[0]
if (roles.length === 1 && roles[0] === ADMIN_DATATABLE_ROLE && role === ADMIN_DATATABLE_ROLE) {
return undefined
}
+10 -1
View File
@@ -32,6 +32,9 @@ export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includ
/** The role every data table has: the one it connects as when it is not under roles. */
export const ADMIN_DATATABLE_ROLE = 'admin'
/** What `default_role` holds for a data table with no default role. Mirrors the backend's
* `NO_DEFAULT_DATATABLE_ROLE`. */
export const NO_DEFAULT_DATATABLE_ROLE = '$none'
/** What the server accepts in `-- role <name>` and `?role=<name>`. */
export function isDatatableRoleName(name: string): boolean {
@@ -52,7 +55,13 @@ export function defaultMigrationRole(
permissioned: boolean | undefined,
defaultRole: string | undefined
): string | undefined {
return permissioned && !datatableNameTakesRole(name) ? defaultRole : undefined
return permissioned && !datatableNameTakesRole(name) ? namedDefaultRole(defaultRole) : undefined
}
/** A data table's default role, or `undefined` when it has none: the server reports that as
* `NO_DEFAULT_DATATABLE_ROLE`, which is not a role anything can connect as. */
export function namedDefaultRole(defaultRole: string | undefined): string | undefined {
return defaultRole === NO_DEFAULT_DATATABLE_ROLE ? undefined : defaultRole
}
/** `datatable://<name>`, with `?role=<role>` when a role is named. Throws rather than build a
@@ -14,6 +14,7 @@
ADMIN_DATATABLE_ROLE,
datatableNameTakesRole,
defaultMigrationRole,
namedDefaultRole,
type DbInput
} from '../dbTypes'
import type { PendingRowAction, SelectedTable } from '../DBManager.svelte'
@@ -173,15 +174,15 @@
return rolesOfCurrent?.permissioned ? selectedRole : undefined
}
const entry = datatableTree.current.find((t) => t.datatable_name === datatable)
return entry?.permissioned ? entry.default_role : undefined
return entry?.permissioned ? namedDefaultRole(entry.default_role) : undefined
}
function defaultRoleOf(datatable: string): string | undefined {
if (datatable === selectedDatatable && rolesOfCurrent?.permissioned) {
return rolesOfCurrent.default_role
return namedDefaultRole(rolesOfCurrent.default_role)
}
const entry = datatableTree.current.find((t) => t.datatable_name === datatable)
return entry?.permissioned ? entry.default_role : undefined
return entry?.permissioned ? namedDefaultRole(entry.default_role) : undefined
}
const tableDatatable = (t: SelectedTable) => t.datatable ?? selectedDatatable
@@ -23,7 +23,7 @@
import { superadmin } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { deepEqual } from 'fast-equals'
import { ADMIN_DATATABLE_ROLE, isDatatableRoleName } from '../dbTypes'
import { ADMIN_DATATABLE_ROLE, NO_DEFAULT_DATATABLE_ROLE, isDatatableRoleName } from '../dbTypes'
import PgAclEditor from '../datatableAcl/PgAclEditor.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
@@ -87,6 +87,8 @@
const pgInstanceName = $derived(pgInstanceNameOf(info?.cluster))
const unusedRoles = $derived(availableRoles.filter((r) => !roles.some((row) => row.id === r.id)))
const confirmationModal = createAsyncConfirmationModal()
// Every member of the governing workspace, as the tenant lists spell it.
const EVERYONE_TENANT = '*'
let aclEditor = $state<PgAclEditor | undefined>(undefined)
const hasUnsavedChanges = $derived(
@@ -111,9 +113,13 @@
(a, b) => Number(b.id === ADMIN_DATATABLE_ROLE) - Number(a.id === ADMIN_DATATABLE_ROLE)
)
// A data table never put under roles comes back with none; admin is what turning the toggle
// on starts from.
// on starts from, open to everyone so that turning it on changes nothing by itself.
if (!loaded.some((r) => r.id === ADMIN_DATATABLE_ROLE)) {
loaded.unshift({ id: ADMIN_DATATABLE_ROLE, name: ADMIN_DATATABLE_ROLE, tenants: [] })
loaded.unshift({
id: ADMIN_DATATABLE_ROLE,
name: ADMIN_DATATABLE_ROLE,
tenants: [EVERYONE_TENANT]
})
}
info = res
catalog = undefined
@@ -137,7 +143,7 @@
FolderService.listFolderNames({ workspace: ws })
])
tenantItems = [
{ value: '*', label: 'Everyone', group: 'Anyone in the workspace' },
{ value: EVERYONE_TENANT, label: 'Everyone', group: 'Anyone in the workspace' },
...users.map((u) => ({ value: `u/${u}`, label: u, group: 'Users' })),
...groups.map((g) => ({ value: `g/${g}`, label: g, group: 'Groups' })),
...folders.map((f) => ({ value: `f/${f}`, label: f, group: 'Folders' }))
@@ -218,11 +224,28 @@
}
}
function removeRole(role: EditedRole) {
async function removeRole(role: EditedRole) {
if (defaultRoleId === role.id) {
const confirmed = await confirmationModal.ask({
title: 'Remove the default role?',
children: `Jobs that name no role connect as ${role.name ?? role.id}. Without it the data table has no default role: they connect with the roles of the person running them, and are refused when that person has none.`,
confirmationText: 'Remove',
type: 'danger'
})
if (!confirmed) return
defaultRoleId = NO_DEFAULT_DATATABLE_ROLE
}
roles = roles.filter((r) => r.id !== role.id)
if (defaultRoleId === role.id) defaultRoleId = ADMIN_DATATABLE_ROLE
}
const defaultRoleName = $derived(roles.find((r) => r.id === defaultRoleId)?.name ?? defaultRoleId)
const defaultsToAdmin = $derived(defaultRoleId === ADMIN_DATATABLE_ROLE)
const hasNoDefault = $derived(defaultRoleId === NO_DEFAULT_DATATABLE_ROLE)
const hasOtherRoles = $derived(roles.some((r) => r.id !== ADMIN_DATATABLE_ROLE))
const adminOpenToEveryone = $derived(
!!roles.find((r) => r.id === ADMIN_DATATABLE_ROLE)?.tenants.includes(EVERYONE_TENANT)
)
async function save() {
saving = true
// Turning roles off clears the whole block, so every role it named is removed.
@@ -320,7 +343,7 @@
options={{
right: 'Put this data table under roles',
rightTooltip:
'Off, every job connects as admin — the connection that owns every table. On, every job resolves to a role, and a caller no tenant covers is refused.'
'Off, every job connects as admin — the connection that owns every table. On, every job resolves to a role, and a caller no tenant covers is refused. It starts with admin open to everyone, so turning it on changes nothing until the roles are edited.'
}}
/>
@@ -391,8 +414,10 @@
<Cell head>
Default
<Tooltip>
The role a job gets when it names none — no `-- role` annotation, no `?role=` in
the reference. Callers still have to be one of its tenants.
The role a job gets when it names none: no `-- role` annotation, no `?role=` in
the reference. There is always one choice: admin until changed, another role, or
none. Unless it is admin, the roles the person running the job is a tenant of
are added to it.
</Tooltip>
</Cell>
<Cell head last />
@@ -440,6 +465,29 @@
</Cell>
</Row>
{/each}
<Row>
<Cell first class="w-56 align-top">
<span class="text-xs text-secondary italic">No default role</span>
</Cell>
<Cell class="align-top">
<span class="text-xs text-secondary">
Jobs that name no role get only the roles of the person running them.
</span>
</Cell>
<Cell class="w-20 align-top">
<div class="flex justify-center">
<Checkbox
checked={hasNoDefault}
disabled={!editable}
title="Use no role when a job names none"
onChange={() => {
defaultRoleId = NO_DEFAULT_DATATABLE_ROLE
}}
/>
</div>
</Cell>
<Cell last class="w-10 align-top" />
</Row>
{#if editable}
<Row>
<Cell colspan={4} class="pt-2 pb-2">
@@ -466,6 +514,47 @@
{/if}
</tbody>
</DataTable>
{#if adminOpenToEveryone}
<Alert type="warning" title="Everyone can connect as admin" size="xs">
Every member of the workspace may use admin{defaultsToAdmin
? ', and a job that names no role connects as it, exactly as with roles off'
: ' by naming it'}. admin owns every table: it reads every row and ignores grants
and row-level security. Once the other roles are set up, remove Everyone from admin's
tenants{defaultsToAdmin ? ' and choose another default, or none' : ''}.
</Alert>
{/if}
{#if defaultsToAdmin && hasOtherRoles && !adminOpenToEveryone}
<Alert type="warning" title="The default role is admin" size="xs">
A job that names no role connects as admin when it runs as a workspace admin or as one
of admin's tenants, which covers every viewer of an app an admin published. admin owns
every table: it reads every row and ignores grants and row-level security. Everyone
else's jobs that name no role are refused. Choose another default, or none, to have
the roles above apply to them.
</Alert>
{:else if !defaultsToAdmin}
<div class="flex flex-col gap-1 text-xs text-secondary">
{#if hasNoDefault}
<span>
A job that names no role connects with the roles here that the person running it
is a tenant of. It is refused when they have none, and so is a job with nobody
behind it: a schedule, a trigger, an anonymous app viewer.
</span>
{:else}
<span>
A job that names no role connects as
<span class="font-mono">{defaultRoleName}</span> when it may use it, plus every other
role here that the person running it is a tenant of. With neither, it is refused.
</span>
{/if}
<span>A job that names a role connects as that role alone.</span>
<span>
A table created over a connection that holds several roles belongs to that
combination of roles alone. Run <span class="font-mono">SET ROLE</span> to one role before
creating it, or name a role.
</span>
</div>
{/if}
{/if}
</div>
+1
View File
@@ -1131,6 +1131,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer
3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.
4. **Qualify table names**: an unqualified name means the \`public\` schema, so write every other table as \`schema.table\`, in table creation and queries alike. The app's \`data\` config sets the default datatable and schema its tables go in; use them consistently across runnables.
5. **Pass the role**: when the app's \`data.roles\` gives a data table a role, every \`wmill.datatable\` call on it passes that role (\`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python). The role only reaches what it was granted, so a query outside it fails with \`permission denied\`.
6. **Per-viewer rows**: a data table with no entry in \`data.roles\` is called with no role. Unless its default role is \`admin\`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as \`pg_has_role(current_user, '<role>', 'member')\` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no \`data.roles\` entry.
### Querying in TypeScript (Bun/Deno)
@@ -188,7 +188,7 @@ data:
- `<datatable>/<table>` — Specific table in public schema
- `<datatable>/<schema>:<table>` — Table in specific schema
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role.
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role, if it has one, plus the roles of whoever is using the app unless that default is `admin`.
## SQL Migrations (sql_to_apply/)
@@ -494,6 +494,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer
3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.
4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.
5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.
6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no `data.roles` entry.
`wmill datatable list` lists the workspace's data tables. Create or change tables with a migration in `sql_to_apply/` (see "SQL Migrations" above), then add them to `data.tables` in `raw_app.yaml`.
+1 -1
View File
@@ -183,7 +183,7 @@ data:
- `<datatable>/<table>` — Specific table in public schema
- `<datatable>/<schema>:<table>` — Table in specific schema
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role.
**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role, if it has one, plus the roles of whoever is using the app unless that default is `admin`.
## SQL Migrations (sql_to_apply/)
+1
View File
@@ -264,6 +264,7 @@ Data tables are PostgreSQL databases managed by Windmill. Backend runnables quer
3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.
4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.
5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.
6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it has one, plus every role the person using the app is a tenant of, and is refused when that is none. Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` return that viewer's rows with nothing added to the runnable. A role pins the connection to that one role for every viewer, so where rows should differ per viewer give the data table no `data.roles` entry.
<!-- cli-only -->
`wmill datatable list` lists the workspace's data tables. Create or change tables with a migration in `sql_to_apply/` (see "SQL Migrations" above), then add them to `data.tables` in `raw_app.yaml`.