From eae44099bcf6d4fa7f748c7b03f1a2315cfbc6d3 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Thu, 23 Jul 2026 18:57:07 +0200 Subject: [PATCH] feat(datatables): permissions drawer in workspace settings and DB manager Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013fBWmrMs2DbpU1iYdCvGX6 --- .../src/lib/components/DBManagerDrawer.svelte | 2 + .../DataTablePermissionsButton.svelte | 353 ++++++++++++++++++ .../DataTableSettings.svelte | 6 + 3 files changed, 361 insertions(+) create mode 100644 frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte diff --git a/frontend/src/lib/components/DBManagerDrawer.svelte b/frontend/src/lib/components/DBManagerDrawer.svelte index 712c4fbccf..892ec635fe 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' @@ -211,6 +212,7 @@ {/if} {#snippet actions()} {#if uriState.isDatatableInput && uriState.selectedDatatable && ws} + + import { Plus, Shield, Trash } from 'lucide-svelte' + import { Alert, Button, Drawer } from '../common' + import DrawerContent from '../common/drawer/DrawerContent.svelte' + import Toggle from '../Toggle.svelte' + import Select from '../select/Select.svelte' + import MultiSelect from '../select/MultiSelect.svelte' + import { safeSelectItems } from '../select/utils.svelte' + import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte' + import Tooltip from '../Tooltip.svelte' + import { + FolderService, + GroupService, + UserService, + WorkspaceService, + type DataTableGrant, + type DataTablePermissions + } from '$lib/gen' + import { enterpriseLicense, superadmin, userStore } from '$lib/stores' + import { sendUserToast } from '$lib/toast' + import { deepEqual } from 'fast-equals' + + let { + workspace, + datatable, + disabled = false + }: { + workspace: string + datatable: string + disabled?: boolean + } = $props() + + const OPERATIONS = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE'] as const + + // EE + admin only: the config is admin-gated server-side and meaningless on CE. + const visible = $derived(!!$enterpriseLicense && (!!$userStore?.is_admin || !!$superadmin)) + + let drawer: Drawer | undefined = $state() + let loading = $state(false) + let saving = $state(false) + let perms: DataTablePermissions | undefined = $state(undefined) + let savedPerms: DataTablePermissions | undefined = $state(undefined) + let usernames: string[] = $state([]) + let groupNames: string[] = $state([]) + let folderNames: string[] = $state([]) + let schemaTables: Record = $state({}) + let isExternalDb = $state(false) + let preflightError: string | undefined = $state(undefined) + + const dirty = $derived(!deepEqual(perms, savedPerms)) + + function normalize(p: DataTablePermissions): DataTablePermissions { + return { + enabled: p.enabled ?? false, + grants: (p.grants ?? []).map((g) => ({ + tenant: g.tenant, + folder_access: g.folder_access, + operations: g.operations ?? [], + schema: g.schema, + tables: g.tables ?? [] + })) + } + } + + async function openDrawer() { + drawer?.openDrawer() + loading = true + preflightError = undefined + try { + const [p, users, groups, folders, tables, datatables] = await Promise.all([ + WorkspaceService.getDataTablePermissions({ workspace, datatableName: datatable }), + UserService.listUsernames({ workspace }), + GroupService.listGroupNames({ workspace }), + FolderService.listFolderNames({ workspace }), + WorkspaceService.listDataTableTables({ workspace }), + WorkspaceService.listDataTables({ workspace }) + ]) + perms = normalize(p) + savedPerms = normalize(p) + usernames = users + groupNames = groups + folderNames = folders + schemaTables = tables.find((t) => t.datatable_name === datatable)?.schemas ?? {} + isExternalDb = datatables.find((d) => d.name === datatable)?.resource_type !== 'instance' + } catch (e) { + sendUserToast(e, true) + } finally { + loading = false + } + } + + async function onToggleEnabled(enabled: boolean) { + preflightError = undefined + if (!enabled || !perms) return + try { + await WorkspaceService.checkDataTablePermissionsSetup({ + workspace, + datatableName: datatable + }) + } catch (e: any) { + preflightError = e?.body ?? e?.message ?? String(e) + } + } + + function tenantKind(tenant: string): 'user' | 'group' | 'folder' { + if (tenant.startsWith('g/')) return 'group' + if (tenant.startsWith('f/')) return 'folder' + return 'user' + } + + function tenantName(tenant: string): string { + const idx = tenant.indexOf('/') + return idx >= 0 ? tenant.slice(idx + 1) : tenant + } + + function setTenantKind(grant: DataTableGrant, kind: 'user' | 'group' | 'folder') { + grant.tenant = { user: 'u/', group: 'g/', folder: 'f/' }[kind] + grant.folder_access = kind === 'folder' ? 'read' : undefined + } + + function tenantItems(kind: 'user' | 'group' | 'folder') { + const names = kind === 'user' ? usernames : kind === 'group' ? groupNames : folderNames + return safeSelectItems(names) + } + + function addGrant() { + if (!perms) return + perms.grants = [ + ...(perms.grants ?? []), + { + tenant: 'u/', + operations: ['SELECT'], + schema: Object.keys(schemaTables)[0] ?? 'public', + tables: [] + } + ] + } + + function removeGrant(index: number) { + if (!perms) return + perms.grants = (perms.grants ?? []).filter((_, i) => i !== index) + } + + async function onSave() { + if (!perms) return + for (const grant of perms.grants ?? []) { + if (!tenantName(grant.tenant)) { + sendUserToast('Every grant must have a user, group or folder selected', true) + return + } + if ((grant.operations ?? []).length === 0) { + sendUserToast('Every grant must have at least one operation', true) + return + } + if (!grant.schema) { + sendUserToast('Every grant must target a schema', true) + return + } + } + saving = true + try { + await WorkspaceService.setDataTablePermissions({ + workspace, + datatableName: datatable, + requestBody: perms + }) + savedPerms = normalize($state.snapshot(perms)) + sendUserToast('Data table permissions saved') + } catch (e) { + sendUserToast(e, true) + } finally { + saving = false + } + } + + +{#if visible} + + + + + {#snippet actions()} + + {/snippet} + {#if loading || !perms} +
Loading...
+ {:else} +
+
+ perms?.enabled ?? false, + (v) => { + if (perms) { + perms.enabled = v + onToggleEnabled(v) + } + } + } + options={{ right: 'Enable fine-grained permissions' }} + /> +

+ When enabled, users without a matching grant lose all access to this data table + (default deny), including from scripts and flows they run. Workspace admins and + superadmins are unaffected and keep full access. Access is enforced by PostgreSQL + itself through short-lived per-user roles. +

+ {#if preflightError} + + {preflightError} + + {/if} +
+ +
+
+ + Grants ({perms.grants?.length ?? 0}) + + Each grant gives a user, a group, or the users of a folder a set of SQL operations + on a schema or on specific tables. A user's effective access is the union of all + matching grants. + + + +
+ {#if (perms.grants ?? []).length === 0} +
+ No grants: only admins can access this data table while permissions are enabled. +
+ {/if} + {#each perms.grants ?? [] as grant, i (i)} +
+
+ setTenantKind(grant, e.detail)} + > + {#snippet children({ item })} + + + + {/snippet} + + {#key tenantKind(grant.tenant)} + + grant.tables ?? [], (v) => (grant.tables = v)} + placeholder="All tables (incl. future ones)" + class="grow min-w-56" + size="sm" + /> +
+
+ {/each} +
+ + {#if isExternalDb} + + Enabling permissions makes Windmill create and drop PostgreSQL roles on the fly + on your external database server (its user needs the + CREATEROLE privilege). These short-lived roles can also connect to other databases on that + server that still grant CONNECT to PUBLIC (the PostgreSQL default) — revoke it there if + that matters to you. + + {/if} + + +
    +
  • + Grants are enforced by PostgreSQL through short-lived per-user roles; schema usage + and sequence usage (for INSERT/UPDATE) are granted automatically. +
  • +
  • + A grant with no table selected covers the whole schema including tables created + later; table-specific grants only cover the listed tables. +
  • +
  • + DDL (CREATE/ALTER/DROP) is never grantable: schema changes go through migrations, + which become admin-only when permissions are enabled. +
  • +
  • + Changes to grants, groups or folders take effect on the next access; a connection + already open keeps its privileges for up to 5 minutes. +
  • +
  • + Creating or editing Postgres triggers on this data table, and running its + migrations, require workspace admin while permissions are enabled. +
  • +
+
+
+ {/if} +
+
+{/if} diff --git a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte index f652ed8704..1e56df5f80 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' @@ -300,6 +301,11 @@
+