feat: add an ACL editor for data table roles

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DsU2Lf6wYQJ9o8ASKRgCmK
This commit is contained in:
Diego Imbert
2026-09-10 16:34:51 +02:00
co-authored by Claude Opus 5
parent 4600942286
commit 8bde6111cc
16 changed files with 2065 additions and 37 deletions
@@ -0,0 +1,78 @@
//! Who may change a data table's grants and owners: its administrators, from the workspace that
//! governs it, on an edition that has the planner. 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.
#[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(())
}
#[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(())
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn only_the_enterprise_edition_changes_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").await?;
assert_eq!(resp.status(), 400, "{action}");
let body = resp.text().await?;
assert!(body.contains("Enterprise Edition"), "{action}: {body}");
}
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
/*
* 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.
//!
//! Reading who owns what stays open in every edition; every change is a plan, so an edition
//! without the planner cannot make one. `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_acl_planner() -> windmill_common::error::Result<()> {
Ok(())
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
use {
crate::datatable_acl::{AclChange, AclPlan, AclTarget, OwnedObject},
windmill_common::error::{Error, Result},
};
/// Checked right after authorization, before anything connects with the instance's credentials.
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub(crate) fn ensure_acl_planner() -> Result<()> {
Err(Error::BadRequest(
"Data table permissions are a Windmill Enterprise Edition feature".to_string(),
))
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub(crate) fn plan_statements(
_target: &AclTarget,
_change: &AclChange,
_dbname: &str,
_pg_role: &str,
_other_pg_roles: &[String],
_existing_objects: &[OwnedObject],
) -> Result<AclPlan> {
ensure_acl_planner()?;
Err(Error::internal_err(
"No ACL planner in this edition".to_string(),
))
}
@@ -1,3 +1,5 @@
pub mod datatable_acl;
pub mod datatable_acl_oss;
pub mod datatable_migrations;
pub mod datatable_permissions;
pub mod deployment_requests;
@@ -8,3 +10,6 @@ pub mod workspaces_oss;
#[cfg(feature = "private")]
pub mod workspaces_ee;
#[cfg(all(feature = "private", feature = "enterprise"))]
pub mod datatable_acl_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))
+224
View File
@@ -5134,6 +5134,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
@@ -32556,6 +32647,139 @@ components:
datatable:
type: string
AclTarget:
type: object
required: [kind]
properties:
kind:
type: string
enum: [database, schema, table]
schema:
type: string
description: required for a schema or table target
table:
type: string
AclChange:
type: object
required: [type, role]
properties:
type:
type: string
enum: [set_owner, grant, revoke]
role:
type: string
description: a data table role of the instance, or admin
privileges:
type: array
items:
type: string
scope:
type: string
enum:
[
target,
all_tables,
all_sequences,
all_functions,
future_tables,
future_sequences,
future_functions,
]
objects:
type: array
description: objects inside the target a revoke covers, empty for the target itself
items:
$ref: "#/components/schemas/AclObject"
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 or FUNCTION — the keyword a REVOKE on it takes
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]
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
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:
@@ -102,9 +102,10 @@ pub fn validate_role_name(name: &str) -> Result<()> {
Ok(())
}
/// SAFETY: every caller must have run [`validate_role_name`] first — the charset it enforces is
/// what makes this quoting sufficient.
fn quote_ident(name: &str) -> String {
/// A double-quoted Postgres identifier. Doubling `"` is Postgres's own escaping inside one, so this
/// quotes any name — schema, table or role. Role names are validated as well
/// ([`validate_role_name`]) because they also travel unquoted, in `-- role <name>` and `?role=`.
pub fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
+14
View File
@@ -989,6 +989,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 {
@@ -1086,8 +1086,10 @@
the flow editor, how data tables and their 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, and whether data tables are
put under roles and whether callers name a role or take the default, last 30 days)</li
imported from the home page and how far that import got, 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
@@ -1150,8 +1152,10 @@
the flow editor, how data tables and their 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, and whether data tables are
put under roles and whether callers name a role or take the default, last 30 days)</li
imported from the home page and how far that import got, 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,74 @@
<script lang="ts">
import { WorkspaceService } from '$lib/gen'
import { resource } from 'runed'
import Select from '../select/Select.svelte'
let {
workspace,
datatable,
schema = $bindable(),
table = $bindable()
}: {
workspace: string
datatable: string
/** Unset for the database itself. */
schema?: string
/** Unset for the whole schema. */
table?: string
} = $props()
const schemas = resource(
() => [workspace, datatable] as const,
async ([ws, dt]) =>
(
await WorkspaceService.getDatatableAcl({
workspace: ws,
datatableName: dt,
kind: 'database'
})
).children
)
const tables = resource(
() => [workspace, datatable, schema] as const,
async ([ws, dt, s]) =>
s
? (
await WorkspaceService.getDatatableAcl({
workspace: ws,
datatableName: dt,
kind: 'schema',
schema: s
})
).children
: []
)
</script>
<div class="flex flex-wrap items-center gap-2">
<Select
items={(schemas.current ?? []).map((s) => ({ value: s, label: s }))}
bind:value={
() => schema,
(s) => {
schema = s
table = undefined
}
}
placeholder="The database itself"
clearable
loading={schemas.loading}
size="sm"
class="w-56"
/>
{#if schema}
<Select
items={(tables.current ?? []).map((t) => ({ value: t, label: t }))}
bind:value={table}
placeholder="The whole schema"
clearable
loading={tables.loading}
size="sm"
class="w-56"
/>
{/if}
</div>
@@ -0,0 +1,260 @@
<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,
grantScopeLabel,
groupGrants,
revocablePrivileges,
revokeScopeOf
} from './aclScopes'
let {
workspace,
datatable,
target
}: {
workspace: string
datatable: string
/** What owner and grants are read and written for. */
target: AclTarget
} = $props()
const acl = resource(
() => [workspace, datatable, target] as const,
async ([ws, dt, t]) =>
await WorkspaceService.getDatatableAcl({
workspace: ws,
datatableName: dt,
kind: t.kind,
schema: t.schema,
table: t.kind === 'table' ? t.table : undefined
})
)
// 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.
</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. Changing it also keeps the new owner in reach of what the other roles create here later.'
: '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.'
: '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 (grant.grantee + grant.objects
.map((o) => `${o.name}(${o.args ?? ''})`)
.join() + (grant.future ?? ''))}
{@const revokeScope = revokeScopeOf(grant)}
{@const revocable = revocablePrivileges(grant, target)}
<Row>
<Cell first>{grant.grantee}</Cell>
<Cell wrap
><span class="font-mono text-2xs">{grant.privileges.join(', ')}</span></Cell
>
<Cell>{grantScopeLabel(grant)}</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 && 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,166 @@
import type { AclGrant, 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
}
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!)
} else {
rows.push({
grantee: grant.grantee,
privileges: grant.privileges,
objects: grant.object ? [grant.object] : [],
future: grant.future
})
}
}
return rows
}
/** The scope a revoke of this row takes, or `undefined` when the builder cannot express it —
* Postgres also records default privileges on types, which nothing here grants and the API has no
* scope for. */
export function revokeScopeOf(grant: GroupedGrant): AclScope | undefined {
if (!grant.future) return '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 leaves out `CONNECT`,
* which the role catalog grants and would grant again. */
export function revocablePrivileges(grant: GroupedGrant, target: AclTarget): string[] {
if (target.kind === 'database' && grant.objects.length === 0) {
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,13 @@
GroupService,
UserService,
WorkspaceService,
type AclTarget,
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 +52,24 @@
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' }
)
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 +140,8 @@
}
export function open() {
aclSchema = undefined
aclTable = undefined
drawer?.openDrawer()
load()
}
@@ -143,7 +161,7 @@
<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."
>
{#if loading}
<p class="text-sm text-secondary">Loading…</p>
@@ -173,7 +191,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>
@@ -271,6 +289,27 @@
</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
{workspace}
{datatable}
bind:schema={aclSchema}
bind:table={aclTable}
/>
{#key JSON.stringify(aclTarget)}
<PgAclEditor {workspace} {datatable} target={aclTarget} />
{/key}
</div>
{/if}
</div>
{/if}
</DrawerContent>
@@ -87,9 +87,9 @@
<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.
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.
</Tooltip>
</div>
@@ -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 { 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 DataTableRolesSection from './DataTableRolesSection.svelte'
import { deepEqual } from 'fast-equals'
import { clone } from '$lib/utils'
import SettingsFooter from './SettingsFooter.svelte'
@@ -472,21 +469,11 @@
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.
<DataTablePermissionsButton
workspace={$workspaceStore ?? ''}
datatable={dataTable.name}
disabled={!!dirtyMap[dataTable.name]}
/>
-->
<Button
size="xs"
color="light"
@@ -613,19 +600,11 @@
{/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 && !isCloudHosted()}
<div class="mt-8">
<DataTableRolesSection />
</div>
{/if}
-->
<SettingsFooter
class="mt-8"