pg_durable Threat Model β DFD Analysis
Last Updated: 2026-03-18
Companion Docs: security-review.md | spec-security-model.md
TM7 File: threat-model.tm7
DFD Source: threat-model.dfd-lite.yaml
Deployment Model: Single-tenant PostgreSQL instance
1. DFD Standard Overview
| Element Type |
Symbol |
Description |
|---|
| External Entity (EE) |
Rectangle |
Actor or system outside the trust boundary |
| Process (P) |
Circle |
Code that transforms or processes data |
| Data Store (DS) |
Parallel lines |
Persistent data storage |
| Data Flow (DF) |
Arrow |
Communication between elements |
| Trust Boundary (TB) |
Dashed box |
Separates areas of different trust levels |
STRIDE Categories:
| Category |
Affects |
Description |
|---|
| Spoofing |
Process, External |
Pretending to be something/someone else |
| Tampering |
Process, Data Store, Flow |
Unauthorized data modification |
| Repudiation |
Process, Data Store, External |
Denying an action occurred |
| Information Disclosure |
Process, Data Store, Flow |
Unauthorized data exposure |
| Denial of Service |
Process, Data Store, Flow |
Degrading or denying service availability |
| Elevation of Privilege |
Process |
Gaining unauthorized capabilities |
2. DFD Elements Inventory
External Entities
| ID |
Name |
Description |
Trust Level |
|---|
| EE-USER |
Database User |
Application user/DBA connecting via PostgreSQL client. Authenticated via pg_hba.conf. |
Semi-trusted (authenticated) |
| EE-HTTP-TARGET |
External HTTP Service |
External HTTP/HTTPS endpoint called by df.http(). Azure Functions, webhooks, APIs. |
Untrusted |
Processes
| ID |
Name |
Description |
Technology |
|---|
| P-BACKEND |
pg_durable Backend (User Session) |
Extension code in userβs PostgreSQL backend. Executes DSL functions via SPI. Captures user identity at df.start() time. |
Rust/pgrx, SPI |
| P-WORKER |
pg_durable Background Worker |
Persistent BGW running duroxide runtime. Connects as superuser for control-plane; creates per-user connections for SQL execution. |
Rust, tokio, sqlx, duroxide |
Data Stores
| ID |
Name |
Description |
Technology |
|---|
| DS-TABLES |
df.instances / df.nodes |
Function graph definitions and instance metadata. RLS-protected (submitted_by = current_user::regrole). |
PostgreSQL tables |
| DS-DUROXIDE |
duroxide.* Tables |
Duroxide runtime state (orchestration history, work items, checkpoints). Worker-role access only. |
PostgreSQL tables |
| DS-VARS |
df.vars |
Per-user key-value store. RLS-protected (owner = current_user::regrole). Plaintext values. |
PostgreSQL table |
Trust Boundaries
| ID |
Name |
Description |
|---|
| TB-PG |
PostgreSQL Server Process |
All extension code and data stores run within the PostgreSQL server process |
| TB-USER |
User Session (Backend) |
Individual user backend processes β each has its own authenticated identity |
| TB-BGW |
Background Worker Process |
Single persistent worker with elevated (superuser) privileges |
| TB-EXTERNAL |
External Network |
Users and HTTP targets outside the PostgreSQL server |
3. Trust Boundary Diagram
graph TB
subgraph TB-EXTERNAL["External Network"]
EE-USER["EE-USER: Database User"]
EE-HTTP["EE-HTTP: External HTTP Service"]
end
subgraph TB-PG["PostgreSQL Server Process"]
subgraph TB-USER["User Session (Backend Process)"]
P-BACKEND["P-BACKEND: pg_durable Backend"]
end
subgraph TB-BGW["Background Worker Process"]
P-WORKER["P-WORKER: Background Worker"]
end
DS-TABLES[("DS-TABLES: df.instances / df.nodes")]
DS-DUROXIDE[("DS-DUROXIDE: duroxide.* Tables")]
DS-VARS[("DS-VARS: df.vars")]
end
EE-USER -->|"DF-1: SQL DSL Calls"| P-BACKEND
P-BACKEND -->|"DF-10: Query Results"| EE-USER
P-BACKEND -->|"DF-2: Graph Persistence (SPI)"| DS-TABLES
P-BACKEND -->|"DF-3: Variable R/W (SPI)"| DS-VARS
P-BACKEND -->|"DF-4: Instance Enqueue"| DS-DUROXIDE
P-WORKER -->|"DF-5: Work Item Polling"| DS-DUROXIDE
P-WORKER -->|"DF-6: Graph Loading"| DS-TABLES
P-WORKER -->|"DF-7: Status Updates"| DS-TABLES
P-WORKER -->|"DF-8: User SQL Execution"| DS-TABLES
P-WORKER -->|"DF-9: HTTP Requests"| EE-HTTP
Privilege Isolation Flow
sequenceDiagram
participant User as Database User
participant Backend as P-BACKEND<br/>(User Session)
participant Tables as DS-TABLES<br/>(df.nodes/instances)
participant Duroxide as DS-DUROXIDE
participant Worker as P-WORKER<br/>(Background Worker)
participant HTTP as External HTTP
User->>Backend: SELECT df.start(df.sql('...'))
Note over Backend: Captures current_user via GetUserId();<br/>validates LOGIN attribute
Backend->>Tables: INSERT nodes + instance<br/>(SPI as calling user, RLS)
Backend->>Duroxide: Enqueue orchestration<br/>(via cached duroxide client)
Backend-->>User: Returns instance_id
Worker->>Duroxide: Poll for work items<br/>(as worker role / superuser)
Duroxide-->>Worker: Orchestration work item
Worker->>Tables: Load function graph<br/>(reads submitted_by)
Note over Worker: Creates per-user connection:<br/>connect directly as submitted_by
Worker->>Tables: Execute user SQL<br/>(per-user connection, user privileges)
Worker->>Tables: Update status/results<br/>(as worker role)
opt df.http() node
Worker->>HTTP: HTTP request<br/>(SSRF-protected)
HTTP-->>Worker: Response
end
User->>Backend: SELECT df.status(instance_id)
Backend->>Tables: SELECT status<br/>(RLS-filtered)
Backend-->>User: 'completed'
4. Data Flow Definitions & STRIDE Analysis
DF-1: SQL DSL Calls
EE-USER ββ[PostgreSQL wire protocol]ββ> P-BACKEND
| Property |
Value |
|---|
| Source |
EE-USER (Database User) |
| Destination |
P-BACKEND (User Session) |
| Data |
SQL commands: df.sql(), df.start(), df.status(), df.http(), etc. |
| Classification |
User input β arbitrary SQL expressions |
| Encryption |
Depends on pg_hba.conf (TLS optional) |
| Trust Boundary Crossed |
TB-EXTERNAL β TB-PG β TB-USER |
| STRIDE |
Threat |
Mitigation |
Status |
|---|
| S Spoofing |
Attacker impersonates legitimate user |
PostgreSQL pg_hba.conf authentication (password, cert, GSSAPI) |
β
Mitigated |
| T Tampering |
Man-in-middle modifies SQL commands |
TLS encryption (if configured in pg_hba.conf); not enforced by default |
β οΈ Partial |
| R Repudiation |
User denies submitting a durable function |
submitted_by captured at df.start() via GetUserId(); current_user must have LOGIN |
β
Mitigated |
| I Information Disclosure |
Eavesdropping on wire protocol |
TLS encryption (if configured); plaintext by default on localhost |
β οΈ Partial |
| D Denial of Service |
Flooding with df.start() calls |
No rate limiting implemented |
β NOT IMPLEMENTED |
| E Elevation of Privilege |
User exposes a SECURITY DEFINER wrapper around df.start() |
GetUserId() captures current_user; SECURITY DEFINER submissions run as the definer, so wrapper authors must restrict EXECUTE appropriately |
β οΈ Documented |
DF-2: Graph Persistence (SPI)
P-BACKEND ββ[SPI (in-process)]ββ> DS-TABLES
| Property |
Value |
|---|
| Source |
P-BACKEND (User Session) |
| Destination |
DS-TABLES (df.instances / df.nodes) |
| Data |
Function graph nodes, instance metadata, user identity (submitted_by) |
| Classification |
Internal control-plane data |
| Encryption |
N/A (in-process SPI) |
| Trust Boundary Crossed |
TB-USER β TB-PG (same process, different privilege context) |
| STRIDE |
Threat |
Mitigation |
Status |
|---|
| T Tampering |
User forges submitted_by on inserted rows |
RLS WITH CHECK (submitted_by = current_user::regrole); identity set by C API, not user input |
β
Mitigated |
| T Tampering |
Direct DML bypasses extension logic |
RLS prevents cross-user modifications; column-level UPDATE grants restrict writable columns |
β
Mitigated |
| R Repudiation |
User modifies their own instance data |
submitted_by is immutable (no UPDATE grant on identity columns) |
β
Mitigated |
| I Information Disclosure |
User reads other users' instances |
RLS USING (submitted_by = current_user::regrole) |
β
Mitigated |
| D Denial of Service |
Mass INSERT of nodes exhausts storage |
No per-user quotas on node count |
β NOT IMPLEMENTED |
DF-3: Variable Read/Write (SPI)
P-BACKEND ββ[SPI (in-process)]ββ> DS-VARS
| Property |
Value |
|---|
| Source |
P-BACKEND (User Session) |
| Destination |
DS-VARS (df.vars) |
| Data |
Key-value pairs (name, value, owner) |
| Classification |
User configuration data; may contain sensitive values |
| Encryption |
N/A (in-process SPI); values stored as plaintext |
| Trust Boundary Crossed |
TB-USER β TB-PG |
| STRIDE |
Threat |
Mitigation |
Status |
|---|
| T Tampering |
User modifies another userβs variables |
RLS (owner = current_user::regrole) + explicit WHERE filters in DSL functions |
β
Mitigated |
| I Information Disclosure |
User reads another userβs variables |
RLS + explicit WHERE owner = current_user::regrole in getvar/unsetvar/clearvars |
β
Mitigated |
| I Information Disclosure |
Superuser reads all users' variables |
By design β superuser bypasses RLS; DSL functions use explicit WHERE for consistency |
β οΈ Accepted |
| I Information Disclosure |
Variables containing secrets stored in plaintext |
No encryption at rest for df.vars values |
β οΈ Partial |
| D Denial of Service |
Mass variable creation exhausts storage |
No per-user quota on variable count |
β NOT IMPLEMENTED |
DF-4: Instance Enqueue
P-BACKEND ββ[sqlx (TCP localhost)]ββ> DS-DUROXIDE
| Property |
Value |
|---|
| Source |
P-BACKEND (User Session) |
| Destination |
DS-DUROXIDE (duroxide.* tables) |
| Data |
Orchestration start request (instance_id, orchestration name) |
| Classification |
Internal control-plane |
| Encryption |
Localhost TCP (trust auth, no TLS) |
| Trust Boundary Crossed |
TB-USER β TB-PG (via cached Duroxide client) |
| STRIDE |
Threat |
Mitigation |
Status |
|---|
| S Spoofing |
Attacker directly inserts into duroxide.* tables |
No GRANT to PUBLIC on duroxide schema; users lack direct access |
β
Mitigated |
| T Tampering |
Corrupted enqueue data causes worker malfunction |
Duroxide client API validates inputs; instance_id is UUID-generated |
β
Mitigated |
| D Denial of Service |
Mass enqueue floods worker queue |
No rate limiting on df.start() calls |
β NOT IMPLEMENTED |
DF-5: Work Item Polling
P-WORKER ββ[sqlx pool (TCP localhost)]ββ> DS-DUROXIDE
| Property |
Value |
|---|
| Source |
P-WORKER (Background Worker) |
| Destination |
DS-DUROXIDE (duroxide.* tables) |
| Data |
Orchestration/activity work items |
| Classification |
Internal runtime state |
| Encryption |
Localhost TCP (trust auth) |
| Trust Boundary Crossed |
TB-BGW β TB-PG (both within PostgreSQL server) |
| STRIDE |
Threat |
Mitigation |
Status |
|---|
| T Tampering |
Poisoned work items cause code execution |
Worker reads from duroxide tables it owns; data provenance is trusted |
β
Mitigated |
| I Information Disclosure |
Worker role exposes all duroxide state |
Worker role is superuser β acceptable for single-tenant; duroxide schema not granted to users |
β
Mitigated |
| D Denial of Service |
Large backlog starves worker connections |
Fixed worker connection pool limits concurrent database work |
β οΈ Partial |
DF-6: Graph Loading
P-WORKER ββ[sqlx (TCP localhost)]ββ> DS-TABLES
| Property |
Value |
|---|
| Source |
P-WORKER (Background Worker) |
| Destination |
DS-TABLES (df.instances / df.nodes) |
| Data |
Function graph nodes including submitted_by, queries |
| Classification |
User-authored SQL + identity metadata |
| Encryption |
Localhost TCP (trust auth) |
| Trust Boundary Crossed |
TB-BGW β TB-PG |
| STRIDE |
Threat |
Mitigation |
Status |
|---|
| T Tampering |
Attacker modifies node queries between insert and execution |
RLS restricts UPDATE; no UPDATE grant on query column for users; timing window is small |
β
Mitigated |
| T Tampering |
Attacker modifies submitted_by to escalate privileges |
No UPDATE grant on submitted_by column; RLS prevents cross-user writes |
β
Mitigated |
| I Information Disclosure |
Worker reads and logs user SQL queries |
Worker logs query text in trace_info; appropriate for debugging; logs should be protected |
β οΈ Partial |
DF-7: Status Updates
P-WORKER ββ[sqlx (TCP localhost)]ββ> DS-TABLES
| Property |
Value |
|---|
| Source |
P-WORKER (Background Worker) |
| Destination |
DS-TABLES (df.instances / df.nodes) |
| Data |
Status transitions (pendingβrunningβcompleted/failed), result JSON |
| Classification |
Internal state management |
| Encryption |
Localhost TCP (trust auth) |
| Trust Boundary Crossed |
TB-BGW β TB-PG |
| STRIDE |
Threat |
Mitigation |
Status |
|---|
| T Tampering |
Worker uses string formatting for status update SQL |
instance_id and node_id originate from trusted duroxide orchestration, not user input; low risk but could be parameterized |
β οΈ Partial |
| I Information Disclosure |
Execution results may contain sensitive data |
Results stored in df.nodes (RLS-protected); visible only to submitting user |
β
Mitigated |
DF-8: User SQL Execution
P-WORKER ββ[sqlx per-user connection (TCP localhost)]ββ> DS-TABLES
| Property |
Value |
|---|
| Source |
P-WORKER (Background Worker) |
| Destination |
DS-TABLES (userβs own tables, any accessible schema) |
| Data |
User-authored SQL queries; query results as JSON |
| Classification |
User-controlled SQL β highest-risk data flow |
| Encryption |
Localhost TCP (trust auth, per-user connection) |
| Trust Boundary Crossed |
TB-BGW β TB-PG (with privilege downgrade to user identity) |
| STRIDE |
Threat |
Mitigation |
Status |
|---|
| S Spoofing |
Worker impersonates wrong user |
submitted_by captured via C API (GetUserId); must have LOGIN attribute; cannot be spoofed |
β
Mitigated |
| T Tampering |
User SQL modifies data beyond their privileges |
Connection authenticated directly as submitted_by; standard PostgreSQL RBAC applies |
β
Mitigated |
| 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 |
| E Elevation via SET ROLE |
User attempts SET ROLE postgres |
SET ROLE requires role membership (checked against submitted_by); standard PostgreSQL RBAC |
β
Mitigated |
| E Elevation via dynamic SQL |
User obfuscates privilege escalation |
Dynamic SQL runs on same per-user connection; authenticated identity is immutable |
β
Mitigated |
| 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 |
| I Information Disclosure |
Result substitution ($name) leaks cross-user data |
Results are per-instance; RLS prevents cross-user access to nodes/instances |
β
Mitigated |
| D Denial of Service |
Long-running queries block worker connections |
Per-user connections are not pooled (created per execution); worker pool not consumed |
β
Mitigated |
DF-9: External HTTP Requests
P-WORKER ββ[HTTP/HTTPS (external network)]ββ> EE-HTTP-TARGET
| Property |
Value |
|---|
| Source |
P-WORKER (Background Worker) |
| Destination |
EE-HTTP-TARGET (External HTTP Service) |
| Data |
HTTP requests with user-specified URL, method, body, headers |
| Classification |
External network I/O β SSRF-critical data flow |
| Encryption |
HTTPS if user specifies; HTTP also allowed |
| Trust Boundary Crossed |
TB-PG β TB-EXTERNAL |
| STRIDE |
Threat |
Mitigation |
Status |
|---|
| S Spoofing |
Attacker redirects HTTP to malicious endpoint |
Redirects disabled in reqwest client (Policy::none()) |
β
Mitigated |
| T Tampering |
Man-in-middle modifies HTTP response |
HTTPS available; HTTP also allowed (userβs choice) |
β οΈ Partial |
| 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 |
| T Tampering (SSRF) |
IPv4-mapped IPv6 bypass (::ffff:169.254.169.254) |
IPv4-mapped IPv6 extraction before blocklist check |
β
Mitigated |
| R Repudiation |
User denies making HTTP request |
Audit logging: submitted_by, URL, method in trace_info |
β
Mitigated |
| I Information Disclosure |
Data exfiltration via HTTP POST to attacker endpoint |
REVOKE EXECUTE on df.http() not yet default; no URL allowlist |
β NOT IMPLEMENTED |
| 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 |
| D Denial of Service |
Attacker creates many HTTP requests to exhaust outbound connections |
No rate limiting; timeout configurable (default 30s) |
β NOT IMPLEMENTED |
DF-10: Query Results to User
P-BACKEND ββ[PostgreSQL wire protocol]ββ> EE-USER
| Property |
Value |
|---|
| Source |
P-BACKEND (User Session) |
| Destination |
EE-USER (Database User) |
| Data |
Instance IDs, status strings, result JSON |
| Classification |
Userβs own workflow results |
| Encryption |
Depends on pg_hba.conf TLS configuration |
| Trust Boundary Crossed |
TB-PG β TB-EXTERNAL |
| STRIDE |
Threat |
Mitigation |
Status |
|---|
| I Information Disclosure |
User receives other users' results |
RLS on df.instances and df.nodes; parameterized SPI in df.status() and df.result() |
β
Mitigated |
| I Information Disclosure |
Results contain sensitive data from executed queries |
User authored the query; results are their own data |
β
Accepted |
| T Tampering |
Eavesdropping on result data |
TLS optional (not enforced by extension) |
β οΈ Partial |
5. Threat Summary by Priority
β Critical / P0 β Preview Blockers
| ID |
Flow |
Threat |
STRIDE |
Status |
|---|
| T-DoS-1 |
DF-1, DF-4 |
No rate limiting on df.start() β unbounded instance creation |
D |
β NOT IMPLEMENTED |
| T-Exfil-1 |
DF-9 |
df.http() data exfiltration β no URL allowlist or REVOKE EXECUTE by default |
I |
β NOT IMPLEMENTED |
π High / P1 β Fix Before Preview
| ID |
Flow |
Threat |
STRIDE |
Status |
|---|
| T-DoS-2 |
DF-9 |
No rate limiting on HTTP requests β outbound connection exhaustion |
D |
β NOT IMPLEMENTED |
| T-DoS-3 |
DF-2 |
No per-user quota on node/instance creation β storage exhaustion |
D |
β NOT IMPLEMENTED |
| T-Secrets-1 |
DF-3 |
df.vars stores values as plaintext β no encryption at rest |
I |
β οΈ Accepted risk |
| T-Creds-1 |
DF-9 |
HTTP auth headers stored unencrypted in df.nodes query column |
I |
β οΈ Partial |
β οΈ Medium / P2 β Fix Before GA
| ID |
Flow |
Threat |
STRIDE |
Status |
|---|
| T-TLS-1 |
DF-1, DF-10 |
PostgreSQL wire protocol not encrypted by default |
I, T |
β οΈ Partial |
| T-SQL-1 |
DF-7 |
Status update activities use string formatting (not parameterized) |
T |
β οΈ Partial |
| T-Log-1 |
DF-6 |
Worker logs user SQL queries in trace_info |
I |
β οΈ Partial |
| T-Var-1 |
DF-8 |
Variable substitution ({var}) is raw SQL injection by design |
T |
β οΈ Accepted |
| T-HTTP-1 |
DF-9 |
HTTP (non-TLS) allowed for outbound requests |
I, T |
β οΈ Partial |
| ID |
Flow |
Threat |
STRIDE |
Status |
|---|
| T-RLS-1 |
DF-2 |
RLS not FORCE-enabled β superuser/table-owner bypass |
I |
β
Accepted (by design) |
| T-SU-1 |
DF-3 |
Superuser sees all variables via direct table queries |
I |
β
Accepted (by design) |
| T-SECDEF-1 |
DF-1 |
SECURITY DEFINER captures definer privileges if df.start() called inside |
E |
β
Documented |
6. Authentication & Authorization Flow
graph LR
subgraph TB-AUTH["Authentication Flow"]
A["pg_hba.conf<br/>Authentication"] --> B["PostgreSQL Backend<br/>session_user established"]
B --> C["Optional: SET ROLE<br/>current_user changes"]
C --> D["df.start() called"]
D --> F["GetUserId()<br/>β submitted_by"]
D --> V["Validates LOGIN attribute"]
F --> G["Stored in df.instances<br/>& df.nodes"]
end
subgraph TB-EXEC["Execution Isolation"]
G --> H["Worker loads graph"]
H --> I["connect_as_user(<br/>submitted_by)"]
I --> K["Execute user SQL<br/>with user privileges"]
end
7. SSRF Protection Architecture
graph TB
subgraph SSRF["SSRF Protection Layers"]
A["df.http(url, method, ...)"] --> B{"Scheme Check"}
B -->|"http/https"| C{"IP Literal?"}
B -->|"file:/ftp:/etc"| BLOCK1["β BLOCKED<br/>unsupported scheme"]
C -->|"Yes (e.g. 169.254.169.254)"| D{"Blocked IP?"}
C -->|"No (hostname)"| E["DNS Resolution"]
D -->|"Private/Reserved"| BLOCK2["β BLOCKED<br/>private IP"]
D -->|"Public"| F["Send Request"]
E --> G["SsrfSafeResolver<br/>filters blocked IPs"]
G -->|"All blocked"| BLOCK3["β BLOCKED<br/>DNS resolved to private"]
G -->|"Has public IP"| F
F --> H{"Redirect?"}
H -->|"302/301"| BLOCK4["β BLOCKED<br/>redirects disabled"]
H -->|"200/4xx/5xx"| I["Return Response"]
end
8. References