[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:
Diego Imbert
2026-07-07 13:59:16 +02:00
parent e896506ae6
commit 1d1ffed01c
11 changed files with 1031 additions and 25 deletions
+1 -1
View File
@@ -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>,
+140
View File
@@ -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)
}
+7
View File
@@ -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")]
+227 -12
View File
@@ -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)]
+10 -2
View File
@@ -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";
+10 -3
View File
@@ -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?
}
})
}
+107
View File
@@ -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