mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 16:09:39 +00:00
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>
108 lines
5.5 KiB
Markdown
108 lines
5.5 KiB
Markdown
# 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.
|