feat(datatables): add an ACL editor for data table roles

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-09-18 14:00:05 +02:00
co-authored by Claude Opus 5
parent 72507d52a0
commit 76b796bc62
18 changed files with 3362 additions and 51 deletions
+1 -1
View File
@@ -1 +1 @@
7e338e4dabf91689bfd7fb0333c6534040b17b59
6f26308c67acf9fcc45773b373aa30a2593b665c
@@ -0,0 +1,108 @@
//! Who may read and change a data table's grants and owners. On the Enterprise Edition: its
//! administrators, from the workspace that governs it. Without it: nobody. Each refusal is decided
//! before anything connects to the data table, so the fixture's database never has to exist.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn grant_select_on_public() -> Value {
json!({
"target": {"kind": "schema", "schema": "public"},
"change": {"type": "grant", "role": "analytics", "privileges": ["SELECT"],
"scope": "all_tables"},
"statements": [r#"GRANT SELECT ON ALL TABLES IN SCHEMA "public" TO "analytics""#]
})
}
async fn post_acl(
port: u16,
w_id: &str,
action: &str,
token: &str,
) -> anyhow::Result<reqwest::Response> {
Ok(reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/{w_id}/workspaces/datatable_acl/main/{action}"
))
.header("Authorization", format!("Bearer {token}"))
.json(&grant_select_on_public())
.send()
.await?)
}
/// A fork reaches the data table through a pointer: it may use it, never change what each role may
/// touch on it — not even as an admin of the fork.
#[cfg(all(feature = "private", feature = "enterprise"))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn a_fork_cannot_change_access_on_the_data_table_it_points_at(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
for action in ["plan", "apply"] {
let resp = post_acl(port, "wm-fork-dt", action, "SECRET_TOKEN_2").await?;
assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?);
}
Ok(())
}
#[cfg(all(feature = "private", feature = "enterprise"))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn a_member_who_is_not_an_admin_cannot_change_access(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
for action in ["plan", "apply"] {
let resp = post_acl(port, "test-workspace", action, "SECRET_TOKEN_2").await?;
assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?);
}
Ok(())
}
/// Not even reading, and not even on a data table that is not under roles — which any member
/// reaches, so only the edition stands between them and the instance's credentials.
#[cfg(not(all(feature = "private", feature = "enterprise")))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn only_the_enterprise_edition_has_the_access_editor(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query(
"UPDATE workspace_settings
SET datatable = datatable #- '{datatables,main,permissions}'
WHERE workspace_id = 'test-workspace'",
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let read = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/workspaces/datatable_acl/main?kind=database"
))
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
.await?;
let mut responses = vec![("read", read)];
for action in ["plan", "apply"] {
responses.push((
action,
post_acl(port, "test-workspace", action, "SECRET_TOKEN").await?,
));
}
for (action, resp) in responses {
assert_eq!(resp.status(), 400, "{action}");
let body = resp.text().await?;
assert!(
body.contains("Data table roles are a Windmill Enterprise Edition feature"),
"{action}: {body}"
);
}
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! Where the ACL planner comes from: the enterprise one, or a refusal.
//!
//! Data table roles are an Enterprise Edition feature, and so is everything here — reading who
//! owns what included. `private` alone is not that edition — community builds carry it — so the
//! planner is behind `enterprise` as well.
#[cfg(all(feature = "private", feature = "enterprise"))]
pub(crate) use crate::datatable_acl_ee::plan_statements;
#[cfg(all(feature = "private", feature = "enterprise"))]
pub(crate) fn ensure_datatable_acl_available() -> windmill_common::error::Result<()> {
Ok(())
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
use {
crate::datatable_acl::{AclChange, AclPlan, AclTarget, CatalogFacts},
windmill_common::{datatable_roles_oss::datatable_roles_unavailable, error::Result},
};
/// Checked first by every ACL route, before anything is read or connected to.
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub(crate) fn ensure_datatable_acl_available() -> Result<()> {
Err(datatable_roles_unavailable())
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub(crate) fn plan_statements(
_target: &AclTarget,
_change: &AclChange,
_dbname: &str,
_pg_role: &str,
_facts: &CatalogFacts,
) -> Result<AclPlan> {
Err(datatable_roles_unavailable())
}
@@ -1,6 +1,8 @@
#[cfg(feature = "parquet")]
pub mod ai_session_backups;
pub mod data_metrics;
pub mod datatable_acl;
pub mod datatable_acl_oss;
pub mod datatable_migrations;
pub mod datatable_permissions;
pub mod datatable_permissions_oss;
@@ -12,5 +14,8 @@ pub mod workspaces_oss;
#[cfg(feature = "private")]
pub mod workspaces_ee;
#[cfg(all(feature = "private", feature = "enterprise"))]
pub mod datatable_acl_ee;
#[cfg(all(feature = "private", feature = "enterprise"))]
pub mod datatable_permissions_ee;
@@ -144,6 +144,7 @@ pub fn workspaced_service() -> Router {
)
.merge(crate::datatable_migrations::routes())
.merge(crate::datatable_permissions::routes())
.merge(crate::datatable_acl::routes())
.route("/git_sync_enabled", get(get_git_sync_enabled))
.route("/git_sync_deploy_mode", get(get_git_sync_deploy_mode))
.route("/edit_git_sync_config", post(edit_git_sync_config))
+340
View File
@@ -5294,6 +5294,97 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/datatable_acl/{datatable_name}:
get:
summary: read the owner and grants of an instance data table's database, schema or table
operationId: getDatatableAcl
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
- name: kind
in: query
required: true
schema:
type: string
enum: [database, schema, table]
- name: schema
in: query
required: false
schema:
type: string
- name: table
in: query
required: false
schema:
type: string
responses:
"200":
description: owner and grants
content:
application/json:
schema:
$ref: "#/components/schemas/DatatableAclInfo"
/w/{workspace}/workspaces/datatable_acl/{datatable_name}/plan:
post:
summary: preview the SQL an ownership or grant change would run (data table administrators only)
operationId: planDatatableAcl
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AclChangeRequest"
responses:
"200":
description: statements that would run, in a single transaction
content:
application/json:
schema:
$ref: "#/components/schemas/AclPlan"
/w/{workspace}/workspaces/datatable_acl/{datatable_name}/apply:
post:
summary: run an ownership or grant change exactly as planned (data table administrators only)
operationId: applyDatatableAcl
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: datatable_name
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AclChangeRequest"
responses:
"200":
description: change applied
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}:
get:
summary: list the data table roles the caller may connect as
@@ -33649,6 +33740,255 @@ components:
datatable:
type: string
AclTarget:
description: what access is read or changed on
oneOf:
- $ref: "#/components/schemas/AclTargetDatabase"
- $ref: "#/components/schemas/AclTargetSchema"
- $ref: "#/components/schemas/AclTargetTable"
discriminator:
propertyName: kind
mapping:
database: "#/components/schemas/AclTargetDatabase"
schema: "#/components/schemas/AclTargetSchema"
table: "#/components/schemas/AclTargetTable"
AclTargetDatabase:
type: object
required: [kind]
properties:
kind:
type: string
enum: [database]
AclTargetSchema:
type: object
required: [kind, schema]
properties:
kind:
type: string
enum: [schema]
schema:
type: string
AclTargetTable:
type: object
required: [kind, schema, table]
properties:
kind:
type: string
enum: [table]
schema:
type: string
table:
type: string
AclChange:
description: one change to plan or apply
oneOf:
- $ref: "#/components/schemas/AclChangeSetOwner"
- $ref: "#/components/schemas/AclChangeGrant"
- $ref: "#/components/schemas/AclChangeRevoke"
discriminator:
propertyName: type
mapping:
set_owner: "#/components/schemas/AclChangeSetOwner"
grant: "#/components/schemas/AclChangeGrant"
revoke: "#/components/schemas/AclChangeRevoke"
AclChangeSetOwner:
type: object
description: >-
hands the target to role — for a schema, with everything already in it but an extension's
members, which stay with the extension
required: [type, role]
properties:
type:
type: string
enum: [set_owner]
role:
type: string
description: a data table role of the instance, or admin
AclChangeGrant:
type: object
required: [type, role, privileges, scope]
properties:
type:
type: string
enum: [grant]
role:
type: string
description: a data table role of the instance, or admin
privileges:
type: array
items:
type: string
scope:
$ref: "#/components/schemas/AclGrantScope"
AclChangeRevoke:
type: object
required: [type, role, privileges, scope]
properties:
type:
type: string
enum: [revoke]
role:
type: string
description: a data table role of the instance, other than admin
privileges:
type: array
items:
type: string
scope:
$ref: "#/components/schemas/AclGrantScope"
objects:
type: array
description: >-
objects inside the target the revoke covers, empty for the target itself. Only with the
target scope; a revoke on all objects of a kind is refused, since it cannot say which
grants it takes back.
items:
$ref: "#/components/schemas/AclObject"
AclGrantScope:
type: string
enum:
[
target,
all_tables,
all_sequences,
all_functions,
future_tables,
future_sequences,
future_functions,
]
AclChangeRequest:
type: object
required: [target, change]
properties:
target:
$ref: "#/components/schemas/AclTarget"
change:
$ref: "#/components/schemas/AclChange"
statements:
type: array
description: >-
The statements the plan showed. Required to apply, which plans again and refuses if
the result differs.
items:
type: string
AclPlan:
type: object
required: [statements, warnings]
properties:
statements:
type: array
items:
type: string
warnings:
type: array
items:
type: string
AclObject:
type: object
required: [name, kind]
properties:
name:
type: string
kind:
type: string
description: >-
TABLE, SEQUENCE, FUNCTION, PROCEDURE or TYPE — what the object is. A revoke turns it
into the keyword it takes, ROUTINE for both routine kinds; a type's grants are read
only.
args:
type: string
description: identity arguments of a routine, which is what tells two of the same name apart
AclGrant:
type: object
required: [grantee, privileges, sources]
properties:
grantee:
type: string
privileges:
type: array
items:
type: string
object:
$ref: "#/components/schemas/AclObject"
future:
type: string
description: >-
set for a default privilege, naming the kind of object it covers (TABLES, SEQUENCES,
FUNCTIONS, TYPES, or SCHEMAS). On a schema, the defaults set in that schema; on the
database, the ones set database-wide, which apply in every schema and which no schema's
own defaults take back.
sources:
type: array
description: >-
the roles the grant comes from, each once — who granted it, or for a default privilege
the role whose future objects it covers. A revoke of some of the grant's privileges
takes them back from every source that gave them.
items:
$ref: "#/components/schemas/AclSource"
AclSource:
type: object
required: [role, privileges, reachable]
properties:
role:
type: string
privileges:
type: array
description: >-
what role gave of the grant's privileges. A revoke is held back only by a source out of
reach that gave some of what it takes back.
items:
type: string
reachable:
type: boolean
description: >-
whether the data table's connection can take back what role gave. On an object that is
the owner, when the connection acts for the owner, and otherwise the connection itself;
for a default privilege, a creating role the connection acts for. What a source out of
reach gave is not revocable from here; privileges only other sources gave still are.
DatatableAclInfo:
type: object
required: [owner, roles, editable, supports_maintain, dbname, grants, children]
properties:
owner:
type: string
roles:
type: array
description: the roles a change may name; empty unless the caller may change anything
items:
type: string
editable:
type: boolean
description: whether the caller may plan and apply changes
supports_maintain:
type: boolean
description: whether the server is Postgres 17+, which added the MAINTAIN table privilege
dbname:
type: string
description: the database the target lives in
grants:
type: array
items:
$ref: "#/components/schemas/AclGrant"
children:
type: array
description: a database's schemas, or a schema's tables
items:
type: string
CustomInstanceDb:
type: object
required:
+14
View File
@@ -1024,6 +1024,20 @@ impl Future for TokioPgConnection {
}
}
impl TokioPgConnection {
/// Drive the connection and hand back what the server sends outside of a query's response —
/// notices above all, which driving it as a future silently discards.
pub fn poll_message(
&mut self,
cx: &mut core::task::Context<'_>,
) -> core::task::Poll<Option<Result<tokio_postgres::AsyncMessage, tokio_postgres::Error>>> {
match self {
TokioPgConnection::Tls(conn) => conn.poll_message(cx),
TokioPgConnection::NoTls(conn) => conn.poll_message(cx),
}
}
}
impl PgDatabase {
/// The role the connection logs in as, whichever way it authenticates.
pub fn login_name(&self) -> &str {
@@ -1093,8 +1093,10 @@
migrations are set up and used, how often an empty workspace home is seen, how often
the home pages create menu and hub-project picker are opened and from which entry
point, the name of any public hub project imported from the home page and how far that
import got, whether a pre-approved trial offer was opened, and whether data tables are
put under roles and whether callers name a role or take the default, last 30 days)</li
import got, whether a pre-approved trial offer was opened, whether data tables are put
under roles and whether callers name a role or take the default, and which kinds of
access change (grant, revoke, ownership, default privileges) are applied to data
tables, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger, worker and data table
@@ -1160,8 +1162,10 @@
migrations are set up and used, how often an empty workspace home is seen, how often
the home pages create menu and hub-project picker are opened and from which entry
point, the name of any public hub project imported from the home page and how far that
import got, whether a pre-approved trial offer was opened, and whether data tables are
put under roles and whether callers name a role or take the default, last 30 days)</li
import got, whether a pre-approved trial offer was opened, whether data tables are put
under roles and whether callers name a role or take the default, and which kinds of
access change (grant, revoke, ownership, default privileges) are applied to data
tables, last 30 days)</li
>
<li
>feature adoption (counts of which flow, script, trigger, worker and data table
@@ -0,0 +1,50 @@
<script lang="ts">
import Select from '../select/Select.svelte'
let {
schemas,
tables,
schemasLoading = false,
schema = $bindable(),
table = $bindable()
}: {
/** The database's schemas, as the editor last read them. */
schemas: string[]
/** The picked schema's tables, as the editor last read them. */
tables: string[]
/** The editor has not read the database yet, so `schemas` is not known to be empty. */
schemasLoading?: boolean
/** Unset for the database itself. */
schema?: string
/** Unset for the whole schema. */
table?: string
} = $props()
</script>
<div class="flex flex-wrap items-center gap-2">
<Select
items={schemas.map((s) => ({ value: s, label: s }))}
bind:value={
() => schema,
(s) => {
schema = s
table = undefined
}
}
placeholder="The database itself"
clearable
loading={schemasLoading}
size="sm"
class="w-56"
/>
{#if schema}
<Select
items={tables.map((t) => ({ value: t, label: t }))}
bind:value={table}
placeholder="The whole schema"
clearable
size="sm"
class="w-56"
/>
{/if}
</div>
@@ -0,0 +1,289 @@
<script lang="ts">
import { WorkspaceService, type AclChange, type AclTarget, type DatatableAclInfo } from '$lib/gen'
import { resource } from 'runed'
import { 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 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 {
ADMIN_ROLE,
blockingSources,
grantKey,
grantScopeLabel,
groupGrants,
revocablePrivileges,
revokeScopeOf,
uncoveredCreators
} from './aclScopes'
let {
workspace,
datatable,
target,
onLoaded
}: {
workspace: string
datatable: string
/** What owner and grants are read and written for. */
target: AclTarget
/** Each read, with the target it was made for: it also lists what the target holds. */
onLoaded?: (target: AclTarget, info: DatatableAclInfo) => void
} = $props()
const acl = resource(
() => [workspace, datatable, target] as const,
async ([ws, dt, t]) => {
const loaded = await WorkspaceService.getDatatableAcl({
workspace: ws,
datatableName: dt,
kind: t.kind,
schema: t.kind === 'database' ? undefined : t.schema,
table: t.kind === 'table' ? t.table : undefined
})
onLoaded?.(t, loaded)
return loaded
}
)
// 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<
{ change: AclChange; statements: string[]; warnings: string[]; title: string } | undefined
>(undefined)
let planning = $state(false)
let applying = $state(false)
const info: DatatableAclInfo | undefined = $derived(acl.current)
const grantRows = $derived(groupGrants(info?.grants ?? []))
const ownerItems = $derived(
info
? (info.roles.includes(info.owner) ? info.roles : [info.owner, ...info.roles]).map((r) => ({
value: r,
label: r
}))
: []
)
/** A revoke listed per object takes them all: say that it does. */
const pendingCoversObjects = $derived(
pending?.change.type === 'revoke' && (pending.change.objects?.length ?? 0) > 1
)
function errorText(e: any): string {
return e?.body ?? e?.message ?? String(e)
}
async function confirm(change: AclChange, title: string) {
planning = true
try {
const plan = await WorkspaceService.planDatatableAcl({
workspace,
datatableName: datatable,
requestBody: { target, change }
})
pending = { change, statements: plan.statements, warnings: plan.warnings, title }
} catch (e) {
sendUserToast(errorText(e), true)
} finally {
planning = false
}
}
async function apply() {
if (!pending) return
applying = true
try {
await WorkspaceService.applyDatatableAcl({
workspace,
datatableName: datatable,
requestBody: { target, change: pending.change, statements: pending.statements }
})
sendUserToast(pending.title)
pending = undefined
await acl.refetch()
} catch (e) {
sendUserToast(errorText(e), true)
} finally {
applying = false
}
}
</script>
{#if acl.error}
<Alert type="error" title="Could not read access" size="xs">{errorText(acl.error)}</Alert>
{:else if !info}
<span class="text-xs text-secondary">Loading…</span>
{:else}
<div class="flex flex-col gap-4">
{#if !info.editable}
<span class="text-xs text-secondary">
Read only: access is changed by the admins of the workspace that governs this data table, on
Windmill Enterprise Edition.
</span>
{/if}
{#if target.kind !== 'database'}
<section class="flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-xs font-semibold text-emphasis">Owner</span>
<span class="text-xs text-secondary">
{target.kind === 'schema'
? 'The role that owns the schema and everything already in it, except what belongs to an extension, which stays with the extension. Changing it also keeps the new owner in reach of what the current roles create here from then on; a role added afterwards is not covered.'
: 'The role that owns the table. Its owner may always read and write it, and is who ALTER and DROP answer to.'}
</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}`)
}
}
}
/>
{:else}
<span class="font-mono text-xs">{info.owner}</span>
{/if}
</section>
{/if}
<section class="flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<span class="text-xs font-semibold text-emphasis">Grants</span>
<span class="text-xs text-secondary">
{target.kind === 'database'
? 'What each role may do on the database itself — CREATE is the right to create schemas in it — and what default privileges set database-wide give it on what is created later, in every schema. No schema can take those back.'
: '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}`
)}
/>
{/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>
<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}
</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}
<Button
unifiedSize="xs"
variant="subtle"
iconOnly
startIcon={{ icon: Trash2 }}
title="Revoke {revocable.join(', ')}"
disabled={planning || applying}
onClick={() =>
confirm(
{
type: 'revoke',
role: grant.grantee,
privileges: revocable,
scope: revokeScope,
objects: grant.objects
},
`Revoked ${revocable.join(', ')} from ${grant.grantee}`
)}
/>
{/if}
</Cell>
</Row>
{/each}
</tbody>
</DataTable>
{/if}
</section>
</div>
{/if}
<ConfirmationModal
open={!!pending}
title="Run the following?"
confirmationText="Run"
type="info"
alwaysPortal
loading={applying}
onConfirmed={apply}
onCanceled={() => (pending = undefined)}
>
<div class="flex flex-col gap-3 min-w-0">
{#if pendingCoversObjects}
<Alert type="info" title="This covers every listed object" size="xs">
The same privileges on several objects read as one row, and are revoked together.
</Alert>
{/if}
{#each pending?.warnings ?? [] as warning (warning)}
<Alert type="warning" title="Warning" size="xs">{warning}</Alert>
{/each}
<span class="text-sm text-secondary">
Runs against <span class="font-mono">{datatable}</span> in a single transaction:
</span>
<pre class="overflow-auto text-xs bg-surface-secondary p-3 rounded select-all max-h-80"
>{(pending?.statements ?? []).join(';\n')};</pre
>
</div>
</ConfirmationModal>
@@ -0,0 +1,95 @@
<script lang="ts">
import type { AclTarget } from '$lib/gen'
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'
let {
target,
roles,
supportsMaintain = false,
dbname,
disabled = false,
onAdd
}: {
target: AclTarget
/** Roles the grant can be handed to. */
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
onAdd: (grant: { role: string; privileges: string[]; scope: AclScope }) => void
} = $props()
let role = $state<string | undefined>(undefined)
let scope = $state<AclScope>('target')
let privileges = $state<string[]>([])
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>
<Select
bind:value={
() => scope,
(s) => {
if (!s) return
scope = s
// A privilege only exists for some objects — SELECT means nothing on a function —
// so drop what the new scope cannot carry rather than send it.
const allowed = privilegesOf(s, target.kind, supportsMaintain)
privileges = privileges.filter((p) => allowed.includes(p))
}
}
items={scopesOf(target.kind)}
{disabled}
size="sm"
class="w-52"
/>
<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}
</div>
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest'
import type { AclGrant } from '$lib/gen'
import {
blockingSources,
grantKey,
groupGrants,
revocablePrivileges,
revokeScopeOf,
uncoveredCreators
} from './aclScopes'
const table = (name: string) => ({ name, kind: 'TABLE' })
const by = (role: string, privileges: string[], reachable = true) => ({
role,
privileges,
reachable
})
const byAdmin = (grant: Omit<AclGrant, 'sources'>): AclGrant => ({
...grant,
sources: [by('admin', grant.privileges)]
})
describe('grantKey', () => {
it('tells apart a table and a function of the same name', () => {
const row = (object: { name: string; kind: string; args?: string }) => ({
grantee: 'analytics',
privileges: ['SELECT'],
objects: [object],
sources: [by('admin', ['SELECT'])]
})
expect(grantKey(row(table('orders')))).not.toBe(
grantKey(row({ name: 'orders', kind: 'FUNCTION', args: '' }))
)
})
})
describe('groupGrants', () => {
// A row's revoke names every object in it, so a row must only hold what one revoke may take.
it('folds the same privileges on objects of one kind, and nothing else', () => {
const grants: AclGrant[] = [
{ grantee: 'analytics', privileges: ['SELECT'], object: table('orders') },
{ grantee: 'analytics', privileges: ['SELECT'], object: table('salaries') },
{ grantee: 'operator', privileges: ['SELECT'], object: table('orders') },
{ grantee: 'analytics', privileges: ['INSERT', 'SELECT'], object: table('events') },
{ grantee: 'analytics', privileges: ['SELECT'], object: { name: 's', kind: 'SEQUENCE' } },
{ grantee: 'analytics', privileges: ['SELECT'], future: 'TABLES' },
{ grantee: 'analytics', privileges: ['USAGE'] }
].map(byAdmin)
const rows = groupGrants(grants)
expect(rows.map((r) => [r.grantee, r.privileges, r.objects, r.future])).toEqual([
['analytics', ['SELECT'], [table('orders'), table('salaries')], undefined],
['operator', ['SELECT'], [table('orders')], undefined],
['analytics', ['INSERT', 'SELECT'], [table('events')], undefined],
['analytics', ['SELECT'], [{ name: 's', kind: 'SEQUENCE' }], undefined],
['analytics', ['SELECT'], [], 'TABLES'],
['analytics', ['USAGE'], [], undefined]
])
})
// A revoke takes the row back from every source, so the row must name them all, with what each
// gave.
it('keeps every source of the grants it folds', () => {
const grants: AclGrant[] = [
{
grantee: 'analytics',
privileges: ['SELECT'],
object: table('orders'),
sources: [by('admin', ['SELECT'])]
},
{
grantee: 'analytics',
privileges: ['SELECT'],
object: table('salaries'),
sources: [by('admin', ['SELECT']), by('operator', ['SELECT'])]
}
]
expect(groupGrants(grants)[0].sources).toEqual([
by('admin', ['SELECT']),
by('operator', ['SELECT'])
])
})
})
describe('revoke of a row', () => {
const row = (future?: string) => ({
grantee: 'analytics',
privileges: ['SELECT'],
objects: [],
future,
sources: [by('admin', ['SELECT'])]
})
it('takes back only what the editor may revoke on the database', () => {
const database = { ...row(), privileges: ['CONNECT', 'CREATE'] }
expect(revocablePrivileges(database, { kind: 'database' })).toEqual(['CREATE'])
expect(revocablePrivileges(database, { kind: 'schema', schema: 'public' })).toEqual([
'CONNECT',
'CREATE'
])
// Set database-wide, so not one a schema's revoke could take back either.
const schemasLater = { ...row('SCHEMAS'), privileges: ['CREATE'] }
expect(revocablePrivileges(schemasLater, { kind: 'database' })).toEqual([])
})
it('maps default privileges to their scope, and refuses the ones it has none for', () => {
expect(revokeScopeOf(row())).toBe('target')
expect(revokeScopeOf(row('TABLES'))).toBe('future_tables')
expect(revokeScopeOf(row('TYPES'))).toBeUndefined()
expect(revokeScopeOf({ ...row(), objects: [{ name: 'mood', kind: 'TYPE' }] })).toBeUndefined()
})
// Postgres takes a grant back only through its source: offering the revoke would promise what
// the plan then refuses. But only the sources of what is revoked count: the catalog's CONNECT
// on the database comes from its owner, out of reach, and must not hold back a CREATE the
// editor granted.
it('is held back only by a source out of reach for what it takes', () => {
const database = {
...row(),
privileges: ['CONNECT', 'CREATE'],
sources: [by('postgres', ['CONNECT'], false), by('admin', ['CREATE'])]
}
const revocable = revocablePrivileges(database, { kind: 'database' })
expect(blockingSources(database, revocable)).toEqual([])
expect(blockingSources(database, ['CONNECT'])).toEqual(['postgres'])
const partly = {
...row('TABLES'),
sources: [by('admin', ['SELECT']), by('postgres', ['SELECT'], false)]
}
expect(blockingSources(partly, ['SELECT'])).toEqual(['postgres'])
})
// Whether a grant can be taken back depends on its object, so a row folding several objects is
// only revocable if each of its grants is.
it('is held back by a source out of reach on any of the objects it folds', () => {
const grants: AclGrant[] = [
{
grantee: 'analytics',
privileges: ['SELECT'],
object: table('orders'),
sources: [by('admin', ['SELECT'])]
},
{
grantee: 'analytics',
privileges: ['SELECT'],
object: table('salaries'),
sources: [by('admin', ['SELECT'], false)]
}
]
const [folded] = groupGrants(grants)
expect(folded.objects).toHaveLength(2)
expect(blockingSources(folded, ['SELECT'])).toEqual(['admin'])
// Folding reads the grants, never rewrites them.
expect(grants[0].sources[0].reachable).toBe(true)
})
})
describe('uncoveredCreators', () => {
// A default privilege binds only the creating roles it was granted for: a role added since is
// left out until the grant is made again.
it('names the roles a created-later row leaves out', () => {
const future = {
grantee: 'analytics',
privileges: ['SELECT'],
objects: [],
future: 'TABLES',
sources: [by('admin', ['SELECT']), by('analytics', ['SELECT'])]
}
expect(uncoveredCreators(future, ['admin', 'analytics', 'late'])).toEqual(['late'])
expect(uncoveredCreators({ ...future, future: undefined }, ['late'])).toEqual([])
// Set by a role outside the catalog, it was never meant to cover the catalog's roles.
expect(
uncoveredCreators({ ...future, sources: [by('postgres', ['SELECT'], false)] }, [
'admin',
'late'
])
).toEqual([])
})
})
@@ -0,0 +1,211 @@
import type { AclGrant, AclSource, AclTarget } from '$lib/gen'
/** The role a data table connects as without roles — `custom_instance_user` in Postgres. */
export const ADMIN_ROLE = 'admin'
/** Privileges Postgres accepts per kind of object. Mirrors the whitelist the backend validates
* against — a privilege missing here just cannot be built. */
/** `CREATE` on a database is the right to create schemas in it, and the only database privilege
* handed out here: `CONNECT` is managed with the instance's role catalog. */
export const DATABASE_PRIVILEGES = ['CREATE']
export const SCHEMA_PRIVILEGES = ['USAGE', 'CREATE']
export const TABLE_PRIVILEGES = [
'SELECT',
'INSERT',
'UPDATE',
'DELETE',
'TRUNCATE',
'REFERENCES',
'TRIGGER'
]
/** Postgres 17 and later only, so it is offered from what the server reports. */
export const MAINTAIN_PRIVILEGE = 'MAINTAIN'
export const SEQUENCE_PRIVILEGES = ['USAGE', 'SELECT', 'UPDATE']
export const FUNCTION_PRIVILEGES = ['EXECUTE']
export type AclScope =
| 'target'
| 'all_tables'
| 'all_sequences'
| 'all_functions'
| 'future_tables'
| 'future_sequences'
| 'future_functions'
export type AclTargetKind = AclTarget['kind']
/** The scopes a target can grant on, in the order the builder offers them. */
export function scopesOf(kind: AclTargetKind): { value: AclScope; label: string }[] {
if (kind === 'database') return [{ value: 'target', label: 'the database itself' }]
if (kind === 'table') return [{ value: 'target', label: 'this table' }]
return [
{ value: 'target', label: 'the schema itself' },
{ value: 'all_tables', label: 'all tables in it' },
{ value: 'all_sequences', label: 'all sequences in it' },
{ value: 'all_functions', label: 'all functions in it' },
{ value: 'future_tables', label: 'tables created later' },
{ value: 'future_sequences', label: 'sequences created later' },
{ value: 'future_functions', label: 'functions created later' }
]
}
export function privilegesOf(
scope: AclScope,
kind: AclTargetKind,
supportsMaintain = false
): string[] {
const tablePrivileges = supportsMaintain
? [...TABLE_PRIVILEGES, MAINTAIN_PRIVILEGE]
: TABLE_PRIVILEGES
switch (scope) {
case 'target':
if (kind === 'database') return DATABASE_PRIVILEGES
return kind === 'schema' ? SCHEMA_PRIVILEGES : tablePrivileges
case 'all_tables':
case 'future_tables':
return tablePrivileges
case 'all_sequences':
case 'future_sequences':
return SEQUENCE_PRIVILEGES
case 'all_functions':
case 'future_functions':
return FUNCTION_PRIVILEGES
}
}
/** 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 = {
grantee: string
privileges: string[]
objects: NonNullable<AclGrant['object']>[]
future?: string
/** Every role the row's grants come from, each once, with what it gave. */
sources: AclSource[]
}
export function groupGrants(grants: AclGrant[]): GroupedGrant[] {
const rows: GroupedGrant[] = []
for (const grant of grants) {
const existing = grant.object
? rows.find(
(r) =>
r.grantee === grant.grantee &&
r.future === grant.future &&
r.objects[0]?.kind === grant.object?.kind &&
r.privileges.join() === grant.privileges.join()
)
: undefined
if (existing) {
existing.objects.push(grant.object!)
for (const source of grant.sources) {
const known = existing.sources.find((s) => s.role === source.role)
// Whether a role's grant can be taken back depends on the object it is on, so a row
// holds a source as reachable only if it is on every object the row folds.
if (known) {
known.reachable &&= source.reachable
known.privileges = [...new Set([...known.privileges, ...source.privileges])].sort()
} else {
existing.sources.push({ ...source, privileges: [...source.privileges] })
}
}
} else {
rows.push({
grantee: grant.grantee,
privileges: grant.privileges,
objects: grant.object ? [grant.object] : [],
future: grant.future,
sources: grant.sources.map((s) => ({ ...s, privileges: [...s.privileges] }))
})
}
}
return rows
}
/** The roles that gave some of `privileges` and that this data table's connection cannot act for.
* Only they can take those grants back, so a revoke of `privileges` is not offered. */
export function blockingSources(grant: GroupedGrant, privileges: string[]): string[] {
return grant.sources
.filter((s) => !s.reachable && s.privileges.some((p) => privileges.includes(p)))
.map((s) => s.role)
}
/** Which of `roles` a "created later" row granted for some of them does not cover. A default
* privilege binds only the creating roles it was granted for, so what the others create stays out
* of it. A row none of `roles` set — the instance's own, say — was never meant to cover them, and
* names none. */
export function uncoveredCreators(grant: GroupedGrant, roles: string[]): string[] {
if (!grant.future || !grant.sources.some((s) => roles.includes(s.role))) return []
return roles.filter((r) => !grant.sources.some((s) => s.role === r))
}
/** A row's identity. Two rows may share a grantee and an object name — a table `orders` and a
* function `orders()` — so the kind and the privileges are part of it too. */
export function grantKey(grant: GroupedGrant): string {
return [
grant.grantee,
grant.future ?? '',
grant.privileges.join(','),
...grant.objects.map((o) => `${o.kind}:${o.name}(${o.args ?? ''})`)
].join('|')
}
/** The scope a revoke of this row takes, or `undefined` when the builder cannot express it —
* Postgres also records privileges on types, present and default, which nothing here grants and
* the API has no scope for. */
export function revokeScopeOf(grant: GroupedGrant): AclScope | undefined {
if (!grant.future) return grant.objects.some((o) => o.kind === 'TYPE') ? undefined : 'target'
const scope = `future_${grant.future.toLowerCase()}`
return (['future_tables', 'future_sequences', 'future_functions'] as const).find(
(s) => s === scope
)
}
/** The privileges of a row a revoke may take back. On the database that is `CREATE` alone:
* `CONNECT` belongs to the role catalog, which would grant it again, and `TEMPORARY` is not one
* the editor hands out — a row holding only those has nothing to revoke here. A database's rows
* "created later" are default privileges set database-wide, which nothing here revokes. */
export function revocablePrivileges(grant: GroupedGrant, target: AclTarget): string[] {
if (target.kind === 'database' && grant.objects.length === 0) {
if (grant.future) return []
return grant.privileges.filter((p) => DATABASE_PRIVILEGES.includes(p))
}
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}`
}
if (grant.objects.length > 1)
return `${grant.objects.length} ${grant.objects[0].kind.toLowerCase()}s`
return 'itself'
}
@@ -11,10 +11,14 @@
GroupService,
UserService,
WorkspaceService,
type AclTarget,
type DatatableAclInfo,
type DatatablePermissions,
type InstanceDatatableRole
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import AclTargetPicker from '../datatableAcl/AclTargetPicker.svelte'
import PgAclEditor from '../datatableAcl/PgAclEditor.svelte'
const ADMIN_ROLE = 'admin'
@@ -49,11 +53,38 @@
const availableRoles: InstanceDatatableRole[] = $derived(info?.available_roles ?? [])
const unusedRoles = $derived(availableRoles.filter((r) => !rows.some((row) => row.id === r.id)))
let aclSchema = $state<string | undefined>(undefined)
let aclTable = $state<string | undefined>(undefined)
const aclTarget: AclTarget = $derived(
aclSchema
? aclTable
? { kind: 'table', schema: aclSchema, table: aclTable }
: { kind: 'schema', schema: aclSchema }
: { kind: 'database' }
)
let aclSchemas = $state<string[]>([])
let aclSchemasLoaded = $state(false)
let aclTables = $state<string[]>([])
// The editor's read of a database lists its schemas, and of a schema its tables — which is what
// the picker offers, so the picker reads nothing of its own. A read for a target since left
// behind is dropped.
function onAclLoaded(target: AclTarget, loaded: DatatableAclInfo) {
if (JSON.stringify(target) !== JSON.stringify(aclTarget)) return
if (target.kind === 'database') {
aclSchemas = loaded.children
aclSchemasLoaded = true
} else if (target.kind === 'schema') aclTables = loaded.children
}
async function load() {
loading = true
loadError = undefined
try {
const res = await WorkspaceService.getDatatablePermissions({ workspace, datatableName: datatable })
const res = await WorkspaceService.getDatatablePermissions({
workspace,
datatableName: datatable
})
info = res
permissioned = res.permissioned
defaultRole = res.default_role
@@ -124,6 +155,11 @@
}
export function open() {
aclSchema = undefined
aclTable = undefined
aclSchemas = []
aclSchemasLoaded = false
aclTables = []
drawer?.openDrawer()
load()
}
@@ -143,8 +179,11 @@
<DrawerContent
title="Roles for {datatable}"
on:close={() => drawer?.closeDrawer()}
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 privileges with SQL. Roles are defined for the whole instance; here you say who may use each one on this data table."
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 loading}
<p class="text-sm text-secondary">Loading…</p>
{:else if loadError}
@@ -173,7 +212,7 @@
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}
{#each info.ungoverned_reachers as reacher (`${reacher.workspace_id}/${reacher.datatable}`)}
<li>{reacher.workspace_id} / {reacher.datatable}</li>
{/each}
</ul>
@@ -194,7 +233,7 @@
{#if 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 adds a data table
role in the data table settings page.
role, from Instance roles at the top of the data tables settings page.
</Alert>
{/if}
@@ -271,6 +310,34 @@
</Button>
</div>
{/if}
{#if info?.supported}
<div class="flex flex-col gap-3 border-t pt-4">
<div class="flex flex-col gap-0.5">
<span class="text-sm font-semibold text-emphasis">Access</span>
<span class="text-xs text-secondary">
What each role may do in Postgres, on the database, a schema or a table. Every
change shows the SQL it runs before running it.
</span>
</div>
<AclTargetPicker
schemas={aclSchemas}
schemasLoading={!aclSchemasLoaded}
tables={aclTables}
bind:schema={
() => aclSchema,
(s) => {
aclSchema = s
aclTables = []
}
}
bind:table={aclTable}
/>
{#key JSON.stringify(aclTarget)}
<PgAclEditor {workspace} {datatable} target={aclTarget} onLoaded={onAclLoaded} />
{/key}
</div>
{/if}
</div>
{/if}
</DrawerContent>
@@ -3,7 +3,6 @@
import CloseButton from '../common/CloseButton.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Toggle from '../Toggle.svelte'
import Tooltip from '../Tooltip.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import Cell from '../table/Cell.svelte'
@@ -83,16 +82,6 @@
<ConfirmationModal {...confirmationModal.props} />
<div class="flex flex-col gap-2">
<div class="flex items-baseline gap-1">
<h3 class="font-semibold text-sm">Instance roles</h3>
<Tooltip>
A data table role is a real Postgres login on this instance, shared by every instance
database. A job that names one connects as it, and Postgres decides what it may touch — grant
it privileges with SQL. Which people may use a role on a given data table is set per data
table, in its roles drawer.
</Tooltip>
</div>
{#if loadError}
<Alert type="error" title="Could not load the instance roles" size="xs">{loadError}</Alert>
{:else}
@@ -92,8 +92,7 @@
type GetSettingsResponse,
type TestDataTableConnectionResponse
} from '$lib/gen'
// `superadmin` gates the commented-out roles section at the bottom; restore it there.
import { workspaceStore } from '$lib/stores'
import { enterpriseLicense, superadmin, workspaceStore } from '$lib/stores'
import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { resource } from 'runed'
@@ -101,10 +100,8 @@
import { Popover } from '../meltComponents'
import ExploreAssetButton from '../ExploreAssetButton.svelte'
import DataTableMigrationsButton from './DataTableMigrationsButton.svelte'
// Both components are complete and reviewed; their call sites in this file are commented
// out until the ACL editor lands. Uncomment these with them.
// import DataTablePermissionsButton from './DataTablePermissionsButton.svelte'
// import DataTableRolesSection from './DataTableRolesSection.svelte'
import DataTablePermissionsButton from './DataTablePermissionsButton.svelte'
import InstanceRolesButton from './InstanceRolesButton.svelte'
import { deepEqual } from 'fast-equals'
import { clone } from '$lib/utils'
import SettingsFooter from './SettingsFooter.svelte'
@@ -318,7 +315,13 @@
title="Data tables"
description="Relational storage the whole workspace shares under one name. Scripts, flows and apps address it as <span class='font-mono'>datatable://main</span> instead of picking a PostgreSQL resource, so nobody needs access to the credentials to query it, and you can point that name at another database without touching a line of code. Browse and edit tables, and version schema changes as migrations, from here."
link="https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables"
/>
>
{#snippet actions()}
{#if $superadmin && $enterpriseLicense && !isCloudHosted()}
<InstanceRolesButton />
{/if}
{/snippet}
</SettingsPageHeader>
{#if isCloudHosted()}
<Alert type="info" title="Instance database not available on cloud" class="mb-4" size="xs">
@@ -472,15 +475,6 @@
datatable={dataTable.name}
disabled={!!dirtyMap[dataTable.name]}
/>
<!-- Data table roles: not mounted yet. The enforcement ships first and this
drawer is what turns it on, so leaving it reachable would expose a half of the
feature whose other half (the ACL editor, which grants the privileges a role
actually needs) does not exist yet.
DataTablePermissionsButton.svelte is complete and reviewed — reuse it rather
than rewriting it, and uncomment this together with the roles section at the
bottom of this file and the two imports at the top.
{#if $enterpriseLicense}
<DataTablePermissionsButton
workspace={$workspaceStore ?? ''}
@@ -488,7 +482,6 @@
disabled={!!dirtyMap[dataTable.name]}
/>
{/if}
-->
<Button
size="xs"
color="light"
@@ -615,20 +608,6 @@
{/if}
{/if}
<!-- The instance role catalog, superadmin-only. Not mounted for the same reason as the
permissions drawer above: creating roles is only useful once there is a way to grant them
privileges, which arrives with the ACL editor.
DataTableRolesSection.svelte is complete and reviewed — reuse it rather than rewriting it,
and uncomment this together with the permissions button above and the two imports at the top.
{#if $superadmin && $enterpriseLicense && !isCloudHosted()}
<div class="mt-8">
<DataTableRolesSection />
</div>
{/if}
-->
<SettingsFooter
class="mt-8"
{hasUnsavedChanges}
@@ -0,0 +1,29 @@
<script lang="ts">
import { Badge, Button, Drawer, DrawerContent } from '../common'
import { Users } from 'lucide-svelte'
import DataTableRolesSection from './DataTableRolesSection.svelte'
let drawer: Drawer | undefined = $state(undefined)
</script>
<Button
unifiedSize="sm"
variant="default"
startIcon={{ icon: Users }}
on:click={() => drawer?.openDrawer()}
>
Instance roles
</Button>
<Drawer bind:this={drawer} size="700px">
<DrawerContent
title="Instance roles"
on:close={() => drawer?.closeDrawer()}
tooltip="A data table role is a real Postgres login on this instance, shared by every instance database. A job that names one connects as it, and Postgres decides what it may touch. Which people may use a role on a given data table, and what it may do there, is set per data table, in its roles drawer."
>
{#snippet titleExtra()}
<Badge color="blue" small>Beta</Badge>
{/snippet}
<DataTableRolesSection />
</DrawerContent>
</Drawer>