diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 71a3acdda6..b29d7f485b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -6c287041cd7edd4a77a4bc07ad0e156cec32cce4 +71701991e6ab76c5b3033a6c765ab246a50c35dd diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 1c740e7b04..d0e1b7d7d6 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -43,9 +43,10 @@ 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, - validate_dev_workspace_id, validate_fork_workspace_id, validate_workspace_name, DataTable, - DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, ProtectionRules, + check_deploy_rules, check_user_against_rule, get_datatable_from_db, + get_datatable_resource_from_db_unchecked, validate_dev_workspace_id, + validate_fork_workspace_id, validate_workspace_name, DataTable, DataTableCatalogResourceType, + DataTableForkBehavior, DataTablePermissions, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME, }; use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; @@ -132,6 +133,18 @@ pub fn workspaced_service() -> Router { get(get_datatable_table_schema), ) .route("/edit_datatable_config", post(edit_datatable_config)) + .route( + "/datatable_permissions/{datatable_name}", + get(get_datatable_permissions), + ) + .route( + "/set_datatable_permissions/{datatable_name}", + post(set_datatable_permissions), + ) + .route( + "/sync_datatable_permissions/{datatable_name}", + post(sync_datatable_permissions), + ) .merge(crate::datatable_migrations::routes()) .route("/git_sync_enabled", get(get_git_sync_enabled)) .route("/edit_git_sync_config", post(edit_git_sync_config)) @@ -2447,18 +2460,21 @@ async fn edit_datatable_config( .map(|r| (r.to.as_str(), r.from.as_str())) .collect(); - // Migrations opt-in is owned by the enable/disable endpoints, not this config - // form: preserve each existing data table's flag, and default brand-new data - // tables to enabled. + // Migrations opt-in and advanced permissions are owned by their dedicated + // endpoints, not this config form: preserve each existing data table's state + // (default brand-new data tables to migrations-enabled / no permissions) so a + // plain config save can neither flip them nor desync the provisioned roles. for (name, dt) in new_config.settings.datatables.iter_mut() { let lookup = rename_src .get(name.as_str()) .copied() .unwrap_or(name.as_str()); - dt.migrations_enabled = match old_datatables.get(lookup) { + let old = old_datatables.get(lookup); + dt.migrations_enabled = match old { Some(old) => old.migrations_enabled, None => Some(true), }; + dt.permissions = old.and_then(|old| old.permissions.clone()); } let args_for_audit = format!("{:?}", new_config.settings); @@ -2517,6 +2533,87 @@ async fn edit_datatable_config( Ok(format!("Edit datatable config for workspace {}", &w_id)) } +async fn get_datatable_permissions( + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, +) -> Result>> { + require_admin(is_admin, &username)?; + let datatable = get_datatable_from_db(&db, &w_id, &datatable_name).await?; + Ok(Json(datatable.permissions)) +} + +/// Persist the advanced-permission config for a single data table and reconcile +/// the target database (roles, grants, RLS policies) to match. Returns the +/// reconciliation log so the modal can surface warnings (e.g. a resource +/// database whose credentials lack the privileges to provision roles). +async fn set_datatable_permissions( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, + Json(perms): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + crate::datatable_migrations::validate_datatable_path_segment(&datatable_name)?; + + // Load first so a missing data table is a clean 404, then write only the + // `permissions` subfield (the rest of the catalog entry is untouched). + let mut datatable = get_datatable_from_db(&db, &w_id, &datatable_name).await?; + let perms_json = + serde_json::to_value(&perms).map_err(|e| Error::internal_err(e.to_string()))?; + // Runtime (non-macro) query so it needs no `.sqlx` cache entry. + sqlx::query( + "UPDATE workspace_settings + SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3::jsonb) + WHERE workspace_id = $1", + ) + .bind(&w_id) + .bind(&datatable_name) + .bind(perms_json) + .execute(&db) + .await?; + + audit_log( + &db, + &authed, + "workspaces.set_datatable_permissions", + ActionKind::Update, + &w_id, + Some(&datatable_name), + None, + ) + .await?; + + datatable.permissions = Some(perms); + let log = windmill_common::datatable_permissions::reconcile_datatable_permissions( + &db, + &w_id, + &datatable_name, + &datatable, + ) + .await?; + Ok(log.join("\n")) +} + +/// Re-run reconciliation from the stored config, e.g. to pick up group-membership +/// changes or newly-migrated tables without editing the permissions. +async fn sync_datatable_permissions( + ApiAuthed { is_admin, username, .. }: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, +) -> Result { + require_admin(is_admin, &username)?; + let datatable = get_datatable_from_db(&db, &w_id, &datatable_name).await?; + let log = windmill_common::datatable_permissions::reconcile_datatable_permissions( + &db, + &w_id, + &datatable_name, + &datatable, + ) + .await?; + Ok(log.join("\n")) +} + #[derive(Deserialize)] pub struct EditGitSyncConfig { pub git_sync_settings: Option, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7714cd89da..f0ee55e439 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -4759,6 +4759,78 @@ paths: error: type: string + /w/{workspace}/workspaces/datatable_permissions/{datatable_name}: + get: + summary: get the permissions configuration of a datatable + operationId: getDatatablePermissions + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + responses: + "200": + description: datatable permissions or null if not configured + content: + application/json: + schema: + nullable: true + allOf: + - $ref: "#/components/schemas/DataTablePermissions" + + /w/{workspace}/workspaces/set_datatable_permissions/{datatable_name}: + post: + summary: set the permissions configuration of a datatable + operationId: setDatatablePermissions + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + requestBody: + description: datatable permissions + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DataTablePermissions" + responses: + "200": + description: reconciliation log + content: + text/plain: + schema: + type: string + + /w/{workspace}/workspaces/sync_datatable_permissions/{datatable_name}: + post: + summary: reconcile the datatable's database with its permissions configuration + operationId: syncDatatablePermissions + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + responses: + "200": + description: reconciliation log + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/enable_datatable_migrations/{datatable_name}: post: summary: opt a datatable in to migrations (admins / super admins only) @@ -29388,6 +29460,74 @@ components: - ran - not_run - unknown + DataTablePermissions: + type: object + required: [enabled] + properties: + enabled: + type: boolean + grants: + type: array + default: [] + items: + $ref: "#/components/schemas/DataTableGrant" + policies: + type: array + default: [] + items: + $ref: "#/components/schemas/DataTablePolicy" + DataTableGrant: + type: object + required: [principal, access] + properties: + principal: + $ref: "#/components/schemas/DataTablePrincipal" + table: + type: string + access: + type: string + enum: + - none + - read + - write + DataTablePrincipal: + type: object + required: [kind, name] + properties: + kind: + type: string + enum: + - user + - group + name: + type: string + description: user email or group name + DataTablePolicy: + type: object + required: [table, name, command] + properties: + table: + type: string + name: + type: string + command: + type: string + default: all + enum: + - all + - select + - insert + - update + - delete + principals: + type: array + default: [] + items: + $ref: "#/components/schemas/DataTablePrincipal" + using: + type: string + check: + type: string DataTableSchema: type: object required: [datatable_name, schemas] diff --git a/backend/windmill-common/src/datatable_permissions_oss.rs b/backend/windmill-common/src/datatable_permissions_oss.rs new file mode 100644 index 0000000000..8db53077ee --- /dev/null +++ b/backend/windmill-common/src/datatable_permissions_oss.rs @@ -0,0 +1,39 @@ +//! OSS fallback for enterprise data-table permissions (implementation in +//! windmill-ee-private, see `datatable_permissions_ee`). The public build has no +//! RLS/role provisioning: reconciling reports that the enterprise edition is +//! required, and query-time resolution always falls through to the default +//! connection (the legacy behavior, where every workspace member shares the +//! owner role and has full access). + +use crate::{ + error::{Error, Result}, + workspaces::{DataTable, DatatableAccessDecision}, + DB, +}; + +/// Reconcile the target database (roles, grants, RLS policies) to match the +/// data table's stored permission config. Enterprise-only. +pub async fn reconcile_datatable_permissions( + _db: &DB, + _w_id: &str, + _datatable_name: &str, + _datatable: &DataTable, +) -> Result> { + Err(Error::internal_err( + "Data table permissions require the enterprise edition".to_string(), + )) +} + +/// Decide which Postgres identity a query against `datatable` should run under +/// for the acting user. The public build never overrides the connection. +pub async fn resolve_datatable_access( + _db: &DB, + _w_id: &str, + _datatable_name: &str, + _datatable: &DataTable, + _base_creds: &serde_json::Value, + _acting_email: &str, + _is_workspace_admin: bool, +) -> Result { + Ok(DatatableAccessDecision::Default) +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index acf97734d8..3d5af0b6cb 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -42,6 +42,13 @@ mod db_entra_ee; mod db_iam_ee; pub mod db_params; #[cfg(feature = "private")] +pub mod datatable_permissions_ee; +pub mod datatable_permissions_oss; +#[cfg(feature = "private")] +pub use datatable_permissions_ee as datatable_permissions; +#[cfg(not(feature = "private"))] +pub use datatable_permissions_oss as datatable_permissions; +#[cfg(feature = "private")] pub mod deployment_requests_ee; pub mod deployment_requests_oss; #[cfg(feature = "private")] diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index a8c6e26f0e..ee55995351 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -777,6 +777,164 @@ pub struct DataTable { /// when migrations already exist (see `datatable_migrations_enabled`). #[serde(default, skip_serializing_if = "Option::is_none")] pub migrations_enabled: Option, + /// Advanced (EE) permissions. Absent/disabled = legacy behavior: every + /// workspace member connects as the shared owner role and has full access. + /// Owned by the dedicated permissions endpoints, not `edit_datatable_config` + /// (mirrors `migrations_enabled`) so a plain config save never clobbers it or + /// desyncs the provisioned Postgres roles/policies from the stored config. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, +} + +/// Desired advanced-permission state for a data table. This is the source of +/// truth the modal edits; the EE provisioning engine reconciles the target +/// Postgres database (roles, grants, RLS policies) to match it. +#[derive(Deserialize, Serialize, Debug, Clone, Default)] +pub struct DataTablePermissions { + /// Opt-in switch. When false, no enforcement happens and provisioned roles + /// (if any) are left in place but unused. + #[serde(default)] + pub enabled: bool, + /// Table/operation access, resolved to Postgres GRANTs per principal. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub grants: Vec, + /// Row-level security policies applied to individual tables. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub policies: Vec, +} + +/// A Windmill principal, mirrored into a Postgres role: `wm_u_` (a LOGIN +/// role, one per user) or `wm_g_` (a NOLOGIN membership role, one per group). +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)] +#[serde(tag = "kind", content = "name", rename_all = "snake_case")] +pub enum DataTablePrincipal { + /// User email. + User(String), + /// Group name. + Group(String), +} + +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct DataTableGrant { + pub principal: DataTablePrincipal, + /// `None` targets every table (existing tables plus future ones via + /// `ALTER DEFAULT PRIVILEGES`); `Some(t)` targets a single table. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub table: Option, + pub access: DataTableAccess, +} + +#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum DataTableAccess { + None, + Read, + Write, +} + +/// A single RLS policy, mapped onto `CREATE POLICY ON FOR +/// TO USING () WITH CHECK ()`. +#[derive(Deserialize, Serialize, Debug, Clone)] +pub struct DataTablePolicy { + pub table: String, + pub name: String, + #[serde(default)] + pub command: DataTablePolicyCommand, + /// Principals the policy applies to (`TO` clause). Empty => `PUBLIC`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub principals: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub using: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub check: Option, +} + +#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum DataTablePolicyCommand { + #[default] + All, + Select, + Insert, + Update, + Delete, +} + +/// Outcome of resolving which Postgres identity a data table query should run +/// under, given the acting Windmill user. Shared across CE/EE so the query +/// executor can branch on it regardless of build. +#[derive(Debug)] +pub enum DatatableAccessDecision { + /// Use the default connection (shared owner role, or resource credentials). + /// This is the legacy path and the workspace-admin bypass. + Default, + /// Connect as a per-user principal role; RLS + grants enforce access. + /// Carries the resolved `PgDatabase` credentials as JSON (host/dbname of the + /// default connection with `user`/`password` overridden). + AsPrincipal(serde_json::Value), + /// The acting user has no access to this data table at all. + Denied, +} + +/// Deterministic, workspace-scoped Postgres role name for a Windmill principal. +/// +/// Postgres roles are cluster-global, and every instance data table lives on the +/// same server, so the workspace id MUST be folded in to keep one workspace's +/// principal roles from colliding with (or connecting to) another's. Hashing +/// yields a stable, valid, <=63-char identifier from arbitrary emails/group +/// names. Both provisioning and query-time resolution call this, so they always +/// agree on the name for a given `(workspace, principal)`. +pub fn datatable_role_name(w_id: &str, principal: &DataTablePrincipal) -> String { + use sha2::{Digest, Sha256}; + let (prefix, raw) = match principal { + DataTablePrincipal::User(email) => ("wm_u_", email.as_str()), + DataTablePrincipal::Group(group) => ("wm_g_", group.as_str()), + }; + let mut hasher = Sha256::new(); + hasher.update(w_id.as_bytes()); + hasher.update([0u8]); + hasher.update(raw.as_bytes()); + format!("{prefix}{}", hex::encode(&hasher.finalize()[..12])) +} + +/// Deterministic password for a principal role, derived by HMAC-SHA256 of the +/// role name under a stable instance secret (see +/// `get_or_create_datatable_role_secret`). The role name already encodes the +/// workspace, so keying on it alone is sufficient. Nothing per-role is stored: +/// both the provisioner (which sets the password) and the executor (which +/// authenticates with it) recompute it. +pub fn derive_datatable_role_password(secret: &str, role: &str) -> String { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let mut mac = + Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length"); + mac.update(role.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +/// Fetch (or lazily create) the stable secret used to derive principal-role +/// passwords. Unlike `custom_instance_user`'s password this is never rotated — +/// rotating it would invalidate every principal password at once — so it lives +/// under its own `global_settings` key generated on first use. +pub async fn get_or_create_datatable_role_secret(db: &DB) -> Result { + // `gen_random_uuid()` provides the randomness in-SQL so we don't pull in a + // CSPRNG here; two concatenated uuids give a 64-char secret. ON CONFLICT DO + // NOTHING makes concurrent first-uses converge on a single value. + // Runtime (non-macro) query so it needs no `.sqlx` cache entry. + sqlx::query( + r#"INSERT INTO global_settings (name, value) + VALUES ('datatable_permissions', + jsonb_build_object('secret', gen_random_uuid()::text || gen_random_uuid()::text)) + ON CONFLICT (name) DO NOTHING"#, + ) + .execute(db) + .await?; + sqlx::query_scalar::<_, Option>( + "SELECT value->>'secret' FROM global_settings WHERE name = 'datatable_permissions'", + ) + .fetch_one(db) + .await? + .ok_or_else(|| Error::internal_err("datatable_permissions secret missing after insert")) } #[derive(Deserialize, Serialize, Debug)] @@ -828,11 +986,8 @@ fn datatable_not_found_error(name: &str, datatables: Option<&serde_json::Value>) )) } -pub async fn get_datatable_resource_from_db_unchecked( - db: &DB, - w_id: &str, - name: &str, -) -> Result { +/// Load a data table's catalog entry (including its permission config). +pub async fn get_datatable_from_db(db: &DB, w_id: &str, name: &str) -> Result { let datatables = sqlx::query_scalar!( r#" SELECT ws.datatable->'datatables' AS datatables @@ -850,26 +1005,86 @@ pub async fn get_datatable_resource_from_db_unchecked( .and_then(|d| d.get(name)) .filter(|v| !v.is_null()) .ok_or_else(|| datatable_not_found_error(name, datatables.as_ref()))?; - let datatable = serde_json::from_value::(datatable.clone())?; + Ok(serde_json::from_value::(datatable.clone())?) +} - let db_resource = if datatable.database.resource_type == DataTableCatalogResourceType::Instance - { +/// Resolve the default connection credentials for a data table — the shared +/// owner role (instance) or the backing resource (postgres). This performs no +/// per-user authorization; permission enforcement is layered on in the `checked` +/// variant. +pub async fn datatable_base_creds( + db: &DB, + w_id: &str, + datatable: &DataTable, +) -> Result { + 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(); 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)))? + .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? - }; + .await + } +} - Ok(db_resource) +pub async fn get_datatable_resource_from_db_unchecked( + db: &DB, + w_id: &str, + name: &str, +) -> Result { + let datatable = get_datatable_from_db(db, w_id, name).await?; + datatable_base_creds(db, w_id, &datatable).await +} + +/// Like `get_datatable_resource_from_db_unchecked`, but applies the data table's +/// advanced (EE) permissions for `acting_email`: an admin (or a data table +/// without permissions) gets the default owner connection; other members get a +/// per-user principal role subject to RLS/grants; members with no access are +/// rejected. On the OSS build the EE hook is a no-op, so this equals the +/// unchecked path. +pub async fn get_datatable_resource_from_db_checked( + db: &DB, + w_id: &str, + name: &str, + acting_email: &str, +) -> Result { + let datatable = get_datatable_from_db(db, w_id, name).await?; + let base = datatable_base_creds(db, w_id, &datatable).await?; + + let is_workspace_admin = sqlx::query_scalar::<_, bool>( + "SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2", + ) + .bind(w_id) + .bind(acting_email) + .fetch_optional(db) + .await? + .unwrap_or(false); + + match crate::datatable_permissions::resolve_datatable_access( + db, + w_id, + name, + &datatable, + &base, + acting_email, + is_workspace_admin, + ) + .await? + { + DatatableAccessDecision::Default => Ok(base), + DatatableAccessDecision::AsPrincipal(creds) => Ok(creds), + DatatableAccessDecision::Denied => Err(Error::BadRequest(format!( + "You do not have access to data table '{name}'. Ask a workspace admin to grant \ + access (data table permissions may need to be re-synced)." + ))), + } } #[derive(Deserialize, Serialize, Debug)] diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index e4e24d6653..ce0958b5e6 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -13,7 +13,7 @@ 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, + get_datatable_resource_from_db_checked, get_ducklake_from_db_unchecked, strip_fork_reserved_attach_args, DucklakeCatalogResourceType, }; use windmill_common::PgDatabase; @@ -1496,6 +1496,7 @@ pub async fn do_duckdb( conn, &mut hidden_passwords, &job.workspace_id, + &job.permissioned_as_email, ) .await? { @@ -1573,6 +1574,7 @@ pub async fn do_duckdb( conn, &mut hidden_passwords, &job.workspace_id, + &job.permissioned_as_email, ) .await? { @@ -2555,6 +2557,7 @@ async fn transform_attach_datatable( conn: &Connection, hidden_passwords: &mut Arc>>, w_id: &str, + acting_email: &str, ) -> Result>> { lazy_static::lazy_static! { static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'datatable(://[^':]+)?'\s*AS\s+([^ ;]+)").unwrap(); @@ -2569,7 +2572,12 @@ async fn transform_attach_datatable( Connection::Http(client) => { get_datatable_resource_from_agent_http(client, name, w_id).await? } - Connection::Sql(db) => get_datatable_resource_from_db_unchecked(db, w_id, name).await?, + // Enforce data table permissions (EE) as the acting user, exactly like the + // postgresql executor — otherwise a DuckDB `ATTACH 'datatable://...'` would + // run as the owner and bypass RLS/grants entirely. + Connection::Sql(db) => { + get_datatable_resource_from_db_checked(db, w_id, name, acting_email).await? + } }; let db_type = "postgres"; diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index 98c1b5bba7..b8f74b360f 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -27,7 +27,7 @@ use windmill_common::error::{self, Error}; use windmill_common::worker::{ to_raw_value, Connection, SqlResultCollectionStrategy, CLOUD_HOSTED, }; -use windmill_common::workspaces::get_datatable_resource_from_db_unchecked; +use windmill_common::workspaces::get_datatable_resource_from_db_checked; use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB}; use windmill_parser::{Arg, Typ}; use windmill_parser_sql::{ @@ -599,8 +599,15 @@ pub async fn do_postgresql( .await? } Connection::Sql(db) => { - get_datatable_resource_from_db_unchecked(db, &job.workspace_id, &db_str) - .await? + // Enforce advanced (EE) data table permissions as the job's + // acting identity; no-op on OSS / when permissions are off. + get_datatable_resource_from_db_checked( + db, + &job.workspace_id, + &db_str, + &job.permissioned_as_email, + ) + .await? } }) } diff --git a/docs/datatable-permissions.md b/docs/datatable-permissions.md new file mode 100644 index 0000000000..5727d57514 --- /dev/null +++ b/docs/datatable-permissions.md @@ -0,0 +1,107 @@ +# Data table permissions (EE) + +Advanced, opt-in permissions for data tables, enforced natively by Postgres +**roles** and **row-level security (RLS)**. Enterprise-only; off by default (a +data table with no permission config keeps the legacy behavior where every +workspace member shares the owner role and has full access). + +## Why roles, not session variables + +Data table queries run **arbitrary user SQL** (a `postgresql` script job with +`database = "datatable://"`). That rules out the usual RLS shortcut of +injecting identity via a session GUC (`SET app.user = ...`) — the user's own SQL +can overwrite it. It also rules out `SET ROLE` down to a low-privilege role — the +user can `RESET ROLE` back to the login role. + +The only tamper-proof signal is the **connected role itself**. So a non-admin +query connects *directly* as a per-user login role; `current_user` is then +authoritative and cannot be escalated, because the session never held more +privilege than that role. + +## Model + +Windmill principals are mirrored into Postgres roles inside the data table's +physical database: + +- `wm_u_` — one **LOGIN** role per user (`INHERIT`, deterministic + HMAC-derived password). The name hashes `(workspace_id, email)`; the workspace + id is folded in because Postgres roles are cluster-global and all instance data + tables share one server. +- `wm_g_` — one **NOLOGIN** role per group. User roles are granted + membership in their group roles, so group grants apply automatically. Roles are + only ever members of their own, minimally-granted groups — there is no path + upward. +- `_wm_principals(role_name, email, username)` — a mapping table (readable only + via the SECURITY DEFINER `wm_email()` helper, which returns the current role's + email) so policies can reference the acting user's email. + +Two layers of control, both configured from the **Permissions** modal in the data +table settings: + +1. **Access** (the `Access` tab) → Postgres `GRANT`s. Per user/group: no + access / read (`SELECT`) / read+write (`SELECT,INSERT,UPDATE,DELETE`), scoped + to one table or all tables (with `ALTER DEFAULT PRIVILEGES` so future tables + inherit the grant). +2. **Row policies** (the `Row policies` tab) → `CREATE POLICY`. RLS is enabled + **only** on tables that have a policy. Templates and raw `USING` / + `WITH CHECK` expressions (e.g. `owner = wm_email()`). + +`CREATE` on `public` is **revoked** from principal roles, so a principal can +never create a table that escapes policy — all DDL flows through the migrations +path (which connects as the table owner). + +### Admin bypass + +We deliberately do **not** `FORCE` RLS. The table owner +(`custom_instance_user` for instance data tables; the resource user for resource +data tables) therefore bypasses RLS. Workspace admins connect as that owner, so +admins always keep full, unfiltered access. Non-admin principals are non-owners +and stay subject to RLS. This is safe because principals can never own a table. + +## Enforcement path + +`pg_executor` resolves `datatable://` through +`get_datatable_resource_from_db_checked(db, w_id, name, acting_email)` +(`acting_email` = the job's `permissioned_as_email`): + +- data table has no permissions / disabled, or the user is a **workspace + admin** → default owner connection (unchanged); +- other member → connect as their `wm_u_*` role; grants + RLS enforce; +- member with no provisioned role (instance data tables) → **denied** with a + clear error. + +The EE logic lives in `windmill-common/src/datatable_permissions_ee.rs` behind the +`datatable_permissions` module switch (`_ee` under `private`, `_oss` stub +otherwise), mirroring the `pipeline_advanced` / `partition` pattern. On OSS the +resolver is a no-op, so the checked path equals the unchecked one. + +## Provisioning + +`set_datatable_permissions` writes the config into the catalog JSONB +(`workspace_settings.datatable`) and then **reconciles** the target database: +creates/updates roles, memberships, `_wm_principals` rows, grants, and policies. +Instance data tables provision as the Windmill superuser; resource data tables +provision with the resource's own credentials (best-effort — the reconciliation +log surfaces any privilege it lacked). `sync_datatable_permissions` re-runs +reconciliation from the stored config. + +## Known limitations (v1) + +- **Instance and resource data tables** are both attempted; resource support is + best-effort (needs `CREATEROLE` + table ownership on the resource credentials). +- **Group-membership drift**: roles, memberships, grants and policies are + reconciled to match the config only at save/**Sync** time. A user added to or + removed from a group (or a datatable getting new tables) takes effect on the + next Sync — reconcile is declarative and revokes removed access, so Sync both + grants and revokes. There is no per-query provisioning, so a brand-new member + is denied until an admin re-syncs. +- **New tables**: run **Sync** after schema migrations so freshly-created tables + pick up grants/policies (grants also propagate via default privileges; RLS + policies must be re-applied). +- **Enforced paths**: `postgresql` and DuckDB (`ATTACH 'datatable://...'`) script + jobs on a normal worker both resolve through the checked path. **Not** enforced + (resolve as owner): schema-introspection and the DB-manager "explore" + endpoints, Postgres-trigger capture, and **agent-worker** job execution. If you + rely on agent workers, treat data table permissions as advisory for those jobs. +- **Row policy expressions** are admin-authored raw SQL (like migrations) — they + are trusted input. diff --git a/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte b/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte new file mode 100644 index 0000000000..3143a8ba97 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte @@ -0,0 +1,380 @@ + + + + + + {#snippet headerLeft()} + + Restrict who can read and write this data table's rows, enforced natively by Postgres roles + and row-level security. Opt in below, then grant access per user/group and optionally add + row-level policies. Workspace admins always keep full access. + + {/snippet} + +
+ {#if loading} +
+ +
+ {:else} +
+ (enabled = e.detail)} + /> +
+ + {#if !ee} +
+ + Data table permissions require an enterprise license. +
+ {/if} + + + + + {#snippet content()} + +
+
+ Grant a user or group access to a specific table, or to all tables (leave the table + field empty). +
+ {#each grants as grant, i (i)} +
+ +
+ {/each} +
+ +
+
+
+ + +
+
+ Row-level policies filter which rows a principal can see or change. Use + wm_email() for the current user's email, and standard SQL in the USING + (read/delete) and WITH CHECK (insert/update) expressions, e.g. + owner = wm_email(). +
+ {#each policies as policy, i (i)} +
+
+ + + + +
+ {/each} +
+ +
+
+ + +
+ {/each} +
+ +
+
+ + {/snippet} + + + {#if log} +
+ {log} +
+ {/if} + +
+ + +
+ {/if} + +
diff --git a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte index f652ed8704..5bcb643d61 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte @@ -78,6 +78,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' @@ -305,6 +306,11 @@ datatable={dataTable.name} disabled={!!dirtyMap[dataTable.name]} /> + {#if dirtyMap[dataTable.name]}