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