Contents
- Row-Level Security (RLS) Design
- 1. Motivation
- 2. Scope
- 3. Design Decisions
- Decision 1: RLS policy column — submitted_by vs current_user
- Decision 2: Separate INSERT policy for df.start()
- Decision 3: df.nodes policy — direct column vs join to df.instances
- Decision 4: Unlinked nodes (pre-df.start()) — NOT AN ISSUE
- Decision 5: df.vars scoping
- Decision 6: Worker role RLS bypass
- Decision 7: df.cancel() and df.signal() ownership checks
- Decision 8: GRANT strategy — extension install vs. manual
- 4. Proposed RLS Policies
- 5. Implementation Plan
- 6. Open Questions
- 7. Test Plan
Row-Level Security (RLS) Design
Status: Draft
Branch: pinodeca/rls
Created: 2026-03-09
1. Motivation
pg_durable functions are all SECURITY INVOKER (the pgrx default — no function uses security_definer). This means every SQL statement inside a df.* function runs with the calling user’s privileges, including table access checks.
Today, the E2E test setup manually grants DML on df.instances, df.nodes, and df.vars to each test user:
GRANT SELECT, INSERT, UPDATE, DELETE ON df.instances TO df_e2e_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON df.nodes TO df_e2e_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON df.vars TO df_e2e_user;
Without table-level grants, df.start() would fail — it INSERTs into df.nodes and df.instances and SELECTs from df.vars via SPI, which runs as the calling user.
But granting unrestricted DML means:
- Any user can read/modify any other user’s instances and nodes (confidentiality + integrity)
- Any user can DELETE another user’s instances (destructive)
- df.vars is a global key-value store with no per-user scoping
- No protection against cross-user df.cancel() / df.signal() (the functions take an arbitrary instance_id and don’t check ownership)
RLS solves this: keep the DML grants (users need them for the SPI calls inside df.* functions), but restrict which rows each user can see and modify.
2. Scope
In scope
| Table | RLS needed? | Reason |
|---|---|---|
df.instances |
Yes | Contains per-user workflow state; has submitted_by column |
df.nodes |
Yes | Contains per-user node definitions; has submitted_by column |
df.vars |
Yes | Per-user variable isolation; has owner column |
Out of scope
| Table / schema | Why |
|---|---|
df._worker_epoch |
Internal sentinel; users should not have access at all (no GRANT) |
duroxide.* tables |
Internal runtime state; accessed only by the background worker’s pooled connection (worker role). Users should not have direct access. The monitoring functions (df.list_instances(), df.metrics(), etc.) access these via a dedicated sqlx pool authenticated as the worker role, not via SPI-as-calling-user |
df.secrets |
Not yet implemented; when it lands, it should be admin-only (no user SELECT, no RLS — just REVOKE) |
Functions that need RLS-aware data access
These df.* functions access df.instances or df.nodes via SPI (which runs as the calling user and therefore goes through RLS):
| Function | Tables accessed | Access type | RLS effect |
|---|---|---|---|
df.start() |
df.nodes (INSERT), df.instances (INSERT), df.vars (SELECT) |
Write + Read | INSERT policies must allow; vars must be readable |
df.status() |
df.instances (SELECT) |
Read | User sees only own instances |
df.result() |
df.instances (SELECT), df.nodes (SELECT) |
Read | User sees only own |
df.cancel() |
df.instances (SELECT for ownership check) |
Read | User can only cancel own instances |
df.signal() |
(no direct table access — goes through duroxide client) | — | No RLS impact |
df.await_instance() |
df.instances (SELECT, polling) |
Read | User sees only own |
df.explain() |
df.nodes (SELECT, for existing instances) |
Read | User sees only own |
df.list_instances() |
df.instances (SELECT for labels) |
Read | User sees only own labels |
df.instance_info() |
df.instances (SELECT for label) |
Read | User sees only own |
df.instance_nodes() |
df.nodes (SELECT) |
Read | User sees only own |
df.setvar() |
df.vars (INSERT/UPDATE) |
Write | User can only set own vars (RLS + owner DEFAULT + ON CONFLICT) |
df.getvar() |
df.vars (SELECT) |
Read | User can only read own vars (RLS + explicit owner filter) |
df.unsetvar() |
df.vars (DELETE) |
Write | User can only delete own vars (RLS + explicit owner filter) |
df.clearvars() |
df.vars (DELETE) |
Write | User can only clear own vars (RLS + explicit owner filter) |
Background worker access (NOT affected by RLS)
The background worker accesses df.instances and df.nodes via sqlx pool connections authenticated as the worker role. The worker role must bypass RLS because it needs to load and update any user’s instances/nodes:
| Activity | Tables accessed | Worker needs |
|---|---|---|
load_function_graph |
df.instances (SELECT), df.nodes (SELECT) |
Read all rows |
update_node_status |
df.nodes (UPDATE) |
Update any row |
update_instance_status |
df.instances (UPDATE) |
Update any row |
execute_sql |
(User SQL on separate per-user connection) | Not applicable |
3. Design Decisions
Decision 1: RLS policy column — submitted_by vs current_user
df.instances.submitted_by stores the effective role (outer user) captured at df.start() time as a REGROLE. The RLS policy compares this to current_user:
CREATE POLICY instances_user_isolation ON df.instances
USING (submitted_by = current_user::regrole);
This is the correct choice because:
- current_user in an SPI call from a SECURITY INVOKER function = the calling user
- submitted_by is set by trusted extension code via GetUserId() — users cannot forge it
- REGROLE comparison handles OID-based identity correctly
Decision: Use submitted_by = current_user::regrole. ✅ No ambiguity.
Decision 2: Separate INSERT policy for df.start()
df.start() inserts rows with submitted_by set to the calling user’s OID. The USING clause applies to SELECT/UPDATE/DELETE. For INSERT, we need a WITH CHECK clause:
CREATE POLICY instances_user_isolation ON df.instances
FOR ALL
USING (submitted_by = current_user::regrole)
WITH CHECK (submitted_by = current_user::regrole);
This ensures a user can only INSERT rows where submitted_by matches their own identity. Since df.start() sets submitted_by via GetUserId(), this is always true for legitimate calls. It also prevents a user from manually inserting a row with a forged submitted_by.
Decision: Single FOR ALL policy with both USING and WITH CHECK. ✅ No ambiguity.
Decision 3: df.nodes policy — direct column vs join to df.instances
Two options:
(A) Direct column check (simpler, faster):
sql
CREATE POLICY nodes_user_isolation ON df.nodes
FOR ALL
USING (submitted_by = current_user::regrole)
WITH CHECK (submitted_by = current_user::regrole);
(B) Join to instances (single source of truth):
sql
CREATE POLICY nodes_user_isolation ON df.nodes
FOR ALL
USING (
instance_id IS NULL -- unlinked nodes (during DSL building)
OR EXISTS (SELECT 1 FROM df.instances WHERE id = instance_id AND submitted_by = current_user::regrole)
)
WITH CHECK (...);
Analysis:
- Option A is simpler and faster (no subquery). The submitted_by column on nodes is set by df.start() at the same time as instance_id, ensuring consistency.
- Option B adds a correctness dependency on df.instances — if df.instances rows are deleted, nodes become invisible even though they still exist. Adds query overhead.
- Unlinked nodes (instance_id IS NULL and submitted_by IS NULL): These are created during DSL expression building (e.g., df.sql('SELECT 1') before df.start()). They don’t yet belong to any user. See Decision 4.
Decision: Option A — use direct column check on df.nodes.submitted_by. Simpler, no join overhead.
Decision 4: Unlinked nodes (pre-df.start()) — NOT AN ISSUE
df.start())An earlier draft of this document stated that DSL functions like df.sql() insert rows into df.nodes with submitted_by = NULL. This is incorrect.
DSL functions (df.sql(), df.seq(), df.sleep(), etc.) do NOT insert rows into df.nodes. They build an in-memory JSON tree (the Durofut struct). All node rows are inserted inside df.start() via its internal insert_nodes() function, which sets instance_id and submitted_by on every node from the very start.
There are no “unlinked” or “orphan” nodes with submitted_by = NULL. Every row in df.nodes has submitted_by set at INSERT time.
Decision: No action needed. ✅ The simple RLS policy submitted_by = current_user::regrole works for both df.instances and df.nodes without any NULL handling.
Note on stale documentation: docs/user-isolation.md (lines 112–164) describes a two-phase design where DSL functions insert nodes with NULL identity columns and
df.start()later fills them via UPDATE. This was a design proposal, but the actual implementation took a different approach: nodes are only inserted insidedf.start(), with identity set from the start. TheDurofut::insert_node()method described in user-isolation.md does not exist in the codebase. The user-isolation.md doc should be updated to reflect the implemented design.
Decision 5: df.vars scoping
df.vars was originally a global key-value table with no user scoping. The security spec (Section 4.3) identified this as a cross-user integrity/confidentiality issue.
Decision: Option A — per-user scoping with owner column + RLS. ✅ Implemented in v0.2.0.
Schema (fresh install):
sql
CREATE TABLE IF NOT EXISTS df.vars (
name TEXT NOT NULL,
value TEXT,
owner REGROLE NOT NULL DEFAULT current_user::regrole,
PRIMARY KEY (owner, name)
);
RLS policy: ```sql ALTER TABLE df.vars ENABLE ROW LEVEL SECURITY;
CREATE POLICY vars_user_isolation ON df.vars FOR ALL USING (owner = current_user::regrole) WITH CHECK (owner = current_user::regrole); ```
Upgrade script (pg_durable--0.1.1--0.2.0.sql):
sql
ALTER TABLE df.vars ADD COLUMN owner REGROLE NOT NULL DEFAULT current_user::regrole;
ALTER TABLE df.vars DROP CONSTRAINT vars_pkey;
ALTER TABLE df.vars ADD PRIMARY KEY (owner, name);
ALTER TABLE df.vars ENABLE ROW LEVEL SECURITY;
CREATE POLICY vars_user_isolation ON df.vars ...;
Superuser behavior: Superusers bypass RLS, so they can see all users' variables via direct table queries. However, read/delete functions (df.getvar(), df.unsetvar(), df.clearvars()) and df.start() vars capture use explicit WHERE owner = current_user::regrole filters, while df.setvar() scopes ownership via the column DEFAULT and ON CONFLICT (owner, name). This ensures that even superusers only interact with their own variables through the DSL functions — RLS is defense-in-depth for non-superusers, while the explicit scoping provides correct behavior for all users including superusers.
Decision 6: Worker role RLS bypass
The background worker must read/write all users' instances and nodes. It also needs to connect as arbitrary users for execute_sql, access duroxide schema tables, and perform other privileged operations.
Decision: The worker role must be a superuser. ✅ Decided.
Superusers bypass all permission checks in PostgreSQL, including RLS. This means:
- No BYPASSRLS attribute needed (superusers bypass RLS automatically)
- No per-table worker policies needed
- No additional GRANTs to the worker role needed
- Consistent with the existing trust model: the extension is installed by a superuser, and the worker is trusted code
The worker role is configured via pg_durable.worker_role GUC (defaults to postgres). The extension should document that this role must be a superuser.
Decision 7: df.cancel() and df.signal() ownership checks
Currently df.cancel() takes any instance_id and cancels it — no ownership check. With RLS on df.instances:
- df.cancel() does UPDATE df.instances SET status = 'cancelled' WHERE id = ... via SPI. RLS will silently filter out non-owned rows, so the UPDATE affects 0 rows. The duroxide cancel_instance() call is made before the UPDATE, and it bypasses RLS (goes through the client connection pool as the worker role).
- df.signal() uses raise_external_event() which goes directly through the duroxide client — no SPI table access. RLS does not apply.
Issue: df.cancel() and df.signal() can operate on other users' instances because the duroxide client calls bypass RLS. The SPI UPDATE in df.cancel() would be filtered by RLS, but the actual cancellation happens through the client.
Options:
(A) Add explicit ownership check in df.cancel() and df.signal() before calling the duroxide client:
rust
// Check ownership via SPI (which goes through RLS)
let exists: bool = Spi::get_one(&format!(
"SELECT EXISTS(SELECT 1 FROM df.instances WHERE id = '{instance_id}')"
)).ok().flatten().unwrap_or(false);
if !exists {
pgrx::error!("Instance not found or access denied: {}", instance_id);
}
This leverages RLS: the SELECT returns false if the row exists but belongs to another user.
(B) Rely on convention — document that df.cancel() / df.signal() on a non-owned instance is a no-op.
Decision: Option A — explicit ownership check via SPI (which goes through RLS) before calling the duroxide client. ✅ Decided.
Decision 8: GRANT strategy — extension install vs. manual
Should CREATE EXTENSION pg_durable automatically GRANT the right permissions to PUBLIC (or a specific role), or require manual GRANTs?
Current state: No automatic grants. Admins must explicitly grant privileges to application roles after CREATE EXTENSION. See README.md for details.
Options (historical — decision has been revised):
(A) Extension sets default grants in extension_sql!: (not used)
(B) Extension grants to PUBLIC with minimal privileges: (was used in v0.1.1, reverted for security)
© Require manual grants (current approach): Admin explicitly grants after CREATE EXTENSION.
Decision: Option C — no automatic grants. Admins must explicitly grant privileges to each application role. ✅ Revised.
Why the change? While RLS mitigates the most obvious risks of broad PUBLIC grants, granting schema usage, function execution, and table DML to every database role by default is not desirable for production/security-sensitive environments. Requiring explicit grants follows the principle of least privilege.
Backward compatibility: Existing installs that upgraded from v0.1.1 retain their PUBLIC grants. No REVOKE statements are added to upgrade scripts. Only fresh installs use the locked-down default.
4. Proposed RLS Policies
4.1 df.instances
ALTER TABLE df.instances ENABLE ROW LEVEL SECURITY;
-- No FORCE — superuser/table-owner bypasses RLS (see Decision 4.4)
CREATE POLICY instances_user_isolation ON df.instances
FOR ALL
USING (submitted_by = current_user::regrole)
WITH CHECK (submitted_by = current_user::regrole);
4.2 df.nodes
ALTER TABLE df.nodes ENABLE ROW LEVEL SECURITY;
CREATE POLICY nodes_user_isolation ON df.nodes
FOR ALL
USING (submitted_by = current_user::regrole)
WITH CHECK (submitted_by = current_user::regrole);
No submitted_by IS NULL clause is needed — all nodes are inserted by df.start() with submitted_by already set (see Decision 4).
4.3 df.vars
ALTER TABLE df.vars ENABLE ROW LEVEL SECURITY;
CREATE POLICY vars_user_isolation ON df.vars
FOR ALL
USING (owner = current_user::regrole)
WITH CHECK (owner = current_user::regrole);
The owner column defaults to current_user::regrole on INSERT, so df.setvar() automatically assigns ownership via the column DEFAULT and ON CONFLICT (owner, name). Read/delete functions (df.getvar(), df.unsetvar(), df.clearvars()) and df.start() vars capture include explicit WHERE owner = current_user::regrole filters for correct behavior when RLS is bypassed (superusers).
4.4 Worker bypass
The worker role must be a superuser (see Decision 6). Superusers bypass RLS automatically — no additional configuration needed.
-- No RLS bypass configuration needed.
-- The worker role (pg_durable.worker_role GUC, default: postgres) must be a superuser.
-- Superusers bypass all permission checks including RLS.
4.5 FORCE ROW LEVEL SECURITY
ENABLE ROW LEVEL SECURITY only applies RLS to non-owner roles. If the table owner queries the table, RLS is bypassed. FORCE ROW LEVEL SECURITY makes RLS apply even to the table owner.
Question: Should we use FORCE? In pg_durable, the table “owner” is whoever ran CREATE EXTENSION (typically the superuser). FORCE would make RLS apply even to the superuser, which may be undesirable for debugging. However, without FORCE, the superuser sees all rows — which is expected admin behavior.
Recommendation: Do NOT use FORCE ROW LEVEL SECURITY on any df.* table (df.instances, df.nodes, df.vars). Superusers should see all rows (standard PostgreSQL convention). The spec already notes that superusers are trusted (they install the extension). Additionally, the background worker runs as a superuser (see Decision 6) and must bypass RLS to manage all users' data — FORCE would break worker access.
Revised:
sql
ALTER TABLE df.instances ENABLE ROW LEVEL SECURITY;
ALTER TABLE df.nodes ENABLE ROW LEVEL SECURITY;
ALTER TABLE df.vars ENABLE ROW LEVEL SECURITY;
-- No FORCE on any table — superuser/table-owner bypasses RLS (standard behavior)
5. Implementation Plan
Phase 1: Core RLS (this PR)
Add RLS policies in
extension_sql!insrc/lib.rs- Enable RLS on
df.instancesanddf.nodes - Create user isolation policies
- Handle worker role bypass
- Enable RLS on
Add ownership checks to
df.cancel()anddf.signal()(Decision 7)- SELECT from
df.instances(goes through RLS) before calling duroxide client
- SELECT from
No automatic GRANTs — admins grant explicitly after
CREATE EXTENSION- No GRANTs to PUBLIC in
extension_sql! - Document the required grant set in README.md and USER_GUIDE.md
- No GRANTs to PUBLIC in
Rework monitoring functions for per-user visibility
df.list_instances(): Querydf.instancesvia SPI first (RLS-filtered to calling user’s rows), then use only those instance IDs when calling the duroxide clientdf.instance_info(): Already queriesdf.instancesvia SPI for label — RLS filters automatically. Add ownership check before calling duroxide clientdf.instance_executions(): Add ownership check (SPI query ondf.instances) before calling duroxide clientdf.instance_nodes(): Already queriesdf.nodesvia SPI — RLS filters automaticallydf.metrics(): System-wide metrics remain private by default. An ordinarydf.grant_usage()omits it, fresh installs revoke PUBLIC EXECUTE, and admins can grant EXECUTE explicitly to trusted roles.df.grant_usage('role', with_grant => true)(a pg_durable admin) grants it automatically. ✅ Implemented in v0.2.4.
Update E2E tests
- Remove manual grants from
00_setup_playground.sql(now automatic) - Update
27_user_isolation.sqlto test RLS-enforced isolation - Add new RLS-specific tests
- Remove manual grants from
Phase 2: Variables scoping ✅ Implemented (v0.2.0)
- Added
owner REGROLEcolumn todf.varswithDEFAULT current_user::regrole - Changed PK from
(name)to(owner, name)for per-user namespace - Added RLS policy
vars_user_isolationondf.vars - Updated all DSL functions (
setvar,getvar,unsetvar,clearvars) with explicitownerfilters - Updated
df.start()vars capture to filter byowner = current_user::regrole - Created upgrade script
sql/pg_durable--0.1.1--0.2.0.sql - Added E2E test
38_rls_vars.sql
6. Open Questions
All decisions have been resolved. No open questions remain.
Resolved decisions
- Decision 5 (vars scoping): Option A implemented — per-user scoping with
ownercolumn + RLS. ✅ Implemented in v0.2.0. - Decision 6 (worker bypass): Worker role must be a superuser → bypasses RLS automatically. ✅
- Decision 7 (cancel/signal ownership): Explicit ownership check before duroxide client call. ✅
- Decision 8 (auto-grants): No automatic grants to PUBLIC. Admins must explicitly grant privileges to application roles after
CREATE EXTENSION. ✅ Revised (was auto-grant to PUBLIC in v0.1.1). - Monitoring functions: Rework
df.list_instances(),df.instance_info(),df.instance_executions(), anddf.instance_nodes()to only show the calling user’s own instances. Currently these functions fetch instance IDs from the duroxide client (which returns ALL instances via the worker’s connection), then join withdf.instancesfor labels. With RLS, the SPI label query is already filtered — but the duroxide client still returns other users' instance IDs, causing a mismatch. Fix: querydf.instancesvia SPI first (RLS-filtered), then use only those IDs when calling the duroxide client. ✅ df.metrics(): Controlled by explicit EXECUTE grants (security review Finding 6, v0.2.4).df.metrics()exposes system-wide aggregate counts (total instances, running/completed/failed counts, total executions and events) from the duroxide store without per-user filtering. Fresh installs revoke PUBLIC EXECUTE, an ordinarydf.grant_usage()does not grant it,df.grant_usage('role', with_grant => true)(a pg_durable admin) grants it WITH GRANT OPTION, anddf.revoke_usage()removes explicit metrics grants so admins can revoke and re-grant ordinary access without metrics. Roles without explicit metrics access should usedf.list_instances()to view a summary of their own workflows. ✅
7. Test Plan
Unit tests
- Verify
submitted_byis set on node creation (not just atdf.start()) - Verify RLS policies are created during
CREATE EXTENSION
E2E tests
Test: User can only see own instances ```sql SET SESSION AUTHORIZATION alice; SELECT df.start(df.sql(‘SELECT 1’), ‘alice-job’); RESET SESSION AUTHORIZATION;
SET SESSION AUTHORIZATION bob; SELECT count(*) FROM df.instances; – Should be 0 SELECT df.status(‘
Test: User cannot cancel another user’s instance ```sql SET SESSION AUTHORIZATION alice; SELECT df.start(df.sql(‘SELECT pg_sleep(10)’), ‘alice-long-job’); RESET SESSION AUTHORIZATION;
SET SESSION AUTHORIZATION bob; SELECT df.cancel(‘
Test: User cannot see another user’s nodes
sql
SET SESSION AUTHORIZATION bob;
SELECT count(*) FROM df.nodes WHERE instance_id = '<alice-instance-id>'; -- Should be 0
RESET SESSION AUTHORIZATION;
Test: Worker can access all instances - Verify the background worker can load/update instances from any user - Verify workflows complete successfully with RLS enabled
Test: df.vars per-user isolation (implemented in 38_rls_vars.sql)
```sql
SET SESSION AUTHORIZATION alice;
SELECT df.setvar(‘key’, ‘alice-value’);
RESET SESSION AUTHORIZATION;
SET SESSION AUTHORIZATION bob; SELECT df.getvar(‘key’); – Returns NULL (per-user scoping) SELECT df.setvar(‘key’, ‘bob-value’); – Bob gets his own ‘key’ RESET SESSION AUTHORIZATION;
– Each user’s df.start() captures only their own vars – df.clearvars() only clears the calling user’s variables – Superuser sees all vars via direct table access but df.start() captures only own ```