Skip to content

Commit aa1b1fe

Browse files
authored
docs: fix stale identity model, incorrect examples, and missing documentation (#219)
Fixes found during a reliability audit of the documentation: Critical: - Rewrite 'How Identity Is Captured' section to describe the current v0.2.0+ model (direct connection as submitted_by) instead of the removed v0.1.1 login_role/SET ROLE model - Fix df.join(a,b,c) → df.join3(a,b,c) in multi-party approval examples (USER_GUIDE.md and spec-signals.md) High: - Add df.wait_for_completion() to DSL Reference table - Mark df.run() as stub in manual grants list - Add 'Superuser Cannot Start Workflows' troubleshooting entry - Add 'current_user lacks LOGIN attribute' troubleshooting entry - Document the result JSON shape ({rows, row_count}) - Update security review docs to remove all login_role references and reflect the current identity model Medium: - Fix CHANGELOG: $name? substitutes NULL, not empty string - Fix grammar.md: use {sku} syntax for setvars, not $sku - Fix df.list_instances() examples to use lowercase status values - Fix broken JSON accessor in scheduled API polling example * test: add explicit timeouts to redirect E2E test The redirect test hit httpbin.org without an explicit HTTP timeout and used the default 30s wait_for_completion. When httpbin.org is slow, the wait races the HTTP timeout causing flaky CI failures. Add 10s HTTP timeout and 60s wait_for_completion to give headroom.
1 parent f5f78b6 commit aa1b1fe

8 files changed

Lines changed: 150 additions & 65 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ First open-source release of `pg_durable` on GitHub under the PostgreSQL License
6969
- Fix: `is_truthy` now correctly treats "false", "no", and "f" as falsy (#57)
7070
- Docs: add "Debugging Failed Workflows" section to User Guide (#71)
7171
- New: Azure Functions integration example (#69)
72-
- Named result substitution now supports dot-notation for column access (`$name.col`), null-safe variants (`$name?`, `$name.col?`), and row-set expansion (`$name.*`). Referencing a named result that returned no rows or a NULL value now fails the orchestration by default; append `?` to substitute an empty string instead.
72+
- Named result substitution now supports dot-notation for column access (`$name.col`), null-safe variants (`$name?`, `$name.col?`), and row-set expansion (`$name.*`). Referencing a named result that returned no rows or a NULL value now fails the orchestration by default; append `?` to substitute `NULL` instead.
7373
- New DSL function `df.if_rows()`: branches on whether a named result returned any rows, without executing a SQL condition query.
7474
- New: Connection limits — four Postmaster-context GUCs (`max_management_connections`, `max_duroxide_connections`, `max_user_connections`, `execution_acquire_timeout`) control the background worker's connection budget. User-execution connections are gated by a semaphore with configurable backpressure timeout. The former polling and activity pools are consolidated into a single management pool. Backend provider pools reduced to 1 connection.
7575
- Breaking change: simplified user isolation by dropping `login_role` from `df.instances` and `df.nodes`. User isolation now captures only `current_user` as `submitted_by`, and the background worker connects directly as `submitted_by` instead of connecting as `login_role` and running `SET ROLE`. `df.start()` now validates that `current_user` has the `LOGIN` attribute. The new binary remains compatible with the v0.1.1 schema shape, but any pending or running v0.1.1 instance whose `submitted_by` is a NOLOGIN role from the old `SET ROLE` workflow will fail after upgrade and must be recreated under the new model.

USER_GUIDE.md

Lines changed: 113 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ df.sql('SELECT 1') ~> df.sql('SELECT 2')
193193
| `df.wait_for_signal(name)` | Wait for external signal | `df.wait_for_signal('approval')` |
194194
| `df.wait_for_signal(name, timeout)` | Wait with timeout (seconds) | `df.wait_for_signal('approval', 3600)` |
195195
| `df.signal(id, name, data)` | Send signal to instance | `df.signal('a1b2', 'go', '{}')` |
196+
| `df.wait_for_completion(id, timeout)` | Block until instance completes (default 30s timeout) | `df.wait_for_completion('a1b2c3d4', 60)` |
196197

197198
### Operators
198199

@@ -284,6 +285,43 @@ SELECT df.start(
284285

285286
This is useful for passing row sets between steps. The expansion generates SQL like `(VALUES (1,'Alice'), (2,'Bob')) AS batch(id, name)`.
286287

288+
### Result Format
289+
290+
When a SQL node completes, its result is stored as a JSON object with this shape:
291+
292+
```json
293+
{
294+
"rows": [
295+
{"column1": "value1", "column2": 42},
296+
{"column1": "value2", "column2": 99}
297+
],
298+
"row_count": 2
299+
}
300+
```
301+
302+
| Field | Type | Description |
303+
|-------|------|-------------|
304+
| `rows` | Array of objects | Each element is one row; keys are column names |
305+
| `row_count` | Integer | Number of rows returned |
306+
307+
When accessing results via `df.result(id)`, you get this JSON text. Use PostgreSQL's JSON operators to extract values:
308+
309+
```sql
310+
-- Get the result
311+
SELECT df.result('a1b2c3d4');
312+
-- Returns: '{"rows":[{"answer":42}],"row_count":1}'
313+
314+
-- Extract a specific value
315+
SELECT df.result('a1b2c3d4')::jsonb->'rows'->0->>'answer';
316+
-- Returns: '42'
317+
```
318+
319+
**Special cases:**
320+
- A query returning no rows produces: `{"rows": [], "row_count": 0}`
321+
- `df.sleep()` and `df.wait_for_schedule()` produce no result (NULL)
322+
- `df.http()` results contain `status`, `body`, and `headers` fields inside the row
323+
- `df.break('value')` stores the literal value as the loop result (not wrapped in `rows`)
324+
287325

288326
### Cron Expression Format
289327

@@ -708,7 +746,7 @@ SELECT df.start(
708746
df.wait_for_schedule('*/5 * * * *') -- Every 5 minutes
709747
~> df.http('https://api.example.com/status', 'GET') |=> 'status'
710748
~> df.if(
711-
'SELECT ($status::jsonb->''body''::jsonb->>''healthy'')::boolean = false',
749+
'SELECT (($status::jsonb->>''body'')::jsonb->>''healthy'')::boolean = false',
712750
'INSERT INTO playground.logs (msg, level) VALUES (''Service unhealthy!'', ''error'')',
713751
'SELECT ''healthy'''
714752
)
@@ -1113,12 +1151,12 @@ SELECT df.signal('a1b2c3d4', 'approval', '{"approved": true, "approver": "jane@a
11131151

11141152
### Example: Multi-Party Approval
11151153

1116-
Wait for multiple approvals using `df.join()`:
1154+
Wait for multiple approvals using `df.join3()`:
11171155

11181156
```sql
11191157
SELECT df.start(
11201158
'SELECT doc_id FROM documents WHERE id = 1' |=> 'doc'
1121-
~> df.join(
1159+
~> df.join3(
11221160
df.wait_for_signal('legal_approval'),
11231161
df.wait_for_signal('tech_approval'),
11241162
df.wait_for_signal('mgmt_approval')
@@ -1384,10 +1422,10 @@ LOOP
13841422
-- All instances
13851423
SELECT * FROM df.list_instances();
13861424

1387-
-- Filter by status
1388-
SELECT * FROM df.list_instances('Running');
1389-
SELECT * FROM df.list_instances('Completed');
1390-
SELECT * FROM df.list_instances('Failed');
1425+
-- Filter by status (lowercase)
1426+
SELECT * FROM df.list_instances('running');
1427+
SELECT * FROM df.list_instances('completed');
1428+
SELECT * FROM df.list_instances('failed');
13911429

13921430
-- With limit
13931431
SELECT * FROM df.list_instances(NULL, 10);
@@ -1496,37 +1534,47 @@ SELECT df.start('SELECT * FROM bob_data');
14961534

14971535
### How Identity Is Captured
14981536

1499-
When you call `df.start()`, pg_durable captures two pieces of identity:
1537+
When you call `df.start()`, pg_durable captures **one** piece of identity:
1538+
1539+
- **`current_user`** — Your effective role at the time of submission (stored as `submitted_by`)
15001540

1501-
1. **Login role** (`session_user`) - The user you authenticated as
1502-
2. **Effective role** (`current_user`) - Your current effective privileges (after `SET ROLE`, if used)
1541+
The background worker then connects to PostgreSQL **directly as `submitted_by`** and executes your SQL with that role's privileges. There is no `SET ROLE` indirection.
15031542

1504-
The background worker then:
1505-
1. Connects to PostgreSQL as your **login role**
1506-
2. Executes `SET ROLE` to your **effective role**
1507-
3. Runs your SQL with the correct privileges
1543+
**Important:** The captured role must have the `LOGIN` attribute, because the background worker authenticates as that role. If `current_user` lacks `LOGIN`, `df.start()` will reject the submission with an error.
15081544

1509-
### Working with Group Roles
1545+
### Working with Roles
15101546

1511-
You can use `SET ROLE` to switch to a group role before submitting a durable function:
1547+
Since the captured role must have `LOGIN`, you cannot use `SET ROLE` to submit workflows as a `NOLOGIN` group role. Instead, grant the necessary table privileges directly to login-capable roles:
15121548

15131549
```sql
1514-
-- Create a group role (no LOGIN)
1550+
-- Grant table access to alice directly
1551+
GRANT SELECT ON analyst_reports TO alice;
1552+
1553+
-- Alice submits as herself (her own login role)
1554+
SET SESSION AUTHORIZATION alice;
1555+
SELECT df.start('SELECT * FROM analyst_reports');
1556+
-- ✅ Runs as 'alice' — alice has LOGIN and the required privileges
1557+
```
1558+
1559+
If you need multiple users to share access to the same tables, grant privileges via a group role but submit as the individual login role:
1560+
1561+
```sql
1562+
-- Create a group role and grant it to users
15151563
CREATE ROLE analysts NOLOGIN;
15161564
GRANT analysts TO alice;
1565+
GRANT analysts TO bob;
15171566

1518-
CREATE TABLE analyst_reports (id INT, report TEXT);
1519-
ALTER TABLE analyst_reports OWNER TO analysts;
1567+
-- Grant table access to the group
1568+
GRANT SELECT ON analyst_reports TO analysts;
15201569

1521-
-- Alice switches to the analysts role
1570+
-- Alice submits as herself (inherits analysts privileges)
15221571
SET SESSION AUTHORIZATION alice;
1523-
SET ROLE analysts;
1524-
1525-
-- Submit as the group role
15261572
SELECT df.start('SELECT * FROM analyst_reports');
1527-
-- ✅ Runs as 'analysts', alice's session user is used for authentication
1573+
-- ✅ Runs as 'alice', who inherits SELECT from 'analysts'
15281574
```
15291575

1576+
**Note:** `SET ROLE` to a `NOLOGIN` role before calling `df.start()` will fail because the worker cannot authenticate as a role without `LOGIN`.
1577+
15301578
### What Happens If a Role Is Dropped?
15311579

15321580
If the user who submitted a function is dropped **before execution**:
@@ -1624,7 +1672,7 @@ GRANT EXECUTE ON FUNCTION df.status(text) TO app_role;
16241672
GRANT EXECUTE ON FUNCTION df.result(text) TO app_role;
16251673
GRANT EXECUTE ON FUNCTION df.cancel(text, text) TO app_role;
16261674
GRANT EXECUTE ON FUNCTION df.wait_for_completion(text, integer) TO app_role;
1627-
GRANT EXECUTE ON FUNCTION df.run(text) TO app_role;
1675+
GRANT EXECUTE ON FUNCTION df.run(text) TO app_role; -- NOTE: stub, not yet implemented
16281676
GRANT EXECUTE ON FUNCTION df.list_instances(text, integer) TO app_role;
16291677
GRANT EXECUTE ON FUNCTION df.instance_info(text) TO app_role;
16301678
GRANT EXECUTE ON FUNCTION df.instance_nodes(text, integer) TO app_role;
@@ -1946,6 +1994,46 @@ CREATE EXTENSION pg_durable;
19461994
2. Verify the background worker is running (see "Extension Exists But Workflows Don't Start")
19471995
3. Check for resource contention (CPU, disk I/O, connection limits)
19481996

1997+
### Superuser Cannot Start Workflows
1998+
1999+
**Symptom**: A superuser calling `df.start()` gets an error like:
2000+
```
2001+
Superuser-submitted instances are disabled. Set pg_durable.enable_superuser_instances = true to allow.
2002+
```
2003+
2004+
**Cause**: By default, `pg_durable.enable_superuser_instances` is `false`. This is a security safeguard — superuser-submitted workflows bypass RLS and run with full privileges, which could be dangerous in shared environments.
2005+
2006+
**Solution**: If you intentionally want to submit workflows as a superuser:
2007+
1. Add to `postgresql.conf`:
2008+
```ini
2009+
pg_durable.enable_superuser_instances = true
2010+
```
2011+
2. Restart PostgreSQL (this is a Postmaster-context GUC)
2012+
2013+
Alternatively, create a dedicated non-superuser role for workflow submission and grant it the necessary privileges.
2014+
2015+
### "current_user lacks LOGIN attribute" Error
2016+
2017+
**Symptom**: Calling `df.start()` returns an error:
2018+
```
2019+
current_user 'role_name' does not have the LOGIN attribute
2020+
```
2021+
2022+
**Cause**: The background worker must connect to PostgreSQL as the role that submitted the workflow. Roles without the `LOGIN` attribute cannot be authenticated, so `df.start()` rejects the submission.
2023+
2024+
This commonly happens when you use `SET ROLE` to switch to a group role (typically `NOLOGIN`) before calling `df.start()`.
2025+
2026+
**Solution**:
2027+
- Submit workflows as a login-capable role (your own user, not a group role)
2028+
- If you need shared table access, grant privileges via a group role and submit as the individual user:
2029+
```sql
2030+
-- Instead of SET ROLE analysts; df.start(...):
2031+
GRANT analysts TO alice; -- alice inherits privileges
2032+
SET SESSION AUTHORIZATION alice;
2033+
SELECT df.start('SELECT * FROM analyst_data'); -- runs as alice
2034+
```
2035+
- If a role needs `LOGIN`, alter it: `ALTER ROLE role_name LOGIN;`
2036+
19492037
### Debugging Failed Workflows
19502038

19512039
When a durable function fails or produces unexpected results, use these steps to diagnose the issue from `psql` — no server log access required.

docs/grammar.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,22 +244,22 @@ df.start(
244244

245245
## Variable Substitution
246246

247-
Variables are set before `df.start()` using `df.setvar()`:
247+
Variables set with `df.setvar()` use curly-brace syntax `{name}` and are substituted at execution time:
248248

249249
```sql
250250
SELECT df.setvar('customer_id', '42');
251251
SELECT df.setvar('sku', 'WIDGET-001');
252-
SELECT df.start('SELECT * FROM products WHERE sku = $sku');
252+
SELECT df.start('SELECT * FROM products WHERE sku = ''{sku}''');
253253
```
254254

255-
Result bindings (`|=>`) create variables available in subsequent steps:
255+
Result bindings (`|=>`) create named results available via dollar-sign syntax `$name` in subsequent steps:
256256

257257
```sql
258-
'SELECT id FROM users WHERE email = $email' |=> 'user_id'
258+
'SELECT id FROM users WHERE email = ''{email}''' |=> 'user_id'
259259
~> 'SELECT * FROM orders WHERE user_id = $user_id'
260260
```
261261

262-
Variables are substituted at execution time, not at definition time.
262+
Variables (`{name}`) and named results (`$name`) are substituted at execution time, not at definition time.
263263

264264
## Auto-Wrapping
265265

docs/security-review/ThreatModelDFD.md

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -113,16 +113,16 @@ sequenceDiagram
113113
participant HTTP as External HTTP
114114
115115
User->>Backend: SELECT df.start(df.sql('...'))
116-
Note over Backend: Captures session_user_oid<br/>and outer_user_oid
116+
Note over Backend: Captures current_user (outer_user_oid);<br/>validates LOGIN attribute
117117
Backend->>Tables: INSERT nodes + instance<br/>(SPI as calling user, RLS)
118118
Backend->>Duroxide: Enqueue orchestration<br/>(via cached duroxide client)
119119
Backend-->>User: Returns instance_id
120120
121121
Worker->>Duroxide: Poll for work items<br/>(as worker role / superuser)
122122
Duroxide-->>Worker: Orchestration work item
123-
Worker->>Tables: Load function graph<br/>(reads submitted_by, login_role)
123+
Worker->>Tables: Load function graph<br/>(reads submitted_by)
124124
125-
Note over Worker: Creates per-user connection:<br/>connect as login_role,<br/>SET ROLE submitted_by
125+
Note over Worker: Creates per-user connection:<br/>connect directly as submitted_by
126126
Worker->>Tables: Execute user SQL<br/>(per-user connection, user privileges)
127127
Worker->>Tables: Update status/results<br/>(as worker role)
128128
@@ -159,7 +159,7 @@ EE-USER ──[PostgreSQL wire protocol]──> P-BACKEND
159159
|---|---|---|---|
160160
| **S** Spoofing | Attacker impersonates legitimate user | PostgreSQL pg_hba.conf authentication (password, cert, GSSAPI) | ✅ Mitigated |
161161
| **T** Tampering | Man-in-middle modifies SQL commands | TLS encryption (if configured in pg_hba.conf); not enforced by default | ⚠️ Partial |
162-
| **R** Repudiation | User denies submitting a durable function | submitted_by and login_role captured at df.start() via GetOuterUserId()/GetSessionUserId() | ✅ Mitigated |
162+
| **R** Repudiation | User denies submitting a durable function | submitted_by captured at df.start() via GetOuterUserId(); current_user must have LOGIN | ✅ Mitigated |
163163
| **I** Information Disclosure | Eavesdropping on wire protocol | TLS encryption (if configured); plaintext by default on localhost | ⚠️ Partial |
164164
| **D** Denial of Service | Flooding with df.start() calls | No rate limiting implemented | ⛔ NOT IMPLEMENTED |
165165
| **E** Elevation of Privilege | User escalates via SECURITY DEFINER | GetOuterUserId() captures *caller* not *definer*; tested in E2E | ✅ Mitigated |
@@ -174,7 +174,7 @@ P-BACKEND ──[SPI (in-process)]──> DS-TABLES
174174
|---|---|
175175
| Source | P-BACKEND (User Session) |
176176
| Destination | DS-TABLES (df.instances / df.nodes) |
177-
| Data | Function graph nodes, instance metadata, user identity (submitted_by, login_role) |
177+
| Data | Function graph nodes, instance metadata, user identity (submitted_by) |
178178
| Classification | Internal control-plane data |
179179
| Encryption | N/A (in-process SPI) |
180180
| Trust Boundary Crossed | TB-USER → TB-PG (same process, different privilege context) |
@@ -262,7 +262,7 @@ P-WORKER ──[sqlx (TCP localhost)]──> DS-TABLES
262262
|---|---|
263263
| Source | P-WORKER (Background Worker) |
264264
| Destination | DS-TABLES (df.instances / df.nodes) |
265-
| Data | Function graph nodes including submitted_by, login_role, queries |
265+
| Data | Function graph nodes including submitted_by, queries |
266266
| Classification | User-authored SQL + identity metadata |
267267
| Encryption | Localhost TCP (trust auth) |
268268
| Trust Boundary Crossed | TB-BGW → TB-PG |
@@ -310,10 +310,10 @@ P-WORKER ──[sqlx per-user connection (TCP localhost)]──> DS-TABLES
310310

311311
| STRIDE | Threat | Mitigation | Status |
312312
|---|---|---|---|
313-
| **S** Spoofing | Worker impersonates wrong user | login_role and submitted_by captured via C API (GetSessionUserId, GetOuterUserId); cannot be spoofed | ✅ Mitigated |
314-
| **T** Tampering | User SQL modifies data beyond their privileges | Connection authenticated as login_role + SET ROLE submitted_by; standard PostgreSQL RBAC applies | ✅ Mitigated |
315-
| **E** Elevation via RESET ROLE | User SQL contains `RESET ROLE` to escape to worker | RESET ROLE reverts to login_role (user's own identity), not worker role; connection is separate | ✅ Mitigated |
316-
| **E** Elevation via SET ROLE | User attempts `SET ROLE postgres` | SET ROLE requires role membership (checked against login_role); standard PostgreSQL RBAC | ✅ Mitigated |
313+
| **S** Spoofing | Worker impersonates wrong user | submitted_by captured via C API (GetOuterUserId); must have LOGIN attribute; cannot be spoofed | ✅ Mitigated |
314+
| **T** Tampering | User SQL modifies data beyond their privileges | Connection authenticated directly as submitted_by; standard PostgreSQL RBAC applies | ✅ Mitigated |
315+
| **E** Elevation via RESET ROLE | User SQL contains `RESET ROLE` to escape to worker | RESET ROLE reverts to submitted_by (user's own identity), not worker role; connection is separate | ✅ Mitigated |
316+
| **E** Elevation via SET ROLE | User attempts `SET ROLE postgres` | SET ROLE requires role membership (checked against submitted_by); standard PostgreSQL RBAC | ✅ Mitigated |
317317
| **E** Elevation via dynamic SQL | User obfuscates privilege escalation | Dynamic SQL runs on same per-user connection; authenticated identity is immutable | ✅ Mitigated |
318318
| **T** Tampering | Variable substitution ({var}) injects SQL | By design: vars are SQL fragments substituted as-is; user controls both var content and query; runs with user's own privileges | ⚠️ Accepted |
319319
| **I** Information Disclosure | Result substitution ($name) leaks cross-user data | Results are per-instance; RLS prevents cross-user access to nodes/instances | ✅ Mitigated |
@@ -340,7 +340,7 @@ P-WORKER ──[HTTP/HTTPS (external network)]──> EE-HTTP-TARGET
340340
| **T** Tampering | Man-in-middle modifies HTTP response | HTTPS available; HTTP also allowed (user's choice) | ⚠️ Partial |
341341
| **T** Tampering (SSRF) | User targets internal network (169.254.169.254, 10.x, 127.x) | Compile-time IP blocklist; scheme validation; IP literal check; SsrfSafeResolver; DNS rebinding protection | ✅ Mitigated |
342342
| **T** Tampering (SSRF) | IPv4-mapped IPv6 bypass (::ffff:169.254.169.254) | IPv4-mapped IPv6 extraction before blocklist check | ✅ Mitigated |
343-
| **R** Repudiation | User denies making HTTP request | Audit logging: submitted_by, login_role, URL, method in trace_info | ✅ Mitigated |
343+
| **R** Repudiation | User denies making HTTP request | Audit logging: submitted_by, URL, method in trace_info | ✅ Mitigated |
344344
| **I** Information Disclosure | Data exfiltration via HTTP POST to attacker endpoint | REVOKE EXECUTE on df.http() not yet default; no URL allowlist | ⛔ NOT IMPLEMENTED |
345345
| **I** Information Disclosure | Credentials in headers stored in df.nodes | HTTP config (including headers with auth tokens) stored in node query column; RLS-protected | ⚠️ Partial |
346346
| **D** Denial of Service | Attacker creates many HTTP requests to exhaust outbound connections | No rate limiting; timeout configurable (default 30s) | ⛔ NOT IMPLEMENTED |
@@ -414,17 +414,15 @@ graph LR
414414
A["pg_hba.conf<br/>Authentication"] --> B["PostgreSQL Backend<br/>session_user established"]
415415
B --> C["Optional: SET ROLE<br/>current_user changes"]
416416
C --> D["df.start() called"]
417-
D --> E["GetSessionUserId()<br/>→ login_role"]
418417
D --> F["GetOuterUserId()<br/>→ submitted_by"]
419-
E --> G["Stored in df.instances<br/>& df.nodes"]
420-
F --> G
418+
D --> V["Validates LOGIN attribute"]
419+
F --> G["Stored in df.instances<br/>& df.nodes"]
421420
end
422421
423422
subgraph TB-EXEC["Execution Isolation"]
424423
G --> H["Worker loads graph"]
425-
H --> I["connect_as_user(<br/>login_role)"]
426-
I --> J["SET ROLE<br/>submitted_by"]
427-
J --> K["Execute user SQL<br/>with user privileges"]
424+
H --> I["connect_as_user(<br/>submitted_by)"]
425+
I --> K["Execute user SQL<br/>with user privileges"]
428426
end
429427
```
430428

0 commit comments

Comments
 (0)