mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
4600942286
commit
8bde6111cc
@@ -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))
|
||||
|
||||
@@ -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('"', "\"\""))
|
||||
}
|
||||
|
||||
|
||||
@@ -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 page’s 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 page’s 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"
|
||||
|
||||
Reference in New Issue
Block a user