mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
72507d52a0
commit
76b796bc62
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 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, 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 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, 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>
|
||||
Reference in New Issue
Block a user