diff --git a/AGENTS.md b/AGENTS.md index fd4b60a64b..4aa6027a18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 connections as the viewer**: `docs/datatable-viewer-roles.md`, on `?as=viewer`: 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` diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 012f205d9c..51f87e8879 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -06610ecc950ed64a2709d7f1aa8c7b49a95fdbae +06083a1d8060b2eb92df954c0aef4a9e7d857e28 \ No newline at end of file diff --git a/backend/migrations/20261012180101_datatable_role_set.down.sql b/backend/migrations/20261012180101_datatable_role_set.down.sql new file mode 100644 index 0000000000..15384edae8 --- /dev/null +++ b/backend/migrations/20261012180101_datatable_role_set.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS datatable_role_set; diff --git a/backend/migrations/20261012180101_datatable_role_set.up.sql b/backend/migrations/20261012180101_datatable_role_set.up.sql new file mode 100644 index 0000000000..15642cc0ca --- /dev/null +++ b/backend/migrations/20261012180101_datatable_role_set.up.sql @@ -0,0 +1,18 @@ +-- 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(), + PRIMARY KEY (cluster, name) +); + +GRANT ALL ON datatable_role_set TO windmill_user; +GRANT ALL ON datatable_role_set TO windmill_admin; diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index 9800d97841..e14fe61986 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -1847,3 +1847,142 @@ 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 connecting_as_the_viewer_takes_the_roles_that_name_them_and_no_other( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::workspaces::{ + get_datatable_resource_from_db, DatatableAccess, DATATABLE_VIEWER_ROLE, + }; + + 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_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 as_viewer = |viewer: Option<&'static str>| { + get_datatable_resource_from_db( + &db, + "test-workspace", + "main", + Some(DATATABLE_VIEWER_ROLE), + DatatableAccess::PermissionedAs { + permissioned_as: "u/test-user", + email: "test@windmill.dev", + end_user_email: viewer, + }, + ) + }; + + 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 roles, one of them through a group, and by `admin`; + // `test-user-3` by one; the workspace admin `test-user` by none. + sqlx::query( + "UPDATE workspace_settings + SET datatable = jsonb_set(datatable, '{datatables,main,permissions,roles}', $1) + WHERE workspace_id = 'test-workspace'", + ) + .bind(json!({ + "admin": {"tenants": ["u/test-user-2"]}, + (ids[0].as_str()): {"tenants": ["u/test-user-2", "u/test-user-3"]}, + (ids[1].as_str()): {"tenants": ["g/analysts"]}, + })) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO usr_to_group (workspace_id, usr, group_) + VALUES ('test-workspace', 'test-user-2', 'analysts')", + ) + .execute(&db) + .await?; + + let both = as_viewer(Some("test2@windmill.dev")).await?; + let login = both["user"].as_str().unwrap().to_string(); + assert!(login.starts_with("wm_set_"), "{login}"); + let mut member_of: Vec = 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?; + member_of.sort(); + let mut expected = names.to_vec(); + expected.sort(); + assert_eq!( + member_of, expected, + "the login holds exactly the viewer's roles" + ); + assert_eq!(as_viewer(Some("test2@windmill.dev")).await?, both); + + let one = as_viewer(Some("test3@windmill.dev")).await?; + assert_eq!(one["user"], names[0].as_str(), "one role is its own login"); + + // An admin may use every role, the one owning the tables included. As a viewer they get + // what names them, which here is nothing. + assert!(as_viewer(Some("test@windmill.dev")).await.is_err()); + // No recorded viewer must not fall back to the job's owner. + assert!(as_viewer(None).await.is_err()); + + // A disabled role must not stay reachable through a login that is a member of it. + authed( + client().post(format!("{roles_url}/{}", ids[1])), + "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"); + let remaining = as_viewer(Some("test2@windmill.dev")).await?; + assert_eq!(remaining["user"], names[0].as_str()); + Ok::<_, anyhow::Error>(()) + } + .await; + + // Roles are cluster-wide, so they outlive this test's throwaway database. + for name in &names { + let logins: Vec = 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 +} diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 29268d3cb2..2b93c6daeb 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -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(), diff --git a/backend/windmill-common/src/datatable_roles.rs b/backend/windmill-common/src/datatable_roles.rs index e7e490a6a4..9488d7846a 100644 --- a/backend/windmill-common/src/datatable_roles.rs +++ b/backend/windmill-common/src/datatable_roles.rs @@ -121,8 +121,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 ` annotation, and a `?role=` query parameter. pub fn validate_role_name(name: &str) -> Result<()> { diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 1a314d34c0..75670fe874 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -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, 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, pub base_internal_url: String, pub worker_name: String, pub conn: crate::worker::Connection, diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index f819c5b796..557ed8e18e 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1589,6 +1589,12 @@ pub struct DataTableRoleTenants { /// Every member of the governing workspace. pub const DATATABLE_TENANT_WILDCARD: &str = "*"; +/// Stands in the `role` of a resolution for "every role the job's viewer is a tenant of", which +/// callers write as `?as=viewer`. `$` is outside the role-name charset, so no role can be named +/// this, and a server predating it refuses it as an unknown role rather than resolving the +/// default one. +pub const DATATABLE_VIEWER_ROLE: &str = "$viewer"; + /// How deep a chain of pointer entries may go before it is called a loop. Data tables are not /// expected to chain at all — a fork points at its parent — so this only has to be generous /// enough to survive a fork of a fork. @@ -2161,9 +2167,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 +2221,12 @@ 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. +/// +/// [`DATATABLE_VIEWER_ROLE`] resolves for the job's viewer rather than its owner: the roles whose +/// tenant lists name the viewer, one login holding all of them. Being an admin adds none, and +/// `admin` is never among them, so an app's viewer cannot be handed the connection that owns the +/// tables and bypasses row-level security. A viewer with no role, or a job with none recorded, is +/// refused. pub async fn get_datatable_resource_from_db( db: &DB, w_id: &str, @@ -2478,22 +2494,36 @@ pub async fn parse_datatable_ref_for( /// resolve the reference to the data table's default role, so `?Role=analytics` or a mistyped /// `?role=` would quietly connect as something the caller did not ask for — the same trap as a /// malformed `-- role` annotation, and `role` is the only parameter a reference takes. +/// +/// `?as=viewer` asks for every role of the job's viewer instead of one by name, and comes back as +/// [`DATATABLE_VIEWER_ROLE`]. pub fn parse_datatable_ref(reference: &str) -> Result<(&str, Option<&str>)> { let (name, query) = reference.split_once('?').unwrap_or((reference, "")); let mut role = None; for param in query.split('&').filter(|p| !p.is_empty()) { let (key, value) = param.split_once('=').unwrap_or((param, "")); - if !key.eq_ignore_ascii_case("role") { + let as_viewer = key.eq_ignore_ascii_case("as"); + if !as_viewer && !key.eq_ignore_ascii_case("role") { return Err(Error::BadRequest(format!( "Data table reference '{name}' carries an unknown parameter '{key}'. \ - The only one it takes is `?role=`." + It takes `?role=` or `?as=viewer`." ))); } if role.is_some() { return Err(Error::BadRequest(format!( - "Data table reference '{name}' names a role more than once." + "Data table reference '{name}' takes one `role` or `as=viewer`, not several." ))); } + if as_viewer { + if value != "viewer" { + return Err(Error::BadRequest(format!( + "Data table reference '{name}' has a malformed `as` parameter '{value}'. \ + Write it as `?as=viewer`." + ))); + } + role = Some(DATATABLE_VIEWER_ROLE); + continue; + } if value.is_empty() || !is_datatable_role_name(value) { return Err(Error::BadRequest(format!( "Data table reference '{name}' has a malformed role '{value}'. Write it as \ @@ -3821,13 +3851,22 @@ mod tests { ("sales", Some("analytics")) ); + assert_eq!( + parse_datatable_ref("sales?as=viewer").unwrap(), + ("sales", Some(DATATABLE_VIEWER_ROLE)) + ); + // A query string that does not parse is refused rather than read as "no role": resolving // it to the data table's default would connect as a login the caller never asked for. + // The viewer cannot be asked for through `role`, nor together with one. for malformed in [ "sales?role=", "sales?role=an;alytics", "sales?x=1&role=analytics", "sales?role=a&role=b", + "sales?as=owner", + "sales?as=viewer&role=analytics", + "sales?role=$viewer", ] { assert!( parse_datatable_ref(malformed).is_err(), diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index b8c850a2f4..58e47b9332 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -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? diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index aac7d549db..cf77f61d80 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -27,6 +27,7 @@ use windmill_common::worker::{ }; use windmill_common::workspaces::{ get_datatable_resource_from_db, parse_datatable_ref, parse_datatable_ref_for, DatatableAccess, + DATATABLE_VIEWER_ROLE, }; use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB}; use windmill_parser::{Arg, Typ}; @@ -1171,6 +1172,23 @@ fn do_postgresql_inner<'a>( Ok(result_f.boxed()) } +/// The annotation wins over a `?role=` in the reference, but naming a role while the reference +/// asks for the viewer's is refused: either reading would connect as something the other half of +/// the request did not ask for. +fn requested_datatable_role<'a>( + annotated: Option<&'a str>, + uri_role: Option<&'a str>, +) -> error::Result> { + if annotated.is_some() && uri_role == Some(DATATABLE_VIEWER_ROLE) { + return Err(Error::BadRequest( + "The query names a data table role (`-- role`) while its data table reference asks \ + for the viewer's roles (`?as=viewer`). Use one or the other." + .to_string(), + )); + } + Ok(annotated.or(uri_role)) +} + pub async fn do_postgresql( job: &MiniPulledJob, client: &AuthedClient, @@ -1213,7 +1231,7 @@ pub async fn do_postgresql( client, name, &job.workspace_id, - annotated.as_deref().or(uri_role), + requested_datatable_role(annotated.as_deref(), uri_role)?, &job.id, ) .await? @@ -1225,10 +1243,11 @@ pub async fn do_postgresql( db, &job.workspace_id, &name, - annotated.as_deref().or(uri_role.as_deref()), + requested_datatable_role(annotated.as_deref(), uri_role.as_deref())?, DatatableAccess::PermissionedAs { permissioned_as: &job.permissioned_as, email: &job.permissioned_as_email, + end_user_email: job.permissioned_as_end_user_email.as_deref(), }, ) .await? diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 7c3c3971fa..3aa377c904 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -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 = None; @@ -7314,7 +7315,7 @@ pub fn init_worker_internal_server_inline_utils( let content_info = get_script_content_by_hash(&script_hash, ¶ms.workspace_id, ¶ms.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 = None; let mut column_order: Option> = None; diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index eb5d46ef57..f9b45b9662 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -1220,6 +1220,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @param name - Database/datatable name (default: "main") * @param opts.role - Connect as this data table role instead of the data table's default one. * Only meaningful on a data table under roles, and only for a role you are a tenant of. + * @param opts.as - \`"viewer"\` connects with every role of the job's viewer at once, so row-level + * security policies see the user of the app rather than its publisher. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -1232,6 +1234,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @example * // Read through a restricted role * let sql = wmill.datatable("main", { role: "analytics" }) + * @example + * // In an app's backend runnable: read what the app's viewer may read + * let sql = wmill.datatable("main", { as: "viewer" }) */ datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction @@ -2063,6 +2068,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @param name - Database/datatable name (default: "main") * @param opts.role - Connect as this data table role instead of the data table's default one. * Only meaningful on a data table under roles, and only for a role you are a tenant of. + * @param opts.as - \`"viewer"\` connects with every role of the job's viewer at once, so row-level + * security policies see the user of the app rather than its publisher. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -2075,6 +2082,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @example * // Read through a restricted role * let sql = wmill.datatable("main", { role: "analytics" }) + * @example + * // In an app's backend runnable: read what the app's viewer may read + * let sql = wmill.datatable("main", { as: "viewer" }) */ datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction @@ -3012,6 +3022,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @param name - Database/datatable name (default: "main") * @param opts.role - Connect as this data table role instead of the data table's default one. * Only meaningful on a data table under roles, and only for a role you are a tenant of. + * @param opts.as - \`"viewer"\` connects with every role of the job's viewer at once, so row-level + * security policies see the user of the app rather than its publisher. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -3024,6 +3036,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @example * // Read through a restricted role * let sql = wmill.datatable("main", { role: "analytics" }) + * @example + * // In an app's backend runnable: read what the app's viewer may read + * let sql = wmill.datatable("main", { as: "viewer" }) */ datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction @@ -4892,10 +4907,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca # role: Connect as this data table role instead of the data table's default one. # Only meaningful on a data table under roles, and only for a role you are a # tenant of. +# as_viewer: Connect with every role of the job's viewer at once: the user of the +# app the job runs for, or whoever ran the script. The server resolves who that +# is. Cannot be combined with role. # # Returns: # DataTableClient instance -def datatable(name: str = 'main', *, role: Optional[str] = None) +def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False) # Get a DuckLake client for DuckDB queries. # @@ -6522,7 +6540,7 @@ data: - \`/\` — Specific table in public schema - \`/:
\` — 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. To use a datatable with the roles of whoever is viewing the app rather than one fixed role, pass \`{ as: 'viewer' }\` (\`as_viewer=True\` in Python) instead of a role. ## SQL Migrations (sql_to_apply/) @@ -6816,6 +6834,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 runnable runs as the app's publisher, whoever is using the app. To have a data table under roles answer for the viewer instead, connect as the viewer: \`wmill.datatable('main', { as: 'viewer' })\` in TypeScript, \`wmill.datatable('main', as_viewer=True)\` in Python, \`datatable://main?as=viewer\` as a SQL runnable's database or in a DuckDB \`ATTACH\`. The connection then holds every role the viewer is a tenant of, so row-level security policies written as \`pg_has_role(current_user, '', 'member')\` return that viewer's rows. The server works out who the viewer is; a viewer with no role on the data table, or an anonymous one, is refused. Never combine it with \`role\`. \`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\`. diff --git a/docs/datatable-viewer-roles.md b/docs/datatable-viewer-roles.md new file mode 100644 index 0000000000..9380adcfb5 --- /dev/null +++ b/docs/datatable-viewer-roles.md @@ -0,0 +1,63 @@ +# Data table connections as the viewer + +A data table under roles (Enterprise Edition) normally resolves to one role: the one a caller +names, or the data table's default. `?as=viewer` resolves to **every role the job's viewer is a +tenant of**, so Postgres row-level security can answer per viewer from a backend runnable that +runs as the app's publisher. + +```ts +wmill.datatable('main', { as: 'viewer' }) // TypeScript +wmill.datatable('main', as_viewer=True) # Python +ATTACH 'datatable://main?as=viewer' AS dt; -- DuckDB +-- PostgreSQL script: `datatable://main?as=viewer` as its `database` argument +``` + +Policies test membership, which is true for a role's own login too, so one policy serves both +kinds of connection: + +```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` | `DATATABLE_VIEWER_ROLE`, `parse_datatable_ref`, `DatatableAccess` | +| `windmill-common/src/datatable_roles_ee.rs` | `resolve_viewer_login`, `ensure_role_set_login`, `retire_role_set_logins_of` | +| `windmill-api-jobs/src/execution.rs` | `run_end_user_email`, where a run's viewer is recorded | + +## What holds it together + +- **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 job without + one (anonymous app viewer, schedule, trigger) is refused rather than resolved as its owner, which + in a publisher-mode app would be the publisher. +- **Only a tenant list naming the viewer counts.** A workspace admin may *use* every role, + `admin` included, and `admin` is the connection that owns the tables and bypasses row-level + security. 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_` 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`. +- **A `datatable_role_set` row always names a login that can authenticate.** 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. +- **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. +- **It travels in the reference, not in an annotation.** A worker that predates `?as=` refuses the + reference as malformed. It would skip an unknown `-- ` annotation and connect as the default + role, which is `admin` unless changed. For the same reason the request reaches an agent worker's + server in the `role` parameter, as a name no role can have. + +## Trust + +A runnable connecting as the viewer 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. diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index 97da60aadc..06eac27f27 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -1456,7 +1456,9 @@ class Windmill: }, ) - def datatable(self, name: str = "main", *, role: Optional[str] = None): + def datatable( + self, name: str = "main", *, role: Optional[str] = None, as_viewer: bool = False + ): """Get a DataTable client for SQL queries. Args: @@ -1464,11 +1466,14 @@ class Windmill: role: Connect as this data table role instead of the data table's default one. Only meaningful on a data table under roles, and only for a role you are a tenant of. + as_viewer: Connect with every role of the job's viewer at once: the user of the + app the job runs for, or whoever ran the script. The server resolves who that + is. Cannot be combined with role. Returns: DataTableClient instance """ - return DataTableClient(self, name, role=role) + return DataTableClient(self, name, role=role, as_viewer=as_viewer) def ducklake(self, name: str = "main"): """Get a DuckLake client for DuckDB queries. @@ -2325,17 +2330,21 @@ def username_to_email(username: str) -> str: @init_global_client -def datatable(name: str = "main", *, role: Optional[str] = None) -> DataTableClient: +def datatable( + name: str = "main", *, role: Optional[str] = None, as_viewer: bool = False +) -> DataTableClient: """Get a DataTable client for SQL queries. Args: name: Database name (default: "main") role: Connect as this data table role instead of the data table's default one. + as_viewer: Connect with every role of the job's viewer at once: the user of the app + the job runs for, or whoever ran the script. Cannot be combined with role. Returns: DataTableClient instance """ - return _client.datatable(name, role=role) + return _client.datatable(name, role=role, as_viewer=as_viewer) @init_global_client def ducklake(name: str = "main") -> DucklakeClient: @@ -2418,20 +2427,30 @@ _ROLE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,63}$") class DataTableClient: """Client for executing SQL queries against Windmill DataTables.""" - def __init__(self, client: Windmill, name: str, role: Optional[str] = None): + def __init__( + self, + client: Windmill, + name: str, + role: Optional[str] = None, + as_viewer: bool = False, + ): """Initialize DataTableClient. Args: client: Windmill client instance name: DataTable name role: Data table role to connect as, or None for the data table's default + as_viewer: Connect with every role of the job's viewer instead of one role """ if role is not None and not _ROLE_NAME_RE.match(role): raise ValueError( f"Invalid data table role '{role}': only letters, digits, '_' and '-' are allowed" ) + if as_viewer and role is not None: + raise ValueError("A data table is used as a role or as the viewer, not both") self.client = client self.role = role + self.as_viewer = as_viewer self.name, self.schema = parse_sql_client_name(name) def query(self, sql: str, *args) -> SqlQuery: """Execute a SQL query against the DataTable. @@ -2460,7 +2479,13 @@ class DataTableClient: lambda sql: self.client.run_inline_script_preview( content=sql, language="postgresql", - args={"database": f"datatable://{self.name}", **args_dict}, + # In the reference rather than an annotation: a worker that does not know `?as=` + # refuses the reference, where it would skip an annotation and connect as the + # default role. + args={ + "database": f"datatable://{self.name}{'?as=viewer' if self.as_viewer else ''}", + **args_dict, + }, ) ) diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 3884684646..a5bdb97f1b 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1127,6 +1127,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 runnable runs as the app's publisher, whoever is using the app. To have a data table under roles answer for the viewer instead, connect as the viewer: \`wmill.datatable('main', { as: 'viewer' })\` in TypeScript, \`wmill.datatable('main', as_viewer=True)\` in Python, \`datatable://main?as=viewer\` as a SQL runnable's database or in a DuckDB \`ATTACH\`. The connection then holds every role the viewer is a tenant of, so row-level security policies written as \`pg_has_role(current_user, '', 'member')\` return that viewer's rows. The server works out who the viewer is; a viewer with no role on the data table, or an anonymous one, is refused. Never combine it with \`role\`. ### Querying in TypeScript (Bun/Deno) @@ -2143,6 +2144,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @param name - Database/datatable name (default: "main") * @param opts.role - Connect as this data table role instead of the data table's default one. * Only meaningful on a data table under roles, and only for a role you are a tenant of. + * @param opts.as - \`"viewer"\` connects with every role of the job's viewer at once, so row-level + * security policies see the user of the app rather than its publisher. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -2155,6 +2158,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @example * // Read through a restricted role * let sql = wmill.datatable("main", { role: "analytics" }) + * @example + * // In an app's backend runnable: read what the app's viewer may read + * let sql = wmill.datatable("main", { as: "viewer" }) */ datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction @@ -2690,10 +2696,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca # role: Connect as this data table role instead of the data table's default one. # Only meaningful on a data table under roles, and only for a role you are a # tenant of. +# as_viewer: Connect with every role of the job's viewer at once: the user of the +# app the job runs for, or whoever ran the script. The server resolves who that +# is. Cannot be combined with role. # # Returns: # DataTableClient instance -def datatable(name: str = 'main', *, role: Optional[str] = None) +def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False) # Get a DuckLake client for DuckDB queries. # @@ -3451,6 +3460,8 @@ Create a SQL template function for PostgreSQL/datatable queries @param name - Database/datatable name (default: "main") @param opts.role - Connect as this data table role instead of the data table's default one. Only meaningful on a data table under roles, and only for a role you are a tenant of. +@param opts.as - \`"viewer"\` connects with every role of the job's viewer at once, so row-level + security policies see the user of the app rather than its publisher. @returns SQL template function for building parameterized queries @example let sql = wmill.datatable() @@ -3463,6 +3474,9 @@ await sql\` @example // Read through a restricted role let sql = wmill.datatable("main", { role: "analytics" }) +@example +// In an app's backend runnable: read what the app's viewer may read +let sql = wmill.datatable("main", { as: "viewer" }) \`\`\`typescript function datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction @@ -3478,10 +3492,12 @@ Import: \`import wmill\` # Args: # name: Database name (default: "main") # role: Connect as this data table role instead of the data table's default one. +# as_viewer: Connect with every role of the job's viewer at once: the user of the app +# the job runs for, or whoever ran the script. Cannot be combined with role. # # Returns: # DataTableClient instance -def datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient +def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False) -> DataTableClient # Client for executing SQL queries against Windmill DataTables. class DataTableClient: @@ -3491,7 +3507,8 @@ class DataTableClient: # client: Windmill client instance # name: DataTable name # role: Data table role to connect as, or None for the data table's default - def __init__(client: Windmill, name: str, role: Optional[str] = None) + # as_viewer: Connect with every role of the job's viewer instead of one role + def __init__(client: Windmill, name: str, role: Optional[str] = None, as_viewer: bool = False) # Execute a SQL query against the DataTable. # diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index 14ca869180..0364a40ce0 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -2020,6 +2020,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @param name - Database/datatable name (default: "main") * @param opts.role - Connect as this data table role instead of the data table's default one. * Only meaningful on a data table under roles, and only for a role you are a tenant of. + * @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level + * security policies see the user of the app rather than its publisher. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -2032,6 +2034,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @example * // Read through a restricted role * let sql = wmill.datatable("main", { role: "analytics" }) + * @example + * // In an app's backend runnable: read what the app's viewer may read + * let sql = wmill.datatable("main", { as: "viewer" }) */ datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction @@ -2567,10 +2572,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca # role: Connect as this data table role instead of the data table's default one. # Only meaningful on a data table under roles, and only for a role you are a # tenant of. +# as_viewer: Connect with every role of the job's viewer at once: the user of the +# app the job runs for, or whoever ran the script. The server resolves who that +# is. Cannot be combined with role. # # Returns: # DataTableClient instance -def datatable(name: str = 'main', *, role: Optional[str] = None) +def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False) # Get a DuckLake client for DuckDB queries. # diff --git a/system_prompts/auto-generated/sdks/datatable-python.md b/system_prompts/auto-generated/sdks/datatable-python.md index 3c7cdb1a09..6cee036843 100644 --- a/system_prompts/auto-generated/sdks/datatable-python.md +++ b/system_prompts/auto-generated/sdks/datatable-python.md @@ -7,10 +7,12 @@ Import: `import wmill` # Args: # name: Database name (default: "main") # role: Connect as this data table role instead of the data table's default one. +# as_viewer: Connect with every role of the job's viewer at once: the user of the app +# the job runs for, or whoever ran the script. Cannot be combined with role. # # Returns: # DataTableClient instance -def datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient +def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False) -> DataTableClient # Client for executing SQL queries against Windmill DataTables. class DataTableClient: @@ -20,7 +22,8 @@ class DataTableClient: # client: Windmill client instance # name: DataTable name # role: Data table role to connect as, or None for the data table's default - def __init__(client: Windmill, name: str, role: Optional[str] = None) + # as_viewer: Connect with every role of the job's viewer instead of one role + def __init__(client: Windmill, name: str, role: Optional[str] = None, as_viewer: bool = False) # Execute a SQL query against the DataTable. # diff --git a/system_prompts/auto-generated/sdks/datatable-typescript.md b/system_prompts/auto-generated/sdks/datatable-typescript.md index 9f6fc13341..5317f07ad2 100644 --- a/system_prompts/auto-generated/sdks/datatable-typescript.md +++ b/system_prompts/auto-generated/sdks/datatable-typescript.md @@ -64,6 +64,8 @@ Create a SQL template function for PostgreSQL/datatable queries @param name - Database/datatable name (default: "main") @param opts.role - Connect as this data table role instead of the data table's default one. Only meaningful on a data table under roles, and only for a role you are a tenant of. +@param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level + security policies see the user of the app rather than its publisher. @returns SQL template function for building parameterized queries @example let sql = wmill.datatable() @@ -76,6 +78,9 @@ await sql` @example // Read through a restricted role let sql = wmill.datatable("main", { role: "analytics" }) +@example +// In an app's backend runnable: read what the app's viewer may read +let sql = wmill.datatable("main", { as: "viewer" }) ```typescript function datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction diff --git a/system_prompts/auto-generated/sdks/python.md b/system_prompts/auto-generated/sdks/python.md index 13a93ed063..0de6606f8a 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -488,10 +488,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca # role: Connect as this data table role instead of the data table's default one. # Only meaningful on a data table under roles, and only for a role you are a # tenant of. +# as_viewer: Connect with every role of the job's viewer at once: the user of the +# app the job runs for, or whoever ran the script. The server resolves who that +# is. Cannot be combined with role. # # Returns: # DataTableClient instance -def datatable(name: str = 'main', *, role: Optional[str] = None) +def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False) # Get a DuckLake client for DuckDB queries. # diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index 55df35ccf7..23976ae54b 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -577,6 +577,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @param name - Database/datatable name (default: "main") * @param opts.role - Connect as this data table role instead of the data table's default one. * Only meaningful on a data table under roles, and only for a role you are a tenant of. + * @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level + * security policies see the user of the app rather than its publisher. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -589,6 +591,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @example * // Read through a restricted role * let sql = wmill.datatable("main", { role: "analytics" }) + * @example + * // In an app's backend runnable: read what the app's viewer may read + * let sql = wmill.datatable("main", { as: "viewer" }) */ datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index 2f7cd52d5f..7e31a1c006 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -188,7 +188,7 @@ data: - `/
` — Specific table in public schema - `/:
` — 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. To use a datatable with the roles of whoever is viewing the app rather than one fixed role, pass `{ as: 'viewer' }` (`as_viewer=True` in Python) instead of a role. ## SQL Migrations (sql_to_apply/) @@ -482,6 +482,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 runnable runs as the app's publisher, whoever is using the app. To have a data table under roles answer for the viewer instead, connect as the viewer: `wmill.datatable('main', { as: 'viewer' })` in TypeScript, `wmill.datatable('main', as_viewer=True)` in Python, `datatable://main?as=viewer` as a SQL runnable's database or in a DuckDB `ATTACH`. The connection then holds every role the viewer is a tenant of, so row-level security policies written as `pg_has_role(current_user, '', 'member')` return that viewer's rows. The server works out who the viewer is; a viewer with no role on the data table, or an anonymous one, is refused. Never combine it with `role`. `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`. diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index 6ad44db068..c408030390 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -773,6 +773,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @param name - Database/datatable name (default: "main") * @param opts.role - Connect as this data table role instead of the data table's default one. * Only meaningful on a data table under roles, and only for a role you are a tenant of. + * @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level + * security policies see the user of the app rather than its publisher. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -785,6 +787,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @example * // Read through a restricted role * let sql = wmill.datatable("main", { role: "analytics" }) + * @example + * // In an app's backend runnable: read what the app's viewer may read + * let sql = wmill.datatable("main", { as: "viewer" }) */ datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index a5f599a946..a3792c8bef 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -785,6 +785,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @param name - Database/datatable name (default: "main") * @param opts.role - Connect as this data table role instead of the data table's default one. * Only meaningful on a data table under roles, and only for a role you are a tenant of. + * @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level + * security policies see the user of the app rather than its publisher. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -797,6 +799,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @example * // Read through a restricted role * let sql = wmill.datatable("main", { role: "analytics" }) + * @example + * // In an app's backend runnable: read what the app's viewer may read + * let sql = wmill.datatable("main", { as: "viewer" }) */ datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index ce0c610da7..d107fa59d9 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -775,6 +775,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @param name - Database/datatable name (default: "main") * @param opts.role - Connect as this data table role instead of the data table's default one. * Only meaningful on a data table under roles, and only for a role you are a tenant of. + * @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level + * security policies see the user of the app rather than its publisher. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -787,6 +789,9 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * @example * // Read through a restricted role * let sql = wmill.datatable("main", { role: "analytics" }) + * @example + * // In an app's backend runnable: read what the app's viewer may read + * let sql = wmill.datatable("main", { as: "viewer" }) */ datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index 4946ed0f6e..e48a756e21 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -696,10 +696,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca # role: Connect as this data table role instead of the data table's default one. # Only meaningful on a data table under roles, and only for a role you are a # tenant of. +# as_viewer: Connect with every role of the job's viewer at once: the user of the +# app the job runs for, or whoever ran the script. The server resolves who that +# is. Cannot be combined with role. # # Returns: # DataTableClient instance -def datatable(name: str = 'main', *, role: Optional[str] = None) +def datatable(name: str = 'main', *, role: Optional[str] = None, as_viewer: bool = False) # Get a DuckLake client for DuckDB queries. # diff --git a/system_prompts/base/raw-app-cli.md b/system_prompts/base/raw-app-cli.md index 59ef0acb58..178bed6a5d 100644 --- a/system_prompts/base/raw-app-cli.md +++ b/system_prompts/base/raw-app-cli.md @@ -183,7 +183,7 @@ data: - `/
` — Specific table in public schema - `/:
` — 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. To use a datatable with the roles of whoever is viewing the app rather than one fixed role, pass `{ as: 'viewer' }` (`as_viewer=True` in Python) instead of a role. ## SQL Migrations (sql_to_apply/) diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 03bde6ec71..07165598f9 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -250,6 +250,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 runnable runs as the app's publisher, whoever is using the app. To have a data table under roles answer for the viewer instead, connect as the viewer: `wmill.datatable('main', { as: 'viewer' })` in TypeScript, `wmill.datatable('main', as_viewer=True)` in Python, `datatable://main?as=viewer` as a SQL runnable's database or in a DuckDB `ATTACH`. The connection then holds every role the viewer is a tenant of, so row-level security policies written as `pg_has_role(current_user, '', 'member')` return that viewer's rows. The server works out who the viewer is; a viewer with no role on the data table, or an anonymous one, is refused. Never combine it with `role`. `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`. diff --git a/typescript-client/sqlUtils.d.ts b/typescript-client/sqlUtils.d.ts index c7463bf7b0..715977147c 100644 --- a/typescript-client/sqlUtils.d.ts +++ b/typescript-client/sqlUtils.d.ts @@ -87,6 +87,7 @@ export interface DatatableSqlTemplateFunction extends SqlTemplateFunction { export interface DatatableOptions { role?: string; + as?: "viewer"; } export declare function datatable(name?: string, opts?: DatatableOptions): DatatableSqlTemplateFunction; export declare function ducklake(name: string): SqlTemplateFunction; diff --git a/typescript-client/sqlUtils.ts b/typescript-client/sqlUtils.ts index 4c1ccd7ea4..97fe56f149 100644 --- a/typescript-client/sqlUtils.ts +++ b/typescript-client/sqlUtils.ts @@ -111,6 +111,12 @@ export interface DatatableSqlTemplateFunction extends SqlTemplateFunction { export interface DatatableOptions { /** The data table role to connect as. Omit to get the data table's default role. */ role?: string; + /** + * `"viewer"` connects with every role of the job's viewer at once: the user of the app the job + * runs for, or whoever ran the script. The server resolves who that is; it cannot be combined + * with `role`. + */ + as?: "viewer"; } // --------------------------------------------------------------------------- @@ -140,17 +146,27 @@ const ROLE_NAME_RE = /^[A-Za-z0-9_-]{1,63}$/; function datatableProvider( name: string, schema?: string, - role?: string + opts?: DatatableOptions ): SqlProvider { + const role = opts?.role; if (role !== undefined && !ROLE_NAME_RE.test(role)) { throw new Error( `Invalid data table role '${role}': only letters, digits, '_' and '-' are allowed` ); } + const as = opts?.as; + if (as !== undefined && as !== "viewer") { + throw new Error(`Invalid data table option as: '${as}': the only value is 'viewer'`); + } + if (as !== undefined && role !== undefined) { + throw new Error("A data table is used as a role or as the viewer, not both"); + } return { providerName: "datatable", language: "postgresql", - extraArgs: { database: `datatable://${name}` }, + // In the reference rather than an annotation: a worker that does not know `?as=` refuses + // the reference, where it would skip an annotation and connect as the default role. + extraArgs: { database: `datatable://${name}${as ? "?as=viewer" : ""}` }, annotations: () => (role !== undefined ? `-- role ${role}\n` : ""), formatArgDecl: (argNum) => `-- $${argNum} arg${argNum}`, formatArgUsage: (argNum, explicitType, inferredType) => @@ -348,6 +364,8 @@ function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction { * @param name - Database/datatable name (default: "main") * @param opts.role - Connect as this data table role instead of the data table's default one. * Only meaningful on a data table under roles, and only for a role you are a tenant of. + * @param opts.as - `"viewer"` connects with every role of the job's viewer at once, so row-level + * security policies see the user of the app rather than its publisher. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -360,13 +378,16 @@ function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction { * @example * // Read through a restricted role * let sql = wmill.datatable("main", { role: "analytics" }) + * @example + * // In an app's backend runnable: read what the app's viewer may read + * let sql = wmill.datatable("main", { as: "viewer" }) */ export function datatable( name: string = "main", opts?: DatatableOptions ): DatatableSqlTemplateFunction { let { name: n, schema } = parseName(name); - let provider = datatableProvider(n, schema, opts?.role); + let provider = datatableProvider(n, schema, opts); let sqlFn = buildSqlTemplateFunction(provider) as DatatableSqlTemplateFunction; // `.query(sql, ...params)` is for SQL strings that already contain // positional placeholders ($1, $2, ...). We DON'T go through the template