Files
windmill/frontend/src/lib/components/workspaceSettings/DataTablePermissionsButton.svelte
T
a298ed3fc1 feat: add the viewer's roles to unnamed data table connections (#11709)
* feat: connect to a data table with every role of the job's viewer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: key a cached result by the run's recorded viewer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: pin the per-viewer cache key and fix a stale doc sentence

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: length-prefix the viewer in the result cache key

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: drop data table role set logins unused for 30 days

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin the ee ref with the set login sweep fixes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: pin the ee ref with the sweep retry fix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: give the cache key test its own migrated database

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: add the viewer's roles to a data table connection that names none

The default role of a data table under roles is now a floor: a connection
naming no role also holds the roles its viewer is a tenant of, unless the
default is admin. This replaces the explicit as=viewer option, which is
removed from the reference parser, the executors and both clients. The roles
drawer warns while the default is admin and confirms before removing the
default role.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: let a data table have no default role

A connection naming no role then holds its viewer's roles only and is
refused without any. The roles drawer offers the choice, states what an
unnamed connection gets for the current settings, says who gets admin while
it is the default, and notes who owns a table created over several roles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat: start a data table's roles with admin open to everyone

Turning roles on from the drawer seeded admin with no tenant, which refused
every non-admin member's job as soon as it was saved. It now starts open to
everyone, so the toggle changes nothing until the roles are edited, and the
drawer warns for as long as everyone can connect as admin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: update ee-repo-ref to 40ab35596e6fcb82a4dfc56e186befb807f8e2af

This commit updates the EE repository reference after PR #873 was merged in windmill-ee-private.

Previous ee-repo-ref: a5def184a29fffab8ed42c70c5a7445b2b3cbe98

New ee-repo-ref: 40ab35596e6fcb82a4dfc56e186befb807f8e2af

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-10-13 09:01:21 +00:00

610 lines
22 KiB
Svelte

<script lang="ts">
import { Alert, Badge, Button, Drawer, DrawerContent } from '../common'
import Select from '../select/Select.svelte'
import MultiSelect from '../select/MultiSelect.svelte'
import Toggle from '../Toggle.svelte'
import Tooltip from '../Tooltip.svelte'
import CloseButton from '../common/CloseButton.svelte'
import Checkbox from '../common/checkbox/Checkbox.svelte'
import Cell from '../table/Cell.svelte'
import DataTable from '../table/DataTable.svelte'
import Head from '../table/Head.svelte'
import Row from '../table/Row.svelte'
import { CircleHelp, KeyRound } from 'lucide-svelte'
import {
FolderService,
GroupService,
SettingService,
UserService,
WorkspaceService,
type DatatablePermissions,
type InstanceDatatableRole
} from '$lib/gen'
import { superadmin } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { deepEqual } from 'fast-equals'
import { ADMIN_DATATABLE_ROLE, NO_DEFAULT_DATATABLE_ROLE, isDatatableRoleName } from '../dbTypes'
import PgAclEditor from '../datatableAcl/PgAclEditor.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import {
pgInstanceName as pgInstanceNameOf,
handleRoleCreationError,
offerUnusedRoleDrop
} from './datatableRoleModals'
let {
workspace,
datatable,
disabled = false,
hideTrigger = false,
onSaved
}: {
workspace: string
datatable: string
disabled?: boolean
/** Mount the drawer without its button, for a caller that opens it with `open()`. */
hideTrigger?: boolean
/** Called once a save went through, so a caller showing the roles can read them again. */
onSaved?: () => void
} = $props()
// `id` is the instance role's catalog id (or the reserved `admin`), which is what the tenant
// lists are keyed by — so renaming a role instance-side moves nothing here.
type EditedRole = { id: string; name: string | undefined; tenants: string[] }
type Edited = { permissioned: boolean; roles: EditedRole[]; defaultRoleId: string }
let drawerOpen = $state(false)
let loading = $state(false)
let saving = $state(false)
let loadError = $state<string | undefined>(undefined)
let info = $state<DatatablePermissions | undefined>(undefined)
let permissioned = $state(false)
let roles = $state<EditedRole[]>([])
let defaultRoleId = $state(ADMIN_DATATABLE_ROLE)
/** The last loaded state, to detect unsaved changes against. */
let saved = $state<Edited>({
permissioned: false,
roles: [],
defaultRoleId: ADMIN_DATATABLE_ROLE
})
// Tenants name principals of the workspace that governs the data table, which is not
// necessarily the one we are browsing from.
let tenantItems = $state<{ value: string; label: string; group: string }[]>([])
const editable = $derived(!!info?.editable)
const governing = $derived(info?.governing_workspace_id)
// The instance catalog, with the roles created from this drawer since it was loaded.
let catalog = $state<InstanceDatatableRole[] | undefined>(undefined)
const availableRoles: InstanceDatatableRole[] = $derived(catalog ?? info?.available_roles ?? [])
// A role is a login on one cluster, and this drawer's is the data table's: name it, or the
// copy would send someone to the wrong catalog.
const clusterName = $derived(
info?.cluster === 'external_instance' ? 'the external cluster' : "Windmill's database"
)
const pgInstanceName = $derived(pgInstanceNameOf(info?.cluster))
const unusedRoles = $derived(availableRoles.filter((r) => !roles.some((row) => row.id === r.id)))
const confirmationModal = createAsyncConfirmationModal()
// Every member of the governing workspace, as the tenant lists spell it.
const EVERYONE_TENANT = '*'
let aclEditor = $state<PgAclEditor | undefined>(undefined)
const hasUnsavedChanges = $derived(
!deepEqual($state.snapshot(saved), {
permissioned,
roles: $state.snapshot(roles) as EditedRole[],
defaultRoleId
})
)
async function load() {
loading = true
loadError = undefined
try {
const res = await WorkspaceService.getDatatablePermissions({
workspace,
datatableName: datatable
})
const loaded: EditedRole[] = res.roles
.map((r) => ({ id: r.id, name: r.name, tenants: [...(r.tenants ?? [])] }))
.sort(
(a, b) => Number(b.id === ADMIN_DATATABLE_ROLE) - Number(a.id === ADMIN_DATATABLE_ROLE)
)
// A data table never put under roles comes back with none; admin is what turning the toggle
// on starts from, open to everyone so that turning it on changes nothing by itself.
if (!loaded.some((r) => r.id === ADMIN_DATATABLE_ROLE)) {
loaded.unshift({
id: ADMIN_DATATABLE_ROLE,
name: ADMIN_DATATABLE_ROLE,
tenants: [EVERYONE_TENANT]
})
}
info = res
catalog = undefined
permissioned = res.permissioned
roles = loaded
defaultRoleId = res.default_role
saved = structuredClone({ permissioned, roles: loaded, defaultRoleId })
await loadTenantItems(res.governing_workspace_id ?? workspace)
} catch (e) {
loadError = e?.body ?? e?.message ?? String(e)
} finally {
loading = false
}
}
async function loadTenantItems(ws: string) {
try {
const [users, groups, folders] = await Promise.all([
UserService.listUsernames({ workspace: ws }),
GroupService.listGroupNames({ workspace: ws }),
FolderService.listFolderNames({ workspace: ws })
])
tenantItems = [
{ value: EVERYONE_TENANT, label: 'Everyone', group: 'Anyone in the workspace' },
...users.map((u) => ({ value: `u/${u}`, label: u, group: 'Users' })),
...groups.map((g) => ({ value: `g/${g}`, label: g, group: 'Groups' })),
...folders.map((f) => ({ value: `f/${f}`, label: f, group: 'Folders' }))
]
} catch {
// A fork member may not be able to list the governing workspace's principals. The
// tenants they cannot name are still shown, they just cannot pick new ones.
tenantItems = []
}
}
function addRole(id: string) {
const role = availableRoles.find((r) => r.id === id)
if (!role) return
roles.push({ id: role.id, name: role.name, tenants: [] })
}
/** Adds the instance's role of that name. One the instance does not define is created first,
* which only a superadmin may do. */
async function addRoleByName(typed: string) {
const name = typed.trim()
if (!isDatatableRoleName(name) || name.toLowerCase() === ADMIN_DATATABLE_ROLE) {
sendUserToast(
`'${name}' cannot be a data table role name: use letters, digits, '_' and '-'`,
true
)
return
}
if (roles.some((r) => r.name === name)) return
const existing = availableRoles.find((r) => r.name === name)
if (existing) {
roles.push({ id: existing.id, name, tenants: [] })
return
}
if (!$superadmin) {
await confirmationModal.ask({
title: `Role ${name} does not exist`,
children: `This role doesn't exist in ${pgInstanceName}. Ask a superadmin to create it.`,
confirmationText: 'OK',
type: 'info',
hideCancel: true
})
return
}
let created: InstanceDatatableRole | undefined
let failure: unknown
await confirmationModal.ask({
title: `Role ${name} does not exist`,
children: `(Superadmin) Create this role in ${pgInstanceName}?`,
confirmationText: 'Create role',
type: 'info',
onConfirmed: async () => {
try {
created = await SettingService.createInstanceDatatableRole({
requestBody: { name, cluster: info?.cluster }
})
} catch (e) {
failure = e
}
}
})
if (created) addCreatedRole(created)
// Handled once this modal has closed: the take-over offer opens in the same one.
if (failure) {
await handleRoleCreationError(failure, {
name,
cluster: info?.cluster,
confirmationModal,
onTakenOver: addCreatedRole
})
}
}
function addCreatedRole(created: InstanceDatatableRole) {
catalog = [...availableRoles, created]
if (!roles.some((r) => r.id === created.id)) {
roles.push({ id: created.id, name: created.name, tenants: [] })
}
}
async function removeRole(role: EditedRole) {
if (defaultRoleId === role.id) {
const confirmed = await confirmationModal.ask({
title: 'Remove the default role?',
children: `Jobs that name no role connect as ${role.name ?? role.id}. Without it the data table has no default role: they connect with the roles of the person running them, and are refused when that person has none.`,
confirmationText: 'Remove',
type: 'danger'
})
if (!confirmed) return
defaultRoleId = NO_DEFAULT_DATATABLE_ROLE
}
roles = roles.filter((r) => r.id !== role.id)
}
const defaultRoleName = $derived(roles.find((r) => r.id === defaultRoleId)?.name ?? defaultRoleId)
const defaultsToAdmin = $derived(defaultRoleId === ADMIN_DATATABLE_ROLE)
const hasNoDefault = $derived(defaultRoleId === NO_DEFAULT_DATATABLE_ROLE)
const hasOtherRoles = $derived(roles.some((r) => r.id !== ADMIN_DATATABLE_ROLE))
const adminOpenToEveryone = $derived(
!!roles.find((r) => r.id === ADMIN_DATATABLE_ROLE)?.tenants.includes(EVERYONE_TENANT)
)
async function save() {
saving = true
// Turning roles off clears the whole block, so every role it named is removed.
const kept = permissioned ? roles.map((r) => r.id) : []
const removedIds = saved.permissioned
? saved.roles
.map((r) => r.id)
.filter((id) => id !== ADMIN_DATATABLE_ROLE && !kept.includes(id))
: []
try {
const msg = await WorkspaceService.setDatatablePermissions({
workspace,
datatableName: datatable,
requestBody: {
permissioned,
default_role: defaultRoleId,
roles: roles.map((r) => ({ id: r.id, tenants: $state.snapshot(r.tenants) }))
}
})
sendUserToast(msg)
await load()
// A role is a login of the whole Postgres instance: whether this was the last data table
// naming it is only known once the save has landed.
const unused = availableRoles.filter((r) => removedIds.includes(r.id) && r.in_use === false)
let dropped = false
for (const role of unused) {
dropped =
(await offerUnusedRoleDrop({
role,
cluster: info?.cluster,
superadmin: !!$superadmin,
confirmationModal
})) || dropped
}
if (dropped) await load()
// The grants list the roles they can name, which the save just changed.
aclEditor?.refresh()
onSaved?.()
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
} finally {
saving = false
}
}
/** Whether the drawer may close: unsaved roles are only lost once the user says so. */
async function confirmDiscard(): Promise<boolean> {
if (!hasUnsavedChanges || !drawerOpen) return true
return await confirmationModal.ask({
title: 'Discard unsaved changes?',
children: `The roles of ${datatable} have changes that are not saved. Closing the drawer discards them.`,
confirmationText: 'Discard changes',
type: 'danger'
})
}
export function open() {
drawerOpen = true
load()
}
</script>
{#if !hideTrigger}
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: KeyRound }}
iconOnly
{disabled}
title={disabled ? 'Save settings first' : 'Roles: who may connect as which Postgres role'}
on:click={open}
/>
{/if}
<Drawer bind:open={drawerOpen} size="900px" confirmClose={confirmDiscard}>
<DrawerContent
title="Roles: {datatable}"
on:close={async () => {
if (await confirmDiscard()) drawerOpen = false
}}
tooltip="A data table role is a Postgres login. A job that names one connects as it, and Postgres decides what it may touch — grant it privileges under Access. Roles are defined for the whole instance; here you say who may use each one on this data table."
>
{#snippet titleExtra()}
<Badge color="blue" small>Beta</Badge>
{/snippet}
{#if loadError}
<Alert type="error" title="Could not load roles" size="xs">{loadError}</Alert>
{:else if loading && !info}
<span class="text-sm text-secondary">Loading…</span>
{:else}
<div class="flex flex-col gap-4">
<Toggle
bind:checked={permissioned}
disabled={!editable || !info?.supported}
options={{
right: 'Put this data table under roles',
rightTooltip:
'Off, every job connects as admin — the connection that owns every table. On, every job resolves to a role, and a caller no tenant covers is refused. It starts with admin open to everyone, so turning it on changes nothing until the roles are edited.'
}}
/>
{#if !info?.supported}
<Alert type="info" title="Not available on this data table" size="xs">
A data table role is a Postgres login on the Windmill instance's own database, so only a
data table backed by that database can use one. This one is backed by a PostgreSQL
resource — grant access on that server directly.
</Alert>
{:else if info?.clone_of}
<Alert type="info" title="Governed by {info.clone_of.workspace_id}" size="xs">
This data table is a clone of <span class="font-mono">{info.clone_of.datatable}</span>
in workspace <span class="font-mono">{info.clone_of.workspace_id}</span>, so it takes
its roles from there. You are evaluated as a member of that workspace.
</Alert>
{:else if governing}
<Alert type="info" title="Governed by {governing}" size="xs">
This data table points at the one in workspace <span class="font-mono">{governing}</span
>, so its roles are decided there. You are evaluated as a member of that workspace.
</Alert>
{:else if !editable}
<Alert type="info" title="Read only" size="xs">
Only admins of this workspace can change who may use which role.
</Alert>
{/if}
{#if info?.ungoverned_reachers?.length}
<Alert type="warning" title="Other workspaces reach this database" size="xs">
These data tables point at the same database with their own entry, so what you set here
does not reach them:
<ul class="mt-1 list-disc list-inside font-mono">
{#each info.ungoverned_reachers as reacher (`${reacher.workspace_id}/${reacher.datatable}`)}
<li>{reacher.workspace_id} / {reacher.datatable}</li>
{/each}
</ul>
</Alert>
{/if}
{#if permissioned}
{#if editable && !$superadmin}
<Alert type="info" title="Only superadmins can create roles" size="xs">
A data table role is a Postgres login on {pgInstanceName}, so only a superadmin can
create one. Ask a superadmin for any role you need that is not listed.
{#if availableRoles.length === 0}
Until then, only <span class="font-mono">admin</span> can be used.
{/if}
</Alert>
{/if}
<DataTable>
<Head>
<tr>
<Cell head first>
Role
<Tooltip>
admin is the connection the data table used before roles, so it owns every
existing object and cannot be removed. Every other role is a login defined for
the whole instance, with only the privileges granted to it under Access.
</Tooltip>
</Cell>
<Cell head>
Tenants
<Tooltip>
Users, groups and folders allowed to connect as this role. Workspace admins can
use every role.
</Tooltip>
</Cell>
<Cell head>
Default
<Tooltip>
The role a job gets when it names none: no `-- role` annotation, no `?role=` in
the reference. There is always one choice: admin until changed, another role, or
none. Unless it is admin, the roles the person running the job is a tenant of
are added to it.
</Tooltip>
</Cell>
<Cell head last />
</tr>
</Head>
<tbody class="divide-y bg-surface-tertiary">
{#each roles as role (role.id)}
{@const isAdmin = role.id === ADMIN_DATATABLE_ROLE}
<Row>
<Cell first class="w-56 align-top">
<div class="flex flex-col gap-0.5 pt-1.5">
<span class="font-mono text-xs text-emphasis">{role.name ?? role.id}</span>
{#if !role.name}
<span class="text-2xs text-secondary italic">
no longer defined on {clusterName}
</span>
{/if}
</div>
</Cell>
<Cell class="align-top">
<MultiSelect
items={tenantItems}
bind:value={role.tenants}
groupBy={(item) => item.group}
disabled={!editable}
placeholder="Nobody — add users, groups or folders"
/>
</Cell>
<Cell class="w-20 align-top">
<div class="flex justify-center pt-2">
<Checkbox
checked={defaultRoleId === role.id}
disabled={!editable}
title="Use this role when a job names none"
onChange={() => {
defaultRoleId = role.id
}}
/>
</div>
</Cell>
<Cell last class="w-10 align-top">
{#if editable && !isAdmin}
<CloseButton small on:close={() => removeRole(role)} />
{/if}
</Cell>
</Row>
{/each}
<Row>
<Cell first class="w-56 align-top">
<span class="text-xs text-secondary italic">No default role</span>
</Cell>
<Cell class="align-top">
<span class="text-xs text-secondary">
Jobs that name no role get only the roles of the person running them.
</span>
</Cell>
<Cell class="w-20 align-top">
<div class="flex justify-center">
<Checkbox
checked={hasNoDefault}
disabled={!editable}
title="Use no role when a job names none"
onChange={() => {
defaultRoleId = NO_DEFAULT_DATATABLE_ROLE
}}
/>
</div>
</Cell>
<Cell last class="w-10 align-top" />
</Row>
{#if editable}
<Row>
<Cell colspan={4} class="pt-2 pb-2">
<div class="flex justify-center">
<Select
items={unusedRoles.map((r) => ({
value: r.id,
label: r.enabled ? r.name : `${r.name} (disabled)`
}))}
placeholder="+ Add a role"
bind:value={
() => undefined,
(id) => {
if (id) addRole(id)
}
}
onCreateItem={addRoleByName}
noItemsMsg={$superadmin ? 'Start typing to create a role' : undefined}
class="w-64"
/>
</div>
</Cell>
</Row>
{/if}
</tbody>
</DataTable>
{#if adminOpenToEveryone}
<Alert type="warning" title="Everyone can connect as admin" size="xs">
Every member of the workspace may use admin{defaultsToAdmin
? ', and a job that names no role connects as it, exactly as with roles off'
: ' by naming it'}. admin owns every table: it reads every row and ignores grants
and row-level security. Once the other roles are set up, remove Everyone from admin's
tenants{defaultsToAdmin ? ' and choose another default, or none' : ''}.
</Alert>
{/if}
{#if defaultsToAdmin && hasOtherRoles && !adminOpenToEveryone}
<Alert type="warning" title="The default role is admin" size="xs">
A job that names no role connects as admin when it runs as a workspace admin or as one
of admin's tenants, which covers every viewer of an app an admin published. admin owns
every table: it reads every row and ignores grants and row-level security. Everyone
else's jobs that name no role are refused. Choose another default, or none, to have
the roles above apply to them.
</Alert>
{:else if !defaultsToAdmin}
<div class="flex flex-col gap-1 text-xs text-secondary">
{#if hasNoDefault}
<span>
A job that names no role connects with the roles here that the person running it
is a tenant of. It is refused when they have none, and so is a job with nobody
behind it: a schedule, a trigger, an anonymous app viewer.
</span>
{:else}
<span>
A job that names no role connects as
<span class="font-mono">{defaultRoleName}</span> when it may use it, plus every other
role here that the person running it is a tenant of. With neither, it is refused.
</span>
{/if}
<span>A job that names a role connects as that role alone.</span>
<span>
A table created over a connection that holds several roles belongs to that
combination of roles alone. Run <span class="font-mono">SET ROLE</span> to one role before
creating it, or name a role.
</span>
</div>
{/if}
{/if}
</div>
<!-- Grants only matter to a data table under roles: without them every job connects as the
default login, whatever is granted here. -->
{#if info?.supported && permissioned}
<div class="mt-6 pt-6 border-t">
<PgAclEditor
bind:this={aclEditor}
{workspace}
{datatable}
target={{ kind: 'database' }}
disabledReason={hasUnsavedChanges ? 'Save the new roles to continue' : undefined}
/>
<div class="mt-3">
<Button
unifiedSize="xs"
variant="subtle"
startIcon={{ icon: CircleHelp }}
on:click={() =>
confirmationModal.ask({
title: 'Permissions on schemas and tables',
children: `<p>In the database manager, click the <b>⋮</b> menu of a schema or a table, then <b>Access</b>, to add grants on it or change its owner.</p>`,
confirmationText: 'OK',
type: 'info',
hideCancel: true
})}
>
How to use permissions on schemas and tables?
</Button>
</div>
</div>
{/if}
{/if}
{#snippet actions()}
{#if editable}
<Button
variant="accent"
unifiedSize="md"
disabled={!hasUnsavedChanges || loading || !!loadError}
loading={saving}
on:click={save}
>
Save
</Button>
{/if}
{/snippet}
</DrawerContent>
</Drawer>
<ConfirmationModal {...confirmationModal.props} />