feat: improve data table roles and grants UX (#11562)

* feat: improve data table roles and grants UX

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep selected bordered badges the same height as unselected ones

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: refuse taking over roles with members and test grant statements

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep grant filter chips while a filter is set

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: check a taken-over role can log in with the new password

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: select several grant filter tags at once

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: explain where schema and table permissions are set

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: keep the grant create row on one line

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: show grants as a role and its privileges

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: show grants in a table with role, privileges and scope

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: hide the grant scope when it can only be the object itself

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: grant schema usage along with grants inside the schema

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat: confirm before closing the roles drawer with unsaved changes

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to c4002f777f56d90bb0471164291889850c85179d

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

Previous ee-repo-ref: 7937634a52d04caeb083da94e189705cd00796b3

New ee-repo-ref: c4002f777f56d90bb0471164291889850c85179d

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Diego Imbert
2026-10-08 08:53:23 +02:00
committed by GitHub
co-authored by Claude Opus 5.5 windmill-internal-app[bot]
parent 8f7e0c7284
commit d7d18f00af
20 changed files with 864 additions and 283 deletions
+1 -1
View File
@@ -1 +1 @@
1600d49ad5513af8b54ad65688d3d1c7edc977d9
6f23819e947b18878000e580ddefccfd01a73166
@@ -417,6 +417,89 @@ async fn concurrent_role_creations_both_survive(db: Pool<Postgres>) -> anyhow::R
outcome
}
#[cfg(all(feature = "private", feature = "enterprise"))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn an_existing_role_is_taken_over_only_when_asked_and_never_with_members(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string();
let plain = format!("wmtest_plain_{suffix}");
let group = format!("wmtest_group_{suffix}");
let member = format!("wmtest_member_{suffix}");
for sql in [
// An expired password and no connection slot: kept as is, the login Windmill sets is dead.
format!(
"CREATE ROLE \"{plain}\" LOGIN PASSWORD 'theirs' VALID UNTIL '2000-01-01' \
CONNECTION LIMIT 0"
),
format!("CREATE ROLE \"{group}\" NOLOGIN"),
format!("CREATE ROLE \"{member}\" LOGIN"),
format!("GRANT \"{group}\" TO \"{member}\""),
] {
sqlx::query(&sql).execute(&db).await?;
}
let create = |name: String, take_over: bool| async move {
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/settings/datatable_roles"
)),
"SECRET_TOKEN",
)
.json(&json!({ "name": name, "take_over": take_over }))
.send()
.await?;
Ok::<_, anyhow::Error>((resp.status().as_u16(), resp.text().await?))
};
let outcome = async {
let (status, body) = create(plain.clone(), false).await?;
assert_eq!(
status, 409,
"an existing role is not taken without asking: {body}"
);
// The group's member would inherit every grant the data table role gets.
let (status, body) = create(group.clone(), true).await?;
assert_eq!(status, 400, "{body}");
assert!(
body.contains(&member),
"the refusal names the member: {body}"
);
let (status, body) = create(plain.clone(), true).await?;
assert_eq!(status, 200, "{body}");
let catalog = windmill_common::datatable_roles::read_role_catalog(
&db,
windmill_common::datatable_roles::DatatableRoleCluster::Instance,
)
.await?;
let taken = catalog
.values()
.find(|r| r.name == plain)
.ok_or_else(|| anyhow::anyhow!("{plain} is not in the catalog: {catalog:?}"))?;
let options = (*db.connect_options())
.clone()
.username(&plain)
.password(taken.pwd.as_deref().unwrap_or_default());
let mut login = <sqlx::PgConnection as sqlx::Connection>::connect_with(&options).await?;
sqlx::query("SELECT 1").execute(&mut login).await?;
Ok::<_, anyhow::Error>(())
}
.await;
// Roles are cluster-wide and outlive this test's database, whatever happened above.
for name in [&member, &group, &plain] {
let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\""))
.execute(&db)
.await;
}
outcome
}
#[cfg(all(feature = "private", feature = "enterprise"))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn a_role_delete_that_fails_part_way_leaves_the_role_disabled(
@@ -683,6 +683,9 @@ pub(crate) struct CatalogFacts {
pub(crate) former_owner: Option<FormerOwnerDefaults>,
/// For a revoke: each grant it takes back.
pub(crate) revoked_grants: Vec<RevokedGrant>,
/// For a grant on what a schema holds: the role cannot reach the schema yet, so the grant
/// would give it nothing it can use.
pub(crate) lacks_schema_usage: bool,
}
/// A grant a revoke takes back, as the catalog records it: what `source` gave on `object` (the
@@ -1526,7 +1529,24 @@ async fn build_plan(
)));
}
}
let facts = CatalogFacts { other_pg_roles, existing_objects, former_owner, revoked_grants };
let lacks_schema_usage = match (&change, target) {
(AclChange::Grant { scope, .. }, AclTarget::Schema { schema })
if !matches!(scope, GrantScope::Target) =>
{
!has_schema_usage(client, schema, &pg_role).await?
}
(AclChange::Grant { .. }, AclTarget::Table { schema, .. }) => {
!has_schema_usage(client, schema, &pg_role).await?
}
_ => false,
};
let facts = CatalogFacts {
other_pg_roles,
existing_objects,
former_owner,
revoked_grants,
lacks_schema_usage,
};
let mut plan =
crate::datatable_acl_oss::plan_statements(target, &change, dbname, &pg_role, &facts)?;
if matches!(change, AclChange::SetOwner { .. }) {
@@ -1540,6 +1560,27 @@ async fn build_plan(
Ok(plan)
}
/// Whether `pg_role` may look into `schema`, directly or through a role it inherits from.
async fn has_schema_usage(
client: &tokio_postgres::Client,
schema: &str,
pg_role: &str,
) -> Result<bool> {
Ok(client
.query_one(
"SELECT has_schema_privilege($1::name, $2::text, 'USAGE')",
&[&pg_role, &schema],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read the role's privileges on schema {schema}: {}",
pg_error_message(&e)
))
})?
.get(0))
}
/// Postgres only hands an object to a role that could have created it: a table to one with
/// `CREATE` on its schema, a schema to one with `CREATE` on the database.
async fn missing_owner_privilege(
+13
View File
@@ -1765,6 +1765,12 @@ paths:
type: string
cluster:
$ref: "#/components/schemas/DatatableRoleCluster"
take_over:
type: boolean
description: >
manage a Postgres role of that name that already exists on the cluster,
replacing its password, instead of refusing it. A role with cluster-wide
attributes or memberships is refused either way
responses:
"200":
description: the created role
@@ -1772,6 +1778,8 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/InstanceDatatableRole"
"409":
description: a Postgres role of that name already exists on the cluster and take_over was not set
/settings/datatable_roles/{id}:
post:
@@ -34525,6 +34533,11 @@ components:
type: boolean
cluster:
$ref: "#/components/schemas/DatatableRoleCluster"
in_use:
type: boolean
description: >-
Whether any data table, in any workspace, lets someone connect as this role. Only set
in a data table's available_roles.
DatatableRoleTenants:
type: object
+10 -2
View File
@@ -331,6 +331,12 @@ pub async fn converge_connect_grants_with(
/// `CREATE ROLE <name> LOGIN PASSWORD ...; GRANT <name> TO custom_instance_user` on `cluster`. No
/// privileges beyond that — an admin grants them through SQL or the ACL editor.
///
/// A role of that name that already exists is refused with a 409, unless `take_over`: then its
/// password is replaced and it is managed from here on like one created here. A role holding
/// cluster-wide privileges, or inheriting another role's, is refused either way, since every tenant
/// would get them. Returns whether an existing role was taken over: undoing a failed take-over
/// must not drop a role that was there before.
///
/// On Windmill's own cluster the DDL runs on `tx`, so it commits with the catalog row. The external
/// cluster is another server: the role is created there before `tx` commits, and callers MUST drop
/// it again ([`drop_datatable_role`]) if `tx` then fails to commit.
@@ -343,8 +349,10 @@ pub async fn create_datatable_role(
cluster: DatatableRoleCluster,
name: &str,
password: &str,
) -> Result<()> {
crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password).await
take_over: bool,
) -> Result<bool> {
crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password, take_over)
.await
}
/// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin
@@ -125,7 +125,8 @@ mod ce {
_cluster: DatatableRoleCluster,
_name: &str,
_password: &str,
) -> Result<()> {
_take_over: bool,
) -> Result<bool> {
Err(unavailable())
}
+20 -3
View File
@@ -375,7 +375,8 @@
}
if (!isInstanceDatatable(datatable))
return {
reason: 'Only data tables stored in the instance database have roles and grants.',
reason:
"Roles and grants are not supported on data tables backed by a Postgres resource. Store the data table on Windmill's database or on an external Postgres instance (set up by a superadmin in instance settings) to use them.",
ee: false
}
if (needsAdmin && !canManageDatatable)
@@ -458,6 +459,11 @@
// default to open — actually be folded; a plain "expanded" set could never
// close them, since the default would keep winning.
// What the permissions drawer is open on, if anything: a schema, or a table.
let aclEditor = $state<PgAclEditor | undefined>(undefined)
/** The open access editor lists the data table's roles, which the roles drawer can change. */
export function refreshAcl() {
aclEditor?.refresh()
}
let aclDrawer = $state<{ datatable: string | undefined; target: AclTarget } | undefined>(
undefined
)
@@ -1466,7 +1472,7 @@
<Portal>
<Drawer open={!!aclDrawer} size="900px" on:close={() => (aclDrawer = undefined)}>
<DrawerContent
title="Access — {aclLabel(aclDrawer?.target)}"
title="Access: {aclLabel(aclDrawer?.target)}"
on:close={() => (aclDrawer = undefined)}
tooltip="Who owns this, and what each role may do with it."
>
@@ -1474,7 +1480,18 @@
{@const dt = aclDrawer.datatable ?? currentDatatable}
{#if dt}
{#key `${dt}~${JSON.stringify(aclDrawer.target)}`}
<PgAclEditor {workspace} datatable={dt} target={aclDrawer.target} />
<PgAclEditor
bind:this={aclEditor}
{workspace}
datatable={dt}
target={aclDrawer.target}
manageRoles={onDatatableAction
? {
open: () => onDatatableAction?.(dt, 'roles'),
blocker: accessBlocker(dt, true)?.reason
}
: undefined}
/>
{/key}
{/if}
{/if}
@@ -311,6 +311,7 @@
function refreshRoles() {
datatables.refetch()
usableRoles.refetch()
dbManagerContent?.dbManager()?.refreshAcl()
}
async function handleExportSchema(explicitSource?: string) {
@@ -107,9 +107,19 @@
<DropdownSubmenuItem {item} {builders} {meltItem} close={close ?? (() => {})} />
{:else if item.disabled && item.tooltip}
<!-- Wrapper carries the native `title`; the disabled button's `pointer-events-none`
lets the hover reach it so the user learns why the item is disabled. -->
<div title={item.tooltip} class="w-full">
{@render menuItem(item)}
lets the hover reach it so the user learns why the item is disabled. The ⓘ sits
outside the disabled button, the only place it can receive a hover. -->
<div class="relative w-full">
<div title={item.tooltip} class="w-full pr-6">
{@render menuItem(item)}
</div>
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex">
<Tooltip>
{#snippet text()}
{item.tooltip}
{/snippet}
</Tooltip>
</div>
</div>
{:else}
{@render menuItem(item)}
@@ -85,7 +85,8 @@
['dark-indigo']: 'bg-indigo-700 text-indigo-100 dark:bg-indigo-800 dark:text-indigo-200',
['dark-orange']: 'bg-orange-700 text-orange-100 dark:bg-orange-800 dark:text-orange-200',
['dark-violet']: 'bg-violet-700 text-violet-100 dark:bg-violet-800 dark:text-violet-200',
transparent: 'bg-surface-accent-selected text-accent border-gray-400 dark:border-gray-500'
// Keeps the unselected chip's border, so selecting one does not change its height.
transparent: 'bg-surface-accent-selected text-accent border border-surface-accent-selected'
}
const hovers: Partial<Record<BadgeColor, string>> = {
@@ -29,6 +29,8 @@
* up confines it. Off by default: it moves the dialog out of its DOM position, so opt
* in per call site rather than assuming every caller wants it. */
alwaysPortal?: boolean
/** For a notice with nothing to decide: the confirm button is the only one. */
hideCancel?: boolean
children?: Snippet
onConfirmed?: () => void | Promise<void>
onCanceled?: () => void
@@ -47,6 +49,7 @@
trashbin = false,
zIndexClass = 'z-[9999]',
alwaysPortal = false,
hideCancel = false,
children,
onConfirmed,
onCanceled
@@ -228,15 +231,17 @@
{/if}
<span class="min-w-20">{confirmationText} </span>
</Button>
<Button
disabled={loading}
on:click={() => (dispatch('canceled'), onCanceled?.())}
variant="default"
size="sm"
shortCut={{ key: 'Esc', hide: !keyListen, withoutModifier: true }}
>
Cancel
</Button>
{#if !hideCancel}
<Button
disabled={loading}
on:click={() => (dispatch('canceled'), onCanceled?.())}
variant="default"
size="sm"
shortCut={{ key: 'Esc', hide: !keyListen, withoutModifier: true }}
>
Cancel
</Button>
{/if}
</div>
</div>
</div>
@@ -32,6 +32,9 @@
children?: import('svelte').Snippet<[any]>
onOpen?: () => void
onClose?: () => void
/** Asked before a click away or Escape closes it; resolving false keeps it open, e.g. to
* confirm discarding unsaved changes. */
confirmClose?: () => boolean | Promise<boolean>
}
let {
@@ -42,9 +45,25 @@
minZIndex = 0,
children,
onOpen,
onClose
onClose,
confirmClose
}: Props = $props()
// While the question is on screen, the keys and clicks are its own.
let confirming = false
async function requestClose() {
if (confirming) return
if (confirmClose) {
confirming = true
try {
if (!(await confirmClose())) return
} finally {
confirming = false
}
}
closeDrawer()
}
let offset = $state(untrack(() => initialOffset))
// Note: when a Modal with minZIndex is open, all disposables (including
// already-open Drawers) are elevated. This is acceptable — relative
@@ -115,7 +134,7 @@
const last = stack.val[stack.val.length - 1]
if (last === id) {
e.stopPropagation()
closeDrawer()
requestClose()
}
}
@@ -125,7 +144,16 @@
if (open) {
switch (event.key) {
case 'Escape':
// The confirmation on screen answers this Escape itself, as its Cancel.
if (confirming) break
if ((id == stack.val[stack.val.length - 1] || stack.val.length == 0) && !preventEscape) {
if (confirmClose) {
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
requestClose()
break
}
stack.val.pop()
event.preventDefault()
event.stopPropagation()
@@ -28,6 +28,8 @@
positionClass?: string | undefined
name?: string
children?: import('svelte').Snippet<[any]>
/** Asked before a click away or Escape closes it; resolving false keeps it open. */
confirmClose?: () => boolean | Promise<boolean>
}
let {
@@ -43,7 +45,8 @@
class: clazz = '',
positionClass = undefined,
name = undefined,
children: children_render
children: children_render,
confirmClose
}: Props = $props()
if (open === undefined) {
@@ -146,6 +149,7 @@
onOpen={() => dispatch('open')}
onClose={() => dispatch('close')}
{preventEscape}
{confirmClose}
>
{#snippet children({ handleClickAway, zIndex, isTop })}
<aside
@@ -1,36 +1,48 @@
<script lang="ts">
import { WorkspaceService, type AclChange, type AclTarget, type DatatableAclInfo } from '$lib/gen'
import { resource } from 'runed'
import { Trash2 } from 'lucide-svelte'
import type { Snippet } from 'svelte'
import { Tooltip } from '../meltComponents'
import { KeyRound, Trash2 } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { Alert, Button } from '../common'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import Select from '../select/Select.svelte'
import PgGrantBuilder from './PgGrantBuilder.svelte'
import DataTable from '../table/DataTable.svelte'
import Head from '../table/Head.svelte'
import Row from '../table/Row.svelte'
import Cell from '../table/Cell.svelte'
import PgGrantBuilder from './PgGrantBuilder.svelte'
import Badge from '../common/badge/Badge.svelte'
import {
ADMIN_ROLE,
blockingSources,
grantKey,
grantScopeLabel,
grantCoverage,
scopesOf,
groupGrants,
revocablePrivileges,
revokeScopeOf,
uncoveredCreators
uncoveredCreators,
type AclScope,
type GroupedGrant
} from './aclScopes'
let {
workspace,
datatable,
target
target,
disabledReason,
manageRoles
}: {
workspace: string
datatable: string
/** What owner and grants are read and written for. */
target: AclTarget
/** Shows owner and grants without letting them change, with this reason on hover. */
disabledReason?: string
/** Offered at the bottom of every role picker. `blocker` keeps the entry, disabled, with why. */
manageRoles?: { open: () => void; blocker?: string }
} = $props()
const acl = resource(
@@ -45,6 +57,11 @@
})
)
/** Re-read owner, grants and the roles they can name, e.g. after the data table's roles changed. */
export function refresh() {
acl.refetch()
}
// Nothing is written before its SQL has been shown, and the apply runs exactly that SQL: the
// server plans again and refuses if the result differs.
let pending = $state<
@@ -54,7 +71,32 @@
let applying = $state(false)
const info: DatatableAclInfo | undefined = $derived(acl.current)
const controlsDisabled = $derived(planning || applying || !!disabledReason)
const grantRows = $derived(groupGrants(info?.grants ?? []))
// Several tags of one kind widen the list; tags of both kinds narrow it to grants matching each.
let roleFilters = $state<string[]>([])
let privilegeFilters = $state<string[]>([])
// A selected tag stays offered after its last grant goes, so it can still be cleared.
const roleChips = $derived(
[...new Set([...grantRows.map((g) => g.grantee), ...roleFilters])].sort()
)
const privilegeChips = $derived(
[...new Set([...grantRows.flatMap((g) => g.privileges), ...privilegeFilters])].sort()
)
const shownGrantRows = $derived(
grantRows.filter(
(g) =>
(roleFilters.length === 0 || roleFilters.includes(g.grantee)) &&
(privilegeFilters.length === 0 || g.privileges.some((p) => privilegeFilters.includes(p)))
)
)
/** What a grant on the object itself reads as, in the scope picker's words. */
const targetLabel = $derived(scopesOf(target.kind)[0].label)
// Most grants are on the object itself, which the column would only repeat.
const showCoverage = $derived(grantRows.some((g) => grantCoverage(g, target) !== undefined))
const columns = $derived(showCoverage ? 4 : 3)
const toggled = (list: string[], value: string) =>
list.includes(value) ? list.filter((v) => v !== value) : [...list, value]
const ownerItems = $derived(
info
? (info.roles.includes(info.owner) ? info.roles : [info.owner, ...info.roles]).map((r) => ({
@@ -68,6 +110,28 @@
pending?.change.type === 'revoke' && (pending.change.objects?.length ?? 0) > 1
)
/** Why a row's delete button is disabled, or undefined when it can revoke. */
function unrevocableReason(
grant: GroupedGrant,
revokeScope: AclScope | undefined,
revocable: string[],
blocked: string[]
): string | undefined {
// What `admin` holds is what every role here connects through.
if (grant.grantee === ADMIN_ROLE)
return 'admin is the connection every role reaches this data table through, so its grants are not changed here'
if (!info?.roles.includes(grant.grantee))
return `Only grants to this data table's roles are revoked here, and ${grant.grantee} is not one`
if (!revokeScope) return 'Grants on types are read only here'
if (revocable.length === 0)
return grant.future
? 'Default privileges set database-wide are not revoked from here'
: "CONNECT follows the data table's roles and TEMPORARY is left as Postgres sets it, so nothing here can be revoked"
if (blocked.length > 0)
return `Only ${blocked.join(', ')} can revoke this: Postgres takes a grant back through the role that made it`
return undefined
}
function errorText(e: any): string {
return e?.body ?? e?.message ?? String(e)
}
@@ -108,6 +172,60 @@
}
</script>
<!-- A disabled control receives no hover, so the tooltip's trigger is a wrapper the hover falls
through to. -->
{#snippet withDisabledReason(content: Snippet, widthClass: string)}
{#if disabledReason}
<Tooltip class="block {widthClass}" anchor="cursor">
<div class="pointer-events-none">{@render content()}</div>
{#snippet text()}{disabledReason}{/snippet}
</Tooltip>
{:else}
{@render content()}
{/if}
{/snippet}
<!-- The home page's filter chips, any number of them selected at once. -->
{#snippet filterChip(label: string, selected: boolean, toggle: () => void)}
<Badge color="transparent" clickable {selected} onclick={toggle}>
{label}
{#if selected}&cross;{/if}
</Badge>
{/snippet}
<!-- Styled like the picker's tags in the row that creates a grant. -->
{#snippet tag(text: string)}
<span class="inline-flex items-center min-h-6 px-2 border bg-surface rounded-full text-emphasis">
{text}
</span>
{/snippet}
{#snippet manageRolesEntry({ close }: { close: () => void })}
<div class="flex items-center gap-1 border-t p-1">
<Button
unifiedSize="sm"
variant="subtle"
startIcon={{ icon: KeyRound }}
disabled={!!manageRoles?.blocker}
btnClasses="w-full justify-start"
wrapperClasses="grow"
onClick={() => {
close()
manageRoles?.open()
}}
>
Manage roles
</Button>
{#if manageRoles?.blocker}
<span class="pr-2 flex">
<Tooltip>
{#snippet text()}{manageRoles?.blocker}{/snippet}
</Tooltip>
</span>
{/if}
</div>
{/snippet}
{#if acl.error}
<Alert type="error" title="Could not read access" size="xs">{errorText(acl.error)}</Alert>
{:else if !info}
@@ -137,22 +255,26 @@
</span>
</div>
{#if info.editable}
<Select
items={ownerItems}
disabled={planning || applying}
size="sm"
class="w-64"
bind:value={
() => info.owner,
(role) => {
// The select shows what the database says; a pick is a request, and only the
// applied change moves it.
if (role && role !== info.owner) {
confirm({ type: 'set_owner', role }, `Ownership transferred to ${role}`)
{#snippet ownerSelect()}
<Select
items={ownerItems}
disabled={controlsDisabled}
size="sm"
class="w-64"
bind:value={
() => info.owner,
(role) => {
// The select shows what the database says; a pick is a request, and only the
// applied change moves it.
if (role && role !== info.owner) {
confirm({ type: 'set_owner', role }, `Ownership transferred to ${role}`)
}
}
}
}
/>
bottomSnippet={manageRoles ? manageRolesEntry : undefined}
/>
{/snippet}
{@render withDisabledReason(ownerSelect, 'w-64')}
{:else}
<span class="font-mono text-xs">{info.owner}</span>
{/if}
@@ -168,75 +290,76 @@
: 'What each role may do here, beyond what it owns.'}
</span>
</div>
{#if info.editable}
<PgGrantBuilder
{target}
roles={info.roles}
disabled={planning || applying}
supportsMaintain={info.supports_maintain}
dbname={info.dbname}
onAdd={({ role, privileges, scope }) =>
confirm(
{ type: 'grant', role, privileges, scope },
`Granted ${privileges.join(', ')} to ${role}`
)}
/>
<!-- A set filter keeps its chips, or a revoke that leaves one grant would strand it. -->
{#if grantRows.length > 1 || roleFilters.length > 0 || privilegeFilters.length > 0}
<div class="flex flex-wrap items-center gap-2">
{#each roleChips as role (`role:${role}`)}
{@render filterChip(role, roleFilters.includes(role), () => {
roleFilters = toggled(roleFilters, role)
})}
{/each}
{#each privilegeChips as privilege (`privilege:${privilege}`)}
{@render filterChip(privilege, privilegeFilters.includes(privilege), () => {
privilegeFilters = toggled(privilegeFilters, privilege)
})}
{/each}
</div>
{/if}
{#if grantRows.length === 0}
<span class="text-xs text-secondary">No grants yet.</span>
{:else}
<DataTable size="xs">
<Head>
<tr>
<Cell head first>Role</Cell>
<Cell head>Privileges</Cell>
<DataTable size="xs">
<Head>
<tr>
<Cell head first>Role</Cell>
<Cell head>Privileges</Cell>
{#if showCoverage}
<Cell head>On</Cell>
<Cell head last></Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each grantRows as grant (grantKey(grant))}
{@const revokeScope = revokeScopeOf(grant)}
{@const revocable = revocablePrivileges(grant, target)}
{@const blocked = blockingSources(grant, revocable)}
{@const uncovered = uncoveredCreators(grant, info.roles)}
<Row>
<Cell first>{grant.grantee}</Cell>
<Cell wrap
><span class="font-mono text-2xs">{grant.privileges.join(', ')}</span></Cell
>
<Cell wrap>
{grantScopeLabel(grant)}
{#if blocked.length > 0}
<span
class="text-2xs text-secondary"
title="Only this role can take the grant back: Postgres revokes a grant through the role that made it"
>
from {blocked.join(', ')}
</span>
{/if}
{#if uncovered.length > 0}
<span
class="text-2xs text-secondary"
title="A default privilege covers only the roles it was granted for: grant it again to cover these"
>
· not for what {uncovered.join(', ')}
{uncovered.length === 1 ? 'creates' : 'create'}
</span>
{/if}
{/if}
<Cell head last />
</tr>
</Head>
<tbody class="divide-y">
{#each shownGrantRows as grant (grantKey(grant))}
{@const revokeScope = revokeScopeOf(grant)}
{@const revocable = revocablePrivileges(grant, target)}
{@const blocked = blockingSources(grant, revocable)}
{@const uncovered = uncoveredCreators(grant, info.roles)}
{@const unrevocable = unrevocableReason(grant, revokeScope, revocable, blocked)}
<Row>
<Cell first class="font-medium text-emphasis">{grant.grantee}</Cell>
<Cell wrap>
<div class="flex flex-wrap items-center gap-1">
{#each grant.privileges as privilege (privilege)}
{@render tag(privilege)}
{/each}
</div>
{#if blocked.length > 0 || uncovered.length > 0}
<div class="mt-0.5 text-2xs text-secondary">
{[
blocked.length > 0 && `granted by ${blocked.join(', ')}`,
uncovered.length > 0 &&
`not for what ${uncovered.join(', ')} ${uncovered.length === 1 ? 'creates' : 'create'}: a default privilege only covers the roles it was granted for`
]
.filter(Boolean)
.join(' · ')}
</div>
{/if}
</Cell>
{#if showCoverage}
<Cell wrap class="text-secondary">
{grantCoverage(grant, target) ?? targetLabel}
</Cell>
<Cell last>
<!-- What `admin` holds is what every role here connects through, so it is not
this editor's to take away. -->
{#if info.editable && revokeScope && revocable.length > 0 && blocked.length === 0 && info.roles.includes(grant.grantee) && grant.grantee !== ADMIN_ROLE}
{/if}
<Cell last class="w-10">
{#if info.editable}
{#snippet revokeButton()}
<Button
unifiedSize="xs"
variant="subtle"
iconOnly
startIcon={{ icon: Trash2 }}
title="Revoke {revocable.join(', ')}"
disabled={planning || applying}
onClick={() =>
title={unrevocable ?? `Revoke ${revocable.join(', ')}`}
disabled={controlsDisabled || !!unrevocable}
onClick={() => {
if (!revokeScope) return
confirm(
{
type: 'revoke',
@@ -246,15 +369,52 @@
objects: grant.objects
},
`Revoked ${revocable.join(', ')} from ${grant.grantee}`
)}
)
}}
/>
{/snippet}
{#if unrevocable && !disabledReason}
<Tooltip>
<div class="pointer-events-none">{@render revokeButton()}</div>
{#snippet text()}{unrevocable}{/snippet}
</Tooltip>
{:else}
{@render withDisabledReason(revokeButton, 'w-fit')}
{/if}
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
{/if}
{/if}
</Cell>
</Row>
{:else}
<Row>
<Cell first last colspan={columns} class="text-secondary">
{grantRows.length === 0 ? 'No grants yet.' : 'No grant matches the filters.'}
</Cell>
</Row>
{/each}
{#if info.editable}
<!-- The grant to create: a row of the table, set apart until it exists. -->
<Row class="bg-surface-secondary">
<Cell first last colspan={columns}>
{#snippet grantBuilder()}
<PgGrantBuilder
{target}
roles={info.roles}
disabled={controlsDisabled}
manageRoles={manageRoles ? manageRolesEntry : undefined}
supportsMaintain={info.supports_maintain}
onAdd={({ role, privileges, scope }) =>
confirm(
{ type: 'grant', role, privileges, scope },
`Granted ${privileges.join(', ')} to ${role}`
)}
/>
{/snippet}
{@render withDisabledReason(grantBuilder, 'w-full')}
</Cell>
</Row>
{/if}
</tbody>
</DataTable>
</section>
</div>
{/if}
@@ -1,17 +1,18 @@
<script lang="ts">
import type { AclTarget } from '$lib/gen'
import type { Snippet } from 'svelte'
import { Plus } from 'lucide-svelte'
import { Button } from '../common'
import Select from '../select/Select.svelte'
import MultiSelect from '../select/MultiSelect.svelte'
import { privilegesOf, scopeSql, scopesOf, type AclScope } from './aclScopes'
import { privilegesOf, scopesOf, type AclScope } from './aclScopes'
let {
target,
roles,
supportsMaintain = false,
dbname,
disabled = false,
manageRoles,
onAdd
}: {
target: AclTarget
@@ -19,9 +20,9 @@
roles: string[]
/** Postgres 17+, which has one more table privilege to offer. */
supportsMaintain?: boolean
/** Names the database in the statement a database target builds. */
dbname?: string
disabled?: boolean
/** Rendered at the bottom of the role picker, to reach the data table's roles. */
manageRoles?: Snippet<[{ close: () => void }]>
onAdd: (grant: { role: string; privileges: string[]; scope: AclScope }) => void
} = $props()
@@ -29,26 +30,24 @@
let scope = $state<AclScope>('target')
let privileges = $state<string[]>([])
const scopes = $derived(scopesOf(target.kind))
const available = $derived(privilegesOf(scope, target.kind, supportsMaintain))
const statement = $derived(
privileges.length && role
? `GRANT ${privileges.join(', ')} ON ${scopeSql(scope, target, dbname)} TO ${role}`
: undefined
)
</script>
<div class="flex flex-col gap-2 border rounded-md p-3">
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
<span class="font-mono text-primary">GRANT</span>
<MultiSelect
bind:value={privileges}
items={available.map((p) => ({ value: p, label: p }))}
placeholder="privileges"
{disabled}
size="sm"
class="min-w-48"
/>
<span class="font-mono text-primary">ON</span>
<!-- Laid out like the grants above it, so it reads as the statement it will run. -->
<div class="flex items-center gap-2 text-xs text-secondary">
<span class="font-mono shrink-0">GRANT</span>
<MultiSelect
bind:value={privileges}
items={available.map((p) => ({ value: p, label: p }))}
placeholder="privileges"
{disabled}
size="md"
class="min-w-0 flex-[4]"
/>
<!-- A database or a table has only itself to grant on. -->
{#if scopes.length > 1}
<span class="font-mono shrink-0">ON</span>
<Select
bind:value={
() => scope,
@@ -61,35 +60,34 @@
privileges = privileges.filter((p) => allowed.includes(p))
}
}
items={scopesOf(target.kind)}
items={scopes}
{disabled}
size="sm"
class="w-52"
size="md"
class="min-w-0 flex-[3]"
/>
<span class="font-mono text-primary">TO</span>
<Select
bind:value={role}
items={roles.map((r) => ({ value: r, label: r }))}
placeholder="role"
{disabled}
size="sm"
class="w-40"
/>
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Plus }}
disabled={disabled || !role || privileges.length === 0}
onClick={() => {
if (!role) return
onAdd({ role, privileges: [...privileges], scope })
privileges = []
}}
>
Add
</Button>
</div>
{#if statement}
<pre class="text-2xs text-tertiary overflow-x-auto">{statement}</pre>
{/if}
<span class="font-mono shrink-0">TO</span>
<Select
bind:value={role}
items={roles.map((r) => ({ value: r, label: r }))}
placeholder="role"
{disabled}
size="md"
class="min-w-0 flex-[2]"
bottomSnippet={manageRoles}
/>
<Button
unifiedSize="md"
variant="default"
wrapperClasses="shrink-0"
startIcon={{ icon: Plus }}
disabled={disabled || !role || privileges.length === 0}
onClick={() => {
if (!role) return
onAdd({ role, privileges: [...privileges], scope })
privileges = []
}}
>
Create
</Button>
</div>
@@ -3,10 +3,12 @@ import type { AclGrant } from '$lib/gen'
import {
blockingSources,
grantKey,
grantCoverage,
groupGrants,
revocablePrivileges,
revokeScopeOf,
uncoveredCreators
uncoveredCreators,
type GroupedGrant
} from './aclScopes'
const table = (name: string) => ({ name, kind: 'TABLE' })
@@ -176,3 +178,29 @@ describe('uncoveredCreators', () => {
).toEqual([])
})
})
describe('grantCoverage', () => {
const schema = { kind: 'schema' as const, schema: 'sales' }
const row = (grant: Partial<GroupedGrant>): GroupedGrant => ({
grantee: 'analytics',
privileges: ['SELECT'],
objects: [],
sources: [],
...grant
})
it('says nothing for the object itself, and names what is created later', () => {
expect(grantCoverage(row({}), schema)).toBeUndefined()
expect(grantCoverage(row({ future: 'TABLES' }), schema)).toBe('tables created later')
expect(grantCoverage(row({ future: 'TABLES' }), { kind: 'database' })).toBe(
'tables created later, in every schema'
)
})
it('names a routine with its arguments, and summarizes past three objects', () => {
const routine = row({ objects: [{ name: 'refresh', kind: 'FUNCTION', args: 'integer' }] })
expect(grantCoverage(routine, schema)).toBe('function refresh(integer)')
const many = row({ objects: ['a', 'b', 'c', 'd', 'e'].map(table) })
expect(grantCoverage(many, schema)).toBe('tables a, b, c and 2 more')
})
})
@@ -73,28 +73,6 @@ export function privilegesOf(
}
}
/** What a statement built at this scope reads as, for the builder's own preview. */
export function scopeSql(scope: AclScope, target: AclTarget, dbname?: string): string {
if (target.kind === 'database') return `DATABASE ${dbname ?? ''}`.trim()
const schema = target.schema
switch (scope) {
case 'target':
return target.kind === 'schema' ? `SCHEMA ${schema}` : `TABLE ${schema}.${target.table}`
case 'all_tables':
return `ALL TABLES IN SCHEMA ${schema}`
case 'all_sequences':
return `ALL SEQUENCES IN SCHEMA ${schema}`
case 'all_functions':
return `ALL FUNCTIONS IN SCHEMA ${schema}`
case 'future_tables':
return `TABLES (default privileges in ${schema})`
case 'future_sequences':
return `SEQUENCES (default privileges in ${schema})`
case 'future_functions':
return `FUNCTIONS (default privileges in ${schema})`
}
}
/** One row of the grants table: the same privileges on several objects read as one line, since
* granting them per object is what `ON ALL TABLES` does. */
export type GroupedGrant = {
@@ -195,17 +173,21 @@ export function revocablePrivileges(grant: GroupedGrant, target: AclTarget): str
return grant.privileges
}
/** How a row reads back: what it covers, in one phrase. */
export function grantScopeLabel(grant: GroupedGrant): string {
if (grant.future) return `${grant.future.toLowerCase()} created later`
if (grant.objects.length === 1) {
const object = grant.objects[0]
// A routine's arguments are part of what it is, so two of the same name would otherwise
// read as one row twice.
const args = object.args !== undefined ? `(${object.args})` : ''
return `${object.kind.toLowerCase()} ${object.name}${args}`
/** How many objects a folded row names before it summarizes the rest. */
const LISTED_OBJECTS = 3
/** What a row covers, when that is not the editor's own object: the objects inside it, or what is
* created later. Undefined for a grant on the object itself, which needs no saying. */
export function grantCoverage(grant: GroupedGrant, target: AclTarget): string | undefined {
if (grant.future) {
const created = `${grant.future.toLowerCase()} created later`
return target.kind === 'database' ? `${created}, in every schema` : created
}
if (grant.objects.length > 1)
return `${grant.objects.length} ${grant.objects[0].kind.toLowerCase()}s`
return 'itself'
if (grant.objects.length === 0) return undefined
// A routine's arguments are part of what it is: two of the same name are different objects.
const names = grant.objects.map((o) => (o.args !== undefined ? `${o.name}(${o.args})` : o.name))
const listed = names.slice(0, LISTED_OBJECTS).join(', ')
const rest = names.length - LISTED_OBJECTS
const kind = grant.objects[0].kind.toLowerCase()
return `${names.length > 1 ? `${kind}s` : kind} ${listed}${rest > 0 ? ` and ${rest} more` : ''}`
}
@@ -10,7 +10,7 @@
import DataTable from '../table/DataTable.svelte'
import Head from '../table/Head.svelte'
import Row from '../table/Row.svelte'
import { KeyRound } from 'lucide-svelte'
import { CircleHelp, KeyRound } from 'lucide-svelte'
import {
FolderService,
GroupService,
@@ -25,7 +25,13 @@
import { deepEqual } from 'fast-equals'
import { ADMIN_DATATABLE_ROLE, isDatatableRoleName } from '../dbTypes'
import PgAclEditor from '../datatableAcl/PgAclEditor.svelte'
import InstanceRolesButton from './InstanceRolesButton.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,
@@ -44,10 +50,8 @@
} = $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. A row without an id
// names a role the instance does not define yet: it cannot be saved until a superadmin creates
// it, and takes the new role's id once they have.
type EditedRole = { id: string | undefined; name: string | undefined; tenants: string[] }
// 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)
@@ -72,7 +76,7 @@
const editable = $derived(!!info?.editable)
const governing = $derived(info?.governing_workspace_id)
// The instance catalog, read again after the instance roles drawer changes it.
// 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
@@ -80,11 +84,10 @@
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 pendingRoles = $derived(roles.filter((r) => r.id === undefined))
let instanceRoles: InstanceRolesButton | undefined = $state(undefined)
const roleKey = (role: EditedRole) => role.id ?? `pending:${role.name}`
const confirmationModal = createAsyncConfirmationModal()
let aclEditor = $state<PgAclEditor | undefined>(undefined)
const hasUnsavedChanges = $derived(
!deepEqual($state.snapshot(saved), {
@@ -152,9 +155,9 @@
roles.push({ id: role.id, name: role.name, tenants: [] })
}
/** Adds a role by name: the instance's role of that name, or a pending row for one it does not
* define yet. */
function addRoleByName(typed: string) {
/** 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(
@@ -165,35 +168,70 @@
}
if (roles.some((r) => r.name === name)) return
const existing = availableRoles.find((r) => r.name === name)
roles.push({ id: existing?.id, name, tenants: [] })
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: [] })
}
}
function removeRole(role: EditedRole) {
const key = roleKey(role)
roles = roles.filter((r) => roleKey(r) !== key)
if (role.id !== undefined && defaultRoleId === role.id) defaultRoleId = ADMIN_DATATABLE_ROLE
}
/** Reads the instance catalog again and gives each pending row the id of the role now defined
* under its name. */
async function refreshCatalog() {
let fresh: InstanceDatatableRole[]
try {
fresh = await SettingService.listInstanceDatatableRoles({ cluster: info?.cluster })
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
return
}
catalog = fresh
for (const row of roles) {
if (row.id !== undefined) continue
const created = fresh.find((r) => r.name === row.name)
if (created) row.id = created.id
}
roles = roles.filter((r) => r.id !== role.id)
if (defaultRoleId === role.id) defaultRoleId = ADMIN_DATATABLE_ROLE
}
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,
@@ -201,11 +239,27 @@
requestBody: {
permissioned,
default_role: defaultRoleId,
roles: roles.map((r) => ({ id: r.id!, tenants: $state.snapshot(r.tenants) }))
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)
@@ -214,6 +268,17 @@
}
}
/** 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()
@@ -232,10 +297,12 @@
/>
{/if}
<Drawer bind:open={drawerOpen} size="900px">
<Drawer bind:open={drawerOpen} size="900px" confirmClose={confirmDiscard}>
<DrawerContent
title="Roles — {datatable}"
on:close={() => (drawerOpen = false)}
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()}
@@ -293,10 +360,13 @@
{/if}
{#if permissioned}
{#if editable && availableRoles.length === 0}
<Alert type="warning" title="No role defined on this instance" size="xs">
Only <span class="font-mono">admin</span> can be used until a superadmin creates a data
table role. Type a name below to add one.
{#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}
@@ -329,7 +399,7 @@
</tr>
</Head>
<tbody class="divide-y bg-surface-tertiary">
{#each roles as role (roleKey(role))}
{#each roles as role (role.id)}
{@const isAdmin = role.id === ADMIN_DATATABLE_ROLE}
<Row>
<Cell first class="w-56 align-top">
@@ -339,23 +409,6 @@
<span class="text-2xs text-secondary italic">
no longer defined on {clusterName}
</span>
{:else if role.id === undefined}
<Alert type="warning" title="This role does not exist yet" size="xs">
{#if $superadmin}
<div class="flex flex-col items-start gap-1">
<span>Create it on {clusterName} to use it here.</span>
<Button
unifiedSize="xs"
variant="default"
on:click={() => instanceRoles?.open(role.name)}
>
Create it
</Button>
</div>
{:else}
Only a superadmin can create it on {clusterName}.
{/if}
</Alert>
{/if}
</div>
</Cell>
@@ -371,11 +424,11 @@
<Cell class="w-20 align-top">
<div class="flex justify-center pt-2">
<Checkbox
checked={role.id !== undefined && defaultRoleId === role.id}
disabled={!editable || role.id === undefined}
checked={defaultRoleId === role.id}
disabled={!editable}
title="Use this role when a job names none"
onChange={() => {
if (role.id !== undefined) defaultRoleId = role.id
defaultRoleId = role.id
}}
/>
</div>
@@ -388,7 +441,7 @@
</Row>
{/each}
{#if editable}
<Row class="!border-0">
<Row>
<Cell colspan={4} class="pt-2 pb-2">
<div class="flex justify-center">
<Select
@@ -418,9 +471,32 @@
<!-- 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 && !hasUnsavedChanges}
{#if info?.supported && permissioned}
<div class="mt-6 pt-6 border-t">
<PgAclEditor {workspace} {datatable} target={{ kind: 'database' }} />
<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}
@@ -430,10 +506,7 @@
<Button
variant="accent"
unifiedSize="md"
disabled={!hasUnsavedChanges || loading || !!loadError || pendingRoles.length > 0}
title={pendingRoles.length > 0
? 'Create the roles that do not exist yet, or remove them'
: undefined}
disabled={!hasUnsavedChanges || loading || !!loadError}
loading={saving}
on:click={save}
>
@@ -444,11 +517,4 @@
</DrawerContent>
</Drawer>
{#if $superadmin}
<InstanceRolesButton
bind:this={instanceRoles}
hideTrigger
cluster={info?.cluster}
onChanged={refreshCatalog}
/>
{/if}
<ConfirmationModal {...confirmationModal.props} />
@@ -15,6 +15,7 @@
import { SettingService, type DatatableRoleCluster, type InstanceDatatableRole } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { isCloudHosted } from '$lib/cloud'
import { handleRoleCreationError } from './datatableRoleModals'
let {
initialName = '',
@@ -134,14 +135,28 @@
async function create() {
const name = newName.trim()
if (!name) return
await run(
() =>
SettingService.createInstanceDatatableRole({
requestBody: { name, cluster }
}),
`Created the data table role ${name}`
)
newName = ''
busy = true
try {
await SettingService.createInstanceDatatableRole({ requestBody: { name, cluster } })
sendUserToast(`Created the data table role ${name}`)
newName = ''
} catch (e) {
const createdOn = cluster
handleRoleCreationError(e, {
name,
cluster: createdOn,
confirmationModal,
onTakenOver: async () => {
newName = ''
if (cluster === createdOn) await load()
onChanged?.()
}
})
} finally {
await load()
busy = false
onChanged?.()
}
}
async function remove(role: InstanceDatatableRole) {
@@ -0,0 +1,120 @@
import { SettingService, type DatatableRoleCluster, type InstanceDatatableRole } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import type { ConfirmationModalHandle } from '../common/confirmationModal/asyncConfirmationModal.svelte'
export function pgInstanceName(cluster: DatatableRoleCluster | undefined): string {
return cluster === 'external_instance'
? 'the external Postgres instance'
: 'the Windmill Postgres instance'
}
function escapeHtml(text: string): string {
return text.replace(
/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!
)
}
/** Reports a failed role creation. One refused because Postgres already has a role of that name
* (a 409) opens the offer to take that role over instead. `confirmationModal` must be closed: the
* offer takes it over. */
export async function handleRoleCreationError(
e: any,
opts: {
name: string
cluster: DatatableRoleCluster | undefined
confirmationModal: ConfirmationModalHandle
onTakenOver: (role: InstanceDatatableRole) => void
}
) {
if (e?.status !== 409) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
return
}
await offerTakeover(opts)
}
/** A role no data table names any more is still a login on its Postgres instance. A superadmin is
* offered to drop it; anyone else is told to ask one. Resolves to whether it was dropped. */
export async function offerUnusedRoleDrop({
role,
cluster,
superadmin,
confirmationModal
}: {
role: InstanceDatatableRole
cluster: DatatableRoleCluster | undefined
superadmin: boolean
confirmationModal: ConfirmationModalHandle
}): Promise<boolean> {
const name = `<span class="font-mono">${escapeHtml(role.name)}</span>`
const instance = pgInstanceName(cluster)
if (!superadmin) {
await confirmationModal.ask({
title: `Role ${role.name} is no longer used`,
children: `<p>No data table uses the role ${name} any more, but its login still exists in ${instance}. Ask a superadmin to drop it there.</p>`,
confirmationText: 'OK',
type: 'info',
hideCancel: true
})
return false
}
let dropped = false
await confirmationModal.ask({
title: `Drop the role ${role.name}?`,
children: `<div class="flex flex-col gap-2">
<p>No data table uses the role ${name} any more, but its login still exists in ${instance}. Windmill can drop it.</p>
<p>Dropping it <b>deletes the login ${name}</b> from ${instance}: anything still connecting as it, such as a script with <span class="font-mono">-- role ${escapeHtml(role.name)}</span>, fails from then on. Everything it owns in the databases Windmill manages there is handed back to admin, and its grants are revoked. This cannot be undone.</p>
<p>Keep it if you plan to give it to a data table again.</p>
</div>`,
confirmationText: 'Drop role',
type: 'danger',
onConfirmed: async () => {
try {
await SettingService.deleteInstanceDatatableRole({ id: role.id })
dropped = true
sendUserToast(`Dropped the role ${role.name}`)
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
}
}
})
return dropped
}
async function offerTakeover({
name,
cluster,
confirmationModal,
onTakenOver
}: {
name: string
cluster: DatatableRoleCluster | undefined
confirmationModal: ConfirmationModalHandle
onTakenOver: (role: InstanceDatatableRole) => void
}) {
const role = `<span class="font-mono">${escapeHtml(name)}</span>`
let taken: InstanceDatatableRole | undefined
await confirmationModal.ask({
title: `Let Windmill take over the role ${name}?`,
children: `<div class="flex flex-col gap-2">
<p>A Postgres role named ${role} already exists in ${pgInstanceName(cluster)}, and Windmill did not create it. A data table role logs in with a password only Windmill knows, so Windmill has to manage it to use it.</p>
<p>Taking it over <b>replaces the password of ${role}</b> with a random one and lets it log in. Anything that logs in as ${role} today with its current password, such as another application, a person or another Windmill instance, will be locked out. The old password cannot be restored.</p>
<p>From then on Windmill manages ${role} like a role it created: deleting it in Windmill drops it from Postgres. A role that is a superuser, can create roles or databases, is a member of other roles or has members of its own is refused.</p>
</div>`,
confirmationText: 'Replace password and take over',
type: 'danger',
onConfirmed: async () => {
try {
taken = await SettingService.createInstanceDatatableRole({
requestBody: { name, cluster, take_over: true }
})
} catch (e) {
sendUserToast(e?.body ?? e?.message ?? String(e), true)
}
}
})
if (!taken) return
sendUserToast(`Windmill now manages the role ${name}`)
onTakenOver(taken)
}