diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 34597debc1..e4502b5bd8 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -332893020ea80cd1e740e1ff4231eb5be3bbc048 +e6e5dc7d20bb7143231a2bede7f53c430aeaa2c1 diff --git a/backend/windmill-common/src/datatable_roles.rs b/backend/windmill-common/src/datatable_roles.rs index 3a7709ccd1..3c269d1ada 100644 --- a/backend/windmill-common/src/datatable_roles.rs +++ b/backend/windmill-common/src/datatable_roles.rs @@ -102,16 +102,13 @@ pub fn validate_role_name(name: &str) -> Result<()> { Ok(()) } -/// SAFETY: every caller must have run [`validate_role_name`] first — the charset it enforces is -/// what makes this quoting sufficient. -fn quote_ident(name: &str) -> String { +/// A double-quoted Postgres identifier. Doubling `"` is Postgres's own escaping inside one, so this +/// quotes any name — schema, table or role. Role names are validated as well +/// ([`validate_role_name`]) because they also travel unquoted, in `-- role ` and `?role=`. +pub fn quote_ident(name: &str) -> String { format!("\"{}\"", name.replace('"', "\"\"")) } -fn quote_literal(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} - /// Serialize the mutations that are not already serialized by the row itself. /// /// A create is an insert and a delete is a delete, which Postgres orders for us — the unique index @@ -149,18 +146,7 @@ pub async fn lock_datatable_streams(conn: &mut sqlx::PgConnection, exclusive: bo /// or an export. Nothing about who may call it: the credential is the whole risk, and `Debug` is /// hand-written to redact it for the same reason. pub async fn read_role_catalog(db: &DB) -> Result { - let rows = sqlx::query!("SELECT id, name, enabled, pwd FROM datatable_role") - .fetch_all(db) - .await?; - Ok(rows - .into_iter() - .map(|r| { - ( - r.id, - InstanceDatatableRole { name: r.name, enabled: r.enabled, pwd: r.pwd }, - ) - }) - .collect()) + crate::datatable_roles_oss::read_role_catalog(db).await } /// As [`read_role_catalog`], reading inside the caller's transaction so the value is the one @@ -168,18 +154,7 @@ pub async fn read_role_catalog(db: &DB) -> Result { pub async fn read_role_catalog_tx( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result { - let rows = sqlx::query!("SELECT id, name, enabled, pwd FROM datatable_role") - .fetch_all(&mut **tx) - .await?; - Ok(rows - .into_iter() - .map(|r| { - ( - r.id, - InstanceDatatableRole { name: r.name, enabled: r.enabled, pwd: r.pwd }, - ) - }) - .collect()) + crate::datatable_roles_oss::read_role_catalog_tx(tx).await } /// Record a role, in the caller's transaction so it commits with the `CREATE ROLE` it describes. @@ -191,16 +166,7 @@ pub async fn insert_role_catalog_entry( id: &str, role: &InstanceDatatableRole, ) -> Result<()> { - sqlx::query!( - "INSERT INTO datatable_role (id, name, enabled, pwd) VALUES ($1, $2, $3, $4)", - id, - role.name, - role.enabled, - role.pwd, - ) - .execute(&mut **tx) - .await?; - Ok(()) + crate::datatable_roles_oss::insert_role_catalog_entry(tx, id, role).await } /// Update a role's recorded name, login flag and password. Same contract as @@ -210,16 +176,7 @@ pub async fn update_role_catalog_entry( id: &str, role: &InstanceDatatableRole, ) -> Result<()> { - sqlx::query!( - "UPDATE datatable_role SET name = $2, enabled = $3, pwd = $4 WHERE id = $1", - id, - role.name, - role.enabled, - role.pwd, - ) - .execute(&mut **tx) - .await?; - Ok(()) + crate::datatable_roles_oss::update_role_catalog_entry(tx, id, role).await } /// Forget a role. Same contract as [`insert_role_catalog_entry`]; run it in the transaction that @@ -228,10 +185,7 @@ pub async fn delete_role_catalog_entry( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, id: &str, ) -> Result<()> { - sqlx::query!("DELETE FROM datatable_role WHERE id = $1", id) - .execute(&mut **tx) - .await?; - Ok(()) + crate::datatable_roles_oss::delete_role_catalog_entry(tx, id).await } /// Resolve the role a caller named to its catalog id. A disabled role is an error rather than a @@ -261,13 +215,7 @@ pub fn role_id_by_name<'a>(catalog: &'a DatatableRoleCatalog, name: &str) -> Res /// Every instance database the registry knows about. Role provisioning has to reach all of them: /// a role that cannot `CONNECT` to a database is refused by Postgres before any grant matters. pub async fn registered_instance_databases(db: &DB) -> Result> { - let names = sqlx::query_scalar!( - "SELECT jsonb_object_keys(value->'databases') FROM global_settings - WHERE name = 'custom_instance_pg_databases'" - ) - .fetch_all(db) - .await?; - Ok(names.into_iter().flatten().collect()) + crate::datatable_roles_oss::registered_instance_databases(db).await } /// `CONNECT` on `dbname` for every enabled role, and none for `PUBLIC`. Run at role creation, at @@ -278,8 +226,7 @@ pub async fn registered_instance_databases(db: &DB) -> Result> { /// Callers MUST have authorized administration of `dbname` — superadmin, or an admin of the /// workspace governing a data table on it. pub async fn converge_connect_grants(db: &DB, dbname: &str) -> Result<()> { - let catalog = read_role_catalog(db).await?; - converge_connect_grants_with(db, dbname, &catalog).await + crate::datatable_roles_oss::converge_connect_grants(db, dbname).await } /// As [`converge_connect_grants`], with a catalog the caller already read. Same contract. @@ -288,18 +235,7 @@ pub async fn converge_connect_grants_with( dbname: &str, catalog: &DatatableRoleCatalog, ) -> Result<()> { - crate::validate_dbname(dbname)?; - let quoted_db = quote_ident(dbname); - let mut sql = format!("REVOKE CONNECT ON DATABASE {quoted_db} FROM PUBLIC;\n"); - for role in catalog.values().filter(|r| r.enabled) { - validate_role_name(&role.name)?; - sql.push_str(&format!( - "GRANT CONNECT ON DATABASE {quoted_db} TO {};\n", - quote_ident(&role.name) - )); - } - sqlx::raw_sql(&sql).execute(db).await?; - Ok(()) + crate::datatable_roles_oss::converge_connect_grants_with(db, dbname, catalog).await } /// `CREATE ROLE LOGIN PASSWORD ...; GRANT TO custom_instance_user`, and `CONNECT` on @@ -313,36 +249,7 @@ pub async fn create_instance_role( name: &str, password: &str, ) -> Result<()> { - validate_role_name(name)?; - let exists = sqlx::query_scalar!( - "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", - name - ) - .fetch_one(&mut **tx) - .await? - .unwrap_or(false); - if exists { - return Err(Error::BadRequest(format!( - "A Postgres role named '{name}' already exists on this cluster" - ))); - } - let quoted = quote_ident(name); - // One statement per call rather than a batch: `raw_sql` takes the simple protocol, which is - // only needed for genuinely multi-statement SQL, and its future is not `Send` — which an axum - // handler holding this transaction requires. - sqlx::query(&format!( - "CREATE ROLE {quoted} LOGIN PASSWORD {}", - quote_literal(password) - )) - .execute(&mut **tx) - .await?; - sqlx::query(&format!( - "GRANT {quoted} TO {}", - quote_ident(CUSTOM_INSTANCE_USER) - )) - .execute(&mut **tx) - .await?; - Ok(()) + crate::datatable_roles_oss::create_instance_role(tx, name, password).await } /// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin @@ -352,15 +259,7 @@ pub async fn set_instance_role_login( name: &str, enabled: bool, ) -> Result<()> { - validate_role_name(name)?; - sqlx::query(&format!( - "ALTER ROLE {} {}", - quote_ident(name), - if enabled { "LOGIN" } else { "NOLOGIN" } - )) - .execute(&mut **tx) - .await?; - Ok(()) + crate::datatable_roles_oss::set_instance_role_login(tx, name, enabled).await } /// A rename discards an md5-hashed password, so the caller has to hand over a fresh one. @@ -373,35 +272,7 @@ pub async fn rename_instance_role( to: &str, password: &str, ) -> Result<()> { - validate_role_name(from)?; - validate_role_name(to)?; - let taken = sqlx::query_scalar!( - "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", - to - ) - .fetch_one(&mut **tx) - .await? - .unwrap_or(false); - if taken { - return Err(Error::BadRequest(format!( - "A Postgres role named '{to}' already exists on this cluster" - ))); - } - sqlx::query(&format!( - "ALTER ROLE {} RENAME TO {}", - quote_ident(from), - quote_ident(to) - )) - .execute(&mut **tx) - .await?; - sqlx::query(&format!( - "ALTER ROLE {} PASSWORD {}", - quote_ident(to), - quote_literal(password) - )) - .execute(&mut **tx) - .await?; - Ok(()) + crate::datatable_roles_oss::rename_instance_role(tx, from, to, password).await } /// A role owning anything in any database blocks its own `DROP ROLE`, and both its objects and the @@ -427,47 +298,7 @@ pub async fn drop_instance_role( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, name: &str, ) -> Result<()> { - validate_role_name(name)?; - let quoted = quote_ident(name); - let reassign = format!( - "REASSIGN OWNED BY {quoted} TO {};\nDROP OWNED BY {quoted};", - quote_ident(CUSTOM_INSTANCE_USER) - ); - - let base = crate::PgDatabase::parse_uri(&crate::get_database_url().await?.as_str().await)?; - for dbname in registered_instance_databases(db).await? { - let creds = crate::PgDatabase { dbname: dbname.clone(), ..base.clone() }; - let (client, connection) = creds.connect(Some(db)).await.map_err(|e| { - Error::BadRequest(format!( - "Cannot delete role '{name}': instance database '{dbname}' is unreachable ({e}). \ - Objects it owns there would be orphaned." - )) - })?; - let join_handle = tokio::spawn(async move { connection.await }); - let result = client.batch_execute(&reassign).await; - drop(client); - crate::shutdown_pg_connection(join_handle).await?; - result.map_err(|e| { - Error::internal_err(format!( - "Reassigning what role '{name}' owns in '{dbname}': {}", - crate::error::pg_error_message(&e) - )) - })?; - } - - sqlx::query(&format!( - "REASSIGN OWNED BY {quoted} TO {}", - quote_ident(CUSTOM_INSTANCE_USER) - )) - .execute(&mut **tx) - .await?; - sqlx::query(&format!("DROP OWNED BY {quoted}")) - .execute(&mut **tx) - .await?; - sqlx::query(&format!("DROP ROLE {quoted}")) - .execute(&mut **tx) - .await?; - Ok(()) + crate::datatable_roles_oss::drop_instance_role(db, tx, name).await } #[cfg(test)] diff --git a/backend/windmill-common/src/datatable_roles_oss.rs b/backend/windmill-common/src/datatable_roles_oss.rs new file mode 100644 index 0000000000..98fef4260f --- /dev/null +++ b/backend/windmill-common/src/datatable_roles_oss.rs @@ -0,0 +1,206 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Where data table roles come from: the enterprise implementation, or a refusal. +//! +//! Roles are an Enterprise Edition feature. An edition without them creates, grants and connects +//! as none, and a data table saved under roles — by an enterprise build, before a downgrade — is +//! refused rather than resolved as `admin`. A data table not under roles, asked for no role, +//! resolves as it always has. `private` alone is not that edition: community builds carry it. + +use crate::error::Error; + +/// What every roles path answers without the Enterprise Edition. +pub fn datatable_roles_unavailable() -> Error { + Error::BadRequest("Data table roles are a Windmill Enterprise Edition feature".to_string()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) use crate::datatable_roles_ee::{ + can_use_datatable_role, can_use_datatable_role_in_governing_workspace, converge_connect_grants, + converge_connect_grants_with, create_instance_role, delete_role_catalog_entry, + drop_instance_role, ensure_can_use_datatable_role, ensure_datatable_admin_access, + ensure_instance_db_grant_options_unchecked, forget_datatable_role_everywhere, + insert_role_catalog_entry, read_role_catalog, read_role_catalog_tx, + registered_instance_databases, rename_instance_role, resolve_datatable_role_connection, + set_instance_role_login, update_role_catalog_entry, +}; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) use ce::*; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +mod ce { + use super::datatable_roles_unavailable as unavailable; + use crate::{ + datatable_roles::{DatatableRoleCatalog, InstanceDatatableRole}, + db::AuthedRef, + error::Result, + workspaces::{ + resolve_governing_datatable, DataTableRoleTenants, DatatableAccess, GoverningDatatable, + }, + DB, + }; + + type Tx<'a> = sqlx::Transaction<'a, sqlx::Postgres>; + + pub(crate) async fn read_role_catalog(_db: &DB) -> Result { + Err(unavailable()) + } + + pub(crate) async fn read_role_catalog_tx(_tx: &mut Tx<'_>) -> Result { + Err(unavailable()) + } + + pub(crate) async fn insert_role_catalog_entry( + _tx: &mut Tx<'_>, + _id: &str, + _role: &InstanceDatatableRole, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn update_role_catalog_entry( + _tx: &mut Tx<'_>, + _id: &str, + _role: &InstanceDatatableRole, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn delete_role_catalog_entry(_tx: &mut Tx<'_>, _id: &str) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn registered_instance_databases(_db: &DB) -> Result> { + Err(unavailable()) + } + + /// Nothing to converge: with no roles to admit, an instance database keeps the `CONNECT` + /// grants it was created with, `PUBLIC`'s included, as it did before roles existed. + pub(crate) async fn converge_connect_grants(_db: &DB, _dbname: &str) -> Result<()> { + Ok(()) + } + + /// As [`converge_connect_grants`]. + pub(crate) async fn converge_connect_grants_with( + _db: &DB, + _dbname: &str, + _catalog: &DatatableRoleCatalog, + ) -> Result<()> { + Ok(()) + } + + pub(crate) async fn create_instance_role( + _tx: &mut Tx<'_>, + _name: &str, + _password: &str, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn set_instance_role_login( + _tx: &mut Tx<'_>, + _name: &str, + _enabled: bool, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn rename_instance_role( + _tx: &mut Tx<'_>, + _from: &str, + _to: &str, + _password: &str, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn drop_instance_role(_db: &DB, _tx: &mut Tx<'_>, _name: &str) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn ensure_instance_db_grant_options_unchecked( + _db: &DB, + _dbname: &str, + ) -> Result<()> { + Err(unavailable()) + } + + /// No tenant list covers anyone: there is no role to connect as. + pub(crate) fn can_use_datatable_role( + _tenants: &DataTableRoleTenants, + _authed: &AuthedRef<'_>, + ) -> bool { + false + } + + pub(crate) async fn can_use_datatable_role_in_governing_workspace( + _db: &DB, + _governing_w_id: &str, + _w_id: &str, + _tenants: &DataTableRoleTenants, + _access: &DatatableAccess<'_>, + ) -> Result { + Err(unavailable()) + } + + /// Reached only for a data table under roles or a caller naming a role: both are refused. + pub(crate) async fn resolve_datatable_role_connection( + _db: &DB, + _w_id: &str, + _name: &str, + _governing: &GoverningDatatable, + _db_resource: serde_json::Value, + _role: Option<&str>, + _access: DatatableAccess<'_>, + ) -> Result { + Err(unavailable()) + } + + /// A data table not under roles, asked for no role, is not a role decision and passes, as it + /// did before roles existed. Anything else is refused. + pub(crate) async fn ensure_can_use_datatable_role( + db: &DB, + w_id: &str, + name: &str, + role: Option<&str>, + _access: &DatatableAccess<'_>, + _context: &str, + ) -> Result<()> { + let governing = resolve_governing_datatable(db, w_id, name).await?; + if governing.datatable.permissions.is_none() && role.is_none() { + Ok(()) + } else { + Err(unavailable()) + } + } + + /// A data table not under roles is the `admin` connection for anyone who reaches it, as before + /// roles existed. One under roles is refused. + pub(crate) async fn ensure_datatable_admin_access( + db: &DB, + w_id: &str, + name: &str, + _access: &DatatableAccess<'_>, + ) -> Result<()> { + let governing = resolve_governing_datatable(db, w_id, name).await?; + if governing.datatable.permissions.is_none() { + Ok(()) + } else { + Err(unavailable()) + } + } + + pub(crate) async fn forget_datatable_role_everywhere( + _tx: &mut Tx<'_>, + _role_id: &str, + ) -> Result<()> { + Err(unavailable()) + } +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index e6c6744483..38eb34ff91 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -38,6 +38,9 @@ pub mod cache; pub mod client; pub mod data_metrics; pub mod datatable_roles; +#[cfg(all(feature = "private", feature = "enterprise"))] +mod datatable_roles_ee; +pub mod datatable_roles_oss; pub mod db; #[cfg(all(feature = "enterprise", feature = "private"))] mod db_entra_ee; @@ -1504,7 +1507,7 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu /// and it is the one that hands privileges to data table roles. Postgres refuses to let a role pass /// on a privilege it does not itself hold with grant option, so without these an admin could own /// the database and still be unable to grant `SELECT` on it to `analytics`. -fn instance_db_grants(dbname: &str) -> String { +pub(crate) fn instance_db_grants(dbname: &str) -> String { format!( "GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION; GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION; @@ -1526,23 +1529,11 @@ fn instance_db_grants(dbname: &str) -> String { /// Authorization: reaches an instance database with the server's own credentials and checks /// nothing. Callers MUST have authorized administration of `dbname` — superadmin, or an admin of /// the workspace governing a data table on it. -pub async fn ensure_instance_db_grant_options_unchecked(db: &DB, dbname: &str) -> error::Result<()> { - let dbname = dbname.trim(); - validate_dbname(dbname)?; - let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; - let creds = PgDatabase { dbname: dbname.to_string(), ..wmill_pg_creds }; - let (client, connection) = creds.connect(Some(db)).await?; - let join_handle = tokio::spawn(async move { connection.await }); - let result = client.batch_execute(&instance_db_grants(dbname)).await; - drop(client); - shutdown_pg_connection(join_handle).await?; - result.map_err(|e| { - error::Error::internal_err(format!( - "Failed to grant permissions on '{}': {}", - dbname, - crate::error::pg_error_message(&e) - )) - }) +pub async fn ensure_instance_db_grant_options_unchecked( + db: &DB, + dbname: &str, +) -> error::Result<()> { + crate::datatable_roles_oss::ensure_instance_db_grant_options_unchecked(db, dbname).await } /// Create a custom instance database: CREATE DATABASE, grant permissions, register in global_settings. diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 6a650c8e10..653ad1dcce 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -1614,18 +1614,7 @@ pub fn can_use_datatable_role( tenants: &DataTableRoleTenants, authed: &crate::db::AuthedRef<'_>, ) -> bool { - *authed.is_admin - || tenants.tenants.iter().any(|tenant| { - if tenant == DATATABLE_TENANT_WILDCARD { - return true; - } - match tenant.split_once('/') { - Some(("u", user)) => authed.username == user, - Some(("g", group)) => authed.groups.iter().any(|g| g == group), - Some(("f", folder)) => authed.folders.iter().any(|(f, _, _)| f == folder), - _ => false, - } - }) + crate::datatable_roles_oss::can_use_datatable_role(tenants, authed) } /// Evaluate a tenant list **as a member of the governing workspace**, whoever is calling. @@ -1642,101 +1631,14 @@ pub async fn can_use_datatable_role_in_governing_workspace( tenants: &DataTableRoleTenants, access: &DatatableAccess<'_>, ) -> Result { - let (permissioned_as, email): (String, String) = match access { - DatatableAccess::Unchecked => return Ok(true), - DatatableAccess::NoIdentity => return Ok(false), - DatatableAccess::Authed(authed) => { - if w_id == governing_w_id { - return Ok(can_use_datatable_role(tenants, authed)); - } - (format!("u/{}", authed.username), authed.email.to_string()) - } - DatatableAccess::PermissionedAs { permissioned_as, email } => { - (permissioned_as.to_string(), email.to_string()) - } - 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}")))?; - (job.permissioned_as, job.permissioned_as_email) - } - }; - - if w_id == governing_w_id { - let authed = - crate::auth::fetch_authed_from_permissioned_as(&permissioned_as, &email, w_id, db) - .await?; - return Ok(can_use_datatable_role(tenants, &authed.to_authed_ref())); - } - if crate::auth::is_super_admin_email(db, &email).await? { - return Ok(true); - } - if !permissioned_as.starts_with("u/") { - return Ok(false); - } - let Some(username) = sqlx::query_scalar!( - "SELECT username FROM usr WHERE workspace_id = $1 AND email = $2 AND disabled = false", - governing_w_id, - &email - ) - .fetch_optional(db) - .await? - else { - return Ok(false); - }; - let authed = crate::auth::fetch_authed_from_permissioned_as( - &format!("u/{username}"), - &email, - governing_w_id, + crate::datatable_roles_oss::can_use_datatable_role_in_governing_workspace( db, + governing_w_id, + w_id, + tenants, + access, ) - .await?; - Ok(can_use_datatable_role(tenants, &authed.to_authed_ref())) -} - -/// Which tenant list a caller's role selection lands on. `Ok(None)` means the data table is -/// unpermissioned and resolves through its own `admin` connection, as it did before roles existed. -/// -/// `role` is the **name** a caller wrote (`-- role analytics`); it is mapped to the catalog id the -/// tenant lists are keyed by here, so a rename moves nothing. -fn datatable_role_entry<'a>( - permissions: Option<&'a DataTablePermissions>, - catalog: &crate::datatable_roles::DatatableRoleCatalog, - name: &str, - role: Option<&str>, -) -> Result> { - let Some(permissions) = permissions else { - return match role { - Some(role) if role != ADMIN_DATATABLE_ROLE => Err(Error::BadRequest(format!( - "Cannot use role '{role}': data table '{name}' is not under roles. \ - Put it under roles in its permissions drawer first." - ))), - _ => Ok(None), - }; - }; - let role_id = match role { - None => permissions.default_role().to_string(), - Some(ADMIN_DATATABLE_ROLE) => ADMIN_DATATABLE_ROLE.to_string(), - Some(role) => crate::datatable_roles::role_id_by_name(catalog, role)?.to_string(), - }; - let tenants = permissions.roles.get(&role_id).ok_or_else(|| { - let display = role.map(str::to_string).unwrap_or_else(|| { - catalog - .get(&role_id) - .map(|r| r.name.clone()) - .unwrap_or_else(|| role_id.clone()) - }); - Error::NotFound(format!( - "Role '{display}' is not among the roles of data table '{name}'" - )) - })?; - Ok(Some((role_id, tenants))) + .await } /// Resolve a data table to connection credentials for one identity. @@ -1755,83 +1657,22 @@ pub async fn get_datatable_resource_from_db( access: DatatableAccess<'_>, ) -> Result { let governing = resolve_governing_datatable(db, w_id, name).await?; - let mut db_resource = resolve_datatable_connection_unchecked(db, &governing, false).await?; - - // A data table role is a login on Windmill's own cluster, so it is only meaningful against an - // instance database. Substituting its password into a resource-backed connection would hand a - // real cluster credential to whatever host that resource names — which a workspace admin - // chooses. Refused rather than ignored: an entry that reached this state was never a shape the - // permissions endpoint accepts, so silently resolving it as admin would hide a broken record. - if !governing.is_instance() { - return match (governing.datatable.permissions.as_ref(), role) { - (None, None | Some(ADMIN_DATATABLE_ROLE)) => Ok(db_resource), - _ => Err(Error::BadRequest(format!( - "Data table '{name}' is backed by a Postgres resource, which cannot be put under \ - data table roles" - ))), - }; - } - - let catalog = crate::datatable_roles::read_role_catalog(db).await?; - let Some((role_id, tenants)) = datatable_role_entry( - governing.datatable.permissions.as_ref(), - &catalog, - name, - role, - )? - else { + let db_resource = resolve_datatable_connection_unchecked(db, &governing, false).await?; + // Not under roles and asked for none: the `admin` connection, as before roles existed, in + // every edition. Anything else is a role decision. + if governing.datatable.permissions.is_none() && role.is_none() { return Ok(db_resource); - }; - - // Only for a data table actually under roles: whether people name a role or ride the default - // is what says if the `-- role` annotation is carrying its weight. - crate::feature_usage::log_feature_usage( - "datatable", - "role_connection", - if role.is_some() { "named" } else { "default" }, - ); - - if !can_use_datatable_role_in_governing_workspace( + } + crate::datatable_roles_oss::resolve_datatable_role_connection( db, - &governing.workspace_id, w_id, - tenants, - &access, + name, + &governing, + db_resource, + role, + access, ) - .await? - { - let display = catalog - .get(&role_id) - .map(|r| r.name.as_str()) - .unwrap_or(role_id.as_str()); - return Err(Error::NotAuthorized(format!( - "Not allowed to use role '{display}' of data table '{name}'" - ))); - } - - if role_id == ADMIN_DATATABLE_ROLE { - return Ok(db_resource); - } - let entry = catalog.get(&role_id).ok_or_else(|| { - Error::NotFound(format!( - "Data table '{name}' names a role that no longer exists on this instance" - )) - })?; - if !entry.enabled { - return Err(Error::BadRequest(format!( - "Data table role '{}' is disabled on this instance", - entry.name - ))); - } - let pwd = entry.pwd.as_ref().ok_or_else(|| { - Error::internal_err(format!( - "Data table role '{}' has no stored credential; recreate it in instance settings", - entry.name - )) - })?; - db_resource["user"] = serde_json::Value::String(entry.name.clone()); - db_resource["password"] = serde_json::Value::String(pwd.clone()); - Ok(db_resource) + .await } /// Would the chokepoint accept this identity connecting as this role? Answers without resolving @@ -1848,38 +1689,8 @@ pub async fn ensure_can_use_datatable_role( access: &DatatableAccess<'_>, context: &str, ) -> Result<()> { - let governing = resolve_governing_datatable(db, w_id, name).await?; - if !governing.is_instance() { - return Ok(()); - } - let catalog = crate::datatable_roles::read_role_catalog(db).await?; - let Some((role_id, tenants)) = datatable_role_entry( - governing.datatable.permissions.as_ref(), - &catalog, - name, - role, - )? - else { - return Ok(()); - }; - if can_use_datatable_role_in_governing_workspace( - db, - &governing.workspace_id, - w_id, - tenants, - access, - ) - .await? - { - return Ok(()); - } - let display = catalog - .get(&role_id) - .map(|r| r.name.as_str()) - .unwrap_or(role_id.as_str()); - Err(Error::NotAuthorized(format!( - "{context} runs as role '{display}' of data table '{name}', which you are not allowed to use" - ))) + crate::datatable_roles_oss::ensure_can_use_datatable_role(db, w_id, name, role, access, context) + .await } /// Gate the operations that see the whole database whatever the roles grant: a migration that @@ -1892,35 +1703,7 @@ pub async fn ensure_datatable_admin_access( name: &str, access: &DatatableAccess<'_>, ) -> Result<()> { - let governing = resolve_governing_datatable(db, w_id, name).await?; - if !governing.is_instance() { - return Ok(()); - } - let Some(permissions) = governing.datatable.permissions.as_ref() else { - return Ok(()); - }; - let admin = permissions - .roles - .get(ADMIN_DATATABLE_ROLE) - .cloned() - .unwrap_or_default(); - if can_use_datatable_role_in_governing_workspace( - db, - &governing.workspace_id, - w_id, - &admin, - access, - ) - .await? - { - Ok(()) - } else { - Err(Error::NotAuthorized(format!( - "Data table '{name}' is under roles; this reaches the whole database, so it is for \ - the admins of workspace '{}', which governs it.", - governing.workspace_id - ))) - } + crate::datatable_roles_oss::ensure_datatable_admin_access(db, w_id, name, access).await } /// Rewrite the `permissions` of every data table entry of one workspace, in the caller's @@ -2056,25 +1839,7 @@ pub async fn forget_datatable_role_everywhere( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, role_id: &str, ) -> Result<()> { - let workspaces = sqlx::query_scalar!( - "SELECT workspace_id FROM workspace_settings WHERE datatable::text LIKE $1", - format!("%{}%", role_id) - ) - .fetch_all(&mut **tx) - .await?; - - for w_id in workspaces { - update_datatable_permissions_in_workspace(tx, &w_id, |permissions| { - let mut touched = permissions.roles.remove(role_id).is_some(); - if permissions.default_role.as_deref() == Some(role_id) { - permissions.default_role = Some(ADMIN_DATATABLE_ROLE.to_string()); - touched = true; - } - touched - }) - .await?; - } - Ok(()) + crate::datatable_roles_oss::forget_datatable_role_everywhere(tx, role_id).await } /// Drop the `permissions` block from a `workspace_settings.datatable` value before it leaves the