Files
windmill/docs/datatable-permissions.md
T
Diego ImbertandClaude Opus 4.8 1d1ffed01c [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>
2026-07-07 13:59:16 +02:00

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.