Contents
- pg_durable Security Model Specification
- Table of Contents
- 1. Executive Summary
- 2. Goals and Non-Goals
- 3. Threat Model
- 4. Functional Requirements
- 5. Securing df.sql()
- 6. Securing df.http()
- 7. Data Isolation (RLS)
- 8. Implementation Specification
- 8.1 Schema Changes
- 8.2 Function Permissions (Extension Installation)
- 8.3 Earlier GUC Proposal (Not Implemented)
- 8.4 Identity Capture (Implemented)
- 8.5 Secure SQL Execution (Implemented)
- 8.6 Orchestration Wiring (Implemented)
- 8.7 Fault Injection Hooks (Test-Only)
- 8.8 SPI as a Potential Future Optimization
- 9. User Experience
- 10. Test Specification
- 11. Open Questions
- Appendix A: Security Checklist for Code Review
- Appendix B: Related PostgreSQL Internals
pg_durable Security Model Specification
Status: Implementation in progress
Authors: pg_durable Team
Created: 2025-12-25
Last Updated: 2026-03-11
Table of Contents
- Executive Summary
- Goals and Non-Goals
- Threat Model
- Functional Requirements
- Securing df.sql()
- Securing df.http() 6.7 Duroxide Background Worker Authentication/Authorization
- Data Isolation (RLS)
- Implementation Specification
- User Experience
- Test Specification
- Open Questions
1. Executive Summary
pg_durable executes user-submitted SQL durably via a background worker. This creates a security challenge: SQL submitted by User A should execute with User A’s privileges, not with elevated background worker privileges.
For a detailed architectural overview of how function graphs are built and executed, please refer to the Architecture Guide.
Implemented approach: The background worker opens a dedicated sqlx connection authenticated directly as submitted_by (current_user at df.start() time). User SQL executes on this per-user connection, inheriting standard PostgreSQL RBAC. Identity is captured at df.start() time via GetUserId() and stored in df.instances and df.nodes.
See User Isolation: Design & Implementation Guide for the full design rationale.
Alternative considered (SPI + SetUserIdAndSecContext): An earlier draft of this spec proposed executing user SQL in-process via SPI with C-level security context switching. This would provide slightly stronger escape prevention (immune to RESET ROLE at the SQL level) and avoid the pg_hba.conf trust-auth requirement. However, the sqlx connection approach was chosen for simplicity, compatibility with the async duroxide runtime, and because it provides equivalent privilege isolation in practice. SPI remains a potential future optimization — see Section 8.8 for analysis.
Key Security Properties
| Property | Guarantee |
|---|---|
| Privilege Isolation | User SQL executes on a connection authenticated as the submitting user |
| Escape Prevention | RESET ROLE reverts to submitted_by (the authenticated connection identity) — no escalation possible |
| Audit Trail | All executions logged with original user identity (submitted_by) |
| Trusted Extension Model | Extension code is trusted; installed by superuser only |
Security Boundary
Important: The pg_durable background worker connects to PostgreSQL as arbitrary users via sqlx, relying on pg_hba.conf trust (or peer) authentication for local connections. This is a trusted code + trusted network model:
- The extension can connect as ANY local PostgreSQL role (given permissive
pg_hba.conf) - Security relies on the extension code being correct and the
pg_hba.confconfiguration being appropriate - The worker’s ambient role (e.g.,
duroxide_worker) is used only for control-plane operations (loading graphs, updating status); user SQL runs on separate per-user connections - This is similar to how
pg_cronoperates — trusted extension code with elevated capabilities
The security guarantee is: only superusers can install the extension, therefore the extension code is trusted.
2. Goals and Non-Goals
Goals
- G1: User SQL executes with the privileges of the user who called
df.start() - G2: Users cannot escalate privileges through durable function execution
- G3: SQL-level grants to the background worker role follow least privilege (while acknowledging the overall model is a trusted, SUPERUSER-installed extension)
- G4: Clear audit trail of who submitted what
- G5: Simple user experience - no complex grant management for basic use cases
- G6: Compatible with Azure Flexible Server managed environment
Non-Goals
- NG1: Supporting different users for different nodes within a single function graph
- NG2: Cross-database durable function execution
- NG3: Supporting untrusted extension installation (pg_durable remains SUPERUSER-install)
- NG4: Real-time privilege revocation (in-flight executions complete with original privileges)
3. Threat Model
3.1 Actors
| Actor | Trust Level | Capabilities |
|---|---|---|
| DBA/Admin | Trusted | Installs extension, manages roles, full database access |
| Application User | Semi-trusted | Can call df.* functions, owns application tables |
| Attacker | Untrusted | Compromised application user, attempts privilege escalation |
3.2 Threats and Mitigations
Implementation Priority Summary
| Threat | Severity | Status | Notes |
|---|---|---|---|
| T8: SSRF via HTTP Activity | CRITICAL | Implemented | Dataplane protection — see http-security.md |
| T4: Information Disclosure via df.* Tables | HIGH | Implemented | RLS on df.instances and df.nodes — see rls.md |
| T9: Unauthorized HTTP Access | HIGH | Not implemented | REVOKE EXECUTE + admin allowlist (future spec) |
| T11: Secret Exfiltration | HIGH | Not implemented | Additive feature; no table/API exists yet |
| T10: Cross-User Variable Injection | MEDIUM-HIGH | Implemented | Per-user df.vars scoping via owner column + RLS — see rls.md |
| T5: Denial of Service | MEDIUM | Not implemented | Rate limiting; deferred |
| T6: Worker Code Vulnerability | MEDIUM | Mitigated by design | Relies on code review |
| T0: SECURITY DEFINER Misuse | MEDIUM | Documentation-only | Expected PG behavior |
| T12: SQL Injection in Internal SPI Queries | CRITICAL | Implemented | df.status() and df.result() now use parameterized SPI queries (Spi::get_one_with_args) |
| T7: Extension Trustworthiness | LOW | Accepted | Standard PG trust model |
T13: search_path Manipulation in PL/pgSQL Helpers |
LOW | Implemented | Helper function definitions set search_path = pg_catalog, df, pg_temp |
| T14: Extension Object Pre-creation | LOW | Accepted | Requires operator error; superuser-only install |
| T1–T3: Privilege Escalation | CRITICAL | Implemented | Per-user sqlx connections |
T0: SECURITY DEFINER Invocation Captures Definer Privileges
Severity: MEDIUM | Status: Documentation-only
Threat: Calling df.start() inside a SECURITY DEFINER function captures the definer’s identity (because GetUserId()/current_user reflect the definer inside the function). Unprivileged callers could cause durable work to run with the definer’s privileges.
Mitigation (documentation-only): This is expected PostgreSQL behavior. The extension does not block this pattern. Operators must avoid invoking df.start() from SECURITY DEFINER unless they explicitly want definer-level execution. Document clearly and, if possible, emit audit logs when df is invoked from SECURITY DEFINER.
Residual Risk: High if misused. Safe if used intentionally and documented.
T1: Privilege Escalation via RESET ROLE
Severity: CRITICAL | Status: Implemented
Threat: User submits SQL containing RESET ROLE to escape back to worker’s identity.
-- Malicious durable function
SELECT df.start(
df.sql('RESET ROLE; DROP TABLE other_users_data;'),
'attack'
);
Mitigation (implemented): User SQL runs on a dedicated sqlx connection authenticated as submitted_by (the user’s identity at df.start() time). RESET ROLE on this connection resets the effective role back to submitted_by — which is still the user’s own authenticated identity. The connection never has the background worker’s elevated privileges, so there is nothing to escape to.
Residual Risk: None — the connection is authenticated as the user, not the worker.
T2: Privilege Escalation via SET ROLE
Severity: CRITICAL | Status: Implemented
Threat: User attempts to assume a more privileged role.
SELECT df.start(
df.sql('SET ROLE postgres; SELECT * FROM pg_shadow;'),
'attack'
);
Mitigation (implemented): The sqlx connection is authenticated as submitted_by. Any SET ROLE by user SQL requires role membership, checked against submitted_by. SET ROLE postgres fails unless submitted_by is a member of postgres. This is standard PostgreSQL RBAC enforced at the connection level.
Residual Risk: None — standard PostgreSQL RBAC applies.
T3: Privilege Escalation via Dynamic SQL
Severity: CRITICAL | Status: Implemented
Threat: User obfuscates malicious commands.
SELECT df.start(
df.sql($$ DO $x$ BEGIN EXECUTE 'RES' || 'ET ROLE'; END $x$ $$),
'attack'
);
Mitigation (implemented): Dynamic SQL runs on the same sqlx connection, which is authenticated as the user’s submitted_by role. The connection’s authenticated identity cannot be changed by any SQL command — RESET ROLE only reverts to submitted_by (the user’s own identity), and SET ROLE requires membership.
Residual Risk: None.
T4: Information Disclosure via df.* Tables
Severity: HIGH | Status: Implemented
Threat: User queries df.instances or df.nodes to see other users' durable functions.
Mitigation (implemented): RLS policies on df.instances and df.nodes enforce per-user visibility using submitted_by = current_user::regrole. Auto-grants provide SELECT+INSERT on both tables and column-level UPDATE (status, updated_at) on instances (no DELETE). Ownership checks in df.cancel() and df.signal() prevent cross-user operations via the duroxide client. Monitoring functions (df.list_instances(), df.instance_info(), etc.) also enforce ownership.
See rls.md for the full design, policy definitions, grant strategy, and decisions.
Residual Risk: Low — RLS is a well-tested PostgreSQL feature.
T5: Denial of Service via Resource Exhaustion
Severity: MEDIUM | Status: Not implemented (deferred)
Threat: User creates many long-running durable functions to exhaust worker capacity.
Mitigation:
- Rate limiting via df.max_concurrent_per_user GUC
- Timeout enforcement via df.execution_timeout
- Queue depth limits
Residual Risk: Medium - requires monitoring; out of scope for initial implementation.
T6: Background Worker Code Vulnerability
Severity: MEDIUM | Status: Mitigated by design
Threat: Bug in extension code allows attacker to control which user the worker connects as.
Attack Vector: If submitted_by values used by connect_as_user() are derived from user-controlled data, an attacker could forge them to connect as a different user.
Mitigation (implemented):
- submitted_by is captured via GetUserId() at df.start() time in the user’s backend process
- It is stored as REGROLE in df.instances and propagated to df.nodes — users cannot write to these columns directly (they are set by the extension’s Rust code via SPI during df.start())
- Worker reads role names from df.nodes when loading the function graph, and passes them to the execute_sql activity
- Code review checklist: verify role name provenance in all paths
Residual Risk: Medium — relies on correct implementation. Code review critical.
T7: Extension Code Trustworthiness
Severity: LOW (accepted) | Status: N/A — inherent to PG extension model
Threat: Malicious or buggy extension code abuses its ability to connect as any user.
Context: PG’s extension architecture has a full trust model - extension code must be safe and correct. Any extension can call C functions, which is stronger than “connect as any user”.
Mitigation:
- Extension requires superuser to install (CREATE EXTENSION pg_durable)
- pg_hba.conf must be configured to allow the worker’s local connections (trust or peer auth)
- Code is open source and auditable
- Standard trusted extension model (same as pg_cron, postgis, etc.)
Residual Risk: Accepted — this is the PostgreSQL trusted extension model. If you install the extension, you trust the code.
T8: Server-Side Request Forgery (SSRF) via HTTP Activity
Severity: CRITICAL | Status: Implemented
Threat: Attacker uses df.http() to access internal network services, cloud metadata endpoints, or localhost services from within the PostgreSQL VM. In a PG-as-a-service deployment, this is a dataplane escape.
Mitigation (implemented): Compile-time IP blocklist that blocks all private/reserved IP ranges, with DNS rebinding protection and IPv4-mapped IPv6 handling. The blocklist is hardcoded and cannot be bypassed by any database user, including superusers, for pg_durable’s built-in df.http() activity path. It does not restrict arbitrary SQL functions, user-defined functions, or third-party Postgres extensions that SQL nodes are permitted to execute.
See http-security.md for the full specification, blocked IP ranges, and implementation details.
Residual Risk: Low for df.http() — hardcoded blocklist cannot be bypassed in that path.
T9: Unauthorized HTTP Access
Severity: HIGH | Status: Partially implemented
Threat: User abuses df.http() to access external resources they shouldn’t (exfiltrate data, attack external services).
Mitigation:
- df.http() has EXECUTE revoked from PUBLIC on fresh installs
- DBA grants HTTP access explicitly, either with df.grant_usage(role, include_http => true) or a direct GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer)
- The worker re-checks EXECUTE at execution time to block raw df.start() JSON injection
- Audit logging records HTTP attempts
Future enhancements: - Per-customer or per-role destination controls beyond the current feature-gated allowlist - Rate limiting
Residual Risk: Medium until customer-level controls are implemented. T8 (SSRF/dataplane) protection is independent and addressed first.
T10: Cross-User Variable Injection via df.vars
Severity: MEDIUM-HIGH | Status: Implemented
Threat: df.vars is a key-value table. Without per-user scoping, users can override or read each other’s variables, potentially redirecting workflows.
Mitigation (implemented): df.vars has an owner REGROLE column with DEFAULT current_user::regrole and a composite primary key (owner, name). RLS policy vars_user_isolation restricts each user to their own variables. df.setvar() scopes ownership via the column DEFAULT and ON CONFLICT (owner, name); read/delete functions (df.getvar(), df.unsetvar(), df.clearvars()) and df.start() vars capture use explicit WHERE owner = current_user::regrole filters. This ensures correct scoping even for superusers who bypass RLS. See rls.md, Decision 5.
Residual Risk: Low — per-user scoping is enforced at both the RLS and application layers.
T11: Secret Exfiltration via df.secrets
Severity: HIGH | Status: Not implemented (additive feature)
Threat: df.secrets are intended to be admin-managed values (API keys, shared tokens) that workflows can use without hard-coding secrets into graphs. If secrets are directly readable by all users, they are not secrets. Without this feature, users must embed credentials directly in function graphs, where they are stored in df.nodes and potentially visible in logs.
Mitigation: - Secrets MUST NOT be directly selectable by non-admin users - Secrets MUST NOT be returned in results or error strings - Secrets should be resolved only inside the worker execution path and substituted into SQL/HTTP requests at execution time
Residual Risk: Medium (by design secrets are high-impact); mitigated by least-privilege, auditing, and never exposing plaintext to users.
T12: SQL Injection in Internal SPI Queries
Severity: CRITICAL | Status: Implemented
Threat: Two extension functions (df.status() and df.result()) previously interpolated user-supplied instance_id directly into SQL without escaping single quotes. That allowed SQL injection to bypass RLS and read other users' instance data.
-- Attack: bypass RLS to read any user's instance status
SELECT df.status('x'' OR 1=1--');
-- Attack: read results from other users' workflows
SELECT df.result('x'' UNION SELECT secret FROM admin_table--');
Current mitigation (src/dsl.rs):
```rust
// df.status() — parameterized SPI query
let status: Option
// df.result() — parameterized SPI query let result: Option
Fix implemented: Replaced string interpolation with parameterized SPI in df.status() and df.result() using Spi::get_one_with_args(). This removes quote-escaping as a correctness requirement for these call sites. Other internal SPI queries should also prefer parameterization wherever supported; manual escaping is fallback-only for cases where parameters are not available.
Note on variable substitution: The substitute_all_with_options() function in src/types.rs inserts user variables ({name}) as-is into SQL without quoting. This is by design — variables are intended to be SQL fragments (e.g., table names, expressions). Users choose what to put in their own variables, and the SQL executes with their own privileges on a per-user connection. This is analogous to psql variable substitution (:name). Result substitution ($name) does properly quote string values.
Residual Risk: Low. Remaining hardening is to continue migrating any remaining string-formatted SPI lookups to parameterized calls where possible.
See this section and Appendix A checklist for ongoing SPI query hardening work.
T13: search_path Manipulation in PL/pgSQL Helper Functions
Severity: LOW | Status: Implemented
Threat: The extension’s PL/pgSQL helper functions (df.if_then_op(), df.if_else_op(), df.ensure_durofut()) and SQL wrapper functions (df.as_op(), df.loop_prefix_op()) do not set a fixed search_path. If an attacker can place a malicious function in a schema that appears earlier in search_path, they could shadow a built-in or extension function.
Mitigations (implemented):
- All function calls within the helpers are already schema-qualified: df.ensure_durofut(), df.sql(), df.if(), df.loop(), df.as()
- Built-in functions used (jsonb_build_object) are in pg_catalog, which is always implicitly first in search_path
- The functions are created in the df schema (owned by superuser)
- The extension requires superuser to install
- Helper definitions in both fresh install SQL and upgrade SQL include SET search_path = pg_catalog, df, pg_temp
Fix implemented: Added SET search_path = pg_catalog, df, pg_temp to the helper function definitions (df.if_then_op(), df.if_else_op(), df.ensure_durofut(), df.as_op(), df.loop_prefix_op()) in both install and upgrade paths.
Residual Risk: Low — current references are schema-qualified and helper search path is pinned as defense-in-depth.
T14: Extension Object Pre-creation Attack
Severity: LOW | Status: Accepted risk
Threat: The extension uses CREATE TABLE IF NOT EXISTS and CREATE OR REPLACE FUNCTION patterns. An attacker could pre-create objects with the same names to retain ownership or inject malicious implementations.
Why this is low risk:
- All DDL runs inside extension_sql!() blocks during CREATE EXTENSION, which requires superuser
- The df schema is created by pgrx during extension installation — it doesn’t exist beforehand
- The duroxide schema is created by sql/duroxide_install.sql with SET LOCAL search_path TO duroxide
- An attacker would need CREATE TABLE privilege in a schema controlled by the extension before the extension is installed — this requires the superuser to have manually created the schema and granted access (operator error)
- The CREATE OR REPLACE FUNCTION for PL/pgSQL helpers is a standard pgrx convention; the #[pg_extern] macro also generates CREATE OR REPLACE
Residual Risk: Low — requires operator error (manually creating df schema and granting usage before installing the extension).
4. Functional Requirements
4.1 Overall Security Requirements
pg_durable follows PostgreSQL’s native security patterns wherever possible:
| Mechanism | PostgreSQL Native | pg_durable Usage |
|---|---|---|
| Function access | GRANT/REVOKE EXECUTE |
Control who can use df.sql(), df.http(), etc. |
| Data isolation | Row-Level Security (RLS) | Users see only their own instances |
| Configuration | GUCs (ALTER SYSTEM SET) |
HTTP allowlists, rate limits, timeouts |
| Role membership | GRANT role TO user |
No custom roles; use standard PostgreSQL |
| Audit | PostgreSQL logging | Log with effective user identity |
Design Principle: Follow the pg_cron model — use RLS for data isolation, standard function permissions for access control, and GUCs for system-wide settings. No custom permission tables or roles.
4.2 Security by Activity Type
| Activity | Permission Model | Additional Controls |
|---|---|---|
df.sql() |
PostgreSQL RBAC via per-user sqlx connection | None needed — native |
df.http() |
GRANT EXECUTE on function |
SSRF blocking, URL allowlist GUC |
df.start() |
GRANT EXECUTE on function |
RLS on df.instances |
| Future activities | GRANT EXECUTE on function |
Activity-specific GUCs |
4.3 Workflow Variables (df.vars)
pg_durable supports workflow variables via df.setvar()/df.getvar()/df.unsetvar()/df.clearvars(). Variables are captured at df.start() time and passed into the orchestration as an immutable vars map.
Security requirement: Variables MUST NOT be global, cross-user state.
Current status: Implemented. df.vars has per-user scoping via an owner REGROLE column with RLS. Each user has their own variable namespace. Variables are captured at df.start() time from the calling user’s namespace only. See rls.md, Decision 5.
4.4 Shared Secrets (df.secrets)
pg_durable supports shared secrets for workflows.
Intent: Provide admin-managed secrets (API keys, bearer tokens, shared credentials) that workflows can reference without embedding secrets in the function graph.
Key security property: Secrets are usable by workflows but are not directly readable by non-admin users.
API surface (proposed):
- df.setsecret(name text, value text) (admin-only)
- df.unsetsecret(name text) (admin-only)
- df.clearsecrets() (admin-only)
- No general-purpose df.getsecret() for non-admins
How users consume secrets:
- Secrets are referenced by name inside node queries/config and resolved by the worker at execution time.
- Example placeholder (conceptual): ${secret:stripe_api_key}.
Permissions:
- Only admins are granted EXECUTE on secret mutators (df.setsecret, df.unsetsecret, df.clearsecrets).
- Non-admins should not have SELECT on df.secrets.
- The worker (trusted code) may read df.secrets to perform substitution.
Audit: - Log secret name usage for traceability (never log values).
Scenarios that this enables:
- Any user granted EXECUTE on df.http(text, text, text, jsonb, integer) can run a workflow that calls df.http() to an allowed host and uses an Authorization header populated from df.secrets.
- Any user can run a workflow that queries an external FDW/API gateway where the credential is provided by the worker.
5. Securing df.sql()
5.1 Overview
SQL execution is the core activity. It uses PostgreSQL’s native permission system via dedicated per-user sqlx connections.
┌─────────────────────────────────────────────────────────────────┐
│ df.sql() Security Model (Implemented) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User calls: df.start(df.sql('SELECT * FROM my_table'), ...) │
│ │
│ 1. df.start() captures: │
│ • GetUserId() → submitted_by (current_user) │
│ • Stored as REGROLE in df.instances and df.nodes │
│ │
│ 2. Background worker executes: │
│ • connect_as_user(submitted_by) via sqlx │
│ → Authenticates TCP connection as submitted_by │
│ → SET df.in_workflow = 'true' (guard variable mutations) │
│ • sqlx::query(user_sql) ← runs with user's privileges │
│ • Connection dropped after execution │
│ │
│ 3. PostgreSQL permission checks: │
│ • All ACL checks use the connection's effective role │
│ • RLS policies evaluate against the connected user │
│ • pg_stat_activity shows the user's identity │
│ │
└─────────────────────────────────────────────────────────────────┘
5.2 Permission Model
No additional permissions needed — PostgreSQL’s native RBAC applies:
-- User A owns their table
CREATE TABLE user_a.my_data (id int, secret text);
-- User A can query it in a durable function
SELECT df.start(df.sql('SELECT * FROM user_a.my_data'), 'my-job');
-- ✓ Succeeds: runs as user_a, who owns the table
-- User B tries to access user_a's table
SET ROLE user_b;
SELECT df.start(df.sql('SELECT * FROM user_a.my_data'), 'steal-data');
-- ✗ Fails: runs as user_b, who has no access
5.3 Escape Prevention
| Attack | Why It Fails |
|---|---|
RESET ROLE |
Reverts to submitted_by — still the user’s own identity, not the worker’s |
SET ROLE postgres |
Requires membership in postgres; checked against submitted_by |
EXECUTE 'RESET ROLE' |
Dynamic SQL runs on the same connection — same identity constraints |
SELECT my_escape_func() |
Function runs on the same connection — same identity constraints |
5.4 Implementation
See Section 8: Implementation Specification for full code.
6. Securing df.http()
6.1 Overview
HTTP requests are guarded by PostgreSQL function privileges plus runtime SSRF defenses. In the current implementation, security is enforced via:
- Function-level permission:
GRANT/REVOKE EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) - Execution-time privilege re-check: the worker validates that
submitted_bystill hasEXECUTEbefore any network activity - SSRF protection: Block internal IPs at the code level
- Compile-time endpoint allowlist: allowed destinations depend on the HTTP Cargo feature
- Redirect handling: redirects are disabled
┌─────────────────────────────────────────────────────────────────┐
│ df.http() Security Model │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Layer 1: Function Permission (PostgreSQL native) │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ REVOKE EXECUTE ON FUNCTION df.http(text, text, text, │ │
│ │ jsonb, integer) FROM PUBLIC; │ │
│ │ SELECT df.grant_usage('api_users', include_http => true); │ │
│ │ -- or GRANT EXECUTE ON FUNCTION df.http(text, text, text, │ │
│ │ -- jsonb, integer) TO api_users; │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Layer 2: Execution-Time Privilege Check │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ submitted_by must still have EXECUTE on df.http(...) │ │
│ │ Blocks raw df.start() JSON injection bypasses │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Layer 3: SSRF Protection + Feature Allowlist │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ Block: private/link-local/loopback ranges │ │
│ │ Allow only feature-approved hostnames │ │
│ │ No redirects │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
6.2 Permission Model
Function-level access control (PostgreSQL native):
-- Fresh installs: HTTP disabled by default
REVOKE EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) FROM PUBLIC;
-- DBA enables HTTP for specific roles
SELECT df.grant_usage('etl_service', include_http => true);
SELECT df.grant_usage('webhook_handler', include_http => true);
-- Regular app users cannot use HTTP
SET ROLE app_user;
SELECT df.start(df.http('https://example.com', 'GET'), 'test');
-- ERROR: permission denied for function df.http
6.3 GUC Configuration
The current implementation does not expose HTTP allowlists or rate limits via GUCs. Allowed destinations are compiled in through Cargo features and the execution-time permission model is documented in http-security.md.
6.4 SSRF Protection
SSRF protection is implemented as a compile-time IP blocklist that blocks all private/reserved IP ranges (RFC 1918, link-local, loopback, IPv6 ULA, etc.), with DNS rebinding protection and IPv4-mapped IPv6 handling.
See http-security.md for the full specification including blocked ranges, implementation architecture, and testing.
6.5 Implementation
See http-security.md for the implemented privilege check, SSRF protections, and feature-gated hostname allowlist.
6.6 User Experience
-- DBA setup (one-time)
SELECT df.grant_usage('etl_service', include_http => true);
-- User with permission
SET ROLE etl_service;
SELECT df.start(
df.http('https://api.company.com/webhook', 'POST', '{"event": "done"}'),
'notify-completion'
);
-- ✓ Succeeds
-- User without permission
SET ROLE app_user;
SELECT df.start(
df.http('https://api.company.com/data', 'GET'),
'fetch-data'
);
-- ✗ ERROR: permission denied for function df.http
-- SSRF attempt (even with permission)
SET ROLE etl_service;
SELECT df.start(
df.http('http://169.254.169.254/latest/meta-data/', 'GET'),
'ssrf-attempt'
);
-- ✗ ERROR: HTTP request blocked: resolves to internal IP
6.7 Duroxide Background Worker Authentication/Authorization
Purpose: Keep the background worker’s control-plane connections passwordless while constraining what the worker can do when it is not running execute_sql on behalf of a user.
Auth (peer over Unix socket + pg_ident mapping): Use a Unix socket host and
peerauth to map the OS userpostgres→ a low-privilege DB role (e.g.,duroxide_worker). Example:pg_hba.conf:local postgres duroxide_worker peer map=duroxide_mappg_ident.conf:duroxide_map postgres duroxide_worker- Connection string (sqlx/duroxide store):
postgresql://duroxide_worker@/postgres?host=/var/run/postgresql - Rationale: passwordless, auditable, and scoped to local socket; avoids
truston TCP.
AuthZ (limit ambient role): Grant the worker role only what the control-plane needs:
GRANT USAGE ON SCHEMA df, duroxide TO duroxide_worker;GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA df, duroxide TO duroxide_worker;REVOKE ALL ON SCHEMA public FROM duroxide_worker;(and avoid grants in other schemas)- Future tables: ensure default privileges in those schemas keep the role scoped to df.* and duroxide.
Privilege boundary vs execute_sql (implemented): The
execute_sqlactivity opens a separate sqlx connection authenticated assubmitted_by(the user’scurrent_useratdf.start()time). This connection is completely independent of the worker’s control-plane connection. All other worker SQL (loading graphs, updating status) runs on the worker’s own pooled connection as the low-privilegeduroxide_workerrole.pg_hba.conf requirement for per-user connections: The
execute_sqlactivity connects as arbitrary users via TCP. This requirespg_hba.confto allow trust (or peer) auth for local connections. In development with pgrx (TCP), the typical configuration is:host all all 127.0.0.1/32 trustSafety notes:
- For control-plane connections, prefer socket path (not
127.0.0.1) forpeerto apply. - This model still assumes trusted extension code; the worker can connect as any user for
execute_sql, but only that activity should do so. - If sockets/
peerare unavailable (managed services), fall back to client cert or AAD/Managed Identity as a “passwordless” token, while keeping the DB role scoped to df.* + duroxide.
- For control-plane connections, prefer socket path (not
7. Data Isolation (RLS)
Status: Implemented
RLS on df.instances and df.nodes enforces per-user data isolation. The extension does not grant privileges to PUBLIC — admins must explicitly grant appropriate permissions (schema usage, function execution, table DML) to application roles. The background worker bypasses RLS as a superuser.
See rls.md for the full design including:
- RLS policy definitions and rationale
- Grant strategy (column-level UPDATE, no DELETE)
- Ownership checks in df.cancel(), df.signal(), and monitoring functions
- Worker bypass strategy
- Deferred df.vars scoping (Phase 2)
8. Implementation Specification
8.1 Schema Changes
df.instances Table Updates
-- df.instances (implemented)
-- submitted_by is created as part of the table definition in src/lib.rs
-- submitted_by: REGROLE NOT NULL — effective role (current_user) when df.start() was called
-- df.nodes (implemented)
-- submitted_by: REGROLE — nullable, set when node is linked to an instance by df.start()
Note on earlier draft: An earlier version of this spec proposed submitted_by OID and a security_context JSONB column. The implemented design uses REGROLE (which stores OIDs but displays as role names) and the current v0.2.0 line tracks a single submitted_by column. The released v0.1.1 schema also carried a second column, login_role, for the session user identity; the v0.2.0 work removes that column and simplifies the model. See user-isolation.md for the full design and upgrade caveats.
df.vars Table Updates (per-user scoping) — Deferred
Per-user scoping of df.vars (adding an owner column + RLS) is deferred to a follow-up PR. See rls.md, Decision 5 for the design.
df.secrets Table (admin-managed, workflow-usable)
df.secrets stores shared secrets that are referenced by workflows but not directly readable by non-admins.
CREATE TABLE IF NOT EXISTS df.secrets (
name TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by REGROLE NOT NULL DEFAULT current_user::regrole
);
-- Permissions
REVOKE ALL ON TABLE df.secrets FROM PUBLIC;
-- Only the worker and admins can read to perform substitution
GRANT SELECT ON TABLE df.secrets TO duroxide;
-- Secret mutators are admin-only
REVOKE EXECUTE ON FUNCTION df.setsecret(name text, value text) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION df.unsetsecret(name text) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION df.clearsecrets() FROM PUBLIC;
Additional requirements:
- Secrets must never be returned in results, status, or logs (only secret names may be logged for audit).
- If secrets are stored in plaintext, storage must be restricted to trusted roles as above; encrypt-at-rest may be added later but is not assumed by this spec.
- Secret substitution occurs inside the worker; users cannot SELECT df.secrets.
8.2 Function Permissions (Extension Installation)
-- Called during CREATE EXTENSION pg_durable
-- Default: all df functions require explicit grant
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA df FROM PUBLIC;
-- df.sql() - available to anyone who can use df.start()
-- (actual SQL permission checked via per-user sqlx connection)
-- df.http() - disabled by default, DBA enables per-role
REVOKE EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) FROM PUBLIC;
-- Convenience helper: grant standard df usage, excluding df.http() unless
-- include_http => true is passed.
-- Example: SELECT df.grant_usage('app_role', include_http => true);
-- df.start(), df.status() - grant to users who need durable functions
-- DBA grants these: GRANT EXECUTE ON FUNCTION df.start TO app_role;
-- df.vars - normal users may set/get their own variables (enforced by RLS)
-- df.secrets - admin-only mutators; no generic getter for non-admins
8.3 Earlier GUC Proposal (Not Implemented)
The following GUC definitions were part of an earlier proposal. The current
implementation uses Cargo features plus the execution-time privilege check
described in docs/http-security.md, not runtime HTTP GUCs.
// src/lib.rs
// HTTP activity controls
GucRegistry::define_string_guc(
"df.http_allowed_hosts",
"Comma-separated list of allowed host patterns for df.http()",
"", // default: empty = no hosts allowed
GucContext::Suset,
);
GucRegistry::define_bool_guc(
"df.http_block_internal_ips",
"Block HTTP requests to private/internal IP ranges (SSRF protection)",
true, // default: enabled
GucContext::Suset,
);
GucRegistry::define_int_guc(
"df.http_timeout_seconds",
"Timeout for HTTP requests in seconds",
30,
1,
300,
GucContext::Suset,
);
GucRegistry::define_int_guc(
"df.http_rate_limit_per_minute",
"Maximum HTTP requests per user per minute",
60,
1,
1000,
GucContext::Suset,
);
8.4 Identity Capture (Implemented)
Identity is captured at df.start() time in the user’s backend process. DSL functions (df.sql(), df.seq(), etc.) do not capture identity — nodes are created without submitted_by, which remains NULL until linked to an instance.
// src/dsl.rs — inside df.start()
// Capture current_user identity directly from PostgreSQL (no SPI needed)
let current_user_oid = unsafe { pgrx::pg_sys::GetUserId() }; // submitted_by
// OID is stored as REGROLE in the instance and node rows:
// INSERT INTO df.instances (..., submitted_by)
// VALUES (..., {current_user_oid}::oid::regrole)
Why GetUserId() (i.e., current_user): This captures the effective user at df.start() time. Inside a SECURITY DEFINER function, this will be the definer’s identity — which is the expected behavior for the simplified model. The user who has current_user privileges at call time is the identity used for SQL execution. See user-isolation.md for the full design.
8.5 Secure SQL Execution (Implemented)
User SQL is executed on a dedicated sqlx connection authenticated as the submitting user, not via SPI:
// src/types.rs — connect_as_user()
pub async fn connect_as_user(
user: &str,
database: Option<&str>,
) -> Result<sqlx::postgres::PgConnection, String> {
let mut options = PgConnectOptions::new()
.username(user) // Authenticate as submitted_by
.database(db)
.port(get_port());
let mut conn = PgConnection::connect_with(&options).await?;
// Mark connection as running inside a workflow (prevents variable mutations)
sqlx::query("SET df.in_workflow = 'true'")
.execute(&mut conn)
.await?;
Ok(conn)
}
// src/activities/execute_sql.rs
#[derive(Serialize, Deserialize)]
struct ExecuteSqlInput {
pub query: String,
pub submitted_by: String, // User identity (current_user at df.start() time)
pub database: Option<String>, // Target database
}
pub async fn execute(
ctx: ActivityContext,
_pool: Arc<PgPool>, // shared pool — not used for user SQL
input_json: String,
) -> Result<String, String> {
let input: ExecuteSqlInput = serde_json::from_str(&input_json)?;
// Open a per-user connection (NOT the worker's shared pool)
let mut conn = connect_as_user(
&input.submitted_by,
input.database.as_deref(),
).await?;
// Execute user SQL on the per-user connection
let result = sqlx::query(&input.query)
.fetch_all(&mut conn)
.await?;
// conn is dropped here — connection closed
Ok(/* serialized result */)
}
Key properties of the implemented approach:
- User SQL runs on a connection authenticated as the user — never on the worker’s shared pool
- RESET ROLE reverts to submitted_by (the user’s own identity) — no escalation possible
- Each SQL node gets a fresh connection (future optimization: per-instance connection caching)
- SET df.in_workflow = 'true' prevents variable mutations (setvar/unsetvar/clearvars) during workflow execution. Note: it does not currently prevent recursive df.start() calls — that is a potential future improvement
8.6 Orchestration Wiring (Implemented)
The orchestration packages the query and both identities together before scheduling the activity:
// src/orchestrations/execute_function_graph.rs
let input = serde_json::json!({
"query": final_query,
"submitted_by": node.submitted_by,
"database": node.database,
});
let result = ctx
.schedule_activity(activities::execute_sql::NAME, input.to_string())
.into_activity()
.await?;
Both values come from the FunctionNode loaded via the load_function_graph activity. They are stable across replays (safe for determinism).
8.7 Fault Injection Hooks (Test-Only)
To validate crash/panic behavior and restart semantics, pg_durable should include a test-only fault injection mechanism that can deterministically trigger failures inside execute_sql.
Mechanism (proposed):
- A superuser-only GUC such as df.test_fault_inject = 'none'|'execute_sql_error'|'execute_sql_panic_before'|'execute_sql_panic_after_connect'.
- The worker checks this setting at runtime and triggers the configured fault.
- This GUC MUST NOT be enabled by default and should be documented as test-only.
8.8 SPI as a Potential Future Optimization
The current implementation executes user SQL via external sqlx connections. An alternative approach — executing via SPI with SetUserIdAndSecContext() — was considered during design and remains a potential future optimization.
What SPI + SetUserIdAndSecContext would provide
| Benefit | Description |
|---|---|
| Stronger escape prevention | SetUserIdAndSecContext() operates at the C level. SQL commands like RESET ROLE only affect the session-level role, not the C-level security context. With the sqlx approach, RESET ROLE reverts to submitted_by — which is still the user’s identity (no escalation), but the effective role does change. |
| No pg_hba.conf dependency | SPI runs in-process; no TCP connection is needed for user SQL. The current approach requires pg_hba.conf trust auth so the worker process can connect as arbitrary users. |
| Lower connection overhead | SPI avoids TCP connection setup/teardown per SQL node. This could matter for workflows with many SQL nodes. |
| Atomic with worker transaction | SPI shares the worker’s transaction context, which could simplify certain consistency scenarios. |
Why the sqlx approach was chosen
| Reason | Description |
|---|---|
| Async compatibility | SPI is synchronous and has thread-affinity requirements. The duroxide runtime is async (tokio). Mixing SPI into an async context requires careful locking and prevents concurrent SQL execution across different instances. The sqlx approach naturally composes with async. |
| Concurrency | With SPI, only one SQL query can execute at a time per background worker (global SPI lock + single backend thread). With sqlx, multiple SQL nodes from different instances could execute concurrently on separate connections. |
| Simplicity | The sqlx approach uses standard PostgreSQL client semantics. SPI + SetUserIdAndSecContext requires unsafe C code, RAII guards, subtransaction management, and careful interaction with the duroxide runtime. |
| Equivalent security | Both approaches provide privilege isolation. The sqlx connection is authenticated as the user — RESET ROLE only reverts to the user’s own submitted_by, not the worker’s identity. There is no privilege escalation path. |
When to reconsider SPI
SPI would be worth revisiting if:
- Connection overhead becomes a bottleneck (many SQL nodes per workflow)
- The pg_hba.conf trust requirement is unacceptable in a deployment environment
- A need arises for tighter integration with the worker’s transaction (e.g., savepoint-based partial rollback)
- The C-level escape prevention of SetUserIdAndSecContext is required for compliance
9. User Experience
9.1 End User Workflow
-- User with df function access (DBA already granted)
SET ROLE app_user;
-- Create a durable function (SQL runs as app_user)
SELECT df.start(
df.sql('INSERT INTO my_table VALUES (now(), ''hello'')'),
'my-job'
);
-- Returns: instance_id 'abc12345'
-- Check status
SELECT df.status('abc12345');
-- Returns: 'completed' (or 'running', 'failed')
-- View my instances only (RLS enforced)
SELECT id, label, status, submitted_at
FROM df.instances
ORDER BY submitted_at DESC;
9.2 Administrator Workflow
-- After CREATE EXTENSION, admins must grant privileges to application roles.
-- Table and schema grants are NOT auto-applied.
-- 1. Grant basic df access to a role
-- (see USER_GUIDE.md "Privilege Grants" for the full list of individual grants)
SELECT df.grant_usage('app_backend');
-- 2. Opt a role into HTTP access only when needed
SELECT df.grant_usage('app_backend_http', include_http => true);
-- 3. Allowed HTTP destinations depend on the build feature set.
-- See docs/http-security.md for the current allowlist.
-- 4. View all instances (superuser bypasses RLS)
SELECT submitted_by, count(*)
FROM df.instances
GROUP BY submitted_by;
9.3 Error Messages
| Scenario | Error Message |
|---|---|
| No function permission | ERROR: permission denied for function df.http or execution-time Blocked: role '{role}' does not have EXECUTE privilege on df.http()... |
| SQL permission denied | ERROR: permission denied for table secret_data (in df.status result) |
| HTTP host not allowed | ERROR: HTTP request blocked: host 'evil.com' not in allowed list |
| SSRF blocked | ERROR: HTTP request blocked: resolves to internal IP 169.254.169.254 |
| RLS filtered | (silent — user simply doesn’t see other users' rows) |
| Cross-user cancel/signal | ERROR: Instance not found or access denied: <id> |
10. Test Specification
10.1 Unit Tests
UT1: Security Context Capture
#[pg_test]
fn test_security_context_capture() {
// Setup: Create test user
Spi::run("CREATE USER test_capture_user").unwrap();
Spi::run("GRANT EXECUTE ON FUNCTION df.start TO test_capture_user").unwrap();
Spi::run("GRANT EXECUTE ON FUNCTION df.sql TO test_capture_user").unwrap();
Spi::run("GRANT EXECUTE ON FUNCTION df.status TO test_capture_user").unwrap();
Spi::run("SET ROLE test_capture_user").unwrap();
let ctx = SecurityContext::capture();
assert_eq!(ctx.user_name, "test_capture_user");
assert!(!ctx.is_superuser);
assert!(ctx.user_oid > 0);
// Cleanup
Spi::run("RESET ROLE").unwrap();
Spi::run("DROP USER test_capture_user").unwrap();
}
UT2: Context Switch and Restore
Note: This unit test validates PostgreSQL’s security context APIs (GetUserIdAndSecContext / SetUserIdAndSecContext) and the general ability to switch/restore identities inside a backend. These APIs are not currently used by pg_durable’s implemented execution path (which uses sqlx connections instead), but this test documents the API’s behavior for potential future SPI-based execution (see Section 8.8).
The pg_durable-specific verification of privilege isolation is covered by the E2E tests (E2E-SEC-01..03) which assert privilege isolation against real tables via the implemented sqlx connection approach.
#[pg_test]
fn test_context_switch_and_restore() {
// Get original context
let original_user = Spi::get_one::<String>("SELECT current_user")
.unwrap().unwrap();
// Create target user
Spi::run("CREATE USER test_switch_user").unwrap();
let target_oid: u32 = Spi::get_one(
"SELECT oid::int4 FROM pg_roles WHERE rolname = 'test_switch_user'"
).unwrap().unwrap();
// Switch context
unsafe {
let mut saved_uid: pg_sys::Oid = 0;
let mut saved_sec: i32 = 0;
pg_sys::GetUserIdAndSecContext(&mut saved_uid, &mut saved_sec);
pg_sys::SetUserIdAndSecContext(
target_oid,
saved_sec | pg_sys::SECURITY_LOCAL_USERID_CHANGE as i32
);
// Verify switch
let current = Spi::get_one::<String>("SELECT current_user").unwrap().unwrap();
assert_eq!(current, "test_switch_user");
// Restore
pg_sys::SetUserIdAndSecContext(saved_uid, saved_sec);
}
// Verify restore
let restored_user = Spi::get_one::<String>("SELECT current_user")
.unwrap().unwrap();
assert_eq!(restored_user, original_user);
// Cleanup
Spi::run("DROP USER test_switch_user").unwrap();
}
10.2 E2E Security Tests
E2E-SEC-01: Basic Privilege Isolation
File: tests/e2e/sql/security_01_privilege_isolation.sql
-- Test: User SQL executes with submitting user's privileges
-- Expected: User can access their own tables, not others'
-- Setup: Create two users with separate tables
DROP TABLE IF EXISTS user_a_data;
DROP TABLE IF EXISTS user_b_data;
DROP USER IF EXISTS sec_test_user_a;
DROP USER IF EXISTS sec_test_user_b;
CREATE USER sec_test_user_a;
CREATE USER sec_test_user_b;
GRANT EXECUTE ON FUNCTION df.start TO sec_test_user_a, sec_test_user_b;
GRANT EXECUTE ON FUNCTION df.sql TO sec_test_user_a, sec_test_user_b;
GRANT EXECUTE ON FUNCTION df.status TO sec_test_user_a, sec_test_user_b;
-- Create tables owned by each user
SET ROLE sec_test_user_a;
CREATE TABLE user_a_data (id serial, value text);
INSERT INTO user_a_data (value) VALUES ('secret_a');
RESET ROLE;
SET ROLE sec_test_user_b;
CREATE TABLE user_b_data (id serial, value text);
INSERT INTO user_b_data (value) VALUES ('secret_b');
RESET ROLE;
-- Test 1: User A can access their own table
SET ROLE sec_test_user_a;
CREATE TEMP TABLE _test_1 (instance_id TEXT);
INSERT INTO _test_1 SELECT df.start(
df.sql('SELECT value FROM user_a_data'),
'sec-test-1-own-table'
);
RESET ROLE;
-- Poll until complete
DO $$
DECLARE
inst_id TEXT;
status TEXT;
attempts INT := 0;
BEGIN
SELECT instance_id INTO inst_id FROM _test_1;
LOOP
SELECT s INTO status FROM df.status(inst_id) s;
EXIT WHEN lower(status) IN ('completed', 'failed') OR attempts > 100;
PERFORM pg_sleep(0.1);
attempts := attempts + 1;
END LOOP;
IF lower(status) != 'completed' THEN
RAISE EXCEPTION 'TEST 1 FAILED: User A could not access own table. Status: %', status;
END IF;
END $$;
-- Test 2: User A cannot access User B's table
SET ROLE sec_test_user_a;
CREATE TEMP TABLE _test_2 (instance_id TEXT);
INSERT INTO _test_2 SELECT df.start(
df.sql('SELECT value FROM user_b_data'),
'sec-test-2-other-table'
);
RESET ROLE;
-- Poll until complete (should fail)
DO $$
DECLARE
inst_id TEXT;
status TEXT;
result_json JSONB;
attempts INT := 0;
BEGIN
SELECT instance_id INTO inst_id FROM _test_2;
LOOP
SELECT s INTO status FROM df.status(inst_id) s;
EXIT WHEN lower(status) IN ('completed', 'failed') OR attempts > 100;
PERFORM pg_sleep(0.1);
attempts := attempts + 1;
END LOOP;
IF lower(status) != 'failed' THEN
RAISE EXCEPTION 'TEST 2 FAILED: User A should NOT access User B table. Status: %', status;
END IF;
-- Verify error message mentions permission denied
SELECT result::jsonb INTO result_json
FROM df.instances WHERE id = inst_id;
IF NOT (result_json->>'error' ILIKE '%permission denied%') THEN
RAISE EXCEPTION 'TEST 2 FAILED: Expected permission denied error, got: %', result_json->>'error';
END IF;
END $$;
-- Cleanup
DROP TABLE _test_1;
DROP TABLE _test_2;
DROP TABLE user_a_data;
DROP TABLE user_b_data;
DROP USER sec_test_user_a;
DROP USER sec_test_user_b;
SELECT 'TEST PASSED: E2E-SEC-01 Privilege Isolation' AS result;
E2E-SEC-02: RESET ROLE Escape Prevention
File: tests/e2e/sql/security_02_reset_role_escape.sql
-- Test: User cannot escape to duroxide privileges via RESET ROLE
-- Expected: RESET ROLE has no effect; SQL still runs as original user
-- Setup
DROP TABLE IF EXISTS admin_only_table;
DROP USER IF EXISTS sec_test_escape_user;
CREATE USER sec_test_escape_user;
GRANT EXECUTE ON FUNCTION df.start TO sec_test_escape_user;
GRANT EXECUTE ON FUNCTION df.sql TO sec_test_escape_user;
GRANT EXECUTE ON FUNCTION df.status TO sec_test_escape_user;
-- Create a table that sec_test_escape_user cannot access
CREATE TABLE admin_only_table (secret text);
INSERT INTO admin_only_table VALUES ('admin_secret');
-- duroxide CAN access this, but sec_test_escape_user CANNOT
-- User attempts to escape via RESET ROLE
SET ROLE sec_test_escape_user;
CREATE TEMP TABLE _test_escape (instance_id TEXT);
INSERT INTO _test_escape SELECT df.start(
df.sql('RESET ROLE; SELECT secret FROM admin_only_table;'),
'sec-test-escape-attempt'
);
RESET ROLE;
-- Poll until complete (should fail)
DO $$
DECLARE
inst_id TEXT;
status TEXT;
attempts INT := 0;
BEGIN
SELECT instance_id INTO inst_id FROM _test_escape;
LOOP
SELECT s INTO status FROM df.status(inst_id) s;
EXIT WHEN lower(status) IN ('completed', 'failed') OR attempts > 100;
PERFORM pg_sleep(0.1);
attempts := attempts + 1;
END LOOP;
-- The key assertion: even with RESET ROLE, the query should FAIL
-- because the connection is authenticated as sec_test_escape_user,
-- and RESET ROLE only reverts to that identity (no escalation possible)
IF lower(status) = 'completed' THEN
RAISE EXCEPTION 'SECURITY VULNERABILITY: RESET ROLE escape succeeded!';
END IF;
IF lower(status) != 'failed' THEN
RAISE EXCEPTION 'TEST FAILED: Unexpected status: %', status;
END IF;
END $$;
-- Cleanup
DROP TABLE _test_escape;
DROP TABLE admin_only_table;
DROP USER sec_test_escape_user;
SELECT 'TEST PASSED: E2E-SEC-02 RESET ROLE Escape Prevention' AS result;
E2E-SEC-03: SET ROLE Escalation Prevention
File: tests/e2e/sql/security_03_set_role_escalation.sql
-- Test: User cannot escalate to a role they're not a member of
-- Expected: SET ROLE to non-member role fails
-- Setup
DROP USER IF EXISTS sec_test_low_priv;
DROP USER IF EXISTS sec_test_high_priv;
CREATE USER sec_test_low_priv;
CREATE USER sec_test_high_priv WITH SUPERUSER; -- high privilege role
GRANT EXECUTE ON FUNCTION df.start TO sec_test_low_priv;
GRANT EXECUTE ON FUNCTION df.sql TO sec_test_low_priv;
GRANT EXECUTE ON FUNCTION df.status TO sec_test_low_priv;
-- NOTE: sec_test_low_priv is NOT a member of sec_test_high_priv
-- Low-priv user attempts to escalate
SET ROLE sec_test_low_priv;
CREATE TEMP TABLE _test_escalate (instance_id TEXT);
INSERT INTO _test_escalate SELECT df.start(
df.sql('SET ROLE sec_test_high_priv; SELECT usename FROM pg_shadow;'),
'sec-test-escalation-attempt'
);
RESET ROLE;
-- Poll until complete (should fail)
DO $$
DECLARE
inst_id TEXT;
status TEXT;
attempts INT := 0;
BEGIN
SELECT instance_id INTO inst_id FROM _test_escalate;
LOOP
SELECT s INTO status FROM df.status(inst_id) s;
EXIT WHEN lower(status) IN ('completed', 'failed') OR attempts > 100;
PERFORM pg_sleep(0.1);
attempts := attempts + 1;
END LOOP;
IF lower(status) = 'completed' THEN
RAISE EXCEPTION 'SECURITY VULNERABILITY: SET ROLE escalation succeeded!';
END IF;
IF lower(status) != 'failed' THEN
RAISE EXCEPTION 'TEST FAILED: Unexpected status: %', status;
END IF;
END $$;
-- Cleanup
DROP TABLE _test_escalate;
DROP USER sec_test_low_priv;
DROP USER sec_test_high_priv;
SELECT 'TEST PASSED: E2E-SEC-03 SET ROLE Escalation Prevention' AS result;
E2E-SEC-04: Row-Level Security Isolation
File: tests/e2e/sql/security_04_rls_isolation.sql
-- Test: Users can only see their own instances in df.instances
-- Expected: User A cannot see User B's instances
-- Setup
DROP USER IF EXISTS sec_test_rls_a;
DROP USER IF EXISTS sec_test_rls_b;
CREATE USER sec_test_rls_a;
CREATE USER sec_test_rls_b;
GRANT EXECUTE ON FUNCTION df.start TO sec_test_rls_a, sec_test_rls_b;
GRANT EXECUTE ON FUNCTION df.sql TO sec_test_rls_a, sec_test_rls_b;
GRANT EXECUTE ON FUNCTION df.status TO sec_test_rls_a, sec_test_rls_b;
-- User A creates an instance
SET ROLE sec_test_rls_a;
CREATE TEMP TABLE _test_rls_a (instance_id TEXT);
INSERT INTO _test_rls_a SELECT df.start(
df.sql('SELECT 1'),
'rls-test-user-a'
);
RESET ROLE;
-- User B creates an instance
SET ROLE sec_test_rls_b;
CREATE TEMP TABLE _test_rls_b (instance_id TEXT);
INSERT INTO _test_rls_b SELECT df.start(
df.sql('SELECT 2'),
'rls-test-user-b'
);
RESET ROLE;
-- Wait for both to complete
PERFORM pg_sleep(2);
-- Test: User A can see their instance
SET ROLE sec_test_rls_a;
DO $$
DECLARE
a_instance_id TEXT;
visible_count INT;
BEGIN
SELECT instance_id INTO a_instance_id FROM _test_rls_a;
-- User A should see exactly 1 instance (their own)
SELECT count(*) INTO visible_count FROM df.instances;
IF visible_count != 1 THEN
RAISE EXCEPTION 'RLS FAILED: User A sees % instances, expected 1', visible_count;
END IF;
-- Verify it's their instance
IF NOT EXISTS (SELECT 1 FROM df.instances WHERE id = a_instance_id) THEN
RAISE EXCEPTION 'RLS FAILED: User A cannot see their own instance';
END IF;
END $$;
RESET ROLE;
-- Test: User B can see their instance, not A's
SET ROLE sec_test_rls_b;
DO $$
DECLARE
a_instance_id TEXT;
b_instance_id TEXT;
visible_count INT;
BEGIN
SELECT instance_id INTO a_instance_id FROM _test_rls_a;
SELECT instance_id INTO b_instance_id FROM _test_rls_b;
-- User B should see exactly 1 instance (their own)
SELECT count(*) INTO visible_count FROM df.instances;
IF visible_count != 1 THEN
RAISE EXCEPTION 'RLS FAILED: User B sees % instances, expected 1', visible_count;
END IF;
-- Verify User B cannot see User A's instance
IF EXISTS (SELECT 1 FROM df.instances WHERE id = a_instance_id) THEN
RAISE EXCEPTION 'RLS FAILED: User B can see User A instance!';
END IF;
END $$;
RESET ROLE;
-- Cleanup
DROP TABLE _test_rls_a;
DROP TABLE _test_rls_b;
DROP USER sec_test_rls_a;
DROP USER sec_test_rls_b;
SELECT 'TEST PASSED: E2E-SEC-04 RLS Isolation' AS result;
E2E-SEC-05: Function Permission Requirement
File: tests/e2e/sql/security_05_function_permission_required.sql
-- Test: Users without EXECUTE on df.start cannot create durable functions
-- Expected: df.start() fails with permission denied
-- Setup
DROP USER IF EXISTS sec_test_no_permission;
CREATE USER sec_test_no_permission;
-- NOTE: NOT granting EXECUTE on df.start/df.sql
-- Attempt to create durable function without required function permissions
SET ROLE sec_test_no_permission;
DO $$
BEGIN
-- This should fail
PERFORM df.start(df.sql('SELECT 1'), 'should-fail');
RAISE EXCEPTION 'SECURITY FAILURE: User without permission could call df.start()!';
EXCEPTION
WHEN OTHERS THEN
IF SQLERRM NOT ILIKE '%permission denied%' AND
SQLERRM NOT ILIKE '%df.start%' THEN
RAISE EXCEPTION 'Unexpected error: %', SQLERRM;
END IF;
-- Expected: permission denied
END $$;
RESET ROLE;
-- Cleanup
DROP USER sec_test_no_permission;
SELECT 'TEST PASSED: E2E-SEC-05 Function Permission Requirement' AS result;
10.3 Test Matrix
| Test ID | Description | Threat | Expected Result |
|---|---|---|---|
| E2E-SEC-01 | Privilege Isolation | T1, T2 | User accesses only own tables |
| E2E-SEC-02 | RESET ROLE Escape | T1 | RESET ROLE has no effect |
| E2E-SEC-03 | SET ROLE Escalation | T2 | SET ROLE to non-member fails |
| E2E-SEC-04 | RLS Isolation | T4 | Users see only own instances (see also 37_rls.sql) |
| E2E-SEC-05 | Function Permission | - | Users without EXECUTE cannot use df.* |
| E2E-SEC-06 | Dynamic SQL Escape | T3 | EXECUTE ‘RESET ROLE’ fails |
| E2E-SEC-07 | HTTP SSRF Blocked | T8 | Internal IP requests denied |
| E2E-SEC-08 | HTTP Function Permission | T9 | Users without GRANT cannot use df.http |
| E2E-SEC-09 | HTTP Allowlist | T9 | Non-allowlisted hosts denied |
| E2E-SEC-10 | Vars RLS Isolation | T10 | Users cannot read/override other users' vars |
| E2E-SEC-11 | execute_sql Fault: Error | - | Instance fails cleanly; worker continues |
| E2E-SEC-12 | execute_sql Fault: Panic + Restart | - | After restart, instance is failed or retried deterministically |
10.4 HTTP Activity Tests
E2E-SEC-07: HTTP SSRF Protection
File: tests/e2e/sql/security_07_http_ssrf.sql
-- Test: HTTP requests to internal IPs are blocked (always-on protection)
-- Expected: SSRF attempts fail regardless of permissions
-- Setup
DROP USER IF EXISTS sec_test_ssrf_user;
CREATE USER sec_test_ssrf_user;
GRANT EXECUTE ON FUNCTION df.start TO sec_test_ssrf_user;
GRANT EXECUTE ON FUNCTION df.sql TO sec_test_ssrf_user;
GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO sec_test_ssrf_user; -- Has HTTP permission
GRANT EXECUTE ON FUNCTION df.status TO sec_test_ssrf_user;
GRANT sec_test_ssrf_user TO duroxide;
-- Build with a feature set that allows the chosen public test destination.
-- SSRF protection still applies regardless of that feature set.
-- Test 1: AWS metadata endpoint (169.254.169.254) should be blocked
SET ROLE sec_test_ssrf_user;
CREATE TEMP TABLE _test_ssrf_1 (instance_id TEXT);
INSERT INTO _test_ssrf_1 SELECT df.start(
df.http('http://169.254.169.254/latest/meta-data/', 'GET'),
'ssrf-test-aws-metadata'
);
RESET ROLE;
DO $$
DECLARE
inst_id TEXT;
status TEXT;
error_msg TEXT;
attempts INT := 0;
BEGIN
SELECT instance_id INTO inst_id FROM _test_ssrf_1;
LOOP
SELECT s INTO status FROM df.status(inst_id) s;
EXIT WHEN lower(status) IN ('completed', 'failed') OR attempts > 50;
PERFORM pg_sleep(0.1);
attempts := attempts + 1;
END LOOP;
IF lower(status) = 'completed' THEN
RAISE EXCEPTION 'SSRF VULNERABILITY: AWS metadata request succeeded!';
END IF;
-- Verify error message mentions blocked/internal
SELECT result->>'error' INTO error_msg FROM df.instances WHERE id = inst_id;
IF error_msg NOT ILIKE '%internal%' AND error_msg NOT ILIKE '%blocked%' THEN
RAISE EXCEPTION 'Expected SSRF block error, got: %', error_msg;
END IF;
END $$;
-- Test 2: localhost should be blocked
SET ROLE sec_test_ssrf_user;
CREATE TEMP TABLE _test_ssrf_2 (instance_id TEXT);
INSERT INTO _test_ssrf_2 SELECT df.start(
df.http('http://127.0.0.1:8080/', 'GET'),
'ssrf-test-localhost'
);
RESET ROLE;
DO $$
DECLARE
inst_id TEXT;
status TEXT;
attempts INT := 0;
BEGIN
SELECT instance_id INTO inst_id FROM _test_ssrf_2;
LOOP
SELECT s INTO status FROM df.status(inst_id) s;
EXIT WHEN lower(status) IN ('completed', 'failed') OR attempts > 50;
PERFORM pg_sleep(0.1);
attempts := attempts + 1;
END LOOP;
IF lower(status) = 'completed' THEN
RAISE EXCEPTION 'SSRF VULNERABILITY: localhost request succeeded!';
END IF;
END $$;
-- Cleanup
DROP TABLE _test_ssrf_1;
DROP TABLE _test_ssrf_2;
REVOKE sec_test_ssrf_user FROM duroxide;
DROP USER sec_test_ssrf_user;
SELECT 'TEST PASSED: E2E-SEC-07 HTTP SSRF Protection' AS result;
E2E-SEC-08: HTTP Function Permission
File: tests/e2e/sql/security_08_http_permission.sql
-- Test: Users need EXECUTE permission on df.http to use it
-- Expected: Users without permission get function permission error
-- Setup
DROP USER IF EXISTS sec_test_http_denied;
DROP USER IF EXISTS sec_test_http_allowed;
CREATE USER sec_test_http_denied;
CREATE USER sec_test_http_allowed;
-- Both users can use df.start and df.sql
GRANT EXECUTE ON FUNCTION df.start TO sec_test_http_denied, sec_test_http_allowed;
GRANT EXECUTE ON FUNCTION df.sql TO sec_test_http_denied, sec_test_http_allowed;
GRANT EXECUTE ON FUNCTION df.status TO sec_test_http_denied, sec_test_http_allowed;
GRANT SELECT ON df.instances TO sec_test_http_denied, sec_test_http_allowed;
-- Only sec_test_http_allowed gets HTTP permission
GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO sec_test_http_allowed;
-- NOTE: sec_test_http_denied does NOT get df.http permission
GRANT sec_test_http_denied TO duroxide;
GRANT sec_test_http_allowed TO duroxide;
-- Build with a feature set that allows httpbingo.org.
-- Test 1: User WITHOUT df.http permission should fail
SET ROLE sec_test_http_denied;
DO $$
BEGIN
PERFORM df.start(
df.http('https://httpbingo.org/get', 'GET'),
'http-test-denied'
);
RAISE EXCEPTION 'SECURITY FAILURE: User without df.http permission could use it!';
EXCEPTION
WHEN insufficient_privilege THEN
-- Expected: permission denied for function df.http
NULL;
WHEN OTHERS THEN
IF SQLERRM NOT ILIKE '%permission denied%' THEN
RAISE EXCEPTION 'Unexpected error: %', SQLERRM;
END IF;
END $$;
RESET ROLE;
-- Test 2: User WITH df.http permission should succeed
SET ROLE sec_test_http_allowed;
CREATE TEMP TABLE _test_http_allowed (instance_id TEXT);
INSERT INTO _test_http_allowed SELECT df.start(
df.http('https://httpbingo.org/get', 'GET'),
'http-test-allowed'
);
RESET ROLE;
DO $$
DECLARE
inst_id TEXT;
status TEXT;
attempts INT := 0;
BEGIN
SELECT instance_id INTO inst_id FROM _test_http_allowed;
LOOP
SELECT s INTO status FROM df.status(inst_id) s;
EXIT WHEN lower(status) IN ('completed', 'failed') OR attempts > 100;
PERFORM pg_sleep(0.1);
attempts := attempts + 1;
END LOOP;
IF lower(status) != 'completed' THEN
RAISE EXCEPTION 'TEST FAILED: Allowed user HTTP should succeed. Status: %', status;
END IF;
END $$;
-- Cleanup
DROP TABLE _test_http_allowed;
REVOKE sec_test_http_denied FROM duroxide;
REVOKE sec_test_http_allowed FROM duroxide;
DROP USER sec_test_http_denied;
DROP USER sec_test_http_allowed;
SELECT 'TEST PASSED: E2E-SEC-08 HTTP Function Permission' AS result;
E2E-SEC-09: HTTP URL Allowlist
File: tests/e2e/sql/security_09_http_allowlist.sql
-- Test: HTTP requests only allowed to the destinations permitted by the
-- build-time feature allowlist
-- Expected: Requests to non-allowlisted hosts are denied
-- Setup
DROP USER IF EXISTS sec_test_allowlist_user;
CREATE USER sec_test_allowlist_user;
GRANT EXECUTE ON FUNCTION df.start TO sec_test_allowlist_user;
GRANT EXECUTE ON FUNCTION df.sql TO sec_test_allowlist_user;
GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO sec_test_allowlist_user;
GRANT EXECUTE ON FUNCTION df.status TO sec_test_allowlist_user;
GRANT SELECT ON df.instances TO sec_test_allowlist_user;
GRANT sec_test_allowlist_user TO duroxide;
-- Build with a feature set that allows httpbingo.org but not evil.com.
-- Test 1: Allowlisted host should succeed
SET ROLE sec_test_allowlist_user;
CREATE TEMP TABLE _test_allowed (instance_id TEXT);
INSERT INTO _test_allowed SELECT df.start(
df.http('https://httpbingo.org/get', 'GET'),
'allowlist-test-allowed'
);
RESET ROLE;
DO $$
DECLARE
inst_id TEXT;
status TEXT;
attempts INT := 0;
BEGIN
SELECT instance_id INTO inst_id FROM _test_allowed;
LOOP
SELECT s INTO status FROM df.status(inst_id) s;
EXIT WHEN lower(status) IN ('completed', 'failed') OR attempts > 100;
PERFORM pg_sleep(0.1);
attempts := attempts + 1;
END LOOP;
IF lower(status) != 'completed' THEN
RAISE EXCEPTION 'TEST FAILED: Allowlisted host should succeed. Status: %', status;
END IF;
END $$;
-- Test 2: Non-allowlisted host should fail
SET ROLE sec_test_allowlist_user;
CREATE TEMP TABLE _test_denied (instance_id TEXT);
INSERT INTO _test_denied SELECT df.start(
df.http('https://evil.com/steal-data', 'GET'),
'allowlist-test-denied'
);
RESET ROLE;
DO $$
DECLARE
inst_id TEXT;
status TEXT;
error_msg TEXT;
attempts INT := 0;
BEGIN
SELECT instance_id INTO inst_id FROM _test_denied;
LOOP
SELECT s INTO status FROM df.status(inst_id) s;
EXIT WHEN lower(status) IN ('completed', 'failed') OR attempts > 50;
PERFORM pg_sleep(0.1);
attempts := attempts + 1;
END LOOP;
IF lower(status) = 'completed' THEN
RAISE EXCEPTION 'SECURITY FAILURE: Non-allowlisted host succeeded!';
END IF;
SELECT result->>'error' INTO error_msg FROM df.instances WHERE id = inst_id;
IF error_msg NOT ILIKE '%allowed%' AND error_msg NOT ILIKE '%blocked%' THEN
RAISE EXCEPTION 'Expected allowlist error, got: %', error_msg;
END IF;
END $$;
-- Cleanup
DROP TABLE _test_allowed;
DROP TABLE _test_denied;
REVOKE sec_test_allowlist_user FROM duroxide;
DROP USER sec_test_allowlist_user;
SELECT 'TEST PASSED: E2E-SEC-09 HTTP URL Allowlist' AS result;
10.5 Fault Injection Tests (execute_sql)
These tests validate behavior when execute_sql fails due to expected errors and unexpected panics.
E2E-SEC-11: execute_sql error path
- Configure
df.test_fault_inject = 'execute_sql_error'(superuser) - Start a simple workflow with a SQL node
- Assert:
- Instance transitions to
failed - Error is recorded without leaking secrets/other-user data
- Worker keeps processing subsequent instances
- Instance transitions to
E2E-SEC-12: execute_sql panic + restart
- Configure
df.test_fault_inject = 'execute_sql_panic_after_connect'(superuser) - Start a workflow that will hit SQL execution
- Assert:
- The worker process crashes/restarts (or is forcibly restarted)
- On restart, the runtime resumes from durable state
- The instance eventually becomes
failed(or is retried if the orchestration semantics retry) - No stale per-user connections remain after restart
Restart validation strategy:
- Use the existing E2E harness to stop/start Postgres (or restart the bgworker) between polling loops.
- After restart, poll df.status(instance_id) until terminal state.
10.6 Vars/Secrets Security Tests
E2E-SEC-10: Vars RLS isolation
- User A sets
df.setvar('k','a') - User B sets
df.setvar('k','b') - User A starts a workflow that reads
$kand asserts it seesa - User B starts a workflow that reads
$kand asserts it seesb - Assert User A cannot read/overwrite User B’s var rows
Secrets tests (design-level)
- Verify non-admin cannot
EXECUTE df.setsecret/df.unsetsecret/df.clearsecrets - Verify non-admin cannot
SELECTfromdf.secrets - Verify workflows can reference a secret by name and the secret value is not returned/logged
11. Open Questions
OQ1: Azure Managed Identity Integration
Question: Should duroxide support Azure AD authentication as an alternative to pg_hba.conf local auth?
Considerations: - Useful for multi-instance deployments - Requires duroxide-pg changes - May need Azure SDK dependency
Recommendation: Defer to Phase 2; pg_hba.conf approach works for Azure Flexible Server.
OQ2: Cross-Database Execution
Question: Should durable functions support cross-database queries (via postgres_fdw)?
Considerations:
- Per-user sqlx connections are per-database (the database field in ExecuteSqlInput supports targeting a specific database)
- FDW connections are separate sessions
- Security model becomes complex
Recommendation: Out of scope; document as unsupported.
OQ3: SECURITY DEFINER Functions
Question: If user SQL calls a SECURITY DEFINER function, which user’s context applies?
Answer: PostgreSQL’s standard behavior applies: - SECURITY DEFINER function runs as its owner - After function returns, context reverts - This is expected and documented PostgreSQL behavior
Recommendation: Document this; no special handling needed.
OQ4: Superuser Durable Functions
Status: Resolved — implemented via pg_durable.enable_superuser_instances.
Question: Should superusers be able to create durable functions that run as superuser?
Considerations:
- Useful for administrative tasks
- Higher risk if function graph is compromised
- BYPASSRLS roles can forge submitted_by to a superuser OID, making this a privilege escalation vector in multi-tenant environments
Resolution: Gated behind pg_durable.enable_superuser_instances (default off). When off, df.start() immediately rejects any submission whose current_user is a superuser, and the background worker rejects any instance whose submitted_by resolves to a superuser at execution time (closing the BYPASSRLS forgery path). When on, superuser submissions are allowed as before. The GUC is SUSET / SUPERUSER_ONLY and hidden from SHOW ALL. See superuser_guc.md for full design rationale.
Appendix A: Security Checklist for Code Review
- [ ] All user SQL goes through
connect_as_user()→ per-user sqlx connection (never the worker’s shared pool) - [ ]
submitted_byis captured fromGetUserId()atdf.start()time, not from user input - [ ] Role names are properly quoted where needed
- [ ] Access control uses PostgreSQL-native function permissions (EXECUTE on df.start/df.sql/df.http)
- [x] RLS policies use
current_user, not user-supplied values - [ ] Error messages don’t leak other users' data
- [ ] Logging includes effective user for audit trail
- [ ]
SET df.in_workflow = 'true'is set on user connections to prevent variable mutation during execution (future: could also guard against recursivedf.start()) - [ ] All SPI queries that accept user-supplied parameters prefer parameterized APIs (
Spi::get_one_with_args()/Spi::run_with_args()). Use manual.replace('\'', "''")escaping only as fallback where parameters cannot be used. Cross-check:df.status(),df.result(),df.cancel(),df.signal(), monitoring functions - [ ] PL/pgSQL and SQL helper functions include
SET search_path = pg_catalog, df, pg_temp - [ ] All function/table references in dynamic SQL are schema-qualified (
df.instances, notinstances)
Appendix B: Related PostgreSQL Internals
Note: These internals are relevant to the potential future SPI-based execution path described in Section 8.8. The current implementation uses sqlx connections and does not directly call these APIs for user SQL execution.
SetUserIdAndSecContext Flags
| Flag | Value | Meaning |
|---|---|---|
SECURITY_LOCAL_USERID_CHANGE |
1 | Temporary userid change (like SECURITY DEFINER) |
SECURITY_RESTRICTED_OPERATION |
2 | Block certain operations |
SECURITY_NOFORCE_RLS |
4 | Don’t force RLS for this operation |
Relevant PostgreSQL Source
src/backend/utils/init/miscinit.c: SetUserIdAndSecContext implementationsrc/backend/utils/adt/acl.c: Permission checking functionssrc/backend/executor/spi.c: SPI implementation
End of Specification