You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -69,7 +69,7 @@ First open-source release of `pg_durable` on GitHub under the PostgreSQL License
69
69
- Fix: `is_truthy` now correctly treats "false", "no", and "f" as falsy (#57)
70
70
- Docs: add "Debugging Failed Workflows" section to User Guide (#71)
71
71
- 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.
73
73
- New DSL function `df.if_rows()`: branches on whether a named result returned any rows, without executing a SQL condition query.
74
74
- 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.
75
75
- 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.
'SELECT doc_id FROM documents WHERE id = 1' |=>'doc'
1121
-
~>df.join(
1159
+
~>df.join3(
1122
1160
df.wait_for_signal('legal_approval'),
1123
1161
df.wait_for_signal('tech_approval'),
1124
1162
df.wait_for_signal('mgmt_approval')
@@ -1384,10 +1422,10 @@ LOOP
1384
1422
-- All instances
1385
1423
SELECT*FROMdf.list_instances();
1386
1424
1387
-
-- Filter by status
1388
-
SELECT*FROMdf.list_instances('Running');
1389
-
SELECT*FROMdf.list_instances('Completed');
1390
-
SELECT*FROMdf.list_instances('Failed');
1425
+
-- Filter by status (lowercase)
1426
+
SELECT*FROMdf.list_instances('running');
1427
+
SELECT*FROMdf.list_instances('completed');
1428
+
SELECT*FROMdf.list_instances('failed');
1391
1429
1392
1430
-- With limit
1393
1431
SELECT*FROMdf.list_instances(NULL, 10);
@@ -1496,37 +1534,47 @@ SELECT df.start('SELECT * FROM bob_data');
1496
1534
1497
1535
### How Identity Is Captured
1498
1536
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`)
1500
1540
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.
1503
1542
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.
1508
1544
1509
-
### Working with Group Roles
1545
+
### Working with Roles
1510
1546
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:
1512
1548
1513
1549
```sql
1514
-
-- Create a group role (no LOGIN)
1550
+
-- Grant table access to alice directly
1551
+
GRANTSELECTON analyst_reports TO alice;
1552
+
1553
+
-- Alice submits as herself (her own login role)
1554
+
SET SESSION AUTHORIZATION alice;
1555
+
SELECTdf.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
1515
1563
CREATE ROLE analysts NOLOGIN;
1516
1564
GRANT analysts TO alice;
1565
+
GRANT analysts TO bob;
1517
1566
1518
-
CREATETABLEanalyst_reports (id INT, report TEXT);
1519
-
ALTERTABLE analyst_reports OWNER TO analysts;
1567
+
-- Grant table access to the group
1568
+
GRANTSELECTON analyst_reports TO analysts;
1520
1569
1521
-
-- Alice switches to the analysts role
1570
+
-- Alice submits as herself (inherits analysts privileges)
1522
1571
SET SESSION AUTHORIZATION alice;
1523
-
SET ROLE analysts;
1524
-
1525
-
-- Submit as the group role
1526
1572
SELECTdf.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'
1528
1574
```
1529
1575
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
+
1530
1578
### What Happens If a Role Is Dropped?
1531
1579
1532
1580
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;
1624
1672
GRANT EXECUTE ON FUNCTION df.result(text) TO app_role;
1625
1673
GRANT EXECUTE ON FUNCTION df.cancel(text, text) TO app_role;
1626
1674
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
1628
1676
GRANT EXECUTE ON FUNCTION df.list_instances(text, integer) TO app_role;
1629
1677
GRANT EXECUTE ON FUNCTION df.instance_info(text) TO app_role;
1630
1678
GRANT EXECUTE ON FUNCTION df.instance_nodes(text, integer) TO app_role;
2. Verify the background worker is running (see "Extension Exists But Workflows Don't Start")
1947
1995
3. Check for resource contention (CPU, disk I/O, connection limits)
1948
1996
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
+
SELECTdf.start('SELECT * FROM analyst_data'); -- runs as alice
2034
+
```
2035
+
- If a role needs `LOGIN`, alter it: `ALTER ROLE role_name LOGIN;`
2036
+
1949
2037
### Debugging Failed Workflows
1950
2038
1951
2039
When a durable function fails or produces unexpected results, use these steps to diagnose the issue from `psql` — no server log access required.
|**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 |
163
163
|**I** Information Disclosure | Eavesdropping on wire protocol | TLS encryption (if configured); plaintext by default on localhost | ⚠️ Partial |
164
164
|**D** Denial of Service | Flooding with df.start() calls | No rate limiting implemented | ⛔ NOT IMPLEMENTED |
165
165
|**E** Elevation of Privilege | User escalates via SECURITY DEFINER | GetOuterUserId() captures *caller* not *definer*; tested in E2E | ✅ Mitigated |
|**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 |
317
317
|**E** Elevation via dynamic SQL | User obfuscates privilege escalation | Dynamic SQL runs on same per-user connection; authenticated identity is immutable | ✅ Mitigated |
318
318
|**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 |
319
319
|**I** Information Disclosure | Result substitution ($name) leaks cross-user data | Results are per-instance; RLS prevents cross-user access to nodes/instances | ✅ Mitigated |
|**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 |
344
344
|**I** Information Disclosure | Data exfiltration via HTTP POST to attacker endpoint | REVOKE EXECUTE on df.http() not yet default; no URL allowlist | ⛔ NOT IMPLEMENTED |
345
345
|**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 |
346
346
|**D** Denial of Service | Attacker creates many HTTP requests to exhaust outbound connections | No rate limiting; timeout configurable (default 30s) | ⛔ NOT IMPLEMENTED |
0 commit comments