diff --git a/backend/.sqlx/query-49ce3f131435f763dad8312c1efc569f005682897d2d8525702ea77c1f9ea99d.json b/backend/.sqlx/query-49ce3f131435f763dad8312c1efc569f005682897d2d8525702ea77c1f9ea99d.json new file mode 100644 index 0000000000..be72792d3e --- /dev/null +++ b/backend/.sqlx/query-49ce3f131435f763dad8312c1efc569f005682897d2d8525702ea77c1f9ea99d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3)\n WHERE workspace_id = $1 AND datatable->'datatables' ? $2\n RETURNING workspace_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "49ce3f131435f763dad8312c1efc569f005682897d2d8525702ea77c1f9ea99d" +} diff --git a/backend/.sqlx/query-4ca292e7b9100ef801fae6ad15ccf91bd05603febcb8788fdbfa9d4f1d12f004.json b/backend/.sqlx/query-4ca292e7b9100ef801fae6ad15ccf91bd05603febcb8788fdbfa9d4f1d12f004.json new file mode 100644 index 0000000000..b549657e00 --- /dev/null +++ b/backend/.sqlx/query-4ca292e7b9100ef801fae6ad15ccf91bd05603febcb8788fdbfa9d4f1d12f004.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings\n SET datatable = jsonb_set(datatable, '{datatables}', (\n SELECT COALESCE(jsonb_object_agg(key, value - 'permissions'), '{}'::jsonb)\n FROM jsonb_each(datatable->'datatables')\n ))\n WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "4ca292e7b9100ef801fae6ad15ccf91bd05603febcb8788fdbfa9d4f1d12f004" +} diff --git a/backend/.sqlx/query-a44a4daf82889cfbec961702f7eb83d015a2a6b1b23fcfbd18e7f2ea36e4d8fc.json b/backend/.sqlx/query-a44a4daf82889cfbec961702f7eb83d015a2a6b1b23fcfbd18e7f2ea36e4d8fc.json new file mode 100644 index 0000000000..43dfba29c0 --- /dev/null +++ b/backend/.sqlx/query-a44a4daf82889cfbec961702f7eb83d015a2a6b1b23fcfbd18e7f2ea36e4d8fc.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permissioned_as, permissioned_as_email FROM v2_job WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "permissioned_as_email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a44a4daf82889cfbec961702f7eb83d015a2a6b1b23fcfbd18e7f2ea36e4d8fc" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f008a12d4c..4b06112d4a 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -aa05ca8e97fc8265cd724753a80db37f83243254 +942ff7ed429f8c76395c4f47a858d5a959737252 diff --git a/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs b/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs index 12d7932a16..5a7c92fa62 100644 --- a/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs +++ b/backend/parsers/windmill-parser-sql-asset/src/asset_parser.rs @@ -1542,6 +1542,29 @@ mod tests { ); } + /// The role selects which postgres login the ATTACH connects as; the asset + /// is still the data table, so it must not leak into the recorded path. + #[test] + fn test_sql_asset_parser_strips_role_from_datatable_ref() { + for (attach, expected) in [ + ("ATTACH 'datatable://my_dt?role=analyst' AS dt;", "my_dt"), + ("ATTACH 'datatable?role=analyst' AS dt;", "main"), + ] { + let input = format!("{attach}\nINSERT INTO dt.table1 VALUES ('test');"); + let s = parse_assets(&input).map(|s| s.assets); + assert_eq!( + s.map_err(|e| e.to_string()), + Ok(vec![ParseAssetsResult { + kind: AssetKind::DataTable, + path: format!("{expected}/table1"), + access_type: Some(W), + columns: None + },]), + "{attach}" + ); + } + } + #[test] fn test_sql_asset_parser_create_table() { let input = r#" diff --git a/backend/parsers/windmill-parser/src/asset_parser.rs b/backend/parsers/windmill-parser/src/asset_parser.rs index de35ccdd6e..4b3c49545a 100644 --- a/backend/parsers/windmill-parser/src/asset_parser.rs +++ b/backend/parsers/windmill-parser/src/asset_parser.rs @@ -715,13 +715,28 @@ pub fn asset_was_used(assets: &Vec, (kind, path): (AssetKind, } pub fn parse_asset_syntax(s: &str, enable_default_syntax: bool) -> Option<(AssetKind, &str)> { - if enable_default_syntax && s == "datatable" { - return Some((AssetKind::DataTable, "main")); - } else if enable_default_syntax && s == "ducklake" { - return Some((AssetKind::Ducklake, "main")); + if enable_default_syntax { + // `datatable` and `datatable?role=` both name the default data + // table; the role picks which postgres login the ATTACH connects as and + // is not part of the asset's identity. + if s.strip_prefix("datatable") + .is_some_and(|rest| rest.is_empty() || rest.starts_with('?')) + { + return Some((AssetKind::DataTable, "main")); + } else if s == "ducklake" { + return Some((AssetKind::Ducklake, "main")); + } } for (prefix, kind) in ASSET_KINDS.iter() { if s.starts_with(prefix) { + // Same for the explicit form: `datatable://?role=` is + // still the `` data table. Only data tables take a query + // string — for a Resource, `?table=` is part of the path. + if *kind == AssetKind::DataTable { + if let Some((path, _)) = s[prefix.len()..].split_once('?') { + return Some((*kind, path)); + } + } // The suffix is kept verbatim. For S3 the path encodes the storage: // `s3:///`, with an EMPTY storage segment for the // workspace default — so `s3:///key` yields `/key` (leading slash diff --git a/backend/windmill-api-workspaces/src/datatable_permissions.rs b/backend/windmill-api-workspaces/src/datatable_permissions.rs new file mode 100644 index 0000000000..0274c3ebdd --- /dev/null +++ b/backend/windmill-api-workspaces/src/datatable_permissions.rs @@ -0,0 +1,849 @@ +/* + * 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. + */ + +//! Role-based access control for data tables: read the config, preview the SQL a +//! change plans out, and apply it. +//! +//! A permissioned data table maps each Windmill role onto a real Postgres login +//! role, so a script that runs as `analyst` connects as `analyst` and the +//! database — not Windmill — enforces what it may touch. `root` is the exception: +//! it is the connection the data table already resolved to before permissions +//! were turned on, so it owns every existing object and is never created, +//! renamed or dropped. + +use axum::{ + extract::{Extension, Path}, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashSet}; + +use windmill_api_auth::ApiAuthed; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::error::{pg_error_message, Error, JsonResult, Result}; +use windmill_common::query_builders::{render_db_quoted_identifier, DbType}; +use windmill_common::utils::{rd_string, require_admin}; +use windmill_common::workspaces::{ + datatable_pg_role_name, get_datatable_resource_from_db_unchecked, DataTable, + DataTablePermissions, DataTableRole, ROOT_DATATABLE_ROLE, +}; +use windmill_common::{PgDatabase, DB}; + +pub(crate) fn routes() -> Router { + Router::new() + .route( + "/datatable_permissions/{datatable_name}", + get(get_datatable_permissions).post(set_datatable_permissions), + ) + .route( + "/datatable_permissions/{datatable_name}/preview", + post(preview_datatable_permissions), + ) +} + +/// A data table role as the UI sees it: the generated password never leaves the +/// server, since it grants direct database access to anyone who reads it. +#[derive(Serialize, Deserialize, Debug)] +pub struct DatatableRoleInfo { + pub name: String, + #[serde(default)] + pub tenants: Vec, + /// The underlying Postgres role, so grants can be written by hand against it. + /// Absent for `root`, which reuses the data table's own connection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pg_rolename: Option, +} + +#[derive(Serialize, Debug)] +pub struct DatatablePermissionsInfo { + pub enabled: bool, + pub roles: Vec, +} + +#[derive(Deserialize, Debug)] +pub struct SetDatatablePermissions { + pub enabled: bool, + #[serde(default)] + pub roles: Vec, + /// Role renames (old -> new), tracked client-side by a stable id so a rename + /// plans an `ALTER ROLE ... RENAME` instead of a drop plus a create — which + /// would destroy the grants the role had accumulated. + #[serde(default)] + pub renames: Vec, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct DatatableRoleRename { + pub from: String, + pub to: String, +} + +#[derive(Serialize, Debug)] +pub struct DatatablePermissionsPreview { + pub statements: Vec, + pub warnings: Vec, +} + +/// One planned statement. `display` is what the preview shows: identical to +/// `sql` except where a generated password would otherwise be printed. +#[derive(Debug)] +struct PlannedStatement { + sql: String, + display: String, +} + +impl PlannedStatement { + fn plain(sql: String) -> Self { + Self { display: sql.clone(), sql } + } +} + +#[derive(Debug)] +struct RolePlan { + statements: Vec, + /// The permissions block to persist once the statements have run. + permissions: DataTablePermissions, + warnings: Vec, +} + +fn quote_ident(ident: &str) -> String { + render_db_quoted_identifier(ident, DbType::Postgresql) +} + +fn quote_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +fn validate_role_name(name: &str) -> Result<()> { + if name.is_empty() + || name.len() > 63 + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(Error::BadRequest(format!( + "Invalid role name '{name}': must be 1-63 characters of letters, digits, '_' or '-'" + ))); + } + Ok(()) +} + +fn validate_tenant(tenant: &str) -> Result<()> { + match tenant.split_once('/') { + Some(("u" | "g" | "f", rest)) if !rest.is_empty() => Ok(()), + _ => Err(Error::BadRequest(format!( + "Invalid tenant '{tenant}': expected u/, g/ or f/" + ))), + } +} + +/// Plan the SQL that takes the data table's Postgres roles from `old` to `req`. +/// +/// `existing_pg_roles` is the set of role names that actually exist in the +/// cluster, so the plan reconciles against reality rather than against what the +/// config claims: a role the config lost track of is still dropped, and one that +/// somehow already exists has its password reset instead of failing the CREATE. +fn plan_role_changes( + w_id: &str, + datatable: &str, + dbname: &str, + root_pg_role: &str, + old: Option<&DataTablePermissions>, + req: &SetDatatablePermissions, + existing_pg_roles: &HashSet, +) -> Result { + // A disabled data table has no Postgres roles, whatever its config says, so + // re-enabling always plans every role as a creation. + let old_roles: BTreeMap = match old { + Some(p) if p.enabled => p.roles.clone(), + _ => BTreeMap::new(), + }; + + let mut statements = Vec::new(); + let mut warnings = Vec::new(); + + let drop_role = |statements: &mut Vec, pg_role: &str| { + if !existing_pg_roles.contains(pg_role) { + return; + } + let q = quote_ident(pg_role); + // Give the objects back to root before dropping, else the DROP fails on + // anything the role still owns. DROP OWNED then clears what is left: + // privileges granted to it and its default-privilege entries. + statements.push(PlannedStatement::plain(format!( + "REASSIGN OWNED BY {q} TO {};", + quote_ident(root_pg_role) + ))); + statements.push(PlannedStatement::plain(format!("DROP OWNED BY {q};"))); + statements.push(PlannedStatement::plain(format!("DROP ROLE {q};"))); + }; + + if !req.enabled { + for (name, role) in old_roles.iter() { + if name == ROOT_DATATABLE_ROLE { + continue; + } + if let Some(pg_role) = role.pg_rolename.as_deref() { + drop_role(&mut statements, pg_role); + } + } + // Opting out drops the roles, so keeping their definitions would leave + // the config describing roles that no longer exist. + return Ok(RolePlan { + statements, + permissions: DataTablePermissions { enabled: false, roles: BTreeMap::new() }, + warnings, + }); + } + + let mut requested: BTreeMap> = BTreeMap::new(); + for role in req.roles.iter() { + validate_role_name(&role.name)?; + for tenant in role.tenants.iter() { + validate_tenant(tenant)?; + } + if requested + .insert(role.name.clone(), role.tenants.clone()) + .is_some() + { + return Err(Error::BadRequest(format!( + "Duplicate role name '{}'", + role.name + ))); + } + } + if !requested.contains_key(ROOT_DATATABLE_ROLE) { + return Err(Error::BadRequest(format!( + "The '{ROOT_DATATABLE_ROLE}' role cannot be removed" + ))); + } + + // new name -> old name, so a renamed role keeps its Postgres role (and the + // grants on it) instead of being planned as a drop plus a create. + let mut rename_src: BTreeMap<&str, &str> = BTreeMap::new(); + for r in req.renames.iter() { + validate_role_name(&r.from)?; + validate_role_name(&r.to)?; + if r.from == ROOT_DATATABLE_ROLE || r.to == ROOT_DATATABLE_ROLE { + return Err(Error::BadRequest(format!( + "The '{ROOT_DATATABLE_ROLE}' role cannot be renamed" + ))); + } + if r.from == r.to { + continue; + } + if !old_roles.contains_key(&r.from) { + return Err(Error::BadRequest(format!( + "Cannot rename unknown role '{}'", + r.from + ))); + } + if !requested.contains_key(&r.to) { + return Err(Error::BadRequest(format!( + "Renamed role '{}' is missing from the submitted roles", + r.to + ))); + } + if rename_src.insert(&r.to, &r.from).is_some() { + return Err(Error::BadRequest(format!( + "Two roles were renamed to '{}'", + r.to + ))); + } + } + let renamed_away: HashSet<&str> = rename_src.values().copied().collect(); + + let mut roles: BTreeMap = BTreeMap::new(); + + for (name, tenants) in requested.iter() { + if name == ROOT_DATATABLE_ROLE { + roles.insert( + name.clone(), + DataTableRole { pg_rolename: None, pg_password: None, tenants: tenants.clone() }, + ); + continue; + } + + let pg_rolename = datatable_pg_role_name(w_id, datatable, name); + let previous = rename_src + .get(name.as_str()) + .and_then(|from| old_roles.get(*from).map(|r| (*from, r))) + .or_else(|| old_roles.get(name).map(|r| (name.as_str(), r))); + + match previous { + Some((from, old_role)) if old_role.pg_rolename.is_some() => { + let old_pg = old_role.pg_rolename.clone().unwrap(); + let password = old_role + .pg_password + .clone() + .unwrap_or_else(|| rd_string(32)); + if old_pg != pg_rolename { + if existing_pg_roles.contains(&old_pg) { + statements.push(PlannedStatement::plain(format!( + "ALTER ROLE {} RENAME TO {};", + quote_ident(&old_pg), + quote_ident(&pg_rolename) + ))); + // RENAME discards an md5-hashed password, so the stored + // one would stop working; re-setting it is a no-op under + // scram-sha-256 and a repair under md5. + statements.push(PlannedStatement { + sql: format!( + "ALTER ROLE {} PASSWORD {};", + quote_ident(&pg_rolename), + quote_literal(&password) + ), + display: format!( + "ALTER ROLE {} PASSWORD '';", + quote_ident(&pg_rolename) + ), + }); + } else { + warnings.push(format!( + "Role '{from}' was expected to exist in the database as '{old_pg}' but does not; it will be created as '{pg_rolename}'." + )); + statements.push(create_role_statement(&pg_rolename, &password)); + statements.push(grant_connect_statement(&pg_rolename, dbname)); + } + } + roles.insert( + name.clone(), + DataTableRole { + pg_rolename: Some(pg_rolename), + pg_password: Some(password), + tenants: tenants.clone(), + }, + ); + } + _ => { + let password = rd_string(32); + if existing_pg_roles.contains(&pg_rolename) { + warnings.push(format!( + "A Postgres role named '{pg_rolename}' already exists; it will be reused and its password reset." + )); + statements.push(PlannedStatement { + sql: format!( + "ALTER ROLE {} WITH LOGIN PASSWORD {};", + quote_ident(&pg_rolename), + quote_literal(&password) + ), + display: format!( + "ALTER ROLE {} WITH LOGIN PASSWORD '';", + quote_ident(&pg_rolename) + ), + }); + } else { + statements.push(create_role_statement(&pg_rolename, &password)); + } + statements.push(grant_connect_statement(&pg_rolename, dbname)); + roles.insert( + name.clone(), + DataTableRole { + pg_rolename: Some(pg_rolename), + pg_password: Some(password), + tenants: tenants.clone(), + }, + ); + } + } + } + + for (name, role) in old_roles.iter() { + if name == ROOT_DATATABLE_ROLE + || renamed_away.contains(name.as_str()) + || requested.contains_key(name) + { + continue; + } + if let Some(pg_role) = role.pg_rolename.as_deref() { + drop_role(&mut statements, pg_role); + } + } + + Ok(RolePlan { + statements, + permissions: DataTablePermissions { enabled: true, roles }, + warnings, + }) +} + +fn create_role_statement(pg_rolename: &str, password: &str) -> PlannedStatement { + PlannedStatement { + sql: format!( + "CREATE ROLE {} LOGIN PASSWORD {};", + quote_ident(pg_rolename), + quote_literal(password) + ), + display: format!( + "CREATE ROLE {} LOGIN PASSWORD '';", + quote_ident(pg_rolename) + ), + } +} + +/// New roles are created bare — privileges are granted additively afterwards — +/// but they do need to reach the database. CONNECT is usually already theirs +/// through PUBLIC, and the data table's own role often cannot grant it (it is +/// rarely the database owner), so the grant is conditional rather than +/// unconditional: it stays a no-op in the common case instead of failing the +/// whole transaction. +fn grant_connect_statement(pg_rolename: &str, dbname: &str) -> PlannedStatement { + PlannedStatement::plain(format!( + "DO $$ BEGIN\n IF NOT has_database_privilege({}, {}, 'CONNECT') THEN\n EXECUTE 'GRANT CONNECT ON DATABASE {} TO {}';\n END IF;\nEND $$;", + quote_literal(pg_rolename), + quote_literal(dbname), + quote_ident(dbname), + quote_ident(pg_rolename) + )) +} + +async fn read_datatable(db: &DB, w_id: &str, datatable_name: &str) -> Result { + let value = sqlx::query_scalar!( + "SELECT ws.datatable->'datatables'->$2 FROM workspace_settings ws WHERE ws.workspace_id = $1", + w_id, + datatable_name, + ) + .fetch_one(db) + .await? + .filter(|v| !v.is_null()) + .ok_or_else(|| Error::NotFound(format!("Data table '{datatable_name}' not found")))?; + serde_json::from_value(value) + .map_err(|e| Error::internal_err(format!("Invalid data table config: {e}"))) +} + +/// Connect to the data table's own database as `root` and report the identity +/// the plan has to be built against: the database name, the role that owns the +/// existing objects, and the roles that actually exist in the cluster. +async fn connect_as_root( + db: &DB, + w_id: &str, + datatable_name: &str, +) -> Result<(tokio_postgres::Client, String, String, HashSet)> { + let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?; + let pg_db: PgDatabase = serde_json::from_value(db_resource) + .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {e}")))?; + let dbname = pg_db.dbname.clone(); + let (client, connection) = pg_db.connect(Some(db)).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!("Datatable permissions connection error: {}", e); + } + }); + + let root_pg_role: String = client + .query_one("SELECT current_user", &[]) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the data table's connection identity: {}", + pg_error_message(&e) + )) + })? + .get(0); + + let existing_pg_roles = client + .query( + "SELECT rolname FROM pg_roles WHERE rolname LIKE 'wm\\_%'", + &[], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to list existing roles: {}", + pg_error_message(&e) + )) + })? + .into_iter() + .map(|row| row.get::<_, String>(0)) + .collect(); + + Ok((client, dbname, root_pg_role, existing_pg_roles)) +} + +async fn build_plan( + db: &DB, + w_id: &str, + datatable_name: &str, + req: &SetDatatablePermissions, +) -> Result<(tokio_postgres::Client, RolePlan)> { + let datatable = read_datatable(db, w_id, datatable_name).await?; + let (client, dbname, root_pg_role, existing_pg_roles) = + connect_as_root(db, w_id, datatable_name).await?; + let plan = plan_role_changes( + w_id, + datatable_name, + &dbname, + &root_pg_role, + datatable.permissions.as_ref(), + req, + &existing_pg_roles, + )?; + Ok((client, plan)) +} + +/// Drop the Postgres roles a data table leaves behind when it is removed from the +/// workspace config, giving its objects back to root first. +/// +/// Best-effort: a data table whose database is already unreachable must still be +/// removable from the config, so a failure is logged rather than propagated. Any +/// role that survives is reconciled by the next plan, which reads `pg_roles`. +pub(crate) async fn drop_roles_of_deleted_datatable(db: &DB, w_id: &str, datatable_name: &str) { + let req = SetDatatablePermissions { enabled: false, roles: vec![], renames: vec![] }; + let res = async { + let datatable = read_datatable(db, w_id, datatable_name).await?; + if !datatable + .permissions + .as_ref() + .is_some_and(|p| p.enabled && p.roles.len() > 1) + { + return Ok(()); + } + let (mut client, plan) = build_plan(db, w_id, datatable_name, &req).await?; + run_statements(&mut client, &plan).await + } + .await; + if let Err(e) = res { + tracing::error!( + "Could not drop the Postgres roles of deleted data table {datatable_name} in {w_id}: {e:#}" + ); + } +} + +/// Run a plan's statements in a single transaction, so a failure part-way leaves +/// the database exactly as it was. +async fn run_statements(client: &mut tokio_postgres::Client, plan: &RolePlan) -> Result<()> { + 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.iter() { + pg_tx.batch_execute(&statement.sql).await.map_err(|e| { + Error::ExecutionErr(format!( + "Failed to run `{}`: {}", + statement.display, + pg_error_message(&e) + )) + })?; + } + pg_tx.commit().await.map_err(|e| { + Error::internal_err(format!( + "Failed to commit the role changes: {}", + pg_error_message(&e) + )) + }) +} + +async fn get_datatable_permissions( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, +) -> JsonResult { + require_admin(authed.is_admin, &authed.username)?; + let datatable = read_datatable(&db, &w_id, &datatable_name).await?; + let permissions = datatable.permissions.unwrap_or_default(); + Ok(Json(DatatablePermissionsInfo { + enabled: permissions.enabled, + roles: permissions + .roles + .into_iter() + .map(|(name, role)| DatatableRoleInfo { + name, + tenants: role.tenants, + pg_rolename: role.pg_rolename, + }) + .collect(), + })) +} + +async fn preview_datatable_permissions( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, + Json(req): Json, +) -> JsonResult { + require_admin(authed.is_admin, &authed.username)?; + let (_client, plan) = build_plan(&db, &w_id, &datatable_name, &req).await?; + Ok(Json(DatatablePermissionsPreview { + statements: plan.statements.into_iter().map(|s| s.display).collect(), + warnings: plan.warnings, + })) +} + +async fn set_datatable_permissions( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + // The plan is rebuilt here rather than trusted from the preview: the client + // never gets to choose what runs against the database. + let (mut client, plan) = build_plan(&db, &w_id, &datatable_name, &req).await?; + + // The roles are committed before the config: a Windmill-side failure after + // this point leaves roles the config does not know about, which the next plan + // reconciles (it reads `pg_roles`), whereas the reverse order would leave the + // config naming roles that were never created. + run_statements(&mut client, &plan).await?; + + let permissions = serde_json::to_value(&plan.permissions) + .map_err(|e| Error::internal_err(format!("Failed to serialize permissions: {e}")))?; + + let mut tx = db.begin().await?; + // Written at the permissions path only, so a concurrent edit of the data + // table's own settings is not clobbered. + let updated = sqlx::query_scalar!( + "UPDATE workspace_settings + SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3) + WHERE workspace_id = $1 AND datatable->'datatables' ? $2 + RETURNING workspace_id", + &w_id, + &datatable_name, + permissions, + ) + .fetch_optional(&mut *tx) + .await?; + if updated.is_none() { + return Err(Error::NotFound(format!( + "Data table '{datatable_name}' not found" + ))); + } + + audit_log( + &mut *tx, + &authed, + "workspaces.set_datatable_permissions", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some( + [ + ("datatable", datatable_name.as_str()), + ("enabled", if req.enabled { "true" } else { "false" }), + ] + .into(), + ), + ) + .await?; + + tx.commit().await?; + + Ok(format!( + "Updated permissions of data table {datatable_name}" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + const W_ID: &str = "acme"; + const DT: &str = "main"; + const DB_NAME: &str = "wm_acme_main"; + const ROOT_PG: &str = "custom_instance_user"; + + fn role(name: &str, tenants: &[&str]) -> DatatableRoleInfo { + DatatableRoleInfo { + name: name.to_string(), + tenants: tenants.iter().map(|t| t.to_string()).collect(), + pg_rolename: None, + } + } + + fn enabled_with(roles: &[&str]) -> DataTablePermissions { + let mut map = BTreeMap::new(); + map.insert(ROOT_DATATABLE_ROLE.to_string(), DataTableRole::default()); + for name in roles { + map.insert( + name.to_string(), + DataTableRole { + pg_rolename: Some(datatable_pg_role_name(W_ID, DT, name)), + pg_password: Some("kept-password".to_string()), + tenants: vec![], + }, + ); + } + DataTablePermissions { enabled: true, roles: map } + } + + fn plan( + old: Option<&DataTablePermissions>, + req: &SetDatatablePermissions, + existing: &[&str], + ) -> Result { + plan_role_changes( + W_ID, + DT, + DB_NAME, + ROOT_PG, + old, + req, + &existing.iter().map(|r| r.to_string()).collect(), + ) + } + + fn sql(plan: &RolePlan) -> Vec<&str> { + plan.statements.iter().map(|s| s.sql.as_str()).collect() + } + + #[test] + fn adding_a_role_creates_it_and_stores_its_credentials() { + let req = SetDatatablePermissions { + enabled: true, + roles: vec![role("root", &[]), role("analyst", &["u/alice", "g/devs"])], + renames: vec![], + }; + let plan = plan(None, &req, &[]).unwrap(); + + let pg_role = datatable_pg_role_name(W_ID, DT, "analyst"); + assert!(sql(&plan)[0].starts_with(&format!("CREATE ROLE \"{pg_role}\" LOGIN PASSWORD "))); + assert!(sql(&plan)[1].contains("has_database_privilege")); + + let stored = &plan.permissions.roles["analyst"]; + assert_eq!(stored.pg_rolename.as_deref(), Some(pg_role.as_str())); + assert!(stored.pg_password.as_ref().is_some_and(|p| p.len() == 32)); + assert_eq!(stored.tenants, vec!["u/alice", "g/devs"]); + // root reuses the data table's own connection, so it never gets one. + assert!(plan.permissions.roles[ROOT_DATATABLE_ROLE] + .pg_rolename + .is_none()); + } + + #[test] + fn renaming_a_role_keeps_its_postgres_role_and_password() { + let old = enabled_with(&["analyst"]); + let old_pg = datatable_pg_role_name(W_ID, DT, "analyst"); + let req = SetDatatablePermissions { + enabled: true, + roles: vec![role("root", &[]), role("reader", &[])], + renames: vec![DatatableRoleRename { + from: "analyst".to_string(), + to: "reader".to_string(), + }], + }; + let plan = plan(Some(&old), &req, &[old_pg.as_str()]).unwrap(); + + let new_pg = datatable_pg_role_name(W_ID, DT, "reader"); + assert_eq!( + sql(&plan)[0], + format!("ALTER ROLE \"{old_pg}\" RENAME TO \"{new_pg}\";") + ); + // The rename must not be planned as a drop plus a create: that would + // silently discard every grant the role had accumulated. + assert!(!sql(&plan).iter().any(|s| s.contains("DROP ROLE"))); + assert!(!sql(&plan).iter().any(|s| s.contains("CREATE ROLE"))); + assert_eq!( + plan.permissions.roles["reader"].pg_password.as_deref(), + Some("kept-password") + ); + } + + #[test] + fn removing_a_role_gives_its_objects_back_to_root_before_dropping_it() { + let old = enabled_with(&["analyst"]); + let pg_role = datatable_pg_role_name(W_ID, DT, "analyst"); + let req = SetDatatablePermissions { + enabled: true, + roles: vec![role("root", &[])], + renames: vec![], + }; + let plan = plan(Some(&old), &req, &[pg_role.as_str()]).unwrap(); + + assert_eq!( + sql(&plan), + vec![ + format!("REASSIGN OWNED BY \"{pg_role}\" TO \"{ROOT_PG}\";"), + format!("DROP OWNED BY \"{pg_role}\";"), + format!("DROP ROLE \"{pg_role}\";"), + ] + ); + assert!(!plan.permissions.roles.contains_key("analyst")); + } + + #[test] + fn opting_out_drops_every_role_and_clears_the_definitions() { + let old = enabled_with(&["analyst", "writer"]); + let existing: Vec = ["analyst", "writer"] + .iter() + .map(|r| datatable_pg_role_name(W_ID, DT, r)) + .collect(); + let req = SetDatatablePermissions { enabled: false, roles: vec![], renames: vec![] }; + let plan = plan( + Some(&old), + &req, + &existing.iter().map(|s| s.as_str()).collect::>(), + ) + .unwrap(); + + assert_eq!( + sql(&plan).iter().filter(|s| s.starts_with("DROP ROLE")).count(), + 2 + ); + assert!(!plan.permissions.enabled); + assert!(plan.permissions.roles.is_empty()); + } + + #[test] + fn a_role_the_config_lost_track_of_is_not_dropped() { + let old = enabled_with(&["analyst"]); + let req = SetDatatablePermissions { enabled: false, roles: vec![], renames: vec![] }; + // The Postgres role is already gone, so planning its drop would fail the + // whole transaction and wedge the opt-out. + let plan = plan(Some(&old), &req, &[]).unwrap(); + assert!(sql(&plan).is_empty()); + } + + #[test] + fn root_cannot_be_dropped_or_renamed() { + let req = SetDatatablePermissions { + enabled: true, + roles: vec![role("analyst", &[])], + renames: vec![], + }; + assert!(plan(None, &req, &[]).is_err()); + + let old = enabled_with(&[]); + let req = SetDatatablePermissions { + enabled: true, + roles: vec![role("owner", &[])], + renames: vec![DatatableRoleRename { + from: ROOT_DATATABLE_ROLE.to_string(), + to: "owner".to_string(), + }], + }; + assert!(plan(Some(&old), &req, &[]).is_err()); + } + + #[test] + fn invalid_role_names_and_tenants_are_rejected() { + for bad_role in ["", "bad name", "a;b", "drop\"role"] { + let req = SetDatatablePermissions { + enabled: true, + roles: vec![role("root", &[]), role(bad_role, &[])], + renames: vec![], + }; + assert!(plan(None, &req, &[]).is_err(), "{bad_role} should be rejected"); + } + for bad_tenant in ["alice", "x/alice", "u/", ""] { + let req = SetDatatablePermissions { + enabled: true, + roles: vec![role("root", &[bad_tenant])], + renames: vec![], + }; + assert!( + plan(None, &req, &[]).is_err(), + "{bad_tenant} should be rejected" + ); + } + } +} diff --git a/backend/windmill-api-workspaces/src/lib.rs b/backend/windmill-api-workspaces/src/lib.rs index 017c9702a5..34f947323f 100644 --- a/backend/windmill-api-workspaces/src/lib.rs +++ b/backend/windmill-api-workspaces/src/lib.rs @@ -1,6 +1,7 @@ -pub mod datatable_migrations; -pub mod deployment_requests; pub mod data_metrics; +pub mod datatable_migrations; +pub mod datatable_permissions; +pub mod deployment_requests; pub mod workspaces; pub mod workspaces_extra; pub mod workspaces_oss; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 6e5e9cecc0..d2f3d2d863 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -44,7 +44,8 @@ use windmill_common::workspaces::GitRepositorySettings; #[cfg(feature = "enterprise")] use windmill_common::workspaces::WorkspaceDeploymentUISettings; use windmill_common::workspaces::{ - check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db_unchecked, + check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db, + get_datatable_resource_from_db_unchecked, DatatableAccess, validate_dev_workspace_id, validate_fork_workspace_id, validate_workspace_name, DataTable, DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME, @@ -138,6 +139,7 @@ pub fn workspaced_service() -> Router { get(test_datatable_connection), ) .merge(crate::datatable_migrations::routes()) + .merge(crate::datatable_permissions::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)) @@ -1946,7 +1948,7 @@ async fn test_datatable_connection( ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; - let db_resource = get_datatable_resource_from_db_unchecked(&db, &w_id, &datatable_name).await?; + let db_resource = get_datatable_resource_as_root(&db, &authed, &w_id, &datatable_name).await?; let pg_db: PgDatabase = serde_json::from_value(db_resource) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; let (client, connection) = pg_db.connect(Some(&db)).await?; @@ -2032,7 +2034,7 @@ async fn test_datatable_connection( } async fn list_datatable_schemas( - _authed: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { @@ -2040,7 +2042,7 @@ async fn list_datatable_schemas( let mut results = Vec::new(); for datatable_name in datatable_names { - let schema = match get_datatable_schema(&db, &w_id, &datatable_name).await { + let schema = match get_datatable_schema(&db, &authed, &w_id, &datatable_name).await { Ok(schemas) => DataTableSchema { datatable_name, schemas, error: None }, Err(e) => DataTableSchema { datatable_name, @@ -2055,7 +2057,7 @@ async fn list_datatable_schemas( } async fn list_datatable_tables( - _authed: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { @@ -2063,7 +2065,7 @@ async fn list_datatable_tables( let mut results = Vec::new(); for datatable_name in datatable_names { - let tables = match get_datatable_tables(&db, &w_id, &datatable_name).await { + let tables = match get_datatable_tables(&db, &authed, &w_id, &datatable_name).await { Ok(schemas) => DataTableTables { datatable_name, schemas, error: None }, Err(e) => DataTableTables { datatable_name, @@ -2078,13 +2080,14 @@ async fn list_datatable_tables( } async fn get_datatable_table_schema( - _authed: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Query(query): Query, ) -> JsonResult { let columns = get_datatable_table_columns( &db, + &authed, &w_id, &query.datatable_name, &query.schema_name, @@ -2100,6 +2103,25 @@ async fn get_datatable_table_schema( })) } +/// Resolve a data table for an API caller. Schema browsing and the database +/// manager always connect as `root`, so a permissioned data table is reachable +/// from the UI only by a tenant of its `root` role (and by admins). +async fn get_datatable_resource_as_root( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + datatable_name: &str, +) -> Result { + get_datatable_resource_from_db( + db, + w_id, + datatable_name, + None, + DatatableAccess::Authed(authed.to_authed_ref()), + ) + .await +} + async fn list_datatable_names(db: &DB, w_id: &str) -> Result> { Ok(sqlx::query_scalar!( r#" @@ -2116,9 +2138,14 @@ async fn list_datatable_names(db: &DB, w_id: &str) -> Result> { .collect()) } -async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Result { +async fn get_datatable_schema( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + datatable_name: &str, +) -> Result { // Get the datatable resource (connection credentials) - let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?; + let db_resource = get_datatable_resource_as_root(db, authed, w_id, datatable_name).await?; // Parse the resource as PgDatabase let pg_db: PgDatabase = serde_json::from_value(db_resource) @@ -2209,8 +2236,13 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu Ok(schema_map) } -async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Result { - let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?; +async fn get_datatable_tables( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + datatable_name: &str, +) -> Result { + let db_resource = get_datatable_resource_as_root(db, authed, w_id, datatable_name).await?; let pg_db: PgDatabase = serde_json::from_value(db_resource) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; let (client, connection) = pg_db.connect(Some(db)).await?; @@ -2276,6 +2308,7 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu async fn get_datatable_table_columns( db: &DB, + authed: &ApiAuthed, w_id: &str, datatable_name: &str, schema_name: &str, @@ -2288,7 +2321,7 @@ async fn get_datatable_table_columns( ))); } - let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?; + let db_resource = get_datatable_resource_as_root(db, authed, w_id, datatable_name).await?; let pg_db: PgDatabase = serde_json::from_value(db_resource) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; let (client, connection) = pg_db.connect(Some(db)).await?; @@ -2441,7 +2474,7 @@ pub(crate) async fn resolve_pg_source_checked( source: &str, ) -> Result { let db_resource = if let Some(name) = source.strip_prefix("datatable://") { - get_datatable_resource_from_db_unchecked(db, w_id, name).await? + get_datatable_resource_as_root(db, authed, w_id, name).await? } else if let Some(path) = source.strip_prefix("$res:") { let db_with_authed = windmill_common::db::DbWithOptAuthed::from_authed( authed, @@ -2976,6 +3009,10 @@ async fn edit_datatable_config( Some(old) => old.migrations_enabled, None => Some(true), }; + // Same for permissions, owned by the datatable_permissions endpoints. + dt.permissions = old_datatables + .get(lookup) + .and_then(|old| old.permissions.clone()); } let args_for_audit = format!("{:?}", new_config.settings); @@ -3009,6 +3046,12 @@ async fn edit_datatable_config( } } + // Before the config is overwritten, while the deleted data tables can still be + // resolved to a connection. + for deleted in &new_config.deleted_datatables { + crate::datatable_permissions::drop_roles_of_deleted_datatable(&db, &w_id, deleted).await; + } + let config: serde_json::Value = serde_json::to_value(new_config.settings) .map_err(|err| Error::internal_err(err.to_string()))?; @@ -6899,6 +6942,21 @@ async fn create_workspace_fork( apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?; } + // Postgres roles are cluster-wide but their grants are per-database, so the cloned + // `permissions` block would point the fork's roles at roles holding privileges on the + // parent's database. A fork therefore starts unpermissioned and is opted in on its own. + sqlx::query!( + r#"UPDATE workspace_settings + SET datatable = jsonb_set(datatable, '{datatables}', ( + SELECT COALESCE(jsonb_object_agg(key, value - 'permissions'), '{}'::jsonb) + FROM jsonb_each(datatable->'datatables') + )) + WHERE workspace_id = $1 AND jsonb_typeof(datatable->'datatables') = 'object'"#, + &forked_id, + ) + .execute(&mut *tx) + .await?; + // The settings clone copies the source's ducklake config verbatim — including a parent // fork's own `fork_behavior` stamps. Sharing is a per-fork-creation choice, never // inherited: reset any cloned stamps first, then apply this fork's requested list. diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index bf70b380a7..6fbfd3b98c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4967,6 +4967,89 @@ paths: error: type: string + /w/{workspace}/workspaces/datatable_permissions/{datatable_name}: + get: + summary: get a datatable's role-based permissions (admins only) + operationId: getDatatablePermissions + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + responses: + "200": + description: datatable permissions + content: + application/json: + schema: + $ref: "#/components/schemas/DatatablePermissions" + post: + summary: set a datatable's role-based permissions, running the corresponding role SQL (admins only) + operationId: setDatatablePermissions + 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/SetDatatablePermissions" + responses: + "200": + description: status + content: + text/plain: + schema: + type: string + + /w/{workspace}/workspaces/datatable_permissions/{datatable_name}/preview: + post: + summary: preview the SQL a datatable permissions change would run (admins only) + operationId: previewDatatablePermissions + 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/SetDatatablePermissions" + responses: + "200": + description: statements that would be run, in a single transaction + content: + application/json: + schema: + type: object + required: [statements, warnings] + properties: + statements: + type: array + items: + type: string + warnings: + type: array + items: + type: string + /w/{workspace}/workspaces/enable_datatable_migrations/{datatable_name}: post: summary: opt a datatable in to migrations (admins / super admins only) @@ -31382,6 +31465,51 @@ components: - ran - not_run - unknown + DatatableRoleInfo: + type: object + required: [name, tenants] + properties: + name: + type: string + tenants: + description: who may run as this role, as u/, g/ or f/ + type: array + items: + type: string + pg_rolename: + description: the underlying postgres role, absent for root + type: string + DatatablePermissions: + type: object + required: [enabled, roles] + properties: + enabled: + type: boolean + roles: + type: array + items: + $ref: "#/components/schemas/DatatableRoleInfo" + SetDatatablePermissions: + type: object + required: [enabled, roles] + properties: + enabled: + type: boolean + roles: + type: array + items: + $ref: "#/components/schemas/DatatableRoleInfo" + renames: + description: role renames (old -> new), so a rename keeps the postgres role and its grants + type: array + items: + type: object + required: [from, to] + properties: + from: + type: string + to: + type: string DataTableSchema: type: object required: [datatable_name, schemas] diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index b9dd29c00a..e169011ecb 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -985,6 +985,41 @@ pub struct SqlAnnotations { pub raw_output: bool, } +impl SqlAnnotations { + /// If the script declares `-- role `, returns the data table role the + /// query runs as. Only meaningful against a permissioned `datatable://` + /// database; absent means the `root` role. + /// + /// Mirrors `BashAnnotations::ssh_target`: only leading comment lines are + /// scanned, and an exact `-- role ` with a valid role name and nothing + /// else on the line is required, so prose like `-- role based access is + /// handled below` never matches. + pub fn datatable_role(code: &str) -> Option { + for line in code.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if !line.starts_with("--") { + break; + } + let mut tokens = line[2..].split_whitespace(); + if tokens.next() == Some("role") { + if let Some(role) = tokens.next() { + let is_role_name = !role.is_empty() + && role + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + if is_role_name && tokens.next().is_none() { + return Some(role.to_string()); + } + } + } + } + None + } +} + #[annotations("#")] pub struct BashAnnotations { pub docker: bool, @@ -2428,6 +2463,35 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn datatable_role_annotation_is_read_from_the_leading_comment_block() { + assert_eq!( + SqlAnnotations::datatable_role("-- role analyst\nSELECT 1"), + Some("analyst".to_string()) + ); + // Argument declarations are comments too, so the annotation still parses + // after them. + assert_eq!( + SqlAnnotations::datatable_role("-- $1 name (text)\n-- role read-only_1\nSELECT 1"), + Some("read-only_1".to_string()) + ); + assert_eq!(SqlAnnotations::datatable_role("SELECT 1"), None); + // Scanning stops at the first non-comment line, so a `-- role` further + // down is prose about the query, not a directive. + assert_eq!( + SqlAnnotations::datatable_role("SELECT 1;\n-- role analyst"), + None + ); + for prose in [ + "-- role based access is handled below\nSELECT 1", + "-- role\nSELECT 1", + "-- roles analyst\nSELECT 1", + "-- role bad;name\nSELECT 1", + ] { + assert_eq!(SqlAnnotations::datatable_role(prose), None, "{prose}"); + } + } + fn matcher(id: &str) -> WorkspaceMatcher { WorkspaceMatcher { id: id.to_string(), include_forks: false } } diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 2af5afaf77..50b5018f60 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1000,6 +1000,101 @@ pub struct DataTable { /// when migrations already exist (see `datatable_migrations_enabled`). #[serde(default, skip_serializing_if = "Option::is_none")] pub migrations_enabled: Option, + /// Role-based access control, opt-in per data table. Absent or + /// `enabled: false` means every workspace member reaches the database + /// through the single connection resolved below. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, +} + +/// The role every permissioned data table has: it is the connection the data +/// table resolves to without permissions (`custom_instance_user`, or the +/// postgres resource's own user), so it owns every object created so far and +/// cannot be created, renamed or dropped. +pub const ROOT_DATATABLE_ROLE: &str = "root"; + +#[derive(Deserialize, Serialize, Debug, Default, Clone)] +pub struct DataTablePermissions { + pub enabled: bool, + /// Always contains `root`. Ordered so the SQL a config change plans out is + /// stable across saves. + #[serde(default)] + pub roles: std::collections::BTreeMap, +} + +#[derive(Deserialize, Serialize, Debug, Default, Clone)] +pub struct DataTableRole { + /// The postgres role this data table role logs in as. `None` for `root`, + /// which reuses the data table's own connection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pg_rolename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pg_password: Option, + /// Who may run as this role, in Windmill's owner syntax: `u/`, + /// `g/`, `f/`. + #[serde(default)] + pub tenants: Vec, +} + +/// Postgres caps identifiers at 63 bytes (NAMEDATALEN - 1) and silently +/// truncates past it, which would collapse two distinct roles onto one. +const PG_IDENTIFIER_MAX_LEN: usize = 63; + +/// The postgres role backing `role` on `w_id`'s `datatable`. +/// +/// Postgres roles are cluster-wide while data table names are per-workspace, so +/// the workspace id has to be part of the name: without it two workspaces that +/// both hold a `main` data table with an `analyst` role would silently share one +/// role, and the second workspace's CREATE would fail. +pub fn datatable_pg_role_name(w_id: &str, datatable: &str, role: &str) -> String { + fn sanitize(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .collect() + } + let name = format!( + "wm_{}_{}_{}", + sanitize(w_id), + sanitize(datatable), + sanitize(role) + ); + if name.len() <= PG_IDENTIFIER_MAX_LEN { + return name; + } + // Truncating alone would let two long names collapse into one role, so the + // discriminator is derived from the full name. + use sha2::{Digest, Sha256}; + let hash = &Sha256::digest(name.as_bytes())[..4]; + format!( + "{}_{:08x}", + &name[..PG_IDENTIFIER_MAX_LEN - 9], + u32::from_be_bytes([hash[0], hash[1], hash[2], hash[3]]) + ) +} + +/// Whether `authed` may run the data table as `role`. Workspace admins and +/// superadmins reach every role, consistent with the rest of Windmill's ACLs +/// and so an admin cannot lock themselves out of their own data table. +pub fn can_use_datatable_role(role: &DataTableRole, authed: &crate::db::AuthedRef<'_>) -> bool { + *authed.is_admin + || role.tenants.iter().any(|tenant| { + match tenant.split_once('/') { + Some(("u", user)) => authed.username == user, + Some(("g", group)) => authed.groups.iter().any(|g| g == group), + // A folder tenant grants the role to everyone holding perms on + // that folder, which is what `Authed::folders` already resolves + // to — for a user, for a group, and for a job permissioned as + // the folder itself. + Some(("f", folder)) => authed.folders.iter().any(|(f, _, _)| f == folder), + _ => false, + } + }) } #[derive(Deserialize, Serialize, Debug)] @@ -1051,12 +1146,52 @@ fn datatable_not_found_error(name: &str, datatables: Option<&serde_json::Value>) )) } +/// Who a data table is being resolved for, when it is permissioned. +pub enum DatatableAccess<'a> { + /// Internal callers that have already authorized the access (or for which + /// there is no user to authorize, such as trigger connections). Reaches + /// every role. + Unchecked, + Authed(crate::db::AuthedRef<'a>), + /// A job's owner. The identity is only fetched if the data table turns out + /// to be permissioned, so unpermissioned resolutions cost no extra query. + PermissionedAs { + permissioned_as: &'a str, + email: &'a str, + }, + /// A job, identified by id: its owner is read from the job row. For callers + /// that authenticate as infrastructure rather than as the job's user, such + /// as agent workers. + Job(uuid::Uuid), + /// No identity could be established. Unpermissioned data tables resolve as + /// before; permissioned ones are refused rather than waved through, so a + /// caller that cannot name who it acts for fails closed. + NoIdentity, +} + +/// Resolve a data table's connection credentials without authorizing the caller. +/// Callers MUST have already authorized the access; anything running on behalf +/// of a user should go through [`get_datatable_resource_from_db`] instead. pub async fn get_datatable_resource_from_db_unchecked( db: &DB, w_id: &str, name: &str, ) -> Result { - get_datatable_resource_inner(db, w_id, name, false).await + get_datatable_resource_inner(db, w_id, name, false, None, DatatableAccess::Unchecked).await +} + +/// Resolve a data table's connection credentials as `role` (default `root`), +/// checking that `access` is allowed to use it when the data table is +/// permissioned. On an unpermissioned data table only `root` is accepted, and +/// the resolution is the same as the unchecked one. +pub async fn get_datatable_resource_from_db( + db: &DB, + w_id: &str, + name: &str, + role: Option<&str>, + access: DatatableAccess<'_>, +) -> Result { + get_datatable_resource_inner(db, w_id, name, false, role, access).await } /// Same as [`get_datatable_resource_from_db_unchecked`] but for postgres trigger @@ -1073,7 +1208,85 @@ pub async fn get_datatable_replication_resource_from_db_unchecked( w_id: &str, name: &str, ) -> Result { - get_datatable_resource_inner(db, w_id, name, true).await + get_datatable_resource_inner(db, w_id, name, true, None, DatatableAccess::Unchecked).await +} + +/// Resolve which postgres login the data table should be reached through, and +/// authorize it. +/// +/// Returns the `(user, password)` to swap into the connection, or `None` when +/// the data table's own credentials are to be used — which is every +/// unpermissioned data table, and the `root` role of a permissioned one. +async fn resolve_datatable_role( + db: &DB, + w_id: &str, + name: &str, + datatable: &DataTable, + role: Option<&str>, + access: DatatableAccess<'_>, +) -> Result> { + let Some(permissions) = datatable.permissions.as_ref().filter(|p| p.enabled) else { + return match role { + Some(role) if role != ROOT_DATATABLE_ROLE => Err(Error::BadRequest(format!( + "Cannot use role '{role}': permissions are not enabled on data table '{name}'. \ + Enable them in the data table's Permissions drawer." + ))), + _ => Ok(None), + }; + }; + + let role_name = role.unwrap_or(ROOT_DATATABLE_ROLE); + let role_entry = permissions.roles.get(role_name).ok_or_else(|| { + Error::NotFound(format!( + "Role '{role_name}' is not defined on data table '{name}'. Defined roles: {}.", + permissions + .roles + .keys() + .map(String::as_str) + .collect::>() + .join(", ") + )) + })?; + + let allowed = match access { + DatatableAccess::Unchecked => true, + DatatableAccess::NoIdentity => false, + DatatableAccess::Authed(ref authed) => can_use_datatable_role(role_entry, authed), + DatatableAccess::PermissionedAs { permissioned_as, email } => { + let authed = + crate::auth::fetch_authed_from_permissioned_as(permissioned_as, email, w_id, db) + .await?; + can_use_datatable_role(role_entry, &authed.to_authed_ref()) + } + DatatableAccess::Job(job_id) => { + let job = sqlx::query!( + "SELECT permissioned_as, permissioned_as_email FROM v2_job WHERE id = $1 AND workspace_id = $2", + job_id, + w_id, + ) + .fetch_optional(db) + .await? + .ok_or_else(|| Error::NotFound(format!("job {job_id} not found in {w_id}")))?; + let authed = crate::auth::fetch_authed_from_permissioned_as( + &job.permissioned_as, + &job.permissioned_as_email, + w_id, + db, + ) + .await?; + can_use_datatable_role(role_entry, &authed.to_authed_ref()) + } + }; + if !allowed { + return Err(Error::NotAuthorized(format!( + "Not allowed to use role '{role_name}' of data table '{name}'" + ))); + } + + Ok(role_entry + .pg_rolename + .clone() + .zip(role_entry.pg_password.clone())) } async fn get_datatable_resource_inner( @@ -1081,6 +1294,8 @@ async fn get_datatable_resource_inner( w_id: &str, name: &str, replication: bool, + role: Option<&str>, + access: DatatableAccess<'_>, ) -> Result { let datatables = sqlx::query_scalar!( r#" @@ -1101,27 +1316,44 @@ async fn get_datatable_resource_inner( .ok_or_else(|| datatable_not_found_error(name, datatables.as_ref()))?; let datatable = serde_json::from_value::(datatable.clone())?; - let db_resource = if datatable.database.resource_type == DataTableCatalogResourceType::Instance - { - let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; - pg_creds.dbname = datatable.database.resource_path.clone(); - if replication { - pg_creds.user = Some("custom_instance_replication_user".to_string()); - pg_creds.password = Some(get_custom_pg_instance_replication_password(&db).await?); + let role_override = resolve_datatable_role(db, w_id, name, &datatable, role, access).await?; + + let mut db_resource = + if datatable.database.resource_type == DataTableCatalogResourceType::Instance { + let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; + pg_creds.dbname = datatable.database.resource_path.clone(); + if replication { + pg_creds.user = Some("custom_instance_replication_user".to_string()); + pg_creds.password = Some(get_custom_pg_instance_replication_password(&db).await?); + } else { + pg_creds.user = Some("custom_instance_user".to_string()); + pg_creds.password = Some(get_custom_pg_instance_password(&db).await?); + } + serde_json::to_value(&pg_creds) + .map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))? } else { - pg_creds.user = Some("custom_instance_user".to_string()); - pg_creds.password = Some(get_custom_pg_instance_password(&db).await?); - } - serde_json::to_value(&pg_creds) - .map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))? - } else { - transform_json_unchecked( - &serde_json::Value::String(format!("$res:{}", datatable.database.resource_path)), - w_id, - db, - ) - .await? - }; + transform_json_unchecked( + &serde_json::Value::String(format!("$res:{}", datatable.database.resource_path)), + w_id, + db, + ) + .await? + }; + + // The role logs in as itself rather than through `SET ROLE`, which a script + // could `RESET ROLE` its way back out of and regain root's privileges. + if let Some((pg_rolename, pg_password)) = role_override { + let creds = db_resource.as_object_mut().ok_or_else(|| { + Error::internal_err(format!( + "Data table '{name}' does not resolve to a postgres resource" + )) + })?; + creds.insert("user".to_string(), serde_json::Value::String(pg_rolename)); + creds.insert( + "password".to_string(), + serde_json::Value::String(pg_password), + ); + } Ok(db_resource) } @@ -2139,6 +2371,77 @@ async fn transform_json_unchecked( mod tests { use super::*; + fn authed(username: &str, groups: &[&str], folders: &[&str]) -> crate::db::Authed { + crate::db::Authed { + email: format!("{username}@windmill.dev"), + username: username.to_string(), + is_admin: false, + is_operator: false, + groups: groups.iter().map(|g| g.to_string()).collect(), + folders: folders + .iter() + .map(|f| (f.to_string(), true, false)) + .collect(), + scopes: None, + token_prefix: None, + } + } + + #[test] + fn datatable_role_tenants_match_users_groups_and_folders() { + let role = DataTableRole { + tenants: vec![ + "u/alice".to_string(), + "g/devs".to_string(), + "f/finance".to_string(), + ], + ..Default::default() + }; + + let alice = authed("alice", &[], &[]); + let bob_in_group = authed("bob", &["devs"], &[]); + let bob_in_folder = authed("bob", &[], &["finance"]); + let stranger = authed("bob", &["ops"], &["hr"]); + + assert!(can_use_datatable_role(&role, &alice.to_authed_ref())); + assert!(can_use_datatable_role(&role, &bob_in_group.to_authed_ref())); + assert!(can_use_datatable_role( + &role, + &bob_in_folder.to_authed_ref() + )); + assert!(!can_use_datatable_role(&role, &stranger.to_authed_ref())); + + // A tenant list is a whitelist, so an empty one grants nobody... + let empty = DataTableRole::default(); + assert!(!can_use_datatable_role(&empty, &alice.to_authed_ref())); + // ...except admins, who reach every role so they cannot lock themselves + // out of their own data table. + let mut admin = authed("alice", &[], &[]); + admin.is_admin = true; + assert!(can_use_datatable_role(&empty, &admin.to_authed_ref())); + } + + #[test] + fn datatable_pg_role_names_are_workspace_scoped_and_fit_postgres() { + assert_eq!( + datatable_pg_role_name("acme", "main", "analyst"), + "wm_acme_main_analyst" + ); + // Two workspaces must never land on the same cluster-wide role. + assert_ne!( + datatable_pg_role_name("acme", "main", "analyst"), + datatable_pg_role_name("globex", "main", "analyst") + ); + // Postgres truncates past 63 bytes, so long names are hashed rather than + // left to collide. + let long = datatable_pg_role_name("w".repeat(60).as_str(), "main", "analyst"); + assert_eq!(long.len(), PG_IDENTIFIER_MAX_LEN); + assert_ne!( + long, + datatable_pg_role_name("w".repeat(61).as_str(), "main", "analyst") + ); + } + #[test] fn test_parse_fork_branch() { // Generated fork (`wm-fork-abc`) and dev workspace (`staging`) forms. diff --git a/backend/windmill-worker/src/agent_workers.rs b/backend/windmill-worker/src/agent_workers.rs index 5320c8ef6c..3e18993423 100644 --- a/backend/windmill-worker/src/agent_workers.rs +++ b/backend/windmill-worker/src/agent_workers.rs @@ -65,15 +65,23 @@ pub async fn get_ducklake_from_agent_http( } #[allow(dead_code)] +/// An agent worker authenticates as the agent rather than as the job's user, so +/// the job id rides along: the API resolves its owner and authorizes the role +/// against that identity. pub async fn get_datatable_resource_from_agent_http( client: &HttpClient, name: &str, w_id: &str, + role: Option<&str>, + job_id: &Uuid, ) -> anyhow::Result { + let role_query = role + .map(|r| format!("&role={}", urlencoding::encode(r))) + .unwrap_or_default(); client .get(&format!( - "/api/w/{}/agent_workers/get_datatable_resource/{}", - w_id, &name + "/api/w/{}/agent_workers/get_datatable_resource/{}?job_id={}{}", + w_id, &name, job_id, role_query )) .await } diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index e2807547e7..577a53443f 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -13,8 +13,8 @@ use windmill_common::error::{to_anyhow, Error, Result}; use windmill_common::utils::sanitize_string_from_password; use windmill_common::worker::{get_memory, to_raw_value, Connection, SqlResultCollectionStrategy}; use windmill_common::workspaces::{ - get_datatable_resource_from_db_unchecked, get_ducklake_from_db_unchecked, - strip_fork_reserved_attach_args, DucklakeCatalogResourceType, + get_datatable_resource_from_db, get_ducklake_from_db_unchecked, + strip_fork_reserved_attach_args, DatatableAccess, DucklakeCatalogResourceType, }; use windmill_common::PgDatabase; use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE; @@ -1491,13 +1491,9 @@ pub async fn do_duckdb( .await? { probe_blocks.extend(q); - } else if let Some(q) = transform_attach_datatable( - &query_block, - conn, - &mut hidden_passwords, - &job.workspace_id, - ) - .await? + } else if let Some(q) = + transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job) + .await? { probe_blocks.extend(q); } else { @@ -1568,13 +1564,9 @@ pub async fn do_duckdb( .await? { v.extend(ducklake_query); - } else if let Some(datatable_query) = transform_attach_datatable( - &query_block, - conn, - &mut hidden_passwords, - &job.workspace_id, - ) - .await? + } else if let Some(datatable_query) = + transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job) + .await? { v.extend(datatable_query); } else { @@ -2550,26 +2542,56 @@ fn fork_defer_statements( Ok(stmts) } +/// Split a `datatable://?role=` reference. The role rides in the +/// reference rather than in a file-level annotation because one DuckDB script can +/// attach several data tables, each under a different role. +fn parse_datatable_ref(reference: &str) -> (&str, Option<&str>) { + let (name, query) = reference.split_once('?').unwrap_or((reference, "")); + let role = query + .split('&') + .find_map(|param| param.strip_prefix("role=")) + .filter(|role| !role.is_empty()); + (name, role) +} + async fn transform_attach_datatable( query: &str, conn: &Connection, hidden_passwords: &mut Arc>>, - w_id: &str, + job: &MiniPulledJob, ) -> Result>> { lazy_static::lazy_static! { - static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'datatable(://[^':]+)?'\s*AS\s+([^ ;]+)").unwrap(); + static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'datatable(://[^':]+)?(\?[^':]*)?'\s*AS\s+([^ ;]+)").unwrap(); } let Some(cap) = RE.captures(query) else { return Ok(None); }; - let name = cap.get(1).map(|m| &m.as_str()[3..]).unwrap_or("main"); - let alias_name = cap.get(2).map(|m| m.as_str()).unwrap_or(""); + let reference = format!( + "{}{}", + cap.get(1).map(|m| &m.as_str()[3..]).unwrap_or("main"), + cap.get(2).map(|m| m.as_str()).unwrap_or("") + ); + let (name, role) = parse_datatable_ref(&reference); + let alias_name = cap.get(3).map(|m| m.as_str()).unwrap_or(""); + let w_id = job.workspace_id.as_str(); let db_resource = match conn { Connection::Http(client) => { - get_datatable_resource_from_agent_http(client, name, w_id).await? + get_datatable_resource_from_agent_http(client, name, w_id, role, &job.id).await? + } + Connection::Sql(db) => { + get_datatable_resource_from_db( + db, + w_id, + name, + role, + DatatableAccess::PermissionedAs { + permissioned_as: &job.permissioned_as, + email: &job.permissioned_as_email, + }, + ) + .await? } - Connection::Sql(db) => get_datatable_resource_from_db_unchecked(db, w_id, name).await?, }; if let Some(pwd) = db_resource.get("password").and_then(|p| p.as_str()) { @@ -2694,6 +2716,47 @@ pub struct Arg { mod tests { use super::*; + /// Reproduce how `transform_attach_datatable` splits an ATTACH reference, + /// which is the syntax users type. + fn attach_ref(query: &str) -> Option<(String, Option, String)> { + lazy_static::lazy_static! { + static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'datatable(://[^':]+)?(\?[^':]*)?'\s*AS\s+([^ ;]+)").unwrap(); + } + let cap = RE.captures(query)?; + let reference = format!( + "{}{}", + cap.get(1).map(|m| &m.as_str()[3..]).unwrap_or("main"), + cap.get(2).map(|m| m.as_str()).unwrap_or("") + ); + let (name, role) = parse_datatable_ref(&reference); + Some(( + name.to_string(), + role.map(|r| r.to_string()), + cap.get(3).map(|m| m.as_str()).unwrap_or("").to_string(), + )) + } + + #[test] + fn attach_datatable_parses_name_and_role() { + assert_eq!( + attach_ref("ATTACH 'datatable://sales?role=analyst' AS dt;"), + Some(("sales".into(), Some("analyst".into()), "dt".into())) + ); + // Implicit name. + assert_eq!( + attach_ref("ATTACH 'datatable?role=analyst' AS dt;"), + Some(("main".into(), Some("analyst".into()), "dt".into())) + ); + assert_eq!( + attach_ref("ATTACH 'datatable://sales' AS dt;"), + Some(("sales".into(), None, "dt".into())) + ); + assert_eq!( + attach_ref("ATTACH 'datatable' AS dt;"), + Some(("main".into(), None, "dt".into())) + ); + } + #[test] fn decode_ffi_error_unescapes_multiline_and_strips_quotes() { // Mirror the FFI: JSON-encode the raw DuckDB message, prefix "ERROR ". diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index 71e78f92fe..6ea86e8aef 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -25,9 +25,9 @@ use uuid::Uuid; use windmill_common::error::to_anyhow; use windmill_common::error::{self, Error}; use windmill_common::worker::{ - to_raw_value, Connection, SqlResultCollectionStrategy, CLOUD_HOSTED, + to_raw_value, Connection, SqlAnnotations, SqlResultCollectionStrategy, CLOUD_HOSTED, }; -use windmill_common::workspaces::get_datatable_resource_from_db_unchecked; +use windmill_common::workspaces::{get_datatable_resource_from_db, DatatableAccess}; use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB}; use windmill_parser::{Arg, Typ}; use windmill_parser_sql::{ @@ -593,14 +593,32 @@ pub async fn do_postgresql( match pg_args.get("database").cloned() { Some(Value::String(db_str)) if db_str.starts_with("datatable://") => { let db_str = db_str.trim_start_matches("datatable://"); + // `-- role ` rather than an argument: an argument named + // `role` would collide with a query parameter of that name. + let role = SqlAnnotations::datatable_role(query); Some(match conn { Connection::Http(client) => { - get_datatable_resource_from_agent_http(client, &db_str, &job.workspace_id) - .await? + get_datatable_resource_from_agent_http( + client, + &db_str, + &job.workspace_id, + role.as_deref(), + &job.id, + ) + .await? } Connection::Sql(db) => { - get_datatable_resource_from_db_unchecked(db, &job.workspace_id, &db_str) - .await? + get_datatable_resource_from_db( + db, + &job.workspace_id, + &db_str, + role.as_deref(), + DatatableAccess::PermissionedAs { + permissioned_as: &job.permissioned_as, + email: &job.permissioned_as_email, + }, + ) + .await? } }) } diff --git a/frontend/src/lib/components/DBManagerDrawer.svelte b/frontend/src/lib/components/DBManagerDrawer.svelte index 712c4fbccf..b760ad4e02 100644 --- a/frontend/src/lib/components/DBManagerDrawer.svelte +++ b/frontend/src/lib/components/DBManagerDrawer.svelte @@ -17,6 +17,7 @@ } from 'lucide-svelte' import DBManagerContent from './DBManagerContent.svelte' import DataTableMigrationsButton from './workspaceSettings/DataTableMigrationsButton.svelte' + import DataTablePermissionsButton from './workspaceSettings/DataTablePermissionsButton.svelte' import { resource } from 'runed' import { untrack } from 'svelte' import type { DbManagerUriState } from './dbManagerDrawerModel.svelte' @@ -216,6 +217,7 @@ datatable={uriState.selectedDatatable} onSchemaChanged={refreshManager} /> + {/if} {#if enableImportExport} diff --git a/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte b/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte new file mode 100644 index 0000000000..4eb37f79b1 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte @@ -0,0 +1,330 @@ + + + + + + + + + {#if nameError} + {nameError} + {/if} + {/if} + + {/if} + + {#snippet actions()} + + {/snippet} + + + + (preview = undefined)} +> +
+ {#each preview?.warnings ?? [] as warning} + {warning} + {/each} + {#if !preview?.statements.length} + + No SQL to run — only the tenants of existing roles changed. + + {:else} + + The following runs against {datatable} in a single transaction: + +
{preview.statements.join('\n')}
+ {/if} +
+
diff --git a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte index 069ef6093d..8fd4cf0abd 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte @@ -83,6 +83,7 @@ import { Popover } from '../meltComponents' import ExploreAssetButton from '../ExploreAssetButton.svelte' import DataTableMigrationsButton from './DataTableMigrationsButton.svelte' + import DataTablePermissionsButton from './DataTablePermissionsButton.svelte' import { deepEqual } from 'fast-equals' import { clone } from '$lib/utils' import SettingsFooter from './SettingsFooter.svelte' @@ -343,6 +344,11 @@ datatable={dataTable.name} disabled={!!dirtyMap[dataTable.name]} /> +