From 7e5ac78447969dbc9012e6dbe689f4788aaf97aa Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Tue, 25 Aug 2026 21:00:26 +0200 Subject: [PATCH] feat(datatables): badge each data table row with the role it is reached through Serve the caller's usable roles and the default one alongside the tree, so the badge costs no extra round trip per data table. Shown only where naming the role says something: a data table without permissions, or one whose only usable role is the implicit admin, gets none. The left-pane filter also matches schema names now. --- .../windmill-api-workspaces/src/workspaces.rs | 67 +++++++++++++++++-- backend/windmill-api/openapi.yaml | 10 ++- frontend/src/lib/components/DBManager.svelte | 35 ++++++++-- .../lib/components/DBManagerContent.svelte | 1 + frontend/src/lib/components/dbTypes.ts | 4 ++ .../DataTablePermissionsButton.svelte | 17 +++-- 6 files changed, 114 insertions(+), 20 deletions(-) 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 @@
- +