mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
[ee] feat: datatable permissions via Postgres roles + RLS
Opt-in, EE-gated permissions for data tables, enforced natively by Postgres roles and row-level security. Non-admin queries connect directly as a per-user login role (tamper-proof against arbitrary user SQL); admins connect as the owner and keep full access. Adds a Permissions modal (Access + Row policies tabs) to the data table settings, get/set/sync endpoints, and enforcement in both the postgresql and DuckDB executors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
6c287041cd7edd4a77a4bc07ad0e156cec32cce4
|
||||
71701991e6ab76c5b3033a6c765ab246a50c35dd
|
||||
|
||||
@@ -43,9 +43,10 @@ use windmill_common::workspaces::GitRepositorySettings;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||||
use windmill_common::workspaces::{
|
||||
check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db_unchecked,
|
||||
validate_dev_workspace_id, validate_fork_workspace_id, validate_workspace_name, DataTable,
|
||||
DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, ProtectionRules,
|
||||
check_deploy_rules, check_user_against_rule, get_datatable_from_db,
|
||||
get_datatable_resource_from_db_unchecked, validate_dev_workspace_id,
|
||||
validate_fork_workspace_id, validate_workspace_name, DataTable, DataTableCatalogResourceType,
|
||||
DataTableForkBehavior, DataTablePermissions, ProtectionRuleKind, ProtectionRules,
|
||||
ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME,
|
||||
};
|
||||
use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType};
|
||||
@@ -132,6 +133,18 @@ pub fn workspaced_service() -> Router {
|
||||
get(get_datatable_table_schema),
|
||||
)
|
||||
.route("/edit_datatable_config", post(edit_datatable_config))
|
||||
.route(
|
||||
"/datatable_permissions/{datatable_name}",
|
||||
get(get_datatable_permissions),
|
||||
)
|
||||
.route(
|
||||
"/set_datatable_permissions/{datatable_name}",
|
||||
post(set_datatable_permissions),
|
||||
)
|
||||
.route(
|
||||
"/sync_datatable_permissions/{datatable_name}",
|
||||
post(sync_datatable_permissions),
|
||||
)
|
||||
.merge(crate::datatable_migrations::routes())
|
||||
.route("/git_sync_enabled", get(get_git_sync_enabled))
|
||||
.route("/edit_git_sync_config", post(edit_git_sync_config))
|
||||
@@ -2447,18 +2460,21 @@ async fn edit_datatable_config(
|
||||
.map(|r| (r.to.as_str(), r.from.as_str()))
|
||||
.collect();
|
||||
|
||||
// Migrations opt-in is owned by the enable/disable endpoints, not this config
|
||||
// form: preserve each existing data table's flag, and default brand-new data
|
||||
// tables to enabled.
|
||||
// Migrations opt-in and advanced permissions are owned by their dedicated
|
||||
// endpoints, not this config form: preserve each existing data table's state
|
||||
// (default brand-new data tables to migrations-enabled / no permissions) so a
|
||||
// plain config save can neither flip them nor desync the provisioned roles.
|
||||
for (name, dt) in new_config.settings.datatables.iter_mut() {
|
||||
let lookup = rename_src
|
||||
.get(name.as_str())
|
||||
.copied()
|
||||
.unwrap_or(name.as_str());
|
||||
dt.migrations_enabled = match old_datatables.get(lookup) {
|
||||
let old = old_datatables.get(lookup);
|
||||
dt.migrations_enabled = match old {
|
||||
Some(old) => old.migrations_enabled,
|
||||
None => Some(true),
|
||||
};
|
||||
dt.permissions = old.and_then(|old| old.permissions.clone());
|
||||
}
|
||||
|
||||
let args_for_audit = format!("{:?}", new_config.settings);
|
||||
@@ -2517,6 +2533,87 @@ async fn edit_datatable_config(
|
||||
Ok(format!("Edit datatable config for workspace {}", &w_id))
|
||||
}
|
||||
|
||||
async fn get_datatable_permissions(
|
||||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
) -> Result<Json<Option<DataTablePermissions>>> {
|
||||
require_admin(is_admin, &username)?;
|
||||
let datatable = get_datatable_from_db(&db, &w_id, &datatable_name).await?;
|
||||
Ok(Json(datatable.permissions))
|
||||
}
|
||||
|
||||
/// Persist the advanced-permission config for a single data table and reconcile
|
||||
/// the target database (roles, grants, RLS policies) to match. Returns the
|
||||
/// reconciliation log so the modal can surface warnings (e.g. a resource
|
||||
/// database whose credentials lack the privileges to provision roles).
|
||||
async fn set_datatable_permissions(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
Json(perms): Json<DataTablePermissions>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
crate::datatable_migrations::validate_datatable_path_segment(&datatable_name)?;
|
||||
|
||||
// Load first so a missing data table is a clean 404, then write only the
|
||||
// `permissions` subfield (the rest of the catalog entry is untouched).
|
||||
let mut datatable = get_datatable_from_db(&db, &w_id, &datatable_name).await?;
|
||||
let perms_json =
|
||||
serde_json::to_value(&perms).map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
// Runtime (non-macro) query so it needs no `.sqlx` cache entry.
|
||||
sqlx::query(
|
||||
"UPDATE workspace_settings
|
||||
SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3::jsonb)
|
||||
WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.bind(&datatable_name)
|
||||
.bind(perms_json)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&db,
|
||||
&authed,
|
||||
"workspaces.set_datatable_permissions",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(&datatable_name),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
datatable.permissions = Some(perms);
|
||||
let log = windmill_common::datatable_permissions::reconcile_datatable_permissions(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&datatable,
|
||||
)
|
||||
.await?;
|
||||
Ok(log.join("\n"))
|
||||
}
|
||||
|
||||
/// Re-run reconciliation from the stored config, e.g. to pick up group-membership
|
||||
/// changes or newly-migrated tables without editing the permissions.
|
||||
async fn sync_datatable_permissions(
|
||||
ApiAuthed { is_admin, username, .. }: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, datatable_name)): Path<(String, String)>,
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
let datatable = get_datatable_from_db(&db, &w_id, &datatable_name).await?;
|
||||
let log = windmill_common::datatable_permissions::reconcile_datatable_permissions(
|
||||
&db,
|
||||
&w_id,
|
||||
&datatable_name,
|
||||
&datatable,
|
||||
)
|
||||
.await?;
|
||||
Ok(log.join("\n"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditGitSyncConfig {
|
||||
pub git_sync_settings: Option<WorkspaceGitSyncSettings>,
|
||||
|
||||
@@ -4759,6 +4759,78 @@ paths:
|
||||
error:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/datatable_permissions/{datatable_name}:
|
||||
get:
|
||||
summary: get the permissions configuration of a datatable
|
||||
operationId: getDatatablePermissions
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: datatable permissions or null if not configured
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
nullable: true
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/DataTablePermissions"
|
||||
|
||||
/w/{workspace}/workspaces/set_datatable_permissions/{datatable_name}:
|
||||
post:
|
||||
summary: set the permissions configuration of a datatable
|
||||
operationId: setDatatablePermissions
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: datatable permissions
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DataTablePermissions"
|
||||
responses:
|
||||
"200":
|
||||
description: reconciliation log
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/sync_datatable_permissions/{datatable_name}:
|
||||
post:
|
||||
summary: reconcile the datatable's database with its permissions configuration
|
||||
operationId: syncDatatablePermissions
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: datatable_name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: reconciliation log
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/enable_datatable_migrations/{datatable_name}:
|
||||
post:
|
||||
summary: opt a datatable in to migrations (admins / super admins only)
|
||||
@@ -29388,6 +29460,74 @@ components:
|
||||
- ran
|
||||
- not_run
|
||||
- unknown
|
||||
DataTablePermissions:
|
||||
type: object
|
||||
required: [enabled]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
grants:
|
||||
type: array
|
||||
default: []
|
||||
items:
|
||||
$ref: "#/components/schemas/DataTableGrant"
|
||||
policies:
|
||||
type: array
|
||||
default: []
|
||||
items:
|
||||
$ref: "#/components/schemas/DataTablePolicy"
|
||||
DataTableGrant:
|
||||
type: object
|
||||
required: [principal, access]
|
||||
properties:
|
||||
principal:
|
||||
$ref: "#/components/schemas/DataTablePrincipal"
|
||||
table:
|
||||
type: string
|
||||
access:
|
||||
type: string
|
||||
enum:
|
||||
- none
|
||||
- read
|
||||
- write
|
||||
DataTablePrincipal:
|
||||
type: object
|
||||
required: [kind, name]
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- user
|
||||
- group
|
||||
name:
|
||||
type: string
|
||||
description: user email or group name
|
||||
DataTablePolicy:
|
||||
type: object
|
||||
required: [table, name, command]
|
||||
properties:
|
||||
table:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
command:
|
||||
type: string
|
||||
default: all
|
||||
enum:
|
||||
- all
|
||||
- select
|
||||
- insert
|
||||
- update
|
||||
- delete
|
||||
principals:
|
||||
type: array
|
||||
default: []
|
||||
items:
|
||||
$ref: "#/components/schemas/DataTablePrincipal"
|
||||
using:
|
||||
type: string
|
||||
check:
|
||||
type: string
|
||||
DataTableSchema:
|
||||
type: object
|
||||
required: [datatable_name, schemas]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! OSS fallback for enterprise data-table permissions (implementation in
|
||||
//! windmill-ee-private, see `datatable_permissions_ee`). The public build has no
|
||||
//! RLS/role provisioning: reconciling reports that the enterprise edition is
|
||||
//! required, and query-time resolution always falls through to the default
|
||||
//! connection (the legacy behavior, where every workspace member shares the
|
||||
//! owner role and has full access).
|
||||
|
||||
use crate::{
|
||||
error::{Error, Result},
|
||||
workspaces::{DataTable, DatatableAccessDecision},
|
||||
DB,
|
||||
};
|
||||
|
||||
/// Reconcile the target database (roles, grants, RLS policies) to match the
|
||||
/// data table's stored permission config. Enterprise-only.
|
||||
pub async fn reconcile_datatable_permissions(
|
||||
_db: &DB,
|
||||
_w_id: &str,
|
||||
_datatable_name: &str,
|
||||
_datatable: &DataTable,
|
||||
) -> Result<Vec<String>> {
|
||||
Err(Error::internal_err(
|
||||
"Data table permissions require the enterprise edition".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Decide which Postgres identity a query against `datatable` should run under
|
||||
/// for the acting user. The public build never overrides the connection.
|
||||
pub async fn resolve_datatable_access(
|
||||
_db: &DB,
|
||||
_w_id: &str,
|
||||
_datatable_name: &str,
|
||||
_datatable: &DataTable,
|
||||
_base_creds: &serde_json::Value,
|
||||
_acting_email: &str,
|
||||
_is_workspace_admin: bool,
|
||||
) -> Result<DatatableAccessDecision> {
|
||||
Ok(DatatableAccessDecision::Default)
|
||||
}
|
||||
@@ -42,6 +42,13 @@ mod db_entra_ee;
|
||||
mod db_iam_ee;
|
||||
pub mod db_params;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod datatable_permissions_ee;
|
||||
pub mod datatable_permissions_oss;
|
||||
#[cfg(feature = "private")]
|
||||
pub use datatable_permissions_ee as datatable_permissions;
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub use datatable_permissions_oss as datatable_permissions;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod deployment_requests_ee;
|
||||
pub mod deployment_requests_oss;
|
||||
#[cfg(feature = "private")]
|
||||
|
||||
@@ -777,6 +777,164 @@ pub struct DataTable {
|
||||
/// when migrations already exist (see `datatable_migrations_enabled`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub migrations_enabled: Option<bool>,
|
||||
/// Advanced (EE) permissions. Absent/disabled = legacy behavior: every
|
||||
/// workspace member connects as the shared owner role and has full access.
|
||||
/// Owned by the dedicated permissions endpoints, not `edit_datatable_config`
|
||||
/// (mirrors `migrations_enabled`) so a plain config save never clobbers it or
|
||||
/// desyncs the provisioned Postgres roles/policies from the stored config.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permissions: Option<DataTablePermissions>,
|
||||
}
|
||||
|
||||
/// Desired advanced-permission state for a data table. This is the source of
|
||||
/// truth the modal edits; the EE provisioning engine reconciles the target
|
||||
/// Postgres database (roles, grants, RLS policies) to match it.
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||
pub struct DataTablePermissions {
|
||||
/// Opt-in switch. When false, no enforcement happens and provisioned roles
|
||||
/// (if any) are left in place but unused.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Table/operation access, resolved to Postgres GRANTs per principal.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub grants: Vec<DataTableGrant>,
|
||||
/// Row-level security policies applied to individual tables.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub policies: Vec<DataTablePolicy>,
|
||||
}
|
||||
|
||||
/// A Windmill principal, mirrored into a Postgres role: `wm_u_<slug>` (a LOGIN
|
||||
/// role, one per user) or `wm_g_<slug>` (a NOLOGIN membership role, one per group).
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[serde(tag = "kind", content = "name", rename_all = "snake_case")]
|
||||
pub enum DataTablePrincipal {
|
||||
/// User email.
|
||||
User(String),
|
||||
/// Group name.
|
||||
Group(String),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct DataTableGrant {
|
||||
pub principal: DataTablePrincipal,
|
||||
/// `None` targets every table (existing tables plus future ones via
|
||||
/// `ALTER DEFAULT PRIVILEGES`); `Some(t)` targets a single table.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub table: Option<String>,
|
||||
pub access: DataTableAccess,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DataTableAccess {
|
||||
None,
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// A single RLS policy, mapped onto `CREATE POLICY <name> ON <table> FOR <command>
|
||||
/// TO <principals> USING (<using>) WITH CHECK (<check>)`.
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct DataTablePolicy {
|
||||
pub table: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub command: DataTablePolicyCommand,
|
||||
/// Principals the policy applies to (`TO` clause). Empty => `PUBLIC`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub principals: Vec<DataTablePrincipal>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub using: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub check: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DataTablePolicyCommand {
|
||||
#[default]
|
||||
All,
|
||||
Select,
|
||||
Insert,
|
||||
Update,
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// Outcome of resolving which Postgres identity a data table query should run
|
||||
/// under, given the acting Windmill user. Shared across CE/EE so the query
|
||||
/// executor can branch on it regardless of build.
|
||||
#[derive(Debug)]
|
||||
pub enum DatatableAccessDecision {
|
||||
/// Use the default connection (shared owner role, or resource credentials).
|
||||
/// This is the legacy path and the workspace-admin bypass.
|
||||
Default,
|
||||
/// Connect as a per-user principal role; RLS + grants enforce access.
|
||||
/// Carries the resolved `PgDatabase` credentials as JSON (host/dbname of the
|
||||
/// default connection with `user`/`password` overridden).
|
||||
AsPrincipal(serde_json::Value),
|
||||
/// The acting user has no access to this data table at all.
|
||||
Denied,
|
||||
}
|
||||
|
||||
/// Deterministic, workspace-scoped Postgres role name for a Windmill principal.
|
||||
///
|
||||
/// Postgres roles are cluster-global, and every instance data table lives on the
|
||||
/// same server, so the workspace id MUST be folded in to keep one workspace's
|
||||
/// principal roles from colliding with (or connecting to) another's. Hashing
|
||||
/// yields a stable, valid, <=63-char identifier from arbitrary emails/group
|
||||
/// names. Both provisioning and query-time resolution call this, so they always
|
||||
/// agree on the name for a given `(workspace, principal)`.
|
||||
pub fn datatable_role_name(w_id: &str, principal: &DataTablePrincipal) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let (prefix, raw) = match principal {
|
||||
DataTablePrincipal::User(email) => ("wm_u_", email.as_str()),
|
||||
DataTablePrincipal::Group(group) => ("wm_g_", group.as_str()),
|
||||
};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(w_id.as_bytes());
|
||||
hasher.update([0u8]);
|
||||
hasher.update(raw.as_bytes());
|
||||
format!("{prefix}{}", hex::encode(&hasher.finalize()[..12]))
|
||||
}
|
||||
|
||||
/// Deterministic password for a principal role, derived by HMAC-SHA256 of the
|
||||
/// role name under a stable instance secret (see
|
||||
/// `get_or_create_datatable_role_secret`). The role name already encodes the
|
||||
/// workspace, so keying on it alone is sufficient. Nothing per-role is stored:
|
||||
/// both the provisioner (which sets the password) and the executor (which
|
||||
/// authenticates with it) recompute it.
|
||||
pub fn derive_datatable_role_password(secret: &str, role: &str) -> String {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
let mut mac =
|
||||
Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
|
||||
mac.update(role.as_bytes());
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
/// Fetch (or lazily create) the stable secret used to derive principal-role
|
||||
/// passwords. Unlike `custom_instance_user`'s password this is never rotated —
|
||||
/// rotating it would invalidate every principal password at once — so it lives
|
||||
/// under its own `global_settings` key generated on first use.
|
||||
pub async fn get_or_create_datatable_role_secret(db: &DB) -> Result<String> {
|
||||
// `gen_random_uuid()` provides the randomness in-SQL so we don't pull in a
|
||||
// CSPRNG here; two concatenated uuids give a 64-char secret. ON CONFLICT DO
|
||||
// NOTHING makes concurrent first-uses converge on a single value.
|
||||
// Runtime (non-macro) query so it needs no `.sqlx` cache entry.
|
||||
sqlx::query(
|
||||
r#"INSERT INTO global_settings (name, value)
|
||||
VALUES ('datatable_permissions',
|
||||
jsonb_build_object('secret', gen_random_uuid()::text || gen_random_uuid()::text))
|
||||
ON CONFLICT (name) DO NOTHING"#,
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
sqlx::query_scalar::<_, Option<String>>(
|
||||
"SELECT value->>'secret' FROM global_settings WHERE name = 'datatable_permissions'",
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::internal_err("datatable_permissions secret missing after insert"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
@@ -828,11 +986,8 @@ fn datatable_not_found_error(name: &str, datatables: Option<&serde_json::Value>)
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_datatable_resource_from_db_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
/// Load a data table's catalog entry (including its permission config).
|
||||
pub async fn get_datatable_from_db(db: &DB, w_id: &str, name: &str) -> Result<DataTable> {
|
||||
let datatables = sqlx::query_scalar!(
|
||||
r#"
|
||||
SELECT ws.datatable->'datatables' AS datatables
|
||||
@@ -850,26 +1005,86 @@ pub async fn get_datatable_resource_from_db_unchecked(
|
||||
.and_then(|d| d.get(name))
|
||||
.filter(|v| !v.is_null())
|
||||
.ok_or_else(|| datatable_not_found_error(name, datatables.as_ref()))?;
|
||||
let datatable = serde_json::from_value::<DataTable>(datatable.clone())?;
|
||||
Ok(serde_json::from_value::<DataTable>(datatable.clone())?)
|
||||
}
|
||||
|
||||
let db_resource = if datatable.database.resource_type == DataTableCatalogResourceType::Instance
|
||||
{
|
||||
/// Resolve the default connection credentials for a data table — the shared
|
||||
/// owner role (instance) or the backing resource (postgres). This performs no
|
||||
/// per-user authorization; permission enforcement is layered on in the `checked`
|
||||
/// variant.
|
||||
pub async fn datatable_base_creds(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
datatable: &DataTable,
|
||||
) -> Result<serde_json::Value> {
|
||||
if datatable.database.resource_type == DataTableCatalogResourceType::Instance {
|
||||
let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?;
|
||||
pg_creds.dbname = datatable.database.resource_path.clone();
|
||||
pg_creds.user = Some("custom_instance_user".to_string());
|
||||
pg_creds.password = Some(get_custom_pg_instance_password(&db).await?);
|
||||
serde_json::to_value(&pg_creds)
|
||||
.map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))?
|
||||
.map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))
|
||||
} else {
|
||||
transform_json_unchecked(
|
||||
&serde_json::Value::String(format!("$res:{}", datatable.database.resource_path)),
|
||||
w_id,
|
||||
db,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
Ok(db_resource)
|
||||
pub async fn get_datatable_resource_from_db_unchecked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let datatable = get_datatable_from_db(db, w_id, name).await?;
|
||||
datatable_base_creds(db, w_id, &datatable).await
|
||||
}
|
||||
|
||||
/// Like `get_datatable_resource_from_db_unchecked`, but applies the data table's
|
||||
/// advanced (EE) permissions for `acting_email`: an admin (or a data table
|
||||
/// without permissions) gets the default owner connection; other members get a
|
||||
/// per-user principal role subject to RLS/grants; members with no access are
|
||||
/// rejected. On the OSS build the EE hook is a no-op, so this equals the
|
||||
/// unchecked path.
|
||||
pub async fn get_datatable_resource_from_db_checked(
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
name: &str,
|
||||
acting_email: &str,
|
||||
) -> Result<serde_json::Value> {
|
||||
let datatable = get_datatable_from_db(db, w_id, name).await?;
|
||||
let base = datatable_base_creds(db, w_id, &datatable).await?;
|
||||
|
||||
let is_workspace_admin = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT is_admin FROM usr WHERE workspace_id = $1 AND email = $2",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(acting_email)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
match crate::datatable_permissions::resolve_datatable_access(
|
||||
db,
|
||||
w_id,
|
||||
name,
|
||||
&datatable,
|
||||
&base,
|
||||
acting_email,
|
||||
is_workspace_admin,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
DatatableAccessDecision::Default => Ok(base),
|
||||
DatatableAccessDecision::AsPrincipal(creds) => Ok(creds),
|
||||
DatatableAccessDecision::Denied => Err(Error::BadRequest(format!(
|
||||
"You do not have access to data table '{name}'. Ask a workspace admin to grant \
|
||||
access (data table permissions may need to be re-synced)."
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
|
||||
@@ -13,7 +13,7 @@ use windmill_common::error::{to_anyhow, Error, Result};
|
||||
use windmill_common::utils::sanitize_string_from_password;
|
||||
use windmill_common::worker::{get_memory, to_raw_value, Connection, SqlResultCollectionStrategy};
|
||||
use windmill_common::workspaces::{
|
||||
get_datatable_resource_from_db_unchecked, get_ducklake_from_db_unchecked,
|
||||
get_datatable_resource_from_db_checked, get_ducklake_from_db_unchecked,
|
||||
strip_fork_reserved_attach_args, DucklakeCatalogResourceType,
|
||||
};
|
||||
use windmill_common::PgDatabase;
|
||||
@@ -1496,6 +1496,7 @@ pub async fn do_duckdb(
|
||||
conn,
|
||||
&mut hidden_passwords,
|
||||
&job.workspace_id,
|
||||
&job.permissioned_as_email,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -1573,6 +1574,7 @@ pub async fn do_duckdb(
|
||||
conn,
|
||||
&mut hidden_passwords,
|
||||
&job.workspace_id,
|
||||
&job.permissioned_as_email,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -2555,6 +2557,7 @@ async fn transform_attach_datatable(
|
||||
conn: &Connection,
|
||||
hidden_passwords: &mut Arc<Mutex<Vec<String>>>,
|
||||
w_id: &str,
|
||||
acting_email: &str,
|
||||
) -> Result<Option<Vec<String>>> {
|
||||
lazy_static::lazy_static! {
|
||||
static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'datatable(://[^':]+)?'\s*AS\s+([^ ;]+)").unwrap();
|
||||
@@ -2569,7 +2572,12 @@ async fn transform_attach_datatable(
|
||||
Connection::Http(client) => {
|
||||
get_datatable_resource_from_agent_http(client, name, w_id).await?
|
||||
}
|
||||
Connection::Sql(db) => get_datatable_resource_from_db_unchecked(db, w_id, name).await?,
|
||||
// Enforce data table permissions (EE) as the acting user, exactly like the
|
||||
// postgresql executor — otherwise a DuckDB `ATTACH 'datatable://...'` would
|
||||
// run as the owner and bypass RLS/grants entirely.
|
||||
Connection::Sql(db) => {
|
||||
get_datatable_resource_from_db_checked(db, w_id, name, acting_email).await?
|
||||
}
|
||||
};
|
||||
let db_type = "postgres";
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ use windmill_common::error::{self, Error};
|
||||
use windmill_common::worker::{
|
||||
to_raw_value, Connection, SqlResultCollectionStrategy, CLOUD_HOSTED,
|
||||
};
|
||||
use windmill_common::workspaces::get_datatable_resource_from_db_unchecked;
|
||||
use windmill_common::workspaces::get_datatable_resource_from_db_checked;
|
||||
use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB};
|
||||
use windmill_parser::{Arg, Typ};
|
||||
use windmill_parser_sql::{
|
||||
@@ -599,8 +599,15 @@ pub async fn do_postgresql(
|
||||
.await?
|
||||
}
|
||||
Connection::Sql(db) => {
|
||||
get_datatable_resource_from_db_unchecked(db, &job.workspace_id, &db_str)
|
||||
.await?
|
||||
// Enforce advanced (EE) data table permissions as the job's
|
||||
// acting identity; no-op on OSS / when permissions are off.
|
||||
get_datatable_resource_from_db_checked(
|
||||
db,
|
||||
&job.workspace_id,
|
||||
&db_str,
|
||||
&job.permissioned_as_email,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Data table permissions (EE)
|
||||
|
||||
Advanced, opt-in permissions for data tables, enforced natively by Postgres
|
||||
**roles** and **row-level security (RLS)**. Enterprise-only; off by default (a
|
||||
data table with no permission config keeps the legacy behavior where every
|
||||
workspace member shares the owner role and has full access).
|
||||
|
||||
## Why roles, not session variables
|
||||
|
||||
Data table queries run **arbitrary user SQL** (a `postgresql` script job with
|
||||
`database = "datatable://<name>"`). That rules out the usual RLS shortcut of
|
||||
injecting identity via a session GUC (`SET app.user = ...`) — the user's own SQL
|
||||
can overwrite it. It also rules out `SET ROLE` down to a low-privilege role — the
|
||||
user can `RESET ROLE` back to the login role.
|
||||
|
||||
The only tamper-proof signal is the **connected role itself**. So a non-admin
|
||||
query connects *directly* as a per-user login role; `current_user` is then
|
||||
authoritative and cannot be escalated, because the session never held more
|
||||
privilege than that role.
|
||||
|
||||
## Model
|
||||
|
||||
Windmill principals are mirrored into Postgres roles inside the data table's
|
||||
physical database:
|
||||
|
||||
- `wm_u_<hash>` — one **LOGIN** role per user (`INHERIT`, deterministic
|
||||
HMAC-derived password). The name hashes `(workspace_id, email)`; the workspace
|
||||
id is folded in because Postgres roles are cluster-global and all instance data
|
||||
tables share one server.
|
||||
- `wm_g_<hash>` — one **NOLOGIN** role per group. User roles are granted
|
||||
membership in their group roles, so group grants apply automatically. Roles are
|
||||
only ever members of their own, minimally-granted groups — there is no path
|
||||
upward.
|
||||
- `_wm_principals(role_name, email, username)` — a mapping table (readable only
|
||||
via the SECURITY DEFINER `wm_email()` helper, which returns the current role's
|
||||
email) so policies can reference the acting user's email.
|
||||
|
||||
Two layers of control, both configured from the **Permissions** modal in the data
|
||||
table settings:
|
||||
|
||||
1. **Access** (the `Access` tab) → Postgres `GRANT`s. Per user/group: no
|
||||
access / read (`SELECT`) / read+write (`SELECT,INSERT,UPDATE,DELETE`), scoped
|
||||
to one table or all tables (with `ALTER DEFAULT PRIVILEGES` so future tables
|
||||
inherit the grant).
|
||||
2. **Row policies** (the `Row policies` tab) → `CREATE POLICY`. RLS is enabled
|
||||
**only** on tables that have a policy. Templates and raw `USING` /
|
||||
`WITH CHECK` expressions (e.g. `owner = wm_email()`).
|
||||
|
||||
`CREATE` on `public` is **revoked** from principal roles, so a principal can
|
||||
never create a table that escapes policy — all DDL flows through the migrations
|
||||
path (which connects as the table owner).
|
||||
|
||||
### Admin bypass
|
||||
|
||||
We deliberately do **not** `FORCE` RLS. The table owner
|
||||
(`custom_instance_user` for instance data tables; the resource user for resource
|
||||
data tables) therefore bypasses RLS. Workspace admins connect as that owner, so
|
||||
admins always keep full, unfiltered access. Non-admin principals are non-owners
|
||||
and stay subject to RLS. This is safe because principals can never own a table.
|
||||
|
||||
## Enforcement path
|
||||
|
||||
`pg_executor` resolves `datatable://<name>` through
|
||||
`get_datatable_resource_from_db_checked(db, w_id, name, acting_email)`
|
||||
(`acting_email` = the job's `permissioned_as_email`):
|
||||
|
||||
- data table has no permissions / disabled, or the user is a **workspace
|
||||
admin** → default owner connection (unchanged);
|
||||
- other member → connect as their `wm_u_*` role; grants + RLS enforce;
|
||||
- member with no provisioned role (instance data tables) → **denied** with a
|
||||
clear error.
|
||||
|
||||
The EE logic lives in `windmill-common/src/datatable_permissions_ee.rs` behind the
|
||||
`datatable_permissions` module switch (`_ee` under `private`, `_oss` stub
|
||||
otherwise), mirroring the `pipeline_advanced` / `partition` pattern. On OSS the
|
||||
resolver is a no-op, so the checked path equals the unchecked one.
|
||||
|
||||
## Provisioning
|
||||
|
||||
`set_datatable_permissions` writes the config into the catalog JSONB
|
||||
(`workspace_settings.datatable`) and then **reconciles** the target database:
|
||||
creates/updates roles, memberships, `_wm_principals` rows, grants, and policies.
|
||||
Instance data tables provision as the Windmill superuser; resource data tables
|
||||
provision with the resource's own credentials (best-effort — the reconciliation
|
||||
log surfaces any privilege it lacked). `sync_datatable_permissions` re-runs
|
||||
reconciliation from the stored config.
|
||||
|
||||
## Known limitations (v1)
|
||||
|
||||
- **Instance and resource data tables** are both attempted; resource support is
|
||||
best-effort (needs `CREATEROLE` + table ownership on the resource credentials).
|
||||
- **Group-membership drift**: roles, memberships, grants and policies are
|
||||
reconciled to match the config only at save/**Sync** time. A user added to or
|
||||
removed from a group (or a datatable getting new tables) takes effect on the
|
||||
next Sync — reconcile is declarative and revokes removed access, so Sync both
|
||||
grants and revokes. There is no per-query provisioning, so a brand-new member
|
||||
is denied until an admin re-syncs.
|
||||
- **New tables**: run **Sync** after schema migrations so freshly-created tables
|
||||
pick up grants/policies (grants also propagate via default privileges; RLS
|
||||
policies must be re-applied).
|
||||
- **Enforced paths**: `postgresql` and DuckDB (`ATTACH 'datatable://...'`) script
|
||||
jobs on a normal worker both resolve through the checked path. **Not** enforced
|
||||
(resolve as owner): schema-introspection and the DB-manager "explore"
|
||||
endpoints, Postgres-trigger capture, and **agent-worker** job execution. If you
|
||||
rely on agent workers, treat data table permissions as advisory for those jobs.
|
||||
- **Row policy expressions** are admin-authored raw SQL (like migrations) — they
|
||||
are trusted input.
|
||||
@@ -0,0 +1,380 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '../common'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
import Modal2 from '../common/modal/Modal2.svelte'
|
||||
import Tabs from '../common/tabs/Tabs.svelte'
|
||||
import Tab from '../common/tabs/Tab.svelte'
|
||||
import TabContent from '../common/tabs/TabContent.svelte'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import { Shield, Plus, Trash2, RefreshCw, Loader2, TriangleAlert } from 'lucide-svelte'
|
||||
import {
|
||||
WorkspaceService,
|
||||
type DataTablePermissions,
|
||||
type DataTableGrant,
|
||||
type DataTablePolicy
|
||||
} from '$lib/gen'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { enterpriseLicense, superadmin, userStore } from '$lib/stores'
|
||||
|
||||
let {
|
||||
workspace,
|
||||
datatable,
|
||||
disabled = false
|
||||
}: {
|
||||
workspace: string
|
||||
datatable: string
|
||||
disabled?: boolean
|
||||
} = $props()
|
||||
|
||||
let open = $state(false)
|
||||
let loading = $state(false)
|
||||
let busy = $state(false)
|
||||
let log = $state<string | undefined>(undefined)
|
||||
let selectedTab = $state('access')
|
||||
|
||||
// Advanced permissions are an enterprise feature; only workspace admins manage them.
|
||||
const ee = $derived(!!$enterpriseLicense)
|
||||
const canManage = $derived(!!$userStore?.is_admin || !!$superadmin)
|
||||
|
||||
let enabled = $state(false)
|
||||
let grants = $state<DataTableGrant[]>([])
|
||||
let policies = $state<DataTablePolicy[]>([])
|
||||
|
||||
const kindItems = [
|
||||
{ label: 'User', value: 'user' },
|
||||
{ label: 'Group', value: 'group' }
|
||||
]
|
||||
const accessItems = [
|
||||
{ label: 'No access', value: 'none' },
|
||||
{ label: 'Read', value: 'read' },
|
||||
{ label: 'Read & write', value: 'write' }
|
||||
]
|
||||
const commandItems = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Select', value: 'select' },
|
||||
{ label: 'Insert', value: 'insert' },
|
||||
{ label: 'Update', value: 'update' },
|
||||
{ label: 'Delete', value: 'delete' }
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
log = undefined
|
||||
try {
|
||||
const res = await WorkspaceService.getDatatablePermissions({
|
||||
workspace,
|
||||
datatableName: datatable
|
||||
})
|
||||
enabled = res?.enabled ?? false
|
||||
grants = res?.grants ?? []
|
||||
policies = res?.policies ?? []
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to load permissions: ${e?.body ?? e?.message ?? e}`, true)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
open = true
|
||||
load()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
busy = true
|
||||
try {
|
||||
const body: DataTablePermissions = { enabled, grants, policies }
|
||||
log = await WorkspaceService.setDatatablePermissions({
|
||||
workspace,
|
||||
datatableName: datatable,
|
||||
requestBody: body
|
||||
})
|
||||
sendUserToast('Permissions saved')
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to save permissions: ${e?.body ?? e?.message ?? e}`, true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sync() {
|
||||
busy = true
|
||||
try {
|
||||
log = await WorkspaceService.syncDatatablePermissions({
|
||||
workspace,
|
||||
datatableName: datatable
|
||||
})
|
||||
sendUserToast('Permissions re-synced')
|
||||
} catch (e: any) {
|
||||
sendUserToast(`Failed to sync permissions: ${e?.body ?? e?.message ?? e}`, true)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function addGrant() {
|
||||
grants = [
|
||||
...grants,
|
||||
{ principal: { kind: 'user', name: '' }, table: undefined, access: 'read' }
|
||||
]
|
||||
}
|
||||
function removeGrant(i: number) {
|
||||
grants = grants.filter((_, j) => j !== i)
|
||||
}
|
||||
function addPolicy() {
|
||||
policies = [...policies, { table: '', name: '', command: 'all', principals: [] }]
|
||||
}
|
||||
function removePolicy(i: number) {
|
||||
policies = policies.filter((_, j) => j !== i)
|
||||
}
|
||||
function addPolicyPrincipal(p: DataTablePolicy) {
|
||||
p.principals = [...(p.principals ?? []), { kind: 'user', name: '' }]
|
||||
}
|
||||
function removePolicyPrincipal(p: DataTablePolicy, i: number) {
|
||||
p.principals = (p.principals ?? []).filter((_, j) => j !== i)
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
{disabled}
|
||||
startIcon={{ icon: Shield }}
|
||||
title="Manage data table permissions"
|
||||
on:click={openModal}
|
||||
>
|
||||
Permissions
|
||||
</Button>
|
||||
|
||||
<Modal2 bind:isOpen={open} title="Permissions — {datatable}" fixedWidth="lg" fixedHeight="lg">
|
||||
{#snippet headerLeft()}
|
||||
<Tooltip>
|
||||
Restrict who can read and write this data table's rows, enforced natively by Postgres roles
|
||||
and row-level security. Opt in below, then grant access per user/group and optionally add
|
||||
row-level policies. Workspace admins always keep full access.
|
||||
</Tooltip>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-3 w-full grow min-h-0">
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center grow text-tertiary">
|
||||
<Loader2 size={18} class="animate-spin" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={enabled}
|
||||
disabled={!ee || !canManage}
|
||||
eeOnly
|
||||
options={{
|
||||
right: 'Advanced permissions',
|
||||
rightTooltip:
|
||||
'When off, every workspace member has full access (legacy behavior). When on, access is restricted to the grants and policies below.'
|
||||
}}
|
||||
on:change={(e) => (enabled = e.detail)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !ee}
|
||||
<div class="flex items-center gap-2 text-xs text-yellow-600 dark:text-yellow-400">
|
||||
<TriangleAlert size={14} />
|
||||
Data table permissions require an enterprise license.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tabs bind:selected={selectedTab} class="grow min-h-0">
|
||||
<Tab value="access" label="Access" />
|
||||
<Tab value="policies" label="Row policies" />
|
||||
{#snippet content()}
|
||||
<TabContent value="access" class="grow min-h-0 overflow-auto pt-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-xs text-tertiary">
|
||||
Grant a user or group access to a specific table, or to all tables (leave the table
|
||||
field empty).
|
||||
</div>
|
||||
{#each grants as grant, i (i)}
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
items={kindItems}
|
||||
bind:value={grant.principal.kind}
|
||||
disabled={!canManage}
|
||||
class="w-28"
|
||||
/>
|
||||
<TextInput
|
||||
bind:value={grant.principal.name}
|
||||
inputProps={{
|
||||
placeholder: grant.principal.kind === 'user' ? 'email' : 'group name',
|
||||
disabled: !canManage
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
bind:value={grant.table}
|
||||
inputProps={{ placeholder: 'all tables', disabled: !canManage }}
|
||||
/>
|
||||
<Select
|
||||
items={accessItems}
|
||||
bind:value={grant.access}
|
||||
disabled={!canManage}
|
||||
class="w-40"
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
iconOnly
|
||||
color="red"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
disabled={!canManage}
|
||||
on:click={() => removeGrant(i)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={!canManage}
|
||||
on:click={addGrant}
|
||||
>
|
||||
Add grant
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabContent>
|
||||
|
||||
<TabContent value="policies" class="grow min-h-0 overflow-auto pt-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-xs text-tertiary">
|
||||
Row-level policies filter which rows a principal can see or change. Use
|
||||
<code>wm_email()</code> for the current user's email, and standard SQL in the USING
|
||||
(read/delete) and WITH CHECK (insert/update) expressions, e.g.
|
||||
<code>owner = wm_email()</code>.
|
||||
</div>
|
||||
{#each policies as policy, i (i)}
|
||||
<div class="flex flex-col gap-2 border rounded-md p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<TextInput
|
||||
bind:value={policy.table}
|
||||
inputProps={{ placeholder: 'table', disabled: !canManage }}
|
||||
/>
|
||||
<TextInput
|
||||
bind:value={policy.name}
|
||||
inputProps={{ placeholder: 'policy name', disabled: !canManage }}
|
||||
/>
|
||||
<Select
|
||||
items={commandItems}
|
||||
bind:value={policy.command}
|
||||
disabled={!canManage}
|
||||
class="w-32"
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
iconOnly
|
||||
color="red"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
disabled={!canManage}
|
||||
on:click={() => removePolicy(i)}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 pl-1">
|
||||
<span class="text-xs text-hint">Applies to</span>
|
||||
{#each policy.principals ?? [] as principal, j (j)}
|
||||
<div class="flex items-center gap-2">
|
||||
<Select
|
||||
items={kindItems}
|
||||
bind:value={principal.kind}
|
||||
disabled={!canManage}
|
||||
class="w-28"
|
||||
/>
|
||||
<TextInput
|
||||
bind:value={principal.name}
|
||||
inputProps={{
|
||||
placeholder: principal.kind === 'user' ? 'email' : 'group name',
|
||||
disabled: !canManage
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
iconOnly
|
||||
color="red"
|
||||
startIcon={{ icon: Trash2 }}
|
||||
disabled={!canManage}
|
||||
on:click={() => removePolicyPrincipal(policy, j)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={!canManage}
|
||||
on:click={() => addPolicyPrincipal(policy)}
|
||||
>
|
||||
Add principal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TextInput
|
||||
bind:value={policy.using}
|
||||
inputProps={{
|
||||
placeholder: 'USING expression (e.g. owner = wm_email())',
|
||||
disabled: !canManage
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
bind:value={policy.check}
|
||||
inputProps={{
|
||||
placeholder: 'WITH CHECK expression (insert/update)',
|
||||
disabled: !canManage
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
startIcon={{ icon: Plus }}
|
||||
disabled={!canManage}
|
||||
on:click={addPolicy}
|
||||
>
|
||||
Add policy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabContent>
|
||||
{/snippet}
|
||||
</Tabs>
|
||||
|
||||
{#if log}
|
||||
<div
|
||||
class="text-xs font-mono whitespace-pre-wrap max-h-32 overflow-auto border rounded-md p-2 text-secondary"
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between gap-2 pt-2 border-t">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
disabled={busy || !ee || !canManage || !enabled}
|
||||
title="Re-apply the stored config (e.g. after group changes or new tables)"
|
||||
on:click={sync}
|
||||
>
|
||||
Sync
|
||||
</Button>
|
||||
<Button variant="accent" size="sm" disabled={busy || !ee || !canManage} on:click={save}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal2>
|
||||
@@ -78,6 +78,7 @@
|
||||
import { Popover } from '../meltComponents'
|
||||
import ExploreAssetButton from '../ExploreAssetButton.svelte'
|
||||
import DataTableMigrationsButton from './DataTableMigrationsButton.svelte'
|
||||
import DataTablePermissionsButton from './DataTablePermissionsButton.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { clone } from '$lib/utils'
|
||||
import SettingsFooter from './SettingsFooter.svelte'
|
||||
@@ -305,6 +306,11 @@
|
||||
datatable={dataTable.name}
|
||||
disabled={!!dirtyMap[dataTable.name]}
|
||||
/>
|
||||
<DataTablePermissionsButton
|
||||
workspace={$workspaceStore ?? ''}
|
||||
datatable={dataTable.name}
|
||||
disabled={!!dirtyMap[dataTable.name]}
|
||||
/>
|
||||
{#if dirtyMap[dataTable.name]}
|
||||
<Popover
|
||||
openOnHover
|
||||
|
||||
Reference in New Issue
Block a user