diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 232e6f7497..81b98b6a7b 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -44,12 +44,12 @@ 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, - get_datatable_resource_from_db_unchecked, redact_datatable_settings_for_export, - validate_dev_workspace_id, validate_fork_workspace_id, validate_workspace_name, DataTable, - DataTableCatalogResourceType, DataTableForkBehavior, DatatableAccess, ProtectionRuleKind, - ProtectionRules, ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, - DEV_WORKSPACE_LOCK_RULE_NAME, + can_use_datatable_role, check_deploy_rules, check_user_against_rule, + get_datatable_resource_from_db, get_datatable_resource_from_db_unchecked, + redact_datatable_settings_for_export, validate_dev_workspace_id, validate_fork_workspace_id, + validate_workspace_name, DataTable, DataTableCatalogResourceType, DataTableForkBehavior, + DatatableAccess, ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult, + WorkspaceGitSyncSettings, ADMIN_DATATABLE_ROLE, DEV_WORKSPACE_LOCK_RULE_NAME, }; use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; use windmill_common::PgDatabase; @@ -2105,6 +2105,12 @@ struct DataTableTables { schemas: TableListMap, #[serde(skip_serializing_if = "Option::is_none")] error: Option, + /// The roles the caller may run this data table as. Empty when it has no + /// permissions, which is also what tells the UI it has no role to show. + #[serde(default)] + usable_roles: Vec, + /// The role the caller gets without naming one. + default_role: String, } #[derive(Deserialize)] @@ -2272,15 +2278,23 @@ async fn list_datatable_tables( Path(w_id): Path, ) -> JsonResult> { let datatable_names = list_datatable_names(&db, &w_id).await?; + let mut roles = list_datatable_roles(&db, &authed, &w_id).await?; let mut results = Vec::new(); for datatable_name in datatable_names { + let (usable_roles, default_role) = roles + .remove(&datatable_name) + .unwrap_or_else(|| (vec![], ADMIN_DATATABLE_ROLE.to_string())); let tables = match get_datatable_tables(&db, &authed, &w_id, &datatable_name).await { - Ok(schemas) => DataTableTables { datatable_name, schemas, error: None }, + Ok(schemas) => { + DataTableTables { datatable_name, schemas, error: None, usable_roles, default_role } + } Err(e) => DataTableTables { datatable_name, schemas: HashMap::new(), error: Some(e.to_string()), + usable_roles, + default_role, }, }; results.push(tables); @@ -2289,6 +2303,45 @@ async fn list_datatable_tables( Ok(Json(results)) } +/// Which roles the caller may use on each data table of the workspace, and the +/// one they get by default. Read in one go: the tree lists every data table, and +/// this is config only, so it costs a single query rather than one per table. +async fn list_datatable_roles( + db: &DB, + authed: &ApiAuthed, + w_id: &str, +) -> Result, String)>> { + let Some(datatables) = sqlx::query_scalar!( + "SELECT ws.datatable->'datatables' FROM workspace_settings ws WHERE ws.workspace_id = $1", + w_id + ) + .fetch_optional(db) + .await? + .flatten() + .and_then(|v| serde_json::from_value::>(v).ok()) else { + return Ok(HashMap::new()); + }; + + let authed_ref = authed.to_authed_ref(); + Ok(datatables + .into_iter() + .map(|(name, dt)| { + let info = match dt.permissions.filter(|p| p.enabled) { + Some(p) => ( + p.roles + .iter() + .filter(|(_, role)| can_use_datatable_role(role, &authed_ref)) + .map(|(role_name, _)| role_name.clone()) + .collect(), + p.default_role().to_string(), + ), + None => (vec![], ADMIN_DATATABLE_ROLE.to_string()), + }; + (name, info) + }) + .collect()) +} + async fn get_datatable_table_schema( authed: ApiAuthed, Extension(db): Extension, diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index bf15bd6d21..abdfa9718a 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -32898,7 +32898,7 @@ components: DataTableTables: type: object - required: [datatable_name, schemas] + required: [datatable_name, schemas, default_role] properties: datatable_name: type: string @@ -32911,6 +32911,14 @@ components: type: string error: type: string + usable_roles: + type: array + description: roles the caller may run this data table as, empty when it has no permissions + items: + type: string + default_role: + type: string + description: the role the caller gets without naming one DataTableTableSchema: type: object diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index ed5d8c627f..45147d7ff7 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -34,8 +34,9 @@ import { capitalize, onlyAlphaNumAndUnderscore, pluralize } from '$lib/utils' import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures' import Star from './Star.svelte' + import Badge from './common/badge/Badge.svelte' import type { Asset, DataTableTables } from '$lib/gen' - import type { DatatableRowAction } from './dbTypes' + import { ADMIN_DATATABLE_ROLE, type DatatableRowAction } from './dbTypes' import TextInput from './text_input/TextInput.svelte' import Checkbox from './common/checkbox/Checkbox.svelte' @@ -70,6 +71,8 @@ datatableTree?: DataTableTables[] datatableTreeLoading?: boolean onSelectDatatable?: (datatable: string) => void + /** Role the manager is connected as, when it is not the default one. */ + currentRole?: string pendingCreate?: PendingCreate | undefined /** Row-menu actions on a data table, run against that row's data table. */ onDatatableAction?: (datatable: string, action: DatatableRowAction) => void @@ -100,6 +103,7 @@ datatableTree, datatableTreeLoading, onSelectDatatable, + currentRole, pendingCreate = $bindable(undefined), onDatatableAction, canManageDatatable = false, @@ -201,6 +205,18 @@ return datatableTree?.find((d) => d.datatable_name === datatable)?.error } + /** The role a data table row is reached through, shown as a badge. Left out + * where naming it says nothing: a data table without permissions, or one whose + * single usable role is already the implicit `admin`. */ + function roleOf(datatable: string): string | undefined { + const entry = datatableTree?.find((d) => d.datatable_name === datatable) + const usable = entry?.usable_roles ?? [] + if (usable.length === 0 || (usable.length === 1 && usable[0] === ADMIN_DATATABLE_ROLE)) { + return undefined + } + return (datatable === currentDatatable ? currentRole : undefined) ?? entry?.default_role + } + const matchesSearch = (t: string) => t.toLowerCase().includes(search.trim().toLowerCase()) /** The tree as rendered: only nodes with a matching descendant survive a search. */ @@ -213,9 +229,14 @@ const schemas = Object.entries(schemasOf(dt)) .map(([schemaKey, tables]) => ({ schemaKey, - tables: tables.filter(matchesSearch).sort() + // A schema that matches keeps all of its tables — the search named + // the schema, so what is in it is the answer. Copy before sorting: + // `tables` belongs to the tree snapshot, which is reactive state. + tables: (matchesSearch(schemaKey) ? [...tables] : tables.filter(matchesSearch)).sort() })) - .filter((sc) => search.trim() === '' || sc.tables.length > 0) + .filter( + (sc) => search.trim() === '' || matchesSearch(sc.schemaKey) || sc.tables.length > 0 + ) schemas.sort((a, b) => a.schemaKey.localeCompare(b.schemaKey)) return { datatable: dt, schemas, error: dt ? errorOf(dt) : undefined } }) @@ -431,7 +452,7 @@
- +