From 8bde6111cc33f1ac195a39dfac4881f9168cfa87 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Thu, 10 Sep 2026 16:34:51 +0200 Subject: [PATCH] feat: add an ACL editor for data table roles Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DsU2Lf6wYQJ9o8ASKRgCmK --- .../tests/datatable_acl.rs | 78 ++ .../src/datatable_acl.rs | 1038 +++++++++++++++++ .../src/datatable_acl_oss.rs | 50 + backend/windmill-api-workspaces/src/lib.rs | 5 + .../windmill-api-workspaces/src/workspaces.rs | 1 + backend/windmill-api/openapi.yaml | 224 ++++ .../windmill-common/src/datatable_roles.rs | 7 +- backend/windmill-common/src/lib.rs | 14 + .../lib/components/InstanceSettings.svelte | 12 +- .../datatableAcl/AclTargetPicker.svelte | 74 ++ .../datatableAcl/PgAclEditor.svelte | 260 +++++ .../datatableAcl/PgGrantBuilder.svelte | 95 ++ .../lib/components/datatableAcl/aclScopes.ts | 166 +++ .../DataTablePermissionsButton.svelte | 45 +- .../DataTableRolesSection.svelte | 6 +- .../DataTableSettings.svelte | 27 +- 16 files changed, 2065 insertions(+), 37 deletions(-) create mode 100644 backend/windmill-api-integration-tests/tests/datatable_acl.rs create mode 100644 backend/windmill-api-workspaces/src/datatable_acl.rs create mode 100644 backend/windmill-api-workspaces/src/datatable_acl_oss.rs create mode 100644 frontend/src/lib/components/datatableAcl/AclTargetPicker.svelte create mode 100644 frontend/src/lib/components/datatableAcl/PgAclEditor.svelte create mode 100644 frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte create mode 100644 frontend/src/lib/components/datatableAcl/aclScopes.ts diff --git a/backend/windmill-api-integration-tests/tests/datatable_acl.rs b/backend/windmill-api-integration-tests/tests/datatable_acl.rs new file mode 100644 index 0000000000..f54ec2ecab --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/datatable_acl.rs @@ -0,0 +1,78 @@ +//! Who may change a data table's grants and owners: its administrators, from the workspace that +//! governs it, on an edition that has the planner. Each refusal is decided before anything +//! connects to the data table, so the fixture's database never has to exist. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +fn grant_select_on_public() -> Value { + json!({ + "target": {"kind": "schema", "schema": "public"}, + "change": {"type": "grant", "role": "analytics", "privileges": ["SELECT"], + "scope": "all_tables"}, + "statements": [r#"GRANT SELECT ON ALL TABLES IN SCHEMA "public" TO "analytics""#] + }) +} + +async fn post_acl( + port: u16, + w_id: &str, + action: &str, + token: &str, +) -> anyhow::Result { + Ok(reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/{w_id}/workspaces/datatable_acl/main/{action}" + )) + .header("Authorization", format!("Bearer {token}")) + .json(&grant_select_on_public()) + .send() + .await?) +} + +/// A fork reaches the data table through a pointer: it may use it, never change what each role may +/// touch on it — not even as an admin of the fork. +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_fork_cannot_change_access_on_the_data_table_it_points_at( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + for action in ["plan", "apply"] { + let resp = post_acl(port, "wm-fork-dt", action, "SECRET_TOKEN_2").await?; + assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?); + } + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_member_who_is_not_an_admin_cannot_change_access( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + for action in ["plan", "apply"] { + let resp = post_acl(port, "test-workspace", action, "SECRET_TOKEN_2").await?; + assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?); + } + Ok(()) +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn only_the_enterprise_edition_changes_access(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + for action in ["plan", "apply"] { + let resp = post_acl(port, "test-workspace", action, "SECRET_TOKEN").await?; + assert_eq!(resp.status(), 400, "{action}"); + let body = resp.text().await?; + assert!(body.contains("Enterprise Edition"), "{action}: {body}"); + } + Ok(()) +} diff --git a/backend/windmill-api-workspaces/src/datatable_acl.rs b/backend/windmill-api-workspaces/src/datatable_acl.rs new file mode 100644 index 0000000000..3fe53efbf4 --- /dev/null +++ b/backend/windmill-api-workspaces/src/datatable_acl.rs @@ -0,0 +1,1038 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Ownership and grants on the objects of an instance data table. +//! +//! [`datatable_permissions`](crate::datatable_permissions) decides who may connect as which role; +//! this decides what each role may then touch. Every change is a real `GRANT`, `REVOKE`, +//! `ALTER ... OWNER TO` or `ALTER DEFAULT PRIVILEGES`, so Postgres is what enforces it. +//! +//! Reading is open to anyone who reaches the data table. Planning and applying are for those who +//! administer it — admins of the workspace that governs it, and superadmins — and the planner +//! itself is Enterprise Edition ([`crate::datatable_acl_oss`]). + +use std::collections::BTreeMap; + +use axum::{ + extract::{Extension, Path, Query}, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; +use tokio_postgres::error::{DbError, SqlState}; +use tokio_postgres::AsyncMessage; + +use windmill_api_auth::ApiAuthed; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::datatable_roles::{ + lock_role_catalog, read_role_catalog, read_role_catalog_tx, DatatableRoleCatalog, + ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER, +}; +use windmill_common::error::{pg_error_message, Error, JsonResult, Result}; +use windmill_common::workspaces::{ + get_datatable_resource_from_db_unchecked, resolve_governing_datatable, GoverningDatatable, +}; +use windmill_common::{PgDatabase, DB}; + +use crate::datatable_permissions::{ensure_governs_datatable, ensure_reaches_datatable}; + +pub(crate) fn routes() -> Router { + Router::new() + .route("/datatable_acl/{datatable_name}", get(get_datatable_acl)) + .route( + "/datatable_acl/{datatable_name}/plan", + post(plan_datatable_acl), + ) + .route( + "/datatable_acl/{datatable_name}/apply", + post(apply_datatable_acl), + ) +} + +/// What a read or a change is about. +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AclTarget { + /// The data table's own database — where the privilege to create schemas lives. + Database, + Schema { + schema: String, + }, + Table { + schema: String, + table: String, + }, +} + +impl AclTarget { + /// The schema the target is in, absent for the database itself. + pub(crate) fn schema(&self) -> Option<&str> { + match self { + AclTarget::Database => None, + AclTarget::Schema { schema } => Some(schema), + AclTarget::Table { schema, .. } => Some(schema), + } + } + + /// What it is called in a message. + pub(crate) fn label(&self, dbname: &str) -> String { + match self { + AclTarget::Database => dbname.to_string(), + AclTarget::Schema { schema } => schema.clone(), + AclTarget::Table { schema, table } => format!("{schema}.{table}"), + } + } +} + +#[derive(Deserialize, Debug)] +pub struct AclTargetQuery { + kind: String, + schema: Option, + table: Option, +} + +impl TryFrom for AclTarget { + type Error = Error; + fn try_from(q: AclTargetQuery) -> Result { + match (q.kind.as_str(), q.schema, q.table) { + ("database", _, _) => Ok(AclTarget::Database), + ("schema", Some(schema), _) => Ok(AclTarget::Schema { schema }), + ("table", Some(schema), Some(table)) => Ok(AclTarget::Table { schema, table }), + ("schema" | "table", None, _) => { + Err(Error::BadRequest("This target needs a schema".to_string())) + } + ("table", _, None) => Err(Error::BadRequest( + "A table target needs a table".to_string(), + )), + (kind, _, _) => Err(Error::BadRequest(format!("Unknown ACL target '{kind}'"))), + } + } +} + +/// Where a set of privileges applies, relative to the target. +/// +/// `Future*` covers what does not exist yet: those become `ALTER DEFAULT PRIVILEGES`, which only +/// binds objects created by the roles it names. +#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum GrantScope { + /// The target itself — the database, the schema, or the table. + Target, + AllTables, + AllSequences, + AllFunctions, + FutureTables, + FutureSequences, + FutureFunctions, +} + +impl GrantScope { + pub(crate) fn is_future(&self) -> bool { + matches!( + self, + GrantScope::FutureTables | GrantScope::FutureSequences | GrantScope::FutureFunctions + ) + } +} + +/// A change to plan. One at a time: each is confirmed against its own SQL. +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AclChange { + /// Hand the target — and, for a schema, everything already in it — to another role. + SetOwner { + role: String, + }, + Grant { + role: String, + privileges: Vec, + scope: GrantScope, + }, + Revoke { + role: String, + privileges: Vec, + scope: GrantScope, + /// Objects inside the target, empty for the target itself. `ON ALL TABLES` grants read + /// back per object, so they are revoked per object — and the same privileges on several + /// of them are revoked together. + #[serde(default)] + objects: Vec, + }, +} + +impl AclChange { + /// The role the change is about, as the editor names it. + fn role(&self) -> &str { + match self { + AclChange::SetOwner { role } + | AclChange::Grant { role, .. } + | AclChange::Revoke { role, .. } => role, + } + } +} + +#[derive(Deserialize, Debug)] +pub struct AclChangeRequest { + pub target: AclTarget, + pub change: AclChange, + /// The statements the plan showed. An apply runs only those: it plans again and refuses if the + /// result differs. + #[serde(default)] + pub statements: Option>, +} + +/// An object inside a schema, named the way `REVOKE ... ON ` needs it. +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct AclObject { + pub name: String, + /// `TABLE`, `SEQUENCE`, ... — what the object is, since the keyword differs. + pub kind: String, + /// A routine is identified by its argument types, not by its name: two `f` in one schema are + /// two objects. Absent for everything else. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args: Option, +} + +/// A grant as the database has it, under the role names the editor uses. +#[derive(Serialize, Debug, PartialEq)] +pub struct AclGrant { + /// A data table role's name, `admin` for `custom_instance_user`, else the raw Postgres role + /// (`PUBLIC` included). + pub grantee: String, + pub privileges: Vec, + /// `None` for the target itself, else the object inside it. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, + /// `TABLES` / `SEQUENCES` / `FUNCTIONS` when this is a default privilege, which applies to + /// objects that do not exist yet. + #[serde(skip_serializing_if = "Option::is_none")] + pub future: Option, +} + +#[derive(Serialize, Debug)] +pub struct DatatableAclInfo { + /// Under the same names as [`AclGrant::grantee`]. + pub owner: String, + /// The roles a change may name: `admin`, then every role of the instance catalog. Only for a + /// caller who may change anything, as the catalog is in the permissions drawer. + pub roles: Vec, + /// Whether this caller may plan and apply changes. + pub editable: bool, + /// Whether the server is Postgres 17 or later, which added the `MAINTAIN` table privilege. + pub supports_maintain: bool, + /// The database the target lives in, which no target carries itself. + pub dbname: String, + pub grants: Vec, + /// What the target holds that is a target of its own: a database's schemas, a schema's tables. + pub children: Vec, +} + +#[derive(Serialize, Debug)] +pub struct AclPlan { + pub statements: Vec, + pub warnings: Vec, +} + +/// The Postgres role a role name stands for. A data table role is a login named exactly like the +/// role, so this is the identity — except `admin`, which is `custom_instance_user`. +/// +/// Anything else is refused, never resolved to some default: every statement a plan writes names +/// the role it is about. +pub(crate) fn pg_role_of(name: &str, catalog: &DatatableRoleCatalog) -> Result { + if name == ADMIN_DATATABLE_ROLE { + return Ok(CUSTOM_INSTANCE_USER.to_string()); + } + if catalog.values().any(|r| r.name == name) { + return Ok(name.to_string()); + } + Err(Error::BadRequest(format!( + "'{name}' is not a data table role of this instance" + ))) +} + +/// The reverse of [`pg_role_of`], for display. A role that is not a data table role reads back as +/// itself. +pub(crate) fn role_name_of(pg_role: &str) -> String { + if pg_role == CUSTOM_INSTANCE_USER { + ADMIN_DATATABLE_ROLE.to_string() + } else { + pg_role.to_string() + } +} + +/// Every role a change may name: `admin` first, then the catalog. +fn role_names(catalog: &DatatableRoleCatalog) -> Vec { + let mut names: Vec = catalog.values().map(|r| r.name.clone()).collect(); + names.sort(); + names.insert(0, ADMIN_DATATABLE_ROLE.to_string()); + names +} + +fn ensure_instance(governing: &GoverningDatatable) -> Result<()> { + if governing.is_instance() { + return Ok(()); + } + Err(Error::BadRequest(format!( + "Data table '{}' is backed by a Postgres resource, so its access is managed on that \ + server directly. Only a data table on the Windmill instance's own database has data \ + table roles to grant to.", + governing.name + ))) +} + +/// The data table's `admin` connection, and the notices Postgres sends on it. +/// +/// Authorization: connects as `custom_instance_user` with the instance's own credentials and checks +/// nothing. Callers MUST have authorized the request first — a request about to be refused must +/// not get as far as this connection. +async fn connect_as_admin_unchecked( + db: &DB, + governing: &GoverningDatatable, +) -> Result<( + tokio_postgres::Client, + mpsc::UnboundedReceiver, + String, +)> { + let resource = + get_datatable_resource_from_db_unchecked(db, &governing.workspace_id, &governing.name) + .await?; + let pg: PgDatabase = serde_json::from_value(resource) + .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {e}")))?; + let dbname = pg.dbname.clone(); + let (client, mut connection) = pg.connect(Some(db)).await?; + // Unbounded: the driver must never wait on the receiver, which only drains once the statement + // the driver is carrying has completed. + let (notices_tx, notices) = mpsc::unbounded_channel(); + tokio::spawn(async move { + loop { + match std::future::poll_fn(|cx| connection.poll_message(cx)).await { + Some(Ok(AsyncMessage::Notice(notice))) => { + let _ = notices_tx.send(notice); + } + Some(Ok(_)) => {} + Some(Err(e)) => { + tracing::error!("Datatable ACL connection error: {e}"); + break; + } + None => break, + } + } + }); + Ok((client, notices, dbname)) +} + +/// An object whose ownership follows the schema's. +#[derive(Debug, PartialEq)] +pub(crate) struct OwnedObject { + pub(crate) name: String, + /// The keyword `ALTER ... OWNER TO` takes for this kind of object. + pub(crate) keyword: &'static str, + /// Identity arguments of a routine, which is what tells two of the same name apart. `None` + /// for a relation. + pub(crate) args: Option, +} + +fn keyword_of_relkind(relkind: i8) -> Option<&'static str> { + match relkind as u8 as char { + 'r' | 'p' => Some("TABLE"), + 'v' => Some("VIEW"), + 'm' => Some("MATERIALIZED VIEW"), + 'S' => Some("SEQUENCE"), + 'f' => Some("FOREIGN TABLE"), + // Indexes and TOAST tables follow their table; composite types are not reachable through + // ALTER TABLE ... OWNER TO. + _ => None, + } +} + +async fn read_owned_objects( + client: &tokio_postgres::Client, + schema: &str, +) -> Result> { + let rows = client + .query( + "SELECT c.relname, c.relkind + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relkind = ANY(ARRAY['r','p','v','m','S','f']::\"char\"[]) + ORDER BY c.relname", + &[&schema], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to list the objects of schema '{schema}': {}", + pg_error_message(&e) + )) + })?; + let mut objects: Vec = rows + .into_iter() + .filter_map(|row| { + keyword_of_relkind(row.get::<_, i8>(1)).map(|keyword| OwnedObject { + name: row.get(0), + keyword, + args: None, + }) + }) + .collect(); + // Routines live in `pg_proc`, not `pg_class`, and would keep the previous owner while the + // schema they are in changes hands. `ALTER ROUTINE` covers functions, procedures and + // aggregates alike. + let routines = client + .query( + "SELECT p.proname, pg_get_function_identity_arguments(p.oid) + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1 + ORDER BY p.proname", + &[&schema], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to list the routines of schema '{schema}': {}", + pg_error_message(&e) + )) + })?; + objects.extend(routines.into_iter().map(|row| OwnedObject { + name: row.get(0), + keyword: "ROUTINE", + args: Some(row.get(1)), + })); + Ok(objects) +} + +/// The keyword a `REVOKE ... ON` takes for one object, checked rather than interpolated: it lands +/// in SQL unquoted. +pub(crate) fn object_keyword(kind: &str) -> Result<&'static str> { + match kind.to_uppercase().as_str() { + "TABLE" | "VIEW" | "MATERIALIZED VIEW" | "FOREIGN TABLE" => Ok("TABLE"), + "SEQUENCE" => Ok("SEQUENCE"), + "FUNCTION" => Ok("FUNCTION"), + other => Err(Error::BadRequest(format!("Unknown object kind '{other}'"))), + } +} + +/// Every object of a schema, named the way the catalog names it. +async fn read_schema_objects( + client: &tokio_postgres::Client, + schema: &str, +) -> Result> { + let rows = client + .query( + "SELECT CASE c.relkind WHEN 'S' THEN 'SEQUENCE' ELSE 'TABLE' END, c.relname, NULL::text + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relkind = ANY(ARRAY['r','p','v','m','S','f']::\"char\"[]) + UNION ALL + SELECT 'FUNCTION', p.proname, pg_get_function_identity_arguments(p.oid) + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1", + &[&schema], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to list the objects of schema '{schema}': {}", + pg_error_message(&e) + )) + })?; + Ok(rows + .into_iter() + .map(|row| AclObject { kind: row.get(0), name: row.get(1), args: row.get(2) }) + .collect()) +} + +/// Replace the objects a revoke names with the catalog's own entry for each. +/// +/// A routine is identified by its argument types, and those go into the statement as written — +/// there is no quoting for them — so the request may name an object but never spell one: what +/// reaches the SQL is read back from Postgres. An object that resolves to nothing is refused rather +/// than dropped, since a revoke that silently covers less than it says is worse than an error. +async fn resolve_acl_objects( + client: &tokio_postgres::Client, + target: &AclTarget, + objects: &[AclObject], +) -> Result> { + if objects.is_empty() { + return Ok(vec![]); + } + let Some(schema) = target.schema() else { + return Err(Error::BadRequest( + "A database has no objects of its own to revoke on".to_string(), + )); + }; + let known = read_schema_objects(client, schema).await?; + objects + .iter() + .map(|requested| { + let keyword = object_keyword(&requested.kind)?; + known + .iter() + .find(|k| { + k.name == requested.name + && k.args == requested.args + && object_keyword(&k.kind).is_ok_and(|k| k == keyword) + }) + .cloned() + .ok_or_else(|| { + Error::NotFound(format!( + "'{}' is not an object of schema '{schema}'", + requested.name + )) + }) + }) + .collect() +} + +async fn read_owner(client: &tokio_postgres::Client, target: &AclTarget) -> Result> { + let row = match target { + AclTarget::Database => { + client + .query_opt( + "SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname = current_database()", + &[], + ) + .await + } + AclTarget::Schema { schema } => { + client + .query_opt( + // `public` is owned by `pg_database_owner`, a placeholder role whose membership + // is whoever owns the database — naming it back would say nothing, so resolve + // it to that owner. + "SELECT pg_get_userbyid(owner) FROM ( + SELECT CASE WHEN n.nspowner = (SELECT oid FROM pg_roles WHERE rolname = 'pg_database_owner') + THEN (SELECT d.datdba FROM pg_database d WHERE d.datname = current_database()) + ELSE n.nspowner END AS owner + FROM pg_namespace n WHERE n.nspname = $1 + ) o", + &[schema], + ) + .await + } + AclTarget::Table { schema, table } => { + client + .query_opt( + "SELECT pg_get_userbyid(c.relowner) + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2", + &[schema, table], + ) + .await + } + } + .map_err(|e| Error::internal_err(format!("Failed to read the owner: {}", pg_error_message(&e))))?; + Ok(row.map(|row| row.get(0))) +} + +async fn read_children(client: &tokio_postgres::Client, target: &AclTarget) -> Result> { + let rows = match target { + AclTarget::Database => { + client + .query( + "SELECT nspname::text FROM pg_namespace + WHERE nspname <> 'information_schema' AND nspname NOT LIKE 'pg\\_%' + ORDER BY nspname", + &[], + ) + .await + } + AclTarget::Schema { schema } => { + client + .query( + "SELECT c.relname::text + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relkind = ANY(ARRAY['r','p']::\"char\"[]) + ORDER BY c.relname", + &[schema], + ) + .await + } + AclTarget::Table { .. } => return Ok(vec![]), + } + .map_err(|e| { + Error::internal_err(format!( + "Failed to list what the target holds: {}", + pg_error_message(&e) + )) + })?; + Ok(rows.into_iter().map(|row| row.get(0)).collect()) +} + +async fn get_datatable_acl( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, + Query(query): Query, +) -> JsonResult { + let target: AclTarget = query.try_into()?; + ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed).await?; + let governing = resolve_governing_datatable(&db, &w_id, &datatable_name).await?; + ensure_instance(&governing)?; + let editable = ensure_governs_datatable(&db, &authed, &w_id, &governing) + .await + .is_ok(); + let roles = if editable { + role_names(&read_role_catalog(&db).await?) + } else { + vec![] + }; + + let (client, _notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?; + let owner = read_owner(&client, &target) + .await? + .ok_or_else(|| Error::NotFound(format!("{} not found", target.label(&dbname))))?; + let grants = read_grants(&client, &target).await?; + let supports_maintain: bool = client + .query_one( + "SELECT current_setting('server_version_num')::int >= 170000", + &[], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the server version: {}", + pg_error_message(&e) + )) + })? + .get(0); + let children = read_children(&client, &target).await?; + + Ok(Json(DatatableAclInfo { + owner: role_name_of(&owner), + roles, + editable, + supports_maintain, + dbname, + grants, + children, + })) +} + +async fn read_grants(client: &tokio_postgres::Client, target: &AclTarget) -> Result> { + // `aclexplode` turns an acl array into one row per (grantee, privilege); grantee 0 is PUBLIC, + // which has no name to resolve. + let mut rows = match target { + AclTarget::Database => client + .query( + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, NULL::text, NULL::text, NULL::text, NULL::text + FROM pg_database d, aclexplode(d.datacl) a + WHERE d.datname = current_database()", + &[], + ) + .await + .map_err(grant_read_error)?, + AclTarget::Schema { schema } => { + let mut out = client + .query( + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, NULL::text, NULL::text, NULL::text, NULL::text + FROM pg_namespace n, aclexplode(n.nspacl) a + WHERE n.nspname = $1", + &[schema], + ) + .await + .map_err(grant_read_error)?; + out.extend( + client + .query( + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, c.relname, NULL::text, + CASE c.relkind WHEN 'S' THEN 'SEQUENCE' ELSE 'TABLE' END, + NULL::text + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace, + aclexplode(c.relacl) a + WHERE n.nspname = $1", + &[schema], + ) + .await + .map_err(grant_read_error)?, + ); + out.extend( + client + .query( + // Routines carry their own acl in `pg_proc`; without this a grant made here + // would vanish on the next read and could never be revoked back. + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, p.proname, NULL::text, 'FUNCTION', + pg_get_function_identity_arguments(p.oid) + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace, + aclexplode(p.proacl) a + WHERE n.nspname = $1", + &[schema], + ) + .await + .map_err(grant_read_error)?, + ); + out.extend( + client + .query( + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, NULL::text, + CASE d.defaclobjtype + WHEN 'r' THEN 'TABLES' WHEN 'S' THEN 'SEQUENCES' + WHEN 'f' THEN 'FUNCTIONS' ELSE 'TYPES' END, NULL::text, NULL::text + FROM pg_default_acl d + JOIN pg_namespace n ON n.oid = d.defaclnamespace, + aclexplode(d.defaclacl) a + WHERE n.nspname = $1", + &[schema], + ) + .await + .map_err(grant_read_error)?, + ); + out + } + AclTarget::Table { schema, table } => client + .query( + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, NULL::text, NULL::text, NULL::text, NULL::text + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace, + aclexplode(c.relacl) a + WHERE n.nspname = $1 AND c.relname = $2", + &[schema, table], + ) + .await + .map_err(grant_read_error)?, + }; + + // One row per privilege — and, for default privileges, one per creating role. Fold them back + // into one entry per grantee and object. + let mut folded: BTreeMap< + ( + String, + Option<(String, String, Option)>, + Option, + ), + Vec, + > = BTreeMap::new(); + for row in rows.drain(..) { + let grantee: String = row.get(0); + let privilege: String = row.get(1); + let object: Option = row.get(2); + let future: Option = row.get(3); + let object_kind: Option = row.get(4); + let object_args: Option = row.get(5); + folded + .entry(( + role_name_of(&grantee), + object.map(|name| { + ( + name, + object_kind.unwrap_or_else(|| "TABLE".to_string()), + object_args, + ) + }), + future, + )) + .or_default() + .push(privilege); + } + Ok(folded + .into_iter() + .map(|((grantee, object, future), mut privileges)| { + privileges.sort(); + privileges.dedup(); + AclGrant { + grantee, + privileges, + object: object.map(|(name, kind, args)| AclObject { name, kind, args }), + future, + } + }) + .collect()) +} + +fn grant_read_error(e: tokio_postgres::Error) -> Error { + Error::internal_err(format!("Failed to read grants: {}", pg_error_message(&e))) +} + +/// Changing a data table's access is administering it. Checked in full before anything connects +/// with the instance's credentials — the edition included, since without the planner every +/// request ends in the same refusal. +async fn authorize_acl_change( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + datatable_name: &str, +) -> Result { + let governing = resolve_governing_datatable(db, w_id, datatable_name).await?; + ensure_governs_datatable(db, authed, w_id, &governing).await?; + ensure_instance(&governing)?; + crate::datatable_acl_oss::ensure_acl_planner()?; + Ok(governing) +} + +/// Plan one change against the catalog and the database as they are now. +async fn build_plan( + client: &tokio_postgres::Client, + dbname: &str, + catalog: &DatatableRoleCatalog, + target: &AclTarget, + change: &AclChange, +) -> Result { + let role = change.role(); + // `admin` is the login the data table itself reaches Postgres through, and the one every + // change here runs as: a revoke that lands leaves nothing able to grant it back. + if matches!(change, AclChange::Revoke { .. }) && role == ADMIN_DATATABLE_ROLE { + return Err(Error::BadRequest(format!( + "'{ADMIN_DATATABLE_ROLE}' is how this data table reaches its database; \ + its own access is not revocable from here" + ))); + } + let pg_role = pg_role_of(role, catalog)?; + let change = match change { + AclChange::Revoke { role, privileges, scope, objects } => AclChange::Revoke { + role: role.clone(), + privileges: privileges.clone(), + scope: *scope, + objects: resolve_acl_objects(client, target, objects).await?, + }, + change => change.clone(), + }; + // Default privileges are recorded per creating role, and a schema's new owner is kept in reach + // of what the others create there, so both are written for every role there is. + let other_pg_roles = role_names(catalog) + .iter() + .map(|name| pg_role_of(name, catalog)) + .filter(|r| r.as_ref().map_or(true, |r| *r != pg_role)) + .collect::>>()?; + let existing_objects = match (&change, target) { + (AclChange::SetOwner { .. }, AclTarget::Schema { schema }) => { + read_owned_objects(client, schema).await? + } + _ => vec![], + }; + let mut plan = crate::datatable_acl_oss::plan_statements( + target, + &change, + dbname, + &pg_role, + &other_pg_roles, + &existing_objects, + )?; + if matches!(change, AclChange::SetOwner { .. }) { + if let Some(missing) = missing_owner_privilege(client, target, &pg_role).await? { + plan.warnings.push(format!( + "{role} does not have {missing}, which Postgres requires of a new owner, so this \ + will be refused. Grant it first." + )); + } + } + Ok(plan) +} + +/// Postgres only hands an object to a role that could have created it: a table to one with +/// `CREATE` on its schema, a schema to one with `CREATE` on the database. +async fn missing_owner_privilege( + client: &tokio_postgres::Client, + target: &AclTarget, + pg_role: &str, +) -> Result> { + let (row, missing) = match target { + AclTarget::Table { schema, .. } => ( + client + .query_one( + "SELECT has_schema_privilege($1::name, $2::text, 'CREATE')", + &[&pg_role, schema], + ) + .await, + format!("CREATE on schema {schema}"), + ), + AclTarget::Schema { .. } => ( + client + .query_one( + "SELECT has_database_privilege($1::name, current_database(), 'CREATE')", + &[&pg_role], + ) + .await, + "CREATE on the database".to_string(), + ), + AclTarget::Database => return Ok(None), + }; + let has: bool = row + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the new owner's privileges: {}", + pg_error_message(&e) + )) + })? + .get(0); + Ok((!has).then_some(missing)) +} + +async fn plan_datatable_acl( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, + Json(req): Json, +) -> JsonResult { + let governing = authorize_acl_change(&db, &authed, &w_id, &datatable_name).await?; + let catalog = read_role_catalog(&db).await?; + let (client, _notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?; + Ok(Json( + build_plan(&client, &dbname, &catalog, &req.target, &req.change).await?, + )) +} + +async fn apply_datatable_acl( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, + Json(req): Json, +) -> Result { + let confirmed = req.statements.as_ref().ok_or_else(|| { + Error::BadRequest( + "An apply runs exactly the statements its plan showed; plan the change first" + .to_string(), + ) + })?; + // Refuses without taking a lock; everything is checked again once they are held. + let governing = authorize_acl_change(&db, &authed, &w_id, &datatable_name).await?; + + // Held until the change is committed: a role renamed or dropped meanwhile would change what + // the plan names, and a settings save could move the entry onto another database. Taken in the + // same order as the permissions save, so the two cannot deadlock. + let mut tx = db.begin().await?; + lock_role_catalog(&mut tx).await?; + sqlx::query!( + "SELECT 1 AS one FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", + &governing.workspace_id + ) + .fetch_optional(&mut *tx) + .await?; + let governing = authorize_acl_change(&db, &authed, &w_id, &datatable_name).await?; + let catalog = read_role_catalog_tx(&mut tx).await?; + + let (mut client, mut notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?; + let plan = build_plan(&client, &dbname, &catalog, &req.target, &req.change).await?; + if &plan.statements != confirmed { + return Err(Error::BadRequest( + "The data table or its roles changed since this was planned, so it would no longer \ + run what was confirmed. Plan it again." + .to_string(), + )); + } + + // Postgres only lets a role pass on a privilege it holds with grant option, and an instance + // database provisioned before data table roles holds none. Best-effort: a grant this fails to + // enable is refused below rather than skipped. + if let Err(e) = windmill_common::ensure_instance_db_grant_options_unchecked(&db, &dbname).await + { + tracing::warn!("Could not refresh grant options on '{dbname}': {e}"); + } + + // One transaction: a half-applied ownership transfer leaves one schema's objects owned by two + // different roles. + let pg_tx = client.transaction().await.map_err(|e| { + Error::internal_err(format!( + "Failed to open a transaction on the data table: {}", + pg_error_message(&e) + )) + })?; + for statement in &plan.statements { + pg_tx.batch_execute(statement).await.map_err(|e| { + Error::ExecutionErr(format!( + "Failed to run `{statement}`: {}", + pg_error_message(&e) + )) + })?; + // A privilege the connection cannot pass on is only a warning to Postgres, which then + // carries on having changed nothing. Returning drops the transaction, rolling back + // everything before it. + while let Ok(notice) = notices.try_recv() { + if *notice.code() == SqlState::WARNING_PRIVILEGE_NOT_GRANTED + || *notice.code() == SqlState::WARNING_PRIVILEGE_NOT_REVOKED + { + return Err(Error::ExecutionErr(format!( + "`{statement}` did not take effect ({}), so nothing was applied", + notice.message() + ))); + } + } + } + + let target_label = req.target.label(&dbname); + audit_log( + &mut *tx, + &authed, + "workspaces.datatable_acl", + ActionKind::Update, + &governing.workspace_id, + Some(&governing.name), + Some( + [ + ("target", target_label.as_str()), + ("change", change_kind(&req.change)), + ("role", req.change.role()), + ] + .into(), + ), + ) + .await?; + pg_tx.commit().await.map_err(|e| { + Error::internal_err(format!( + "Failed to commit the changes: {}", + pg_error_message(&e) + )) + })?; + tx.commit().await?; + + windmill_common::feature_usage::log_feature_usage( + "datatable", + "acl_applied", + change_kind(&req.change), + ); + + Ok(format!("Updated access on {target_label}")) +} + +/// What kind of change, never what it named: the telemetry key and the audit's summary. +fn change_kind(change: &AclChange) -> &'static str { + match change { + AclChange::SetOwner { .. } => "owner", + AclChange::Grant { scope, .. } | AclChange::Revoke { scope, .. } if scope.is_future() => { + "default_privileges" + } + AclChange::Grant { .. } => "grant", + AclChange::Revoke { .. } => "revoke", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use windmill_common::datatable_roles::InstanceDatatableRole; + + #[test] + fn a_role_is_its_own_postgres_role_except_admin() { + let catalog: DatatableRoleCatalog = BTreeMap::from([( + "role1".to_string(), + InstanceDatatableRole { name: "analytics".to_string(), enabled: true, pwd: None }, + )]); + assert_eq!( + pg_role_of("admin", &catalog).unwrap(), + "custom_instance_user" + ); + assert_eq!(pg_role_of("analytics", &catalog).unwrap(), "analytics"); + assert_eq!(role_name_of("custom_instance_user"), "admin"); + assert_eq!(role_name_of("analytics"), "analytics"); + // A catalog id, a name the catalog lacks, or the admin login spelled out never stands for + // some other role. + for unknown in ["role1", "operator", "custom_instance_user", "PUBLIC", ""] { + assert!( + matches!(pg_role_of(unknown, &catalog), Err(Error::BadRequest(_))), + "{unknown}" + ); + } + } +} diff --git a/backend/windmill-api-workspaces/src/datatable_acl_oss.rs b/backend/windmill-api-workspaces/src/datatable_acl_oss.rs new file mode 100644 index 0000000000..6bac6cd2de --- /dev/null +++ b/backend/windmill-api-workspaces/src/datatable_acl_oss.rs @@ -0,0 +1,50 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Where the ACL planner comes from: the enterprise one, or a refusal. +//! +//! Reading who owns what stays open in every edition; every change is a plan, so an edition +//! without the planner cannot make one. `private` alone is not that edition — community builds +//! carry it — so the planner is behind `enterprise` as well. + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) use crate::datatable_acl_ee::plan_statements; + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) fn ensure_acl_planner() -> windmill_common::error::Result<()> { + Ok(()) +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +use { + crate::datatable_acl::{AclChange, AclPlan, AclTarget, OwnedObject}, + windmill_common::error::{Error, Result}, +}; + +/// Checked right after authorization, before anything connects with the instance's credentials. +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) fn ensure_acl_planner() -> Result<()> { + Err(Error::BadRequest( + "Data table permissions are a Windmill Enterprise Edition feature".to_string(), + )) +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) fn plan_statements( + _target: &AclTarget, + _change: &AclChange, + _dbname: &str, + _pg_role: &str, + _other_pg_roles: &[String], + _existing_objects: &[OwnedObject], +) -> Result { + ensure_acl_planner()?; + Err(Error::internal_err( + "No ACL planner in this edition".to_string(), + )) +} diff --git a/backend/windmill-api-workspaces/src/lib.rs b/backend/windmill-api-workspaces/src/lib.rs index 8e5aff09ec..e88eddc4ef 100644 --- a/backend/windmill-api-workspaces/src/lib.rs +++ b/backend/windmill-api-workspaces/src/lib.rs @@ -1,3 +1,5 @@ +pub mod datatable_acl; +pub mod datatable_acl_oss; pub mod datatable_migrations; pub mod datatable_permissions; pub mod deployment_requests; @@ -8,3 +10,6 @@ pub mod workspaces_oss; #[cfg(feature = "private")] pub mod workspaces_ee; + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub mod datatable_acl_ee; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index f0f99c7a73..6d0635a4d8 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -144,6 +144,7 @@ pub fn workspaced_service() -> Router { ) .merge(crate::datatable_migrations::routes()) .merge(crate::datatable_permissions::routes()) + .merge(crate::datatable_acl::routes()) .route("/git_sync_enabled", get(get_git_sync_enabled)) .route("/git_sync_deploy_mode", get(get_git_sync_deploy_mode)) .route("/edit_git_sync_config", post(edit_git_sync_config)) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5bec378add..d150a5a444 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -5134,6 +5134,97 @@ paths: schema: type: string + /w/{workspace}/workspaces/datatable_acl/{datatable_name}: + get: + summary: read the owner and grants of an instance data table's database, schema or table + operationId: getDatatableAcl + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + - name: kind + in: query + required: true + schema: + type: string + enum: [database, schema, table] + - name: schema + in: query + required: false + schema: + type: string + - name: table + in: query + required: false + schema: + type: string + responses: + "200": + description: owner and grants + content: + application/json: + schema: + $ref: "#/components/schemas/DatatableAclInfo" + + /w/{workspace}/workspaces/datatable_acl/{datatable_name}/plan: + post: + summary: preview the SQL an ownership or grant change would run (data table administrators only) + operationId: planDatatableAcl + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AclChangeRequest" + responses: + "200": + description: statements that would run, in a single transaction + content: + application/json: + schema: + $ref: "#/components/schemas/AclPlan" + + /w/{workspace}/workspaces/datatable_acl/{datatable_name}/apply: + post: + summary: run an ownership or grant change exactly as planned (data table administrators only) + operationId: applyDatatableAcl + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AclChangeRequest" + responses: + "200": + description: change applied + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}: get: summary: list the data table roles the caller may connect as @@ -32556,6 +32647,139 @@ components: datatable: type: string + AclTarget: + type: object + required: [kind] + properties: + kind: + type: string + enum: [database, schema, table] + schema: + type: string + description: required for a schema or table target + table: + type: string + + AclChange: + type: object + required: [type, role] + properties: + type: + type: string + enum: [set_owner, grant, revoke] + role: + type: string + description: a data table role of the instance, or admin + privileges: + type: array + items: + type: string + scope: + type: string + enum: + [ + target, + all_tables, + all_sequences, + all_functions, + future_tables, + future_sequences, + future_functions, + ] + objects: + type: array + description: objects inside the target a revoke covers, empty for the target itself + items: + $ref: "#/components/schemas/AclObject" + + AclChangeRequest: + type: object + required: [target, change] + properties: + target: + $ref: "#/components/schemas/AclTarget" + change: + $ref: "#/components/schemas/AclChange" + statements: + type: array + description: >- + The statements the plan showed. Required to apply, which plans again and refuses if + the result differs. + items: + type: string + + AclPlan: + type: object + required: [statements, warnings] + properties: + statements: + type: array + items: + type: string + warnings: + type: array + items: + type: string + + AclObject: + type: object + required: [name, kind] + properties: + name: + type: string + kind: + type: string + description: TABLE, SEQUENCE or FUNCTION — the keyword a REVOKE on it takes + args: + type: string + description: identity arguments of a routine, which is what tells two of the same name apart + + AclGrant: + type: object + required: [grantee, privileges] + properties: + grantee: + type: string + privileges: + type: array + items: + type: string + object: + $ref: "#/components/schemas/AclObject" + future: + type: string + description: set for a default privilege, naming the kind of object it covers + + DatatableAclInfo: + type: object + required: [owner, roles, editable, supports_maintain, dbname, grants, children] + properties: + owner: + type: string + roles: + type: array + description: the roles a change may name; empty unless the caller may change anything + items: + type: string + editable: + type: boolean + description: whether the caller may plan and apply changes + supports_maintain: + type: boolean + description: whether the server is Postgres 17+, which added the MAINTAIN table privilege + dbname: + type: string + description: the database the target lives in + grants: + type: array + items: + $ref: "#/components/schemas/AclGrant" + children: + type: array + description: a database's schemas, or a schema's tables + items: + type: string + CustomInstanceDb: type: object required: diff --git a/backend/windmill-common/src/datatable_roles.rs b/backend/windmill-common/src/datatable_roles.rs index ab5e519c3a..21f75df08d 100644 --- a/backend/windmill-common/src/datatable_roles.rs +++ b/backend/windmill-common/src/datatable_roles.rs @@ -102,9 +102,10 @@ pub fn validate_role_name(name: &str) -> Result<()> { Ok(()) } -/// SAFETY: every caller must have run [`validate_role_name`] first — the charset it enforces is -/// what makes this quoting sufficient. -fn quote_ident(name: &str) -> String { +/// A double-quoted Postgres identifier. Doubling `"` is Postgres's own escaping inside one, so this +/// quotes any name — schema, table or role. Role names are validated as well +/// ([`validate_role_name`]) because they also travel unquoted, in `-- role ` and `?role=`. +pub fn quote_ident(name: &str) -> String { format!("\"{}\"", name.replace('"', "\"\"")) } diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index e0a60e498b..74072d534c 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -989,6 +989,20 @@ impl Future for TokioPgConnection { } } +impl TokioPgConnection { + /// Drive the connection and hand back what the server sends outside of a query's response — + /// notices above all, which driving it as a future silently discards. + pub fn poll_message( + &mut self, + cx: &mut core::task::Context<'_>, + ) -> core::task::Poll>> { + match self { + TokioPgConnection::Tls(conn) => conn.poll_message(cx), + TokioPgConnection::NoTls(conn) => conn.poll_message(cx), + } + } +} + impl PgDatabase { /// The role the connection logs in as, whichever way it authenticates. pub fn login_name(&self) -> &str { diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index fa15251b27..0eaa845e08 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -1086,8 +1086,10 @@ the flow editor, how data tables and their migrations are set up and used, how often an empty workspace home is seen, how often the home page’s create menu and hub-project picker are opened and from which entry point, the name of any public hub project - imported from the home page and how far that import got, and whether data tables are - put under roles and whether callers name a role or take the default, last 30 days)
  • feature adoption (counts of which flow, script, trigger, worker and data table @@ -1150,8 +1152,10 @@ the flow editor, how data tables and their migrations are set up and used, how often an empty workspace home is seen, how often the home page’s create menu and hub-project picker are opened and from which entry point, the name of any public hub project - imported from the home page and how far that import got, and whether data tables are - put under roles and whether callers name a role or take the default, last 30 days)
  • feature adoption (counts of which flow, script, trigger, worker and data table diff --git a/frontend/src/lib/components/datatableAcl/AclTargetPicker.svelte b/frontend/src/lib/components/datatableAcl/AclTargetPicker.svelte new file mode 100644 index 0000000000..505c5733ed --- /dev/null +++ b/frontend/src/lib/components/datatableAcl/AclTargetPicker.svelte @@ -0,0 +1,74 @@ + + +
    + ({ value: t, label: t }))} + bind:value={table} + placeholder="The whole schema" + clearable + loading={tables.loading} + size="sm" + class="w-56" + /> + {/if} +
    diff --git a/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte b/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte new file mode 100644 index 0000000000..65b599cd6e --- /dev/null +++ b/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte @@ -0,0 +1,260 @@ + + +{#if acl.error} + {errorText(acl.error)} +{:else if !info} + Loading… +{:else} +
    + {#if !info.editable} + + Read only: access is changed by the admins of the workspace that governs this data table. + + {/if} + + {#if target.kind !== 'database'} +
    +
    + Owner + + {target.kind === 'schema' + ? 'The role that owns the schema and everything already in it. Changing it also keeps the new owner in reach of what the other roles create here later.' + : 'The role that owns the table. Its owner may always read and write it, and is who ALTER and DROP answer to.'} + +
    + {#if info.editable} + scope, + (s) => { + if (!s) return + scope = s + // A privilege only exists for some objects — SELECT means nothing on a function — + // so drop what the new scope cannot carry rather than send it. + const allowed = privilegesOf(s, target.kind, supportsMaintain) + privileges = privileges.filter((p) => allowed.includes(p)) + } + } + items={scopesOf(target.kind)} + {disabled} + size="sm" + class="w-52" + /> + TO +