From 85e73ae4f198f93c93a224cb28e4115e5049e5b4 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 09:27:24 +0200 Subject: [PATCH] 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 --- AGENTS.md | 6 +- backend/ee-repo-ref.txt | 2 +- .../tests/datatable_roles.rs | 114 +++++++++++------- backend/windmill-common/src/workspaces.rs | 47 ++------ backend/windmill-worker/src/pg_executor.rs | 22 +--- cli/src/guidance/skills.gen.ts | 24 +--- docs/datatable-viewer-roles.md | 64 +++++----- .../DataTablePermissionsButton.svelte | 36 +++++- python-client/wmill/wmill/client.py | 37 +----- system_prompts/auto-generated/prompts.ts | 24 +--- system_prompts/auto-generated/script.md | 10 +- .../auto-generated/sdks/datatable-python.md | 7 +- .../sdks/datatable-typescript.md | 5 - system_prompts/auto-generated/sdks/python.md | 5 +- .../auto-generated/sdks/typescript.md | 5 - .../auto-generated/skills/raw-app/SKILL.md | 4 +- .../skills/write-script-bun/SKILL.md | 5 - .../skills/write-script-bunnative/SKILL.md | 5 - .../skills/write-script-deno/SKILL.md | 5 - .../skills/write-script-python3/SKILL.md | 5 +- system_prompts/base/raw-app-cli.md | 2 +- system_prompts/base/raw-app.md | 2 +- typescript-client/sqlUtils.d.ts | 1 - typescript-client/sqlUtils.ts | 27 +---- 24 files changed, 175 insertions(+), 289 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4aa6027a18..a57215cde5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,9 +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 +- **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` diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index bd7d2f0126..7f06f1927d 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -ce07ffe5584efd040e7d412a4b604627a7ffa205 +7e756258eeaa1a613510d5c4fc2184239f3b395e diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index 627e99fb1a..e7180ffed8 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -1850,18 +1850,17 @@ async fn cleanup_waits_out_a_save_racing_it_for_an_instance_database( #[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( +async fn a_connection_naming_no_role_holds_the_default_one_and_the_viewers( db: Pool, ) -> anyhow::Result<()> { - use windmill_common::workspaces::{ - get_datatable_resource_from_db, DatatableAccess, DATATABLE_VIEWER_ROLE, - }; + 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}"), ]; @@ -1871,12 +1870,12 @@ async fn connecting_as_the_viewer_takes_the_roles_that_name_them_and_no_other( 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>| { + let connect = |role: Option<&'static str>, viewer: Option<&'static str>| { get_datatable_resource_from_db( &db, "test-workspace", "main", - Some(DATATABLE_VIEWER_ROLE), + role, DatatableAccess::PermissionedAs { permissioned_as: "u/test-user", email: "test@windmill.dev", @@ -1884,6 +1883,15 @@ async fn connecting_as_the_viewer_takes_the_roles_that_name_them_and_no_other( }, ) }; + 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![]; @@ -1897,55 +1905,71 @@ async fn connecting_as_the_viewer_takes_the_roles_that_name_them_and_no_other( .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!({ + // `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": ["u/test-user-2", "u/test-user-3"]}, - (ids[1].as_str()): {"tenants": ["g/analysts"]}, - })) - .execute(&db) - .await?; + (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 = 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 both = as_viewer(Some("test2@windmill.dev")).await?; - let login = both["user"].as_str().unwrap().to_string(); + 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 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" + member_of(login.clone()).await?, + expected, + "the login holds the default role and the viewer's, and not `admin`" ); - assert_eq!(as_viewer(Some("test2@windmill.dev")).await?, both); + assert_eq!(connect(None, Some("test2@windmill.dev")).await?, all); - let one = as_viewer(Some("test3@windmill.dev")).await?; - assert_eq!(one["user"], names[0].as_str(), "one role is its own login"); + 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 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()); + // 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_")); + 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. @@ -1965,13 +1989,13 @@ async fn connecting_as_the_viewer_takes_the_roles_that_name_them_and_no_other( windmill_common::datatable_roles::sweep_unused_role_set_logins(&db).await?; assert!(!exists().await?, "an unused set login was kept"); assert_eq!( - as_viewer(Some("test2@windmill.dev")).await?["user"], + 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[1])), + client().post(format!("{roles_url}/{}", ids[2])), "SECRET_TOKEN", ) .json(&json!({ "enabled": false })) @@ -1984,8 +2008,10 @@ async fn connecting_as_the_viewer_takes_the_roles_that_name_them_and_no_other( .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()); + assert_eq!( + connect(None, Some("test2@windmill.dev")).await?["user"], + two["user"] + ); Ok::<_, anyhow::Error>(()) } .await; diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index b01c848f7f..725eb3d596 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1589,12 +1589,6 @@ 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. @@ -2222,11 +2216,11 @@ 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. +/// A default role other than `admin` is a floor rather than the whole connection: a resolution +/// naming no role also holds the roles whose tenant lists name the job's viewer, as one login that +/// is a member of each. Being an admin adds none 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, @@ -2493,37 +2487,23 @@ pub async fn parse_datatable_ref_for( /// A query string that does not parse is an error rather than an absent role. Falling back would /// 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. -/// -/// The one other parameter is `?as=viewer`, which asks for every role of the job's viewer instead -/// of one by name and comes back as [`DATATABLE_VIEWER_ROLE`]. +/// malformed `-- role` annotation, and `role` is the only parameter a reference takes. 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, "")); - let as_viewer = key.eq_ignore_ascii_case("as"); - if !as_viewer && !key.eq_ignore_ascii_case("role") { + if !key.eq_ignore_ascii_case("role") { return Err(Error::BadRequest(format!( "Data table reference '{name}' carries an unknown parameter '{key}'. \ - It takes `?role=` or `?as=viewer`." + The only one it takes is `?role=`." ))); } if role.is_some() { return Err(Error::BadRequest(format!( - "Data table reference '{name}' takes one `role` or `as=viewer`, not several." + "Data table reference '{name}' names a role more than once." ))); } - 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 \ @@ -3851,22 +3831,13 @@ 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/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index cf77f61d80..6309ba8c95 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -27,7 +27,6 @@ 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}; @@ -1172,23 +1171,6 @@ 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, @@ -1231,7 +1213,7 @@ pub async fn do_postgresql( client, name, &job.workspace_id, - requested_datatable_role(annotated.as_deref(), uri_role)?, + annotated.as_deref().or(uri_role), &job.id, ) .await? @@ -1243,7 +1225,7 @@ pub async fn do_postgresql( db, &job.workspace_id, &name, - requested_datatable_role(annotated.as_deref(), uri_role.as_deref())?, + annotated.as_deref().or(uri_role.as_deref()), DatatableAccess::PermissionedAs { permissioned_as: &job.permissioned_as, email: &job.permissioned_as_email, diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 4a92f1e5d2..7623709426 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -1223,8 +1223,6 @@ 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() @@ -1237,9 +1235,6 @@ 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 @@ -2074,8 +2069,6 @@ 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() @@ -2088,9 +2081,6 @@ 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 @@ -3031,8 +3021,6 @@ 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() @@ -3045,9 +3033,6 @@ 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 @@ -4916,13 +4901,10 @@ 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, as_viewer: bool = False) +def datatable(name: str = 'main', *, role: Optional[str] = None) # Get a DuckLake client for DuckDB queries. # @@ -6575,7 +6557,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. 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. +**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, plus the roles of whoever is using the app unless that default is \`admin\`. ## SQL Migrations (sql_to_apply/) @@ -6881,7 +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 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\`. +6. **Per-viewer rows**: a call that passes no role connects as the data table's default role and, unless that default is \`admin\`, also as every role the person using the app is a tenant of. Row-level security policies written as \`pg_has_role(current_user, '', 'member')\` then return that viewer's rows with nothing added to the runnable. Passing a role pins the connection to that one role for every viewer, so leave it out where rows should differ per viewer. \`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 index fcfa1c59db..89bb331412 100644 --- a/docs/datatable-viewer-roles.md +++ b/docs/datatable-viewer-roles.md @@ -1,19 +1,13 @@ -# Data table connections as the viewer +# Data table connections and the viewer's roles -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. +A data table under roles (Enterprise Edition) resolves a connection that names a role to that +role alone. A connection that names none gets the data table's default role, and when that +default is not `admin` it is a floor: the connection also holds **every role the job's viewer is +a tenant of**. 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. -```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: +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 @@ -26,29 +20,39 @@ A policy names its role as text, so renaming the role breaks it. | 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`, `sweep_unused_role_set_logins` | +| `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. +- **The default role is still tenant-checked against the job's own identity**, the publisher for + a publisher-mode app. The viewer's roles are added to a connection that was allowed, never a + way into one that was not. +- **No viewer means the default role alone, and nothing is refused.** A job without a recorded + viewer (anonymous app viewer, schedule, trigger, a tool job started by an AI agent step), or + one whose viewer is not a member of the governing workspace, connects as it always did. Access + only grows with identity. - **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, a tool job started by an AI agent step) is refused - rather than resolved as its owner, which in a publisher-mode app would be the publisher. + 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, 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`. + `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_` 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 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. @@ -60,15 +64,13 @@ A policy names its role as text, so renaming the role breaks it. 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. -- **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. +- **A worker or server that predates this connects as the default role alone**, so a mixed fleet + shows a viewer fewer rows, never more. ## 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. 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; nothing on the data table opts in to -that. +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. diff --git a/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte b/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte index 680421450d..fd7bb4fb05 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte @@ -218,11 +218,24 @@ } } - 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 they connect as admin, which reads every row and ignores grants and row-level security.`, + confirmationText: 'Remove and default to admin', + type: 'danger' + }) + if (!confirmed) return + defaultRoleId = ADMIN_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 hasOtherRoles = $derived(roles.some((r) => r.id !== ADMIN_DATATABLE_ROLE)) + async function save() { saving = true // Turning roles off clears the whole block, so every role it named is removed. @@ -391,8 +404,9 @@ Default - 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. Callers still have to be one of its tenants. Unless it is admin, + the other roles the person running the job is a tenant of are added to it. @@ -466,6 +480,20 @@ {/if} + + {#if defaultsToAdmin && hasOtherRoles} + + admin owns every table, so it reads every row and ignores grants and row-level + security. The roles above only apply to jobs that name one. Make another role the + default to have them apply to every job. + + {:else if !defaultsToAdmin} + + A job that names no role connects as + {defaultRoleName}, plus every other role here that the + person running it is a tenant of. A job that names a role connects as that role alone. + + {/if} {/if} diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index 1bb5ff7efe..824db9741f 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -1456,9 +1456,7 @@ class Windmill: }, ) - def datatable( - self, name: str = "main", *, role: Optional[str] = None, as_viewer: bool = False - ): + def datatable(self, name: str = "main", *, role: Optional[str] = None): """Get a DataTable client for SQL queries. Args: @@ -1466,14 +1464,11 @@ 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, as_viewer=as_viewer) + return DataTableClient(self, name, role=role) def ducklake(self, name: str = "main"): """Get a DuckLake client for DuckDB queries. @@ -2330,21 +2325,17 @@ def username_to_email(username: str) -> str: @init_global_client -def datatable( - name: str = "main", *, role: Optional[str] = None, as_viewer: bool = False -) -> DataTableClient: +def datatable(name: str = "main", *, role: Optional[str] = None) -> 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, as_viewer=as_viewer) + return _client.datatable(name, role=role) @init_global_client def ducklake(name: str = "main") -> DucklakeClient: @@ -2427,30 +2418,20 @@ _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, - as_viewer: bool = False, - ): + def __init__(self, client: Windmill, name: str, role: Optional[str] = None): """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. @@ -2479,13 +2460,7 @@ class DataTableClient: lambda sql: self.client.run_inline_script_preview( content=sql, language="postgresql", - # 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, - }, + args={"database": f"datatable://{self.name}", **args_dict}, ) ) diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 6883ebd04d..64f4a9f22d 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1131,7 +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 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\`. +6. **Per-viewer rows**: a call that passes no role connects as the data table's default role and, unless that default is \`admin\`, also as every role the person using the app is a tenant of. Row-level security policies written as \`pg_has_role(current_user, '', 'member')\` then return that viewer's rows with nothing added to the runnable. Passing a role pins the connection to that one role for every viewer, so leave it out where rows should differ per viewer. ### Querying in TypeScript (Bun/Deno) @@ -2153,8 +2153,6 @@ 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() @@ -2167,9 +2165,6 @@ 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 @@ -2705,13 +2700,10 @@ 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, as_viewer: bool = False) +def datatable(name: str = 'main', *, role: Optional[str] = None) # Get a DuckLake client for DuckDB queries. # @@ -3483,8 +3475,6 @@ 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() @@ -3497,9 +3487,6 @@ 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 @@ -3515,12 +3502,10 @@ 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, as_viewer: bool = False) -> DataTableClient +def datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient # Client for executing SQL queries against Windmill DataTables. class DataTableClient: @@ -3530,8 +3515,7 @@ class DataTableClient: # 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 - def __init__(client: Windmill, name: str, role: Optional[str] = None, as_viewer: bool = False) + def __init__(client: Windmill, name: str, role: Optional[str] = None) # Execute a SQL query against the DataTable. # diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index a99cce108f..d178ddb6c0 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -2044,8 +2044,6 @@ 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() @@ -2058,9 +2056,6 @@ 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 @@ -2596,13 +2591,10 @@ 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, as_viewer: bool = False) +def datatable(name: str = 'main', *, role: Optional[str] = None) # 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 6cee036843..3c7cdb1a09 100644 --- a/system_prompts/auto-generated/sdks/datatable-python.md +++ b/system_prompts/auto-generated/sdks/datatable-python.md @@ -7,12 +7,10 @@ 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, as_viewer: bool = False) -> DataTableClient +def datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient # Client for executing SQL queries against Windmill DataTables. class DataTableClient: @@ -22,8 +20,7 @@ class DataTableClient: # 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 - def __init__(client: Windmill, name: str, role: Optional[str] = None, as_viewer: bool = False) + def __init__(client: Windmill, name: str, role: Optional[str] = None) # 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 5317f07ad2..9f6fc13341 100644 --- a/system_prompts/auto-generated/sdks/datatable-typescript.md +++ b/system_prompts/auto-generated/sdks/datatable-typescript.md @@ -64,8 +64,6 @@ 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() @@ -78,9 +76,6 @@ 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 c6fc84c602..1964c88685 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -488,13 +488,10 @@ 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, as_viewer: bool = False) +def datatable(name: str = 'main', *, role: Optional[str] = None) # 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 0f398ad453..da9e79ec94 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -580,8 +580,6 @@ 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() @@ -594,9 +592,6 @@ 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 a85077bca2..c049104785 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. 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. +**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, plus the roles of whoever is using the app unless that default is `admin`. ## SQL Migrations (sql_to_apply/) @@ -494,7 +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 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`. +6. **Per-viewer rows**: a call that passes no role connects as the data table's default role and, unless that default is `admin`, also as every role the person using the app is a tenant of. Row-level security policies written as `pg_has_role(current_user, '', 'member')` then return that viewer's rows with nothing added to the runnable. Passing a role pins the connection to that one role for every viewer, so leave it out where rows should differ per viewer. `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 0b4d4f32ba..055b1613b2 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -776,8 +776,6 @@ 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() @@ -790,9 +788,6 @@ 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 d03648555b..beaf7817bb 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -788,8 +788,6 @@ 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() @@ -802,9 +800,6 @@ 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 cfadd2a925..afefb7e987 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -778,8 +778,6 @@ 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() @@ -792,9 +790,6 @@ 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 b972b25f4f..ee50b6d62d 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -696,13 +696,10 @@ 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, as_viewer: bool = False) +def datatable(name: str = 'main', *, role: Optional[str] = None) # 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 8d8c82e0f5..223bb9e539 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. 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. +**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, plus the roles of whoever is using the app unless that default is `admin`. ## SQL Migrations (sql_to_apply/) diff --git a/system_prompts/base/raw-app.md b/system_prompts/base/raw-app.md index 834cc31361..443deed61a 100644 --- a/system_prompts/base/raw-app.md +++ b/system_prompts/base/raw-app.md @@ -264,7 +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 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`. +6. **Per-viewer rows**: a call that passes no role connects as the data table's default role and, unless that default is `admin`, also as every role the person using the app is a tenant of. Row-level security policies written as `pg_has_role(current_user, '', 'member')` then return that viewer's rows with nothing added to the runnable. Passing a role pins the connection to that one role for every viewer, so leave it out where rows should differ per viewer. `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 715977147c..c7463bf7b0 100644 --- a/typescript-client/sqlUtils.d.ts +++ b/typescript-client/sqlUtils.d.ts @@ -87,7 +87,6 @@ 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 97fe56f149..4c1ccd7ea4 100644 --- a/typescript-client/sqlUtils.ts +++ b/typescript-client/sqlUtils.ts @@ -111,12 +111,6 @@ 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"; } // --------------------------------------------------------------------------- @@ -146,27 +140,17 @@ const ROLE_NAME_RE = /^[A-Za-z0-9_-]{1,63}$/; function datatableProvider( name: string, schema?: string, - opts?: DatatableOptions + role?: string ): 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", - // 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" : ""}` }, + extraArgs: { database: `datatable://${name}` }, annotations: () => (role !== undefined ? `-- role ${role}\n` : ""), formatArgDecl: (argNum) => `-- $${argNum} arg${argNum}`, formatArgUsage: (argNum, explicitType, inferredType) => @@ -364,8 +348,6 @@ 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() @@ -378,16 +360,13 @@ 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); + let provider = datatableProvider(n, schema, opts?.role); 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