diff --git a/CHANGELOG.md b/CHANGELOG.md index 483db67c..8ee0d6fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ Pre-1.0 note: while `pg_durable` is in major version `0`, minor releases may inc ### Added +- **Explicit secret bindings:** `df.secret(server, key)` returns a JSONB + descriptor for named header/query/form fields through `df.with_http_options`. + Individual `"secret."` user-mapping options support per-key addition, + rotation and removal. Literal form data + stays separate from references; activities encode fields and resolve credentials + under the submitting role without scanning payloads for markers. Named-secret-only + servers may omit `base_url` with `auth_scheme 'none'`. +- **Endpoint HTTP requests:** `df.endpoint(server, path)` returns a typed + `df.http_endpoint` value accepted by `df.http` and `df.http_multipart`. + TEXT destinations remain URLs, never serialized endpoint references. + Activities resolve per-role credentials, + enforce server `USAGE`, preserve the configured base URL and reject routing or + credential overrides. Existing HTTP signatures and raw-URL workflow inputs + remain unchanged; the grant/revoke helpers cover URLs and endpoints together. + General body secret interpolation is deferred. +- **Endpoint credential catalog:** handler-less `pg_durable_fdw`, a closed-set + option validator, and per-user catalog resolution for unauthenticated, bearer, + named-header and query-string endpoint authentication. FDW creation authority + is delegated with native grants. User mappings remain plaintext and may be + included in dumps; `DROP EXTENSION ... CASCADE` removes dependent endpoints + and mappings. Catalogs live in the control database independently of SQL targets; + each request uses a consistent caller-authenticated snapshot and shares the SQL + connection budget, releasing its connection before HTTP I/O. - **Failure-isolated loops:** the unified `df.loop(body, condition DEFAULT NULL, continue_on_failure DEFAULT false)` signature supports resilient infinite and conditional loops. With diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8dc3ce00..c295d781 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -260,6 +260,7 @@ df.sql('SELECT 1') ~> df.sql('SELECT 2') | `df.sleep(seconds)` | Pause for N seconds | `df.sleep(60)` | | `df.wait_for_schedule(cron)` | Wait until cron matches | `df.wait_for_schedule('0 * * * *')` | | `df.http(url, method, body, headers, timeout)` | Make HTTP request | `df.http('https://api.example.com', 'POST', '{"key": "value"}')` | +| `df.endpoint(server, path)` | Reference an endpoint destination | `df.http(df.endpoint('partner_api', '/v1/items'), 'GET')` | | `df.join(a, b)` | Execute in parallel, wait for all | `df.join('SELECT 1', 'SELECT 2')` | | `df.join3(a, b, c)` | Three in parallel | `df.join3(a, b, c)` | | `df.race(a, b)` | Execute in parallel, first wins | `df.race(fast_query, slow_query)` | @@ -701,6 +702,29 @@ df.http( ) RETURNS TEXT -- JSON response object ``` +The destination can also be the `df.http_endpoint` value returned by +[`df.endpoint`](#calling-an-endpoint). All other request arguments are the same. + +### df.with_http_options() Function + +`df.with_http_options(fut TEXT, options JSONB) RETURNS TEXT` is the entry point for +HTTP modifiers beyond the arguments passed to `df.http` and `df.http_multipart`. + +```sql +df.with_http_options(df.http('https://api.github.com/', 'GET'), '{}'::jsonb) + |=> 'response' +``` + +Supported keys are `secret_bindings` and `form_fields`, described under +[Explicit Secret Bindings](#explicit-secret-bindings). SQL `NULL` and `{}` return +the input text byte-for-byte. Unknown keys and non-object JSON values, including +JSON `null`, are rejected. Reapplying a supplied key replaces that entire option; +omitted keys are retained. + +The input must be a single `HTTP` or `HTTP_MULTIPART` node, optionally named with +`|=>`. SQL nodes and compound graphs are rejected, so apply the helper before +combining nodes. It does not execute a request or change HTTP permissions. + ### Response Format HTTP calls return a JSON object with full response details: @@ -765,6 +789,265 @@ df.http('https://api.example.com/report.pdf', 'GET') |=> 'pdf' Because the body is *already* base64, it can be handed straight to a multipart upload with no round trip through a table — see [Multipart Uploads](#multipart-uploads). +### Endpoint Credential Catalog + +Endpoint definitions use a handler-less `pg_durable_fdw`. A foreign server holds +the base URL and authentication scheme; each caller's user mapping holds its +credentials. There are no foreign tables or scans. + +`auth_scheme` is required. `base_url` may be omitted only with `auth_scheme 'none'` +for a server used solely for named secrets. A supplied base URL must be a nonempty, +literal HTTPS URL, optionally with a path prefix, without userinfo, query or +fragment. Using a server as an HTTP endpoint always requires a base URL. Creating +a server does not authorize network access or bypass HTTP destination restrictions. + +| `auth_scheme` | Additional server option | Required user-mapping option | +|---|---|---| +| `none` | None | None for endpoint authentication; named bindings require a mapping | +| `bearer` | None | `token` (without the `Bearer ` prefix) | +| `header` | `header_name`, such as `x-api-key` | `header_value` | +| `query` | None | `query_string`, already URL-encoded, optionally starting with `?` | + +Unknown options and authentication schemes are rejected. `managed-identity` is +reserved and rejected until its authentication controls are available. The +catalog does not accept free-form `resource`, `scope` or `client_id` settings. +Header names cannot override routing, framing or multipart content type. +Credential values must be nonempty and valid for their transport; validation +errors do not echo those values. The mapping validator checks individual options; +resolution also requires the option for the server's selected scheme. + +Endpoint creation is delegated separately from HTTP execution: + +```sql +SELECT df.grant_usage('endpoint_admin', include_http => true); +GRANT USAGE ON FOREIGN DATA WRAPPER pg_durable_fdw TO endpoint_admin; + +SET ROLE endpoint_admin; +CREATE SERVER partner_api FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://partner.azure-api.net', auth_scheme 'bearer'); +GRANT USAGE ON FOREIGN SERVER partner_api TO app_role; +RESET ROLE; + +SELECT df.grant_usage('app_role', include_http => true); +SET ROLE app_role; +CREATE USER MAPPING FOR CURRENT_USER SERVER partner_api + OPTIONS (token ''); +ALTER USER MAPPING FOR CURRENT_USER SERVER partner_api + OPTIONS (SET token ''); +RESET ROLE; +``` + +The roles above must already exist. `df.grant_usage` does not grant FDW creation +authority, even with `with_grant => true`; use the native FDW grant explicitly. +A caller with server `USAGE` can create and read its own mapping. Other ordinary +roles cannot read its values, including a server owner who is not that mapped +role. `PUBLIC` mappings are not a fallback for endpoint credential lookup. + +Server owners can change the destination, so they must be trusted with credentials +sent through their servers. Catalog masking does not prevent an owner from +redirecting a subsequent request to a destination they control. + +The catalog resolver checks server `USAGE` and reads the authenticated caller's +mapping on every attempt. Rotation affects the next lookup, not an already-sent +request. Missing or inaccessible credentials fail without privileged fallback. +Mappings also accept individual `secret.` options for explicit bindings. +These are separate from `token`, `header_value` and `query_string`; other option +names remain invalid. Named values may be empty or contain arbitrary text, +including JSON or `=`; they are not parsed as JSON. Header bindings validate the +resolved text before sending it. + +User mappings are plaintext in catalogs, WAL and backups. Superuser dumps include +their credential values; less privileged dumps can omit options. Literal values +in provisioning DDL can appear in PostgreSQL logs. Treat backup/restore and +credential provisioning accordingly. Dropping the extension with `CASCADE` +also removes dependent servers and mappings. + +### Calling an Endpoint + +Pass `df.endpoint(server, path)` as the destination of either HTTP constructor: + +```sql +SELECT df.start( + df.http(df.endpoint('partner_api', '/v1/invoices?status=pending'), 'GET'), + 'fetch-invoices' +); + +SELECT df.start( + df.http_multipart( + df.endpoint('partner_api', '/v1/upload'), + parts => '[{"name":"file","filename":"hello.txt","data_b64":"aGVsbG8="}]'::jsonb + ), + 'upload-file' +); +``` + +`df.endpoint` returns a `df.http_endpoint` value containing the server name and +path, not an HTTP request or a resolved URL. It does not look up the server or +read credentials. Pass this value directly to the HTTP constructor; keep it typed +when storing it in a SQL variable or column. TEXT destinations are always treated +as URLs, never as serialized endpoint references. The HTTP +constructor records only the server name and path template; the activity checks +the caller's HTTP grant and server `USAGE`, resolves the mapping, and applies +normal HTTP destination checks. `df.explain` shows the server and path without +resolving either. + +Use `df.grant_usage('app_role', include_http => true)` to enable HTTP access for +URLs and endpoints. After upgrading an existing installation, run the helper for +roles that need the newly added endpoint support. Existing URL calls retain their +permissions. + +The path starts with one `/` and is appended to the server's base path prefix: +`https://host/api/` plus `/items` becomes `https://host/api/items`. Absolute URLs, +protocol-relative paths (`//host`), backslashes, fragments, whitespace, dot +traversal segments and percent-encoded path separators are rejected. Encode +spaces and other URL data before supplying them. Existing `{var}` and `$result` +substitution works in the path; validation runs again after substitution. Server +names are fixed references, not workflow-variable templates. + +Caller headers cannot set `Host` or override the credential header (matching +case-insensitively). For query authentication, caller query parameters cannot +duplicate credential parameter names, including percent-encoded spellings. +Credential query values are appended without re-encoding and redacted in request +diagnostics. Secret-looking body text is not expanded by endpoint authentication; +ordinary workflow-variable substitution remains unchanged. + +Endpoints and mappings live in the control database where `pg_durable` is installed +(selected by `pg_durable.database`). The submitting role must be able to connect +there. The `database` argument to `df.start` selects the SQL execution database; +it does not change HTTP credential lookup or require installing the extension in +that SQL target. + +Each HTTP attempt reads endpoint configuration and all referenced mappings in one +consistent, read-only catalog snapshot under the submitting role. Atomic catalog +updates cannot mix an old destination with new credentials within that request. +Later attempts take new snapshots; completed results still replay from history. +The catalog connection shares the SQL user-connection budget and is closed before +HTTP I/O. Raw requests and literal forms without references need no catalog +connection. No credentials are read at graph construction time. +Returned or echoed credentials are still response data and can enter history; +endpoint authentication does not redact response bodies or headers. + +### Explicit Secret Bindings + +`df.secret(server, key)` returns a JSONB descriptor such as +`{"server":"partner_api","key":"api_key"}`, not a credential or an embeddable +string marker. It performs no lookup. Use it in dedicated maps passed to +`df.with_http_options`: + +| Option | Meaning | +|---|---| +| `secret_bindings.headers` | Header names mapped to descriptors, with an optional literal `prefix` | +| `secret_bindings.query` | Query parameter names mapped to descriptors | +| `secret_bindings.form` | Form field names mapped to descriptors | +| `form_fields` | Ordinary form field names and string values, kept literal | + +Provision named values in the caller's user mapping: + +```sql +CREATE USER MAPPING FOR CURRENT_USER SERVER partner_api + OPTIONS ("secret.api_key" ''); + +ALTER USER MAPPING FOR CURRENT_USER SERVER partner_api + OPTIONS (ADD "secret.client_secret" ''); + +ALTER USER MAPPING FOR CURRENT_USER SERVER partner_api + OPTIONS (SET "secret.api_key" ''); +``` + +When a credential is no longer needed, remove it independently: + +```sql +ALTER USER MAPPING FOR CURRENT_USER SERVER partner_api + OPTIONS (DROP "secret.client_secret"); +``` + +If the mapping already exists, use `ADD` instead of creating another mapping. +Each `ADD`, `SET` or `DROP` leaves other options unchanged; adding an existing +option or changing/dropping a nonexistent option fails. Quote the entire option +name because it contains a dot. The `secret.` prefix is reserved for named values: +`df.secret('partner_api', 'api_key')` reads `"secret.api_key"`, not `token` or any +other endpoint-authentication option. Keys are case-sensitive, nonempty and +cannot contain control characters or `=`. Quoted names follow PostgreSQL's normal +identifier-length limit. + +The same plaintext, backup and provisioning-log caveats as other credentials +apply. A server used only for named secrets can omit `base_url`: + +```sql +CREATE SERVER app_secrets FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (auth_scheme 'none'); +``` + +Server `USAGE` and the caller's user mapping are still required. Such a server +cannot be used as an HTTP endpoint until a valid `base_url` is added. A named +secret's server URL, when present, does not restrict the request destination. + +An explicit header binding works with raw URLs or endpoint references: + +```sql +SELECT df.start( + df.with_http_options( + df.http('https://partner.azure-api.net/v1/items', 'GET'), + jsonb_build_object('secret_bindings', jsonb_build_object( + 'headers', jsonb_build_object('X-Api-Key', df.secret('partner_api', 'api_key')) + )) + ), + 'fetch-items' +); +``` + +For a header prefix, use +`df.secret('partner_api', 'api_key') || '{"prefix":"Bearer "}'::jsonb`. +Prefixes are literal text and allowed only on headers. Query/form bindings encode +their values automatically; no encoding argument is needed. + +A form request keeps ordinary data separate from references: + +```sql +SELECT df.start( + df.with_http_options( + df.http('https://partner.azure-api.net/oauth2/token', 'POST'), + jsonb_build_object( + 'secret_bindings', jsonb_build_object( + 'form', jsonb_build_object('client_secret', df.secret('partner_api', 'client_secret')) + ), + 'form_fields', jsonb_build_object( + 'grant_type', 'client_credentials', + 'client_id', 'a1b2c3d4', + 'state', '${secret:literal.text}' + ) + ) + ), + 'token-request' +); +``` + +The activity generates `application/x-www-form-urlencoded` and sets that content +type. Both ordinary and secret fields are encoded, including Unicode, delimiters +and empty values. `form_fields` are literal strings: `${secret:...}`, `$result`, +`{var}` and reference-shaped text are not interpreted. Compute ordinary values at +node construction; runtime result bindings for form fields are not supported. +Existing raw-body substitution is unchanged. + +Form mode requires POST, PUT or PATCH and cannot accompany a raw `body` or +multipart request. Multipart supports header/query bindings, not secret-valued +parts. Conflicting content types, explicit form-body framing and collisions with +ordinary fields or endpoint authentication fail rather than overwrite values. +Header matching is case-insensitive; query collision checks decode parameter names. + +Binding maps, names and prefixes are trusted workflow configuration: never source +them from untrusted payloads. The destination of a credential-bearing request must +also be trusted: a secret's server name selects its mapping, not the allowed +recipient. Only ordinary data belongs in `form_fields` or raw request fields. +Activities re-check HTTP permission and `USAGE` on every referenced +server, using the caller's mapping in the control database. Missing keys +or mappings fail explicitly, and resolved values are never recursively interpreted. + +Secret insertion into paths, raw bodies, nested JSON or multipart contents remains +out of scope. The OAuth example protects the outgoing client secret, but its +returned access token is still response data recorded in history. Response-secret +storage and endpoint-managed OAuth token acquisition are separate capabilities. + ### Error Handling - **2xx responses**: Success - `ok` is `true` @@ -2249,7 +2532,7 @@ This function is purely additive — it never issues REVOKE. To downgrade a role | Parameter | Default | Description | |-----------|---------|-------------| | `p_role` | *(required)* | Target role name | -| `include_http` | `false` | Grant EXECUTE on `df.http()` (opt-in — makes outbound network requests) | +| `include_http` | `false` | Enable `df.http()` and `df.http_multipart()` for URLs and endpoints (opt-in network access) | | `with_grant` | `false` | Grant all privileges WITH GRANT OPTION and allow the role to call `df.grant_usage()` / `df.revoke_usage()` to manage other roles' access. Also grants EXECUTE on `df.metrics()` (system-wide aggregate counts), since `with_grant => true` designates a pg_durable admin. The caller must hold each underlying privilege WITH GRANT OPTION (automatically true for superusers and delegated admins). |
@@ -2261,7 +2544,7 @@ The ordinary DSL functions (`df.sql`, `df.start`, `df.status`, etc.) keep Postgr -- Access gate: schema USAGE makes every ordinary df.* function callable GRANT USAGE ON SCHEMA df TO app_role; -- Optional: HTTP access (include_http => true) --- GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO app_role; +-- SELECT df.grant_usage('app_role', include_http => true); -- Optional: system-wide metrics access (also granted automatically by -- df.grant_usage(role, with_grant => true)) @@ -2375,7 +2658,7 @@ are being launched: |----------|---------|-----|---------| | **Management pool** | Extension lifecycle checks, graph loading, status updates | `pg_durable.max_management_connections` | 6 | | **Duroxide pool** | Orchestration state, LISTEN/NOTIFY for work dispatch | `pg_durable.max_duroxide_connections` | 10 | -| **User-execution** | Per-SQL-node connections authenticated as the submitting user | `pg_durable.max_user_connections` | 10 | +| **User-execution** | SQL execution and HTTP credential catalog reads, authenticated as the submitting user | `pg_durable.max_user_connections` | 10 | | **New-start loopback** | Extra sessions that persist `df.start(..., transaction_mode => 'new')` outside the caller's transaction | `pg_durable.max_new_transaction_starts` | 2 | Each PG backend session (user calling `df.start()`, `df.cancel()`, etc.) creates **1 additional connection** for duroxide client operations. @@ -2395,11 +2678,11 @@ pg_durable.max_management_connections = 6 # Minimum: 2 (1 reserved for listener). Worker refuses to start if < 2. pg_durable.max_duroxide_connections = 10 -# Maximum concurrent SQL node executions (user connections) +# Maximum concurrent SQL execution and HTTP catalog connections # Additional executions queue until a slot frees up or timeout expires. pg_durable.max_user_connections = 10 -# How long (seconds) a SQL node waits for a user-execution slot +# How long (seconds) SQL execution or HTTP catalog lookup waits for a slot # before failing with an error. pg_durable.execution_acquire_timeout = 30 @@ -2432,10 +2715,12 @@ With defaults and 5 connected users: `6 + 10 + 10 + 2 + 5 = 33 connections`. ### Backpressure Behavior -When all user-execution slots are occupied, additional SQL node executions **queue** (they don't fail immediately). The semaphore-based backpressure ensures: +SQL execution and HTTP credential lookup share user-execution slots. When all slots +are occupied, additional work **queues** before opening a caller connection. The +semaphore-based backpressure ensures: - Queued executions proceed as slots free up -- If the wait exceeds `execution_acquire_timeout`, the SQL node fails with: +- If the wait exceeds `execution_acquire_timeout`, the waiting node fails with: ``` pg_durable: connection limit reached (max_user_connections=10). Timed out after 30s waiting for an available execution slot. @@ -2443,6 +2728,11 @@ When all user-execution slots are occupied, additional SQL node executions **que - The failed node causes the workflow to enter `failed` status - Other nodes in the same workflow that have already acquired slots continue normally +An HTTP request uses at most one catalog connection for its endpoint and named +bindings, and releases that slot before sending the request. Network transfer does +not occupy a database slot. Raw HTTP and literal forms without credential references +do not acquire a slot. + For `df.start(..., transaction_mode => 'new')`, admission control applies *before* the loopback session is opened: diff --git a/docs/api-reference.md b/docs/api-reference.md index 325c5f50..da30370d 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -289,7 +289,7 @@ Makes an HTTP request. | Parameter | Type | Auto-wrap | Description | |-----------|------|-----------|-------------| -| `url` | TEXT | ❌ Literal | Request URL (supports `$var` substitution) | +| `url` | TEXT or `df.http_endpoint` | ❌ Literal | Request URL or `df.endpoint(...)` value (path supports workflow substitution) | | `method` | TEXT | ❌ Literal | HTTP method (default: POST) | | `body` | TEXT | ❌ Literal | Request body JSON (supports `$var`) | | `headers` | JSONB | ❌ Literal | Request headers | @@ -316,6 +316,51 @@ bytes into a subsequent upload. --- +### df.endpoint(server, path) + +Returns a `df.http_endpoint` composite value with `server TEXT` and `path TEXT` +fields for `df.http` or `df.http_multipart`. Both arguments are required. +Construction reads no endpoint catalogs or credentials. Pass the typed value +directly to an HTTP constructor; TEXT arguments are URLs and are never interpreted +as endpoint references. No implicit TEXT-to-endpoint conversion is installed. + +```sql +df.http(df.endpoint('partner_api', '/v1/items'), 'GET') +df.http_multipart(df.endpoint('partner_api', '/upload'), + parts => '[{"name":"file","data_b64":"aGVsbG8="}]'::jsonb) +``` + +Manage HTTP access with `df.grant_usage(..., include_http => true)` and +`df.revoke_usage(...)`; the helpers cover URL and endpoint requests together. + +The server name is fixed; the path supports existing workflow substitutions and +is appended to the base URL's path prefix. Invalid path shapes, traversal, routing +overrides and credential overrides fail explicitly. Resolution uses the submitting +role in the control database where `pg_durable` is installed and re-checks server +`USAGE` in addition to the corresponding HTTP function grant. The workflow's SQL +target does not select the credential catalog. Each request resolves its endpoint +and named bindings from one consistent catalog snapshot, using a shared +user-connection slot that is released before HTTP I/O. See +[Calling an Endpoint](../USER_GUIDE.md#calling-an-endpoint). + +### Endpoint Credential Catalog + +`pg_durable_fdw` stores endpoint configuration in native foreign servers and +per-role credentials in user mappings. It has no handler and does not support +foreign tables. `df.endpoint_option_validator(options text[], catalog oid)` is +the FDW validator invoked by PostgreSQL on creation and alteration; it returns +`void` or raises an error without echoing credential values. + +The server options are `base_url`, `auth_scheme`, and `header_name` (only for +header authentication). `auth_scheme` is required; `base_url` may be omitted only +with `auth_scheme 'none'` for named-secret storage. A supplied URL retains all +validation requirements, and endpoint requests fail explicitly if it is absent. +Mapping options are `token`, `header_value`, +`query_string`, and individual `"secret."` values. Named credentials support +native per-option `ADD`, `SET` and `DROP`. See +[Endpoint Credential Catalog](../USER_GUIDE.md#endpoint-credential-catalog) +for option combinations, grants, rotation and backup implications. + ### df.http_multipart(url [, method, parts, headers, timeout]) Makes an HTTP request with a `multipart/form-data` body. Requires the same @@ -323,7 +368,7 @@ Makes an HTTP request with a `multipart/form-data` body. Requires the same | Parameter | Type | Auto-wrap | Description | |-----------|------|-----------|-------------| -| `url` | TEXT | ❌ Literal | Request URL (supports `$var` substitution) | +| `url` | TEXT or `df.http_endpoint` | ❌ Literal | Request URL or `df.endpoint(...)` value (path supports workflow substitution) | | `method` | TEXT | ❌ Literal | HTTP method (default: POST) | | `parts` | JSONB | ❌ Literal | Array of part objects (see below) | | `headers` | JSONB | ❌ Literal | Request headers | @@ -367,6 +412,51 @@ Returns the same envelope as `df.http()`. --- +### df.secret(server, key) + +Returns JSONB `{"server":"...","key":"..."}` without reading credentials. +Both arguments are required, nonempty and cannot contain control characters. +`key` selects `"secret."` in the submitting role's user mapping. Keys are +case-sensitive and cannot contain `=`; values are opaque text, including empty +strings. Endpoint-authentication options are not searched as a fallback. +Only explicit binding slots interpret this descriptor; ordinary values never do. +Header slots may add a literal `prefix`; unknown descriptor fields are rejected. + +```sql +df.secret('partner_api', 'api_key') +df.secret('partner_api', 'token') || '{"prefix":"Bearer "}'::jsonb +``` + +See [Explicit Secret Bindings](../USER_GUIDE.md#explicit-secret-bindings). + +### df.with_http_options(fut, options) + +HTTP-specific modifier entry point. Returns the JSON-encoded TEXT node for use in +a workflow, not an HTTP response. Neither existing HTTP function changes signature. + +| Parameter | Type | Auto-wrap | Description | +|-----------|------|-----------|-------------| +| `fut` | TEXT | ❌ Literal | A single `HTTP` or `HTTP_MULTIPART` node, optionally named with `\|=>` | +| `options` | JSONB | ❌ Literal | Object containing `secret_bindings` and/or `form_fields`; SQL `NULL` and `{}` are no-ops | + +```sql +df.with_http_options(df.http('https://api.github.com/', 'GET'), '{}'::jsonb) + |=> 'response' +``` + +`secret_bindings` contains named `headers`, `query` and `form` reference maps. +`form_fields` contains literal form strings. A supplied option replaces the entire +previous option; omitted options remain intact. See [Explicit Secret Bindings](../USER_GUIDE.md#explicit-secret-bindings) +for shapes, encoding and conflict rules. Unknown keys, non-object JSON values (including +JSON `null`), malformed nodes, SQL nodes, and compound graphs raise an error. +SQL `NULL` and `{}` return the original node text byte-for-byte, preserving its +config and result name. Apply the helper to each HTTP node before combining nodes. +It neither resolves secrets nor grants HTTP access; activity-time permission and +network checks still apply. Existing installations need `ALTER EXTENSION pg_durable +UPDATE` to use this new helper, but not to keep using the original HTTP functions. + +--- + ## Control Functions ### df.start(fut [, label] [, database] [, transaction_mode]) diff --git a/docs/design-azure-functions.md b/docs/design-azure-functions.md index 201dbbfb..8e3732cb 100644 --- a/docs/design-azure-functions.md +++ b/docs/design-azure-functions.md @@ -2,6 +2,11 @@ This document outlines the design for calling Azure Functions from pg_durable, with a focus on AI scenarios like RAG pipelines, embeddings, and intelligent data processing. +> **Historical proposal; credential handling superseded (2026-09-10).** +> The `df.azure` helper and `df.secrets` table below are unimplemented proposals. Their construction-time key lookup would embed credentials in workflow state and must not be used for the new implementation. [Endpoint credentials](spec-security-model.md#44-endpoint-credentials) instead store references to foreign servers and user mappings, resolving them inside HTTP activities. +> +> The scenarios remain design examples, not a statement that all shown APIs are supported. See the [API reference](api-reference.md) and [HTTP security documentation](http-security.md) for current behavior. + --- ## Table of Contents @@ -1428,6 +1433,8 @@ SELECT df.start( ### Secrets Table +**Superseded:** The table and provisioning examples below belong to the historical proposal. The FDW store holds per-role credentials, which are readable by the mapped role with server `USAGE`; it does not implement opaque admin-managed shared secrets. See the [security contract and open decisions](spec-security-model.md#44-endpoint-credentials). + ```sql CREATE TABLE df.secrets ( name TEXT PRIMARY KEY, diff --git a/docs/http-security.md b/docs/http-security.md index f4f7f9d5..1499a470 100644 --- a/docs/http-security.md +++ b/docs/http-security.md @@ -4,6 +4,10 @@ This document describes the security model for `df.http()` — the durable HTTP activity that lets workflows make outbound HTTP(S) requests from within the PostgreSQL background worker. +The same HTTP policy applies to `df.http_multipart`, which has its own function +privilege check. For endpoint credentials, see +[the credential security contract](spec-security-model.md#44-endpoint-credentials). + --- ## Table of Contents @@ -100,22 +104,42 @@ hand-crafted `Durofut` JSON string, inserting an HTTP node without ever calling To close this gap, `execute_http` checks at execution time whether the `submitted_by` role recorded in the node still holds `EXECUTE` privilege on `df.http()`. If the role's grant has been revoked since the node was created, -the node fails immediately. +and no other effective grant remains, the next execution attempt fails before +sending a request. Revocation does not cancel a request already in progress. ### 3.2 Mechanism -`execute_http` runs the following check before any network activity: +The activity selects the required HTTP function signature from the node's actual +destination and body mode, then checks the submitting role before network activity: ```sql -SELECT has_function_privilege($submitted_by::regrole, - 'df.http(text,text,text,jsonb,integer)'::regprocedure, - 'EXECUTE') +SELECT COALESCE(pg_catalog.has_function_privilege( + role.oid, pg_catalog.to_regprocedure($http_signature)::pg_catalog.oid, + 'EXECUTE'), false) +FROM pg_catalog.pg_roles AS role +WHERE role.rolname OPERATOR(pg_catalog.=) $submitted_by; ``` +The signature is selected internally based on whether or not the node would +have been created using a `df.http_endpoint` (the type that `df.endpoint` +returns), not supplied as a permission override in node JSON. Role lookup +uses the exact catalog name. Missing functions or roles fail closed; +hand-crafted nodes cannot bypass the check. + `has_function_privilege` honours PostgreSQL's standard privilege model: superusers always return `true`; regular roles return `true` only when an -explicit `GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO ` (or a role that -inherits one) is in effect. +effective grant exists, whether direct, inherited from another role or granted +to `PUBLIC`. + +Multipart activities use the same check for `df.http_multipart`. Manage the full +HTTP permission set through `df.grant_usage` and `df.revoke_usage`. + +`df.with_http_options(text,jsonb)` is a node modifier, not a network operation. +Like other combinators, it uses ordinary `df` schema access and default PUBLIC +`EXECUTE`. Wrapping a hand-crafted HTTP node does not bypass the activity's +privilege check. Supported keys are `secret_bindings` and `form_fields`, described +in [Explicit secret bindings](#37-explicit-secret-bindings). SQL `NULL` and `{}` +preserve the original node text. ### 3.3 Managing access @@ -123,34 +147,36 @@ HTTP access is **opt-in** and separate from general `df` access. #### Granting access -Use `df.grant_usage()` with `include_http => true`: +Use `df.grant_usage()` with `include_http => true` to enable normal and multipart +HTTP with either URLs or endpoints: ```sql SELECT df.grant_usage('my_role', include_http => true); ``` -Or grant directly: - -```sql -GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO my_role; -``` - `df.grant_usage('my_role')` (without `include_http`) grants all standard `df` -privileges but **not** `df.http()`. HTTP access must be explicitly opted in to. +privileges but does not grant either HTTP function. The helper is **additive**: +`include_http => false` does not revoke previously granted or inherited HTTP +access. Ordinary helpers retain PostgreSQL's default `PUBLIC EXECUTE`; schema +`USAGE` is their access gate. Sensitive functions are granted explicitly. #### Revoking access -To remove HTTP access without removing all `df` access: +To remove HTTP access while retaining standard `df` access, revoke the current +helper-managed grants and regrant without HTTP: ```sql -REVOKE EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) FROM my_role; +SELECT df.revoke_usage('my_role'); +SELECT df.grant_usage('my_role'); ``` -After this, any existing or future HTTP nodes submitted by `my_role` will fail -at execution time with a "permission denied" error. All other `df` functions -remain accessible. +Once no effective HTTP grant remains, later execution attempts fail with a +privilege error. Other `df` functions remain accessible. Check for grants through +`PUBLIC` or inherited roles: revoking a direct grant does not remove those paths. -`df.revoke_usage('my_role')` removes all `df` access, including `df.http()`. +`df.revoke_usage('my_role')` also revokes standard `df` access and sensitive +function grants within the caller's grant authority. It does not erase +independent grants through `PUBLIC` or other roles. #### PUBLIC grant and upgrades @@ -165,23 +191,31 @@ run manually: REVOKE EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) FROM PUBLIC; ``` -When `df.grant_usage(role, include_http => false)` is called and the role still -has effective HTTP access via the PUBLIC grant (or another inherited grant), a -`WARNING` is emitted to signal that the revocation had no net effect. +Calling `df.grant_usage(role, include_http => false)` does not revoke the legacy +grant or warn about residual access. Use `has_function_privilege` to check the +role's effective permissions after changing grants. + +Endpoint support adds new functions without copying existing grants onto them. +After upgrading, run `df.grant_usage(role, include_http => true)` for roles that +need endpoint requests. Existing TEXT function OIDs and ACLs remain unchanged. ### 3.4 Admin function protection `df.grant_usage()` and `df.revoke_usage()` are admin-only functions. -`EXECUTE` is revoked from `PUBLIC` at `CREATE EXTENSION` time, so only -superusers can call them. +`EXECUTE` is revoked from `PUBLIC` at `CREATE EXTENSION` time, but administration +can be delegated. A role must have permission to call a helper, and its operations +are additionally constrained by PostgreSQL's native grant authority because the +helpers run as `SECURITY INVOKER`. + +`df.grant_usage(..., with_grant => true)` grants privileges with `WITH GRANT +OPTION`, including execution of the grant/revoke helpers. Such a delegated admin +can grant only privileges it has authority to grant; execution permission alone +does not confer the extension owner's privileges. -> **Caution:** `df.grant_usage()` internally runs -> `GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA df`, which temporarily includes -> `df.grant_usage()` and `df.revoke_usage()` themselves before the function -> immediately revokes them from the target role. If an admin replicates the -> blanket `GRANT` manually without the matching `REVOKE`s, the target role -> will gain access to these admin helpers. Always use `df.grant_usage()` -> rather than hand-crafting the equivalent `GRANT` statements. +`df.grant_usage` issues explicit schema, table and sensitive-function grants. +It does not use a blanket function grant followed by revocations. When granting +HTTP access, the caller must be able to grant the complete HTTP function set; otherwise the +call fails rather than silently skipping the HTTP grant. ### 3.5 Feature-flag interaction @@ -192,6 +226,81 @@ remains compiled in and still runs before any network activity. --- +### 3.6 Endpoint requests + +`df.endpoint` returns the native `df.http_endpoint` type. TEXT arguments to the +HTTP constructors are not decoded as endpoint references. An endpoint value does +not grant authority. Normal and multipart activities +first check their existing HTTP function grant, then resolve the foreign server +and the submitting role's user mapping on a connection authenticated as that role. +Server `USAGE` is mandatory. Catalogs are in the control database selected by +`pg_durable.database`, regardless of the workflow's SQL target. Caller-supplied +database/identity fields in node JSON cannot select another credential catalog or +override the trusted submitting identity. + +One request uses one read-only `REPEATABLE READ` snapshot for endpoint configuration +and all referenced mappings, including bindings from other servers. Catalog rows +are reused within the attempt, not cached across attempts. This prevents atomic +catalog updates from producing mixed destination/credential generations. The +caller connection acquires the same admission slot as SQL execution and is closed, +releasing the slot, before network I/O. Requests without catalog references open +no caller connection. + +Server owners must be trusted with credentials sent through their endpoints: +changing a destination can redirect subsequent authenticated requests, even when +the owner's catalog view cannot reveal the caller's mapping values. + +Path composition preserves the base URL's authority and path prefix. Traversal, +protocol-relative paths and encoded path separators are rejected after variable +substitution as well as at construction. The final URL, including any credential +query parameters, passes the same scheme, allow-list and DNS protections as a raw +URL. Endpoint requests cannot supply `Host`, duplicate a configured credential +header, or override credential query parameter names. Headers carrying resolved +credentials are added only after destination validation. + +Only the server name and path template enter node configuration and recorded +request inputs. Resolved credentials stay within the activity. Request diagnostics +redact the composed URL; response echoes and response secrets remain outside that +guarantee. Request bodies are not scanned for secret markers. See +[Calling an Endpoint](../USER_GUIDE.md#calling-an-endpoint) for the API and catalog +requirements. + +--- + +### 3.7 Explicit secret bindings + +`df.secret(server, key)` returns a JSONB reference, not a value or an embeddable +marker. Only named header/query/form slots in `secret_bindings` interpret these +references. Ordinary request fields, literal `form_fields`, multipart bytes and +resolved strings are never searched for secret markers. Binding maps are trusted +workflow configuration, not untrusted payload data. Credential-bearing destinations +must also be trusted; a reference's server name selects the credential namespace, +not a restriction on which destination can receive it. + +Activities validate field shapes, reject conflicts with ordinary fields and +endpoint authentication, and resolve each referenced server under `submitted_by` +in the request's control-database snapshot after destination policy checks. +Server `USAGE` and a caller-owned mapping are +required even for `auth_scheme 'none'`. With that scheme, a named-secret-only +server may omit `base_url`; endpoint requests fail without it. A supplied URL +still undergoes the standard validation. Named values come only from individual +`"secret."` user-mapping options, not ambient identity, endpoint-authentication +options or server options. The prefix is a credential namespace, not an instruction +to interpret the value. Native `ADD`, `SET` and `DROP` update one credential +without rewriting unrelated options. + +Header values are validated and marked sensitive. Query/form names and values +are form-urlencoded; query insertion cannot change the destination authority. +Form mode owns body framing/content type, rejects raw body/multipart combinations, +and leaves ordinary field values literal. Missing secrets fail without fallback +or values in error messages. Request URL diagnostics are redacted after secret +query insertion; response credentials remain outside this guarantee. + +See [Explicit Secret Bindings](../USER_GUIDE.md#explicit-secret-bindings) for API +examples and deferred general-composition cases. + +--- + ## 4. Layer 1: IP Blocklist (SSRF protection) ### 4.1 Blocked IPv4 ranges @@ -390,7 +499,7 @@ endpoint can echo them in its response. | Scenario | Message | |----------|---------| -| No EXECUTE privilege on df.http() | `Blocked: role '{role}' does not have EXECUTE privilege on df.http(). Grant EXECUTE ON FUNCTION df.http(text,text,text,jsonb,integer) TO {role} to allow HTTP requests.` | +| No HTTP EXECUTE privilege | `Blocked: role '{role}' does not have EXECUTE privilege on {function}() for this request.` The error identifies the required signature and recommends `df.grant_usage` with `include_http => true`. | | HTTP disabled (no feature) | `Blocked: outbound HTTP requests are disabled. Rebuild with the 'http-allow-azure-domains' Cargo feature to enable them.` | | Plaintext HTTP in a restricted build | `Blocked: plaintext HTTP is not permitted in restricted builds. HTTPS is required.` | | Unsupported scheme | `Blocked: unsupported URL scheme. Only {allowed} is allowed.` where `{allowed}` is `https` in restricted builds or `http and https` with `http-allow-all` | diff --git a/docs/rls.md b/docs/rls.md index 433d0684..3d2d1f88 100644 --- a/docs/rls.md +++ b/docs/rls.md @@ -46,7 +46,7 @@ RLS solves this: keep the DML grants (users need them for the SPI calls inside ` |----------------|-----| | `df._worker_epoch` | Internal sentinel; users should not have access at all (no GRANT) | | `duroxide.*` tables | Internal runtime state; accessed only by the background worker's pooled connection (worker role). Users should not have direct access. The monitoring functions (`df.list_instances()`, `df.metrics()`, etc.) access these via a dedicated sqlx pool authenticated as the worker role, not via SPI-as-calling-user | -| `df.secrets` | Not yet implemented; when it lands, it should be admin-only (no user SELECT, no RLS — just REVOKE) | +| HTTP endpoint servers and user mappings | Native PostgreSQL privileges and catalog-view masking, not extension RLS. A mapped role with server `USAGE` can read its own credential options. This supersedes the proposed `df.secrets` table; see [Endpoint Credentials](spec-security-model.md#44-endpoint-credentials). | ### Functions that need RLS-aware data access diff --git a/docs/security-review/security-review.md b/docs/security-review/security-review.md index 8c5927c5..c492bfab 100644 --- a/docs/security-review/security-review.md +++ b/docs/security-review/security-review.md @@ -7,6 +7,10 @@ **Reviewer**: Security Review Agent (SDL methodology) **Companion**: [ThreatModelDFD.md](ThreatModelDFD.md) | [threat-model.tm7](threat-model.tm7) +> **Follow-up (2026-09-10):** Status tables below are historical. HTTP opt-in grants and execution-time privilege checks are implemented; see [HTTP Security](../http-security.md) for current behavior. +> +> Recommendation 8 follows the [endpoint credential design](../spec-security-model.md#44-endpoint-credentials), replacing the `df.secrets` table recommendation with per-role foreign-server/user-mapping references resolved inside HTTP activities. This is not encryption or a guarantee that callers cannot read their own mapping values. Response credentials remain a separate exposure path. + --- ## 1. Executive Summary @@ -255,7 +259,7 @@ The extension demonstrates strong security design for its core threat model: | # | Recommendation | Effort | Related Finding | |---|---|---|---| | 7 | **Document TLS requirements**: Add production deployment guide requiring TLS on the PostgreSQL wire protocol | Low | I-5 | -| 8 | **Credential separation for HTTP headers**: Store auth tokens separately from df.nodes query column (future df.secrets table) | High | I-4 | +| 8 | **Credential separation for HTTP requests**: [Endpoint and secret references](../spec-security-model.md#44-endpoint-credentials), resolved inside HTTP activities using per-role user mappings (updated 2026-09-10) | High | I-4 | | 9 | **Centralized audit log table**: Create df.audit_log for security-relevant events (SSRF blocks, auth failures, cancellations) | Medium | R-3 | | 10 | **REVOKE EXECUTE on df.* from PUBLIC**: Default to a `df_user` role; require explicit GRANT | Low | E-4 | | 11 | **Add SAST scanning to CI**: Integrate cargo-audit and/or cargo-deny for supply chain and vulnerability scanning | Low | — | diff --git a/docs/spec-http-function-permissions.md b/docs/spec-http-function-permissions.md index b5611fe3..46ed80e1 100644 --- a/docs/spec-http-function-permissions.md +++ b/docs/spec-http-function-permissions.md @@ -1,10 +1,11 @@ # Spec: df.http() Function Permissions -> **Status:** Implemented (see implementation plan below). -> This spec is a retroactive design record for the changes landed in -> PR #100. Once `docs/http-security.md` has been updated to reflect this -> design, this file can be deleted — `http-security.md` is the authoritative -> reference. +> **Status:** Historical design record for PR #100. +> [HTTP Security](http-security.md#3-layer-0-postgresql-privilege-check) is the +> authoritative description of current behavior. The blanket-grant/revoke and +> residual-warning sketches below are obsolete: current helpers use explicit, +> additive grants and support delegated administration through native grant +> options. The original bypass analysis and review history are retained here. --- diff --git a/docs/spec-http-support.md b/docs/spec-http-support.md index ec49c07d..13132033 100644 --- a/docs/spec-http-support.md +++ b/docs/spec-http-support.md @@ -1,5 +1,10 @@ # Spec: HTTP Support for pg_durable +> **Historical design sketch; credential proposal superseded (2026-09-10).** +> `df.http` and `df.http_multipart` are implemented; use the [API reference](api-reference.md) and [HTTP security documentation](http-security.md) for current behavior. The `df.azure` and `df.secrets` examples below are unimplemented proposals, not current APIs. +> +> [Endpoint credentials](spec-security-model.md#44-endpoint-credentials) use foreign servers, user mappings and inert reference helpers. Do not copy the construction-time key lookup below: passing its resolved key to `df.http` puts the credential back into the graph and durable history. Resolution belongs inside the HTTP activity. + ## Overview Add `df.http()` as a new node type that makes HTTP requests as a durable activity. This automatically enables Azure Functions, webhooks, external APIs, and any HTTP service. @@ -84,6 +89,8 @@ SELECT df.start( ### 1. Secrets Table +**Superseded:** This DDL records the old proposal. The per-role credential store uses native user mappings, with the readability and authorization boundaries described in [Section 4.4 of the security spec](spec-security-model.md#44-endpoint-credentials). + ```sql -- Add to extension_sql! in lib.rs CREATE TABLE IF NOT EXISTS df.secrets ( @@ -762,6 +769,8 @@ DROP TABLE _http_test; ## Checklist +*Note: This is the historical implementation checklist, not the current work plan.* + - [ ] Add `df.secrets` table to `extension_sql!` - [ ] Add `HttpConfig` to `src/types.rs` - [ ] Add `df.http()` to `src/dsl.rs` diff --git a/docs/spec-security-model.md b/docs/spec-security-model.md index 7cf67e9f..26bb6cfa 100644 --- a/docs/spec-security-model.md +++ b/docs/spec-security-model.md @@ -3,7 +3,9 @@ **Status**: Implementation in progress **Authors**: pg_durable Team **Created**: 2025-12-25 -**Last Updated**: 2026-03-11 +**Last Updated**: 2026-09-10 + +The endpoint credential design in [Section 4.4](#44-endpoint-credentials) supersedes the earlier `df.secrets` table proposal. See [HTTP Security](http-security.md) for HTTP access controls. --- @@ -74,7 +76,7 @@ The security guarantee is: **only superusers can install the extension**, theref - **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) +- **NG4**: Cancelling an already-running HTTP request immediately on privilege revocation. HTTP permissions are checked against the live catalog before each execution attempt; a previously granted workflow does not retain HTTP access indefinitely. --- @@ -96,8 +98,8 @@ The security guarantee is: **only superusers can install the extension**, theref |--------|----------|--------|-------| | **T8**: SSRF via HTTP Activity | **CRITICAL** | Implemented | Dataplane protection — see [http-security.md](http-security.md) | | **T4**: Information Disclosure via df.* Tables | **HIGH** | Implemented | RLS on `df.instances` and `df.nodes` — see [rls.md](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 | +| **T9**: Unauthorized HTTP Access | **HIGH** | Partially implemented | Opt-in function grants and execution-time checks; endpoint access requires server `USAGE` | +| **T11**: Credential Exposure | **HIGH** | In progress | Endpoint credentials and secret references; see Section 4.4 | | **T10**: Cross-User Variable Injection | **MEDIUM-HIGH** | Implemented | Per-user `df.vars` scoping via `owner` column + RLS — see [rls.md](rls.md) | | **T5**: Denial of Service | **MEDIUM** | Not implemented | Rate limiting; deferred | | **T6**: Worker Code Vulnerability | **MEDIUM** | Mitigated by design | Relies on code review | @@ -265,7 +267,7 @@ See [http-security.md](http-security.md) for the full specification, blocked IP **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)` +- DBA grants HTTP access explicitly with `df.grant_usage(role, include_http => true)`. - The worker re-checks `EXECUTE` at execution time to block raw `df.start()` JSON injection - Audit logging records HTTP attempts @@ -289,18 +291,21 @@ See [http-security.md](http-security.md) for the full specification, blocked IP --- -#### T11: Secret Exfiltration via df.secrets +#### T11: Credential Exposure in Durable HTTP Workflows -**Severity**: HIGH | **Status**: Not implemented (additive feature) +**Severity**: HIGH -**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. +**Threat**: Credentials supplied in HTTP request configuration can be copied into `df.nodes`, durable history and logs. Putting them in `df.vars` does not solve this: variables are captured in workflow state and substituted before the HTTP activity is scheduled. **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 +- Persist endpoint and secret references, not resolved credentials, in request configuration and activity inputs. +- Resolve references only inside the HTTP or multipart activity, after authorization, using a connection authenticated as `submitted_by`. +- Never resolve credentials during graph construction, in orchestration code, or in a separate activity that returns a credential as its result. +- Exclude resolved credentials from request diagnostics and errors. Response bodies and headers require their own handling; an endpoint can echo a request credential. + +**Boundary**: Per-role user mappings isolate credentials from other ordinary roles, but the mapped role with server `USAGE` can read its own values. This is credential-persistence reduction, not a guarantee that callers can use an admin's shared secret without reading it. See [Section 4.4](#44-endpoint-credentials). -**Residual Risk**: Medium (by design secrets are high-impact); mitigated by least-privilege, auditing, and never exposing plaintext to users. +**Residual Risk**: Mapping values remain plaintext in PostgreSQL catalogs and backups. Literal credentials and credentials returned by remote services remain separate exposure paths. Neither RLS nor user mappings protect against superusers. --- @@ -420,35 +425,35 @@ pg_durable supports workflow variables via `df.setvar()/df.getvar()/df.unsetvar( --- -### 4.4 Shared Secrets (df.secrets) +### 4.4 Endpoint Credentials -pg_durable supports shared secrets for workflows. +**Intent**: Keep request credentials out of durable workflow state. An endpoint is a PostgreSQL `FOREIGN SERVER` using a handler-less `pg_durable_fdw`; per-role credentials are `USER MAPPING` options. This replaces the proposed `df.secrets` table and `df.setsecret`/`df.unsetsecret`/`df.clearsecrets` API for this work. -**Intent**: Provide admin-managed secrets (API keys, bearer tokens, shared credentials) that workflows can reference without embedding secrets in the function graph. +**API**: +- `df.endpoint(server text, path text)` returns a native `df.http_endpoint` composite containing the server name and path template. Both `df.http` and `df.http_multipart` accept it. TEXT destinations remain URLs and cannot implicitly select endpoint credentials. +- `df.secret(server text, key text)` constructs a JSONB descriptor with `server` and `key`, interpreted only in explicit `secret_bindings.headers`, `.query` or `.form` slots supplied through `df.with_http_options`. It never returns a credential. Header descriptors may include a literal `prefix`. +- Neither helper reads endpoint configuration or credentials. Ordinary data is not searched for descriptors or secret markers. -**Key security property**: Secrets are **usable** by workflows but are **not directly readable** by non-admin users. +**Catalog options**: Servers require `auth_scheme`; `base_url` may be omitted only with `auth_scheme 'none'` for named-secret storage. A supplied URL retains all validation requirements, and HTTP endpoint execution always requires one. `header_name` is required only for header authentication. Schemes are `none` (no mapping required for endpoint authentication), `bearer` (`token` mapping option), `header` (`header_value`), and `query` (`query_string`). Named bindings read individual `"secret."` mapping options, allowing native per-key addition, rotation and removal without rewriting other credentials. Values are opaque text, not JSON. The prefix is accepted only in user mappings and confers no endpoint-authentication or ambient-identity behavior; server options remain closed. See [Endpoint Credential Catalog](../USER_GUIDE.md#endpoint-credential-catalog). FDW creation authority uses a native grant separate from `df.grant_usage`. -**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 +**Body handling**: Explicit form-urlencoded fields are supported; general templates, JSON-body insertion and secret-valued multipart parts are deferred. `form_fields` contains literal strings and `secret_bindings.form` contains references. Both sets are serialized inside the activity without interpreting markers, descriptors or workflow placeholders in ordinary values. Whole-body marker scanning remains unsafe even when opted in, because untrusted data concatenated during graph construction is indistinguishable from intentional references. -**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}`. +**Resolution and authorization**: +- The activity derives the required HTTP function from the actual destination/body mode and re-checks `EXECUTE`, then checks `USAGE` on every referenced server for `submitted_by`. Hand-crafted node JSON must pass the same checks as helper-produced requests. The grant/revoke helpers cover all HTTP variants; binding configuration itself must come from trusted workflow code, not untrusted request data. +- Credential lookup uses `pg_user_mappings` over `connect_as_user(submitted_by)`, never the worker's privileged pool. Missing servers, mappings, options or masked values fail explicitly; there is no fallback to another role's mapping or an unauthenticated request. +- Catalogs live in the control database where the extension is installed, independently of the workflow's SQL target. One read-only consistent snapshot supplies endpoint configuration and all named bindings for an attempt. The caller connection uses the shared SQL/catalog admission budget and closes before HTTP I/O; subsequent attempts start fresh snapshots. +- Compose the destination without allowing a path or substituted value to replace the server's authority. Apply scheme, allow-list and SSRF checks to the actual destination, and keep redirects disabled. Secret substitutions in URL components require context-appropriate encoding. +- Resolved credentials exist only within the executing HTTP activity. Do not put them in orchestration inputs, activity results or request diagnostics. Log the submitting role and reference names rather than resolved values. +- Secret resolution is an HTTP request feature, not general SQL substitution. Managed-identity tokens belong to endpoint authentication, not the named-secret resolver. Binding field names and reference metadata remain fixed; ordinary form strings are not runtime templates. -**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. +**Readability and lifetime**: +- A mapped role with server `USAGE` can read its own credential options through PostgreSQL's [pg_user_mappings view](https://www.postgresql.org/docs/17/view-pg-user-mappings.html). Core masking protects other roles' mappings; it does not hide a role's own credentials from that role. +- `PUBLIC` mappings are unsupported by this per-user resolution design: server `USAGE` alone does not make their options visible to ordinary grantees. +- Resolve against current permissions and mapping values on each attempt. Rotation affects the next resolution without rewriting workflow history; it does not change a request already sent or a completion replayed from history. -**Audit**: -- Log secret *name* usage for traceability (never log values). +**Limits**: Literal credentials supplied outside this mechanism remain unsafe. A response may contain a retrieved or echoed secret, so request-side resolution alone cannot promise that no secret ever appears in results or logs. Secret-bearing paths need diagnostic protection after resolution; the current URL redactor preserves paths. See [HTTP redaction coverage](http-security.md#71-url-redaction). -**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. +The authorization baseline accepts caller-readable mapping credentials ([OQ5](#oq5-opaque-shared-http-credentials)) and uses existing HTTP function grants plus server `USAGE` ([OQ6](#oq6-endpoint-only-http-access)). Opaque shared credentials and endpoint-only access are outside this work. --- @@ -523,7 +528,7 @@ See [Section 8: Implementation Specification](#8-implementation-specification) f HTTP requests are guarded by PostgreSQL function privileges plus runtime SSRF defenses. In the current implementation, security is enforced via: -1. **Function-level permission**: `GRANT/REVOKE EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer)` +1. **Function-level permission**: managed through `df.grant_usage` and `df.revoke_usage` for the full HTTP function set 2. **Execution-time privilege re-check**: the worker validates that `submitted_by` still has `EXECUTE` before any network activity 3. **SSRF protection**: Block internal IPs at the code level 4. **Compile-time endpoint allowlist**: allowed destinations depend on the HTTP Cargo feature @@ -536,11 +541,7 @@ HTTP requests are guarded by PostgreSQL function privileges plus runtime SSRF de │ │ │ 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 │ @@ -565,7 +566,6 @@ HTTP requests are guarded by PostgreSQL function privileges plus runtime SSRF de ```sql -- 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); @@ -650,7 +650,7 @@ SELECT df.start( - **Safety notes**: - For control-plane connections, prefer socket path (not `127.0.0.1`) for `peer` to 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. + - This model still assumes trusted extension code. User SQL uses `execute_sql`'s per-user connection; HTTP credential resolution must use the same authenticated-user boundary for catalog access, not the worker's ambient privileges. - If sockets/`peer` are 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. --- @@ -691,46 +691,35 @@ See [rls.md](rls.md) for the full design including: Per-user scoping of `df.vars` (adding an `owner` column + RLS) is deferred to a follow-up PR. See [rls.md, Decision 5](rls.md) for the design. -#### df.secrets Table (admin-managed, workflow-usable) +#### Endpoint Catalog Objects -`df.secrets` stores shared secrets that are referenced by workflows but not directly readable by non-admins. +Per-role HTTP credentials use native user mappings, not a `df.secrets` table. The extension installs `pg_durable_fdw`, its option validator and reference helpers; endpoint owners manage foreign servers and user mappings using native PostgreSQL DDL. The validator must ship with the wrapper and reject unsupported options and authentication schemes. Managed identity must remain unavailable until its authorization controls are implemented. -```sql -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 -); +Credential values remain plaintext in catalogs, WAL and backups. Superuser dumps include user-mapping credentials; dumps by less privileged roles can omit mapping options. Document and test backup/restore behavior. Dropping the extension with `CASCADE` can remove dependent endpoints and mappings. Supplying literal credentials in mapping DDL can also expose them through statement logging. --- 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; -``` +#### Upgrade & Migration -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`. +- Add the FDW, validator, helpers and their grants to the extension upgrade script as well as fresh installation. Existing HTTP function signatures and grants must remain intact. +- The new binary must work against all previous schemas in the same major version without requiring `ALTER EXTENSION UPDATE`. Detect unavailable endpoint objects when a reference is used and fail clearly; existing raw-URL workflows must continue working on older schemas. +- Preserve the serialized activity inputs and durable operation sequence for existing workflows. Add endpoint handling without changing the representation of legacy HTTP nodes. +- Extend upgrade comparisons to cover FDW/validator definitions and grants, not just objects in the `df` schema. Include representative customer servers and mappings in upgrade tests. ### 8.2 Function Permissions (Extension Installation) ```sql -- Called during CREATE EXTENSION pg_durable --- Default: all df functions require explicit grant -REVOKE ALL ON ALL FUNCTIONS IN SCHEMA df FROM PUBLIC; +-- Ordinary helpers retain PUBLIC EXECUTE; schema USAGE is their access gate. +-- Sensitive functions have PUBLIC EXECUTE revoked and are granted explicitly. -- 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; +REVOKE EXECUTE ON FUNCTION df.http(df.http_endpoint, text, text, jsonb, integer) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION df.http_multipart(text, text, jsonb, jsonb, integer) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION df.http_multipart(df.http_endpoint, text, jsonb, jsonb, integer) FROM PUBLIC; -- Convenience helper: grant standard df usage, excluding df.http() unless -- include_http => true is passed. @@ -740,7 +729,7 @@ REVOKE EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) FROM PUBLIC -- 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 +-- Endpoint helpers construct references; HTTP activities enforce access (Section 4.4). ``` ### 8.3 Earlier GUC Proposal (Not Implemented) @@ -1769,9 +1758,14 @@ These tests validate behavior when `execute_sql` fails due to expected errors an **Secrets tests (design-level)** -- Verify non-admin cannot `EXECUTE df.setsecret/df.unsetsecret/df.clearsecrets` -- Verify non-admin cannot `SELECT` from `df.secrets` -- Verify workflows can reference a secret by name and the secret value is not returned/logged +- Verify `df.endpoint` and `df.secret` construct references without looking up credentials; test concatenation, escaping and malformed references. +- Verify a mapped role with server `USAGE` can read its own options, other ordinary roles cannot read them, and unsupported `PUBLIC` mappings produce an explicit error. +- Verify HTTP function and server permissions are checked on each attempt, including hand-crafted node JSON, forged references and grants revoked after submission. +- Verify missing or masked credentials fail without privileged fallback or silently sending an unauthenticated request. +- Verify rotation changes the next attempt's credential without changing recorded request configuration. Resolved values containing placeholder-like text must not trigger recursive expansion. +- With a non-echoing test endpoint, inspect node configuration, durable inputs and request errors/logs for plaintext and encoded sentinel credentials. Cover URL paths, queries, headers and both ordinary and multipart HTTP. +- Test response echo/retrieval separately against the selected response policy; do not use request-side non-persistence as evidence of response secrecy. Verify SQL nodes do not expand secret markers. +- Verify legacy HTTP replay inputs and older-schema execution remain unchanged, and test fresh-install/upgrade parity for the new catalog objects and grants. --- @@ -1831,6 +1825,22 @@ These tests validate behavior when `execute_sql` fails due to expected errors an --- +### OQ5: Opaque Shared HTTP Credentials + +**Question**: Must an administrator be able to provide credentials that workflows can use but their callers cannot read? + +**Resolution (2026-09-10)**: No. Callers may read their own mapping credentials. Opaque shared secrets are outside this work. An admin-only table plus unrestricted SQL/HTTP substitution would not provide that stronger property anyway: callers could return the substituted value or send it to a destination they control. + +--- + +### OQ6: Endpoint-Only HTTP Access + +**Question**: Must roles be able to call approved endpoints without permission to use raw-URL HTTP? + +**Resolution (2026-09-10)**: Use existing HTTP function grants plus server `USAGE`. `df.endpoint` composes with the existing HTTP constructors; it does not remove the role's ability to make raw-URL requests allowed by HTTP policy. Endpoint-only authorization is outside this work. + +--- + ## 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) diff --git a/docs/upgrade-testing.md b/docs/upgrade-testing.md index 116d5c90..2669ee40 100644 --- a/docs/upgrade-testing.md +++ b/docs/upgrade-testing.md @@ -203,7 +203,41 @@ gate, so they never need to be added to the exclude list. Each schema-changing PR should add a section here documenting what changed, what the upgrade script handles, and any backward compatibility considerations. -### 0.2.8 +### v0.2.7 → v0.2.8 + +#### Typed HTTP endpoints + +- Adds composite type `df.http_endpoint(server text, path text)`, + `df.endpoint(text, text) RETURNS df.http_endpoint`, and typed destination + variants of both HTTP constructors. The existing TEXT signatures, wrapper + symbols, OIDs, ACLs, and dependent views are preserved. No implicit TEXT cast + is installed; TEXT constructor arguments remain raw URLs. +- Endpoint nodes add a fixed `endpoint` server name and use + `url` for the path template; only these nodes receive the trusted target + `database` in activity inputs. Existing raw-URL nodes retain their serialized + inputs and activity names. Both HTTP activities resolve credentials locally, + using the same endpoint preparation and validation rules. +- Adds the handler-less `pg_durable_fdw` and + `df.endpoint_option_validator(text[], oid)` in fresh and upgraded schemas. + FDW `USAGE` is not granted to `PUBLIC` or by `df.grant_usage`; administrators + delegate creation with a native FDW grant. The catalog resolver uses only + native catalogs, verifies extension ownership of the wrapper, and reports + unavailable endpoint support without changing legacy workflow execution. +- Upgrade snapshots include FDW ownership, handler/validator, extension + membership and ACLs, the composite type's fields, plus endpoint server and mapping metadata. Mapping + credential values are excluded. B2 exercises delegated server/mapping DDL + after upgrade. +- Fresh and upgraded schemas revoke PUBLIC EXECUTE on the typed HTTP functions. + `CREATE OR REPLACE` updates `df.grant_usage` and `df.revoke_usage` to cover URL + and endpoint requests while retaining the helper OIDs and grants. Existing + HTTP grants are not automatically copied to new functions: run + `df.grant_usage(role, include_http => true)` after upgrade to enable endpoints. +- B1 raw HTTP requests keep checking only existing catalog functions; missing + endpoint functions fail closed. Activity names, scheduling and existing raw + request bytes are unchanged. B2 verifies typed construction, helper grant/revoke + coverage, and preservation of the original HTTP OIDs/ACLs. + +#### Loop failure continuation - `sql/pg_durable--0.2.7--0.2.8.sql` renames `df.loop(text, text)` to `df._loop_legacy(text, text)`, preserving its function OID and dependent @@ -233,6 +267,32 @@ what the upgrade script handles, and any backward compatibility considerations. binary, which continues toward the higher backstop instead. Drain such long-running loops before upgrade when continuity is required. +#### Add explicit secret bindings + +- Adds `df.secret(text, text) RETURNS jsonb` in fresh and upgraded schemas and + accepts individual `"secret."` user-mapping options. Only explicitly configured nodes + gain binding/form fields and trusted target-database metadata. Existing HTTP + signatures, grants and legacy raw-URL activity inputs remain unchanged. +- Named credential lookup uses native catalogs, checks server `USAGE` and reads + the authenticated caller's mapping in the control database, independently of + the SQL target. Endpoint and named-binding reads share one read-only consistent + snapshot and the existing user-connection budget, released before HTTP I/O. + No additional DDL, grant changes or replay-visible activity inputs are needed + for catalog snapshot or connection admission. Missing endpoint schema support or named + keys fails explicitly, without changing legacy requests on older schemas. +- Named credentials use native `ADD`, `SET` and `DROP`; the B2 catalog test + verifies that these preserve unrelated named values and endpoint-auth options. +- Servers using `auth_scheme 'none'` may omit `base_url` for named-secret storage; + HTTP endpoint execution still requires a URL. This validator rule needs no + additional upgrade DDL and leaves existing server definitions valid. The B2 + probe covers URL-less creation and removal of a URL when switching to `none`. + +#### Add `df.with_http_options()` +- **DDL change:** Adds `df.with_http_options(fut text, options jsonb) RETURNS text`. The input must be a single `HTTP` or `HTTP_MULTIPART` node. SQL `NULL` and `{}` preserve input bytes; `secret_bindings` and `form_fields` configure references and literal form data. Other values and unsupported keys raise an error. +- **Upgrade script:** [sql/pg_durable--0.2.7--0.2.8.sql](../sql/pg_durable--0.2.7--0.2.8.sql) adds this helper without replacing the existing HTTP functions. The new helper uses the same schema-access and default PUBLIC `EXECUTE` model as other combinators; it does not grant HTTP access. +- **Scenario A considerations:** The added function matches pgrx-generated fresh-install SQL, including argument names, null handling and the `with_http_options_wrapper` C symbol. +- **Scenario B1 considerations:** The new helper remains absent until `ALTER EXTENSION UPDATE`. The new `.so` exports `with_http_options_wrapper`; existing HTTP function signatures, C symbols, OIDs and ACLs are unchanged. + ### v0.2.6 → v0.2.7 #### Transaction-aware graph admission diff --git a/scripts/test-upgrade.sh b/scripts/test-upgrade.sh index bd04f63f..33005d14 100755 --- a/scripts/test-upgrade.sh +++ b/scripts/test-upgrade.sh @@ -495,6 +495,61 @@ create_extension_at_version() { # ordinal_position) don't cause spurious diffs between the upgrade and # fresh-install snapshots. SCHEMA_QUERY=" +SELECT 'fdw' AS obj_type, + wrapper.fdwname, + pg_catalog.pg_get_userbyid(wrapper.fdwowner), + wrapper.fdwhandler::pg_catalog.regprocedure::text, + wrapper.fdwvalidator::pg_catalog.regprocedure::text, + wrapper.fdwoptions::text, + EXISTS ( + SELECT 1 FROM pg_catalog.pg_depend dependency + JOIN pg_catalog.pg_extension extension ON extension.oid = dependency.refobjid + WHERE dependency.classid = 'pg_catalog.pg_foreign_data_wrapper'::pg_catalog.regclass + AND dependency.objid = wrapper.oid + AND dependency.refclassid = 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependency.deptype = 'e' AND extension.extname = 'pg_durable' + )::text +FROM pg_catalog.pg_foreign_data_wrapper wrapper +WHERE wrapper.fdwname = 'pg_durable_fdw'; + +SELECT 'grant_fdw', wrapper.fdwname, + CASE WHEN privilege.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(privilege.grantee) END, + privilege.privilege_type, privilege.is_grantable::text, + pg_catalog.pg_get_userbyid(privilege.grantor), '' +FROM pg_catalog.pg_foreign_data_wrapper wrapper +CROSS JOIN LATERAL pg_catalog.aclexplode(COALESCE(wrapper.fdwacl, pg_catalog.acldefault('F', wrapper.fdwowner))) privilege +WHERE wrapper.fdwname = 'pg_durable_fdw' +ORDER BY 2, 3, 4, 6; + +SELECT 'endpoint_server', server.srvname, + pg_catalog.pg_get_userbyid(server.srvowner), + server.srvtype, server.srvversion, + ARRAY(SELECT option_name || '=' || option_value FROM pg_catalog.pg_options_to_table(server.srvoptions) ORDER BY option_name)::text, '' +FROM pg_catalog.pg_foreign_server server +JOIN pg_catalog.pg_foreign_data_wrapper wrapper ON wrapper.oid = server.srvfdw +WHERE wrapper.fdwname = 'pg_durable_fdw' +ORDER BY server.srvname; + +SELECT 'grant_endpoint_server', server.srvname, + CASE WHEN privilege.grantee = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(privilege.grantee) END, + privilege.privilege_type, privilege.is_grantable::text, + pg_catalog.pg_get_userbyid(privilege.grantor), '' +FROM pg_catalog.pg_foreign_server server +JOIN pg_catalog.pg_foreign_data_wrapper wrapper ON wrapper.oid = server.srvfdw +CROSS JOIN LATERAL pg_catalog.aclexplode(COALESCE(server.srvacl, pg_catalog.acldefault('S', server.srvowner))) privilege +WHERE wrapper.fdwname = 'pg_durable_fdw' +ORDER BY 2, 3, 4, 6; + +SELECT 'endpoint_mapping', server.srvname, + CASE WHEN mapping.umuser = 0 THEN 'PUBLIC' ELSE pg_catalog.pg_get_userbyid(mapping.umuser) END, + ARRAY(SELECT option_name FROM pg_catalog.pg_options_to_table(mapping.umoptions) ORDER BY option_name)::text, + '', '', '' +FROM pg_catalog.pg_user_mapping mapping +JOIN pg_catalog.pg_foreign_server server ON server.oid = mapping.umserver +JOIN pg_catalog.pg_foreign_data_wrapper wrapper ON wrapper.oid = server.srvfdw +WHERE wrapper.fdwname = 'pg_durable_fdw' +ORDER BY 2, 3; + -- Tables and columns (ordinal_position renumbered to avoid dropped-column gaps) SELECT 'column' AS obj_type, c.table_name, @@ -529,11 +584,20 @@ WHERE n.nspname = 'df' AND NOT EXISTS ( SELECT 1 FROM pg_class c - WHERE c.reltype = t.oid + WHERE c.reltype = t.oid AND c.relkind <> 'c' ) GROUP BY t.typname, t.typtype, t.typbasetype, t.typtypmod ORDER BY t.typname; +SELECT 'composite_field', t.typname, a.attname, + pg_catalog.format_type(a.atttypid, a.atttypmod), a.attnum::text +FROM pg_catalog.pg_type t +JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace +JOIN pg_catalog.pg_class c ON c.oid = t.typrelid AND c.relkind = 'c' +JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid +WHERE n.nspname = 'df' AND a.attnum > 0 AND NOT a.attisdropped +ORDER BY t.typname, a.attnum; + -- Constraints -- -- Read from pg_constraint so CHECK bodies (pg_get_constraintdef) and the @@ -778,6 +842,17 @@ test_b1_conditional_loop() { assert_sql_contains "SELECT df.loop('SELECT 1', 'SELECT false');" '"node_type":"LOOP"' } +test_b1_http_construction() { + assert_sql_contains "SELECT df.http('https://api.github.com/');" '"node_type":"HTTP"' && + assert_sql_equals \ + "SELECT (df.http('https://api.github.com/', 'GET', NULL, NULL, 7)::jsonb->>'query')::jsonb->>'timeout_seconds';" \ + "7" +} + +test_b1_http_options_absent() { + assert_sql_equals "SELECT to_regprocedure('df.with_http_options(text,jsonb)') IS NULL;" "t" +} + test_b1_dsl_chain() { assert_sql_contains "SELECT df.sql('SELECT 1') ~> df.sql('SELECT 2');" '"node_type":"THEN"' } @@ -909,6 +984,10 @@ else run_test "B1 [v${B1_VERSION}]: df.version()" test_b1_version run_test "B1 [v${B1_VERSION}]: df.sql() construction" test_b1_dsl_construction run_test "B1 [v${B1_VERSION}]: df.loop(body, condition)" test_b1_conditional_loop + run_test "B1 [v${B1_VERSION}]: df.http() construction" test_b1_http_construction + if ! version_ge "$B1_VERSION" "0.2.8"; then + run_test "B1 [v${B1_VERSION}]: new HTTP options helper remains absent" test_b1_http_options_absent + fi run_test "B1 [v${B1_VERSION}]: DSL chain (~>)" test_b1_dsl_chain run_test "B1 [v${B1_VERSION}]: conditional operators (?>/!>)" test_b1_conditional_operators run_test "B1 [v${B1_VERSION}]: df.start()/wait_for_completion()" test_b1_start_and_complete @@ -1030,6 +1109,129 @@ test_b2_grant_usage_after_upgrade() { run_sql_capture "DROP OWNED BY ${probe_role}; DROP ROLE IF EXISTS ${probe_role};" >/dev/null 2>&1 || true } +test_b2_http_api_after_upgrade() { + create_extension_at_version "$PREV_VERSION" + + local output + output=$(run_sql_capture " + CREATE ROLE durable_b2_http_probe; + GRANT EXECUTE ON FUNCTION df.http(text,text,text,jsonb,integer), + df.http_multipart(text,text,jsonb,jsonb,integer) + TO durable_b2_http_probe WITH GRANT OPTION; + + CREATE TEMP TABLE http_api_before AS + SELECT oid, proacl FROM pg_proc + WHERE oid IN ( + 'df.http(text,text,text,jsonb,integer)'::regprocedure, + 'df.http_multipart(text,text,jsonb,jsonb,integer)'::regprocedure + ); + CREATE TEMP VIEW http_calls_before AS + SELECT df.http('https://api.github.com/') AS http_node, + df.http_multipart('https://api.github.com/', + parts => '[{\"name\":\"field\",\"data_b64\":\"aGk=\"}]'::jsonb) AS multipart_node; + + ALTER EXTENSION pg_durable UPDATE TO '${CURRENT_VERSION}'; + + DO \$verify\$ + BEGIN + IF (SELECT count(*) FROM http_api_before) <> 2 OR EXISTS ( + SELECT 1 FROM http_api_before AS previous + LEFT JOIN pg_proc AS current ON current.oid = previous.oid + WHERE current.oid IS NULL OR current.proacl IS DISTINCT FROM previous.proacl + ) THEN + RAISE EXCEPTION 'HTTP function OIDs or ACLs changed during upgrade'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM http_calls_before + WHERE http_node::jsonb->>'node_type' = 'HTTP' + AND multipart_node::jsonb->>'node_type' = 'HTTP_MULTIPART' + AND df.with_http_options(http_node, '{}'::jsonb) = http_node + AND df.with_http_options(multipart_node, NULL) = multipart_node + ) THEN + RAISE EXCEPTION 'Legacy HTTP calls or the additive helper failed after upgrade'; + END IF; + END + \$verify\$; + + DO \$verify\$ + DECLARE + signature text; + BEGIN + IF pg_catalog.pg_typeof(df.endpoint('missing_server', '/')) <> 'df.http_endpoint'::regtype THEN + RAISE EXCEPTION 'Endpoint type was not installed'; + END IF; + FOREACH signature IN ARRAY ARRAY[ + 'df.http(df.http_endpoint,text,text,jsonb,integer)', + 'df.http_multipart(df.http_endpoint,text,jsonb,jsonb,integer)' + ] LOOP + IF pg_catalog.has_function_privilege('durable_b2_http_probe', signature, 'EXECUTE') THEN + RAISE EXCEPTION 'Upgrade implicitly granted endpoint HTTP'; + END IF; + END LOOP; + END + \$verify\$; + SELECT df.grant_usage('durable_b2_http_probe', include_http => true); + SET ROLE durable_b2_http_probe; + SELECT df.http(df.endpoint('missing_server', '/'), 'GET'); + SELECT df.http_multipart(df.endpoint('missing_server', '/'), parts => '[{\"name\":\"field\",\"data_b64\":\"aGk=\"}]'); + RESET ROLE; + SELECT df.revoke_usage('durable_b2_http_probe'); + DO \$verify\$ + DECLARE + signature text; + BEGIN + FOREACH signature IN ARRAY ARRAY[ + 'df.http(text,text,text,jsonb,integer)', + 'df.http(df.http_endpoint,text,text,jsonb,integer)', + 'df.http_multipart(text,text,jsonb,jsonb,integer)', + 'df.http_multipart(df.http_endpoint,text,jsonb,jsonb,integer)' + ] LOOP + IF pg_catalog.has_function_privilege('durable_b2_http_probe', signature, 'EXECUTE') THEN + RAISE EXCEPTION 'HTTP permission remains after revoke_usage: %', signature; + END IF; + END LOOP; + END + \$verify\$; + + DROP OWNED BY durable_b2_http_probe; + DROP ROLE durable_b2_http_probe; + ") || { echo "$output"; return 1; } +} + +test_b2_endpoint_catalog_after_upgrade() { + run_sql_capture "CREATE ROLE durable_b2_endpoint_probe LOGIN; + SELECT df.grant_usage('durable_b2_endpoint_probe');" >/dev/null || return 1 + assert_sql_equals "SELECT pg_catalog.has_foreign_data_wrapper_privilege('durable_b2_endpoint_probe', 'pg_durable_fdw', 'USAGE');" "f" || return 1 + run_sql_capture "GRANT USAGE ON FOREIGN DATA WRAPPER pg_durable_fdw TO durable_b2_endpoint_probe; + SET ROLE durable_b2_endpoint_probe; + CREATE SERVER durable_b2_endpoint FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://api.github.com', auth_scheme 'bearer'); + CREATE USER MAPPING FOR CURRENT_USER SERVER durable_b2_endpoint OPTIONS (token 'UPGRADE_SENTINEL'); + ALTER USER MAPPING FOR CURRENT_USER SERVER durable_b2_endpoint OPTIONS (SET token 'ROTATED_SENTINEL');" >/dev/null || return 1 + assert_sql_equals "SELECT fdwhandler = 0 AND fdwvalidator = pg_catalog.to_regprocedure('df.endpoint_option_validator(text[],oid)')::oid FROM pg_catalog.pg_foreign_data_wrapper WHERE fdwname = 'pg_durable_fdw';" "t" || return 1 + assert_sql_equals "SELECT umoptions = ARRAY['token=ROTATED_SENTINEL'] FROM pg_catalog.pg_user_mappings WHERE srvname = 'durable_b2_endpoint';" "t" || return 1 + run_sql_capture "ALTER USER MAPPING FOR durable_b2_endpoint_probe SERVER durable_b2_endpoint OPTIONS (ADD \"secret.key\" 'NAMED_SENTINEL', ADD \"secret.other\" 'UNCHANGED'); + ALTER USER MAPPING FOR durable_b2_endpoint_probe SERVER durable_b2_endpoint OPTIONS (SET \"secret.key\" 'ROTATED_NAMED_SENTINEL');" >/dev/null || return 1 + assert_sql_equals "SELECT umoptions @> ARRAY['token=ROTATED_SENTINEL', 'secret.key=ROTATED_NAMED_SENTINEL', 'secret.other=UNCHANGED'] FROM pg_catalog.pg_user_mappings WHERE srvname = 'durable_b2_endpoint';" "t" || return 1 + run_sql_capture "ALTER USER MAPPING FOR durable_b2_endpoint_probe SERVER durable_b2_endpoint OPTIONS (DROP \"secret.other\");" >/dev/null || return 1 + assert_sql_equals "SELECT umoptions @> ARRAY['token=ROTATED_SENTINEL', 'secret.key=ROTATED_NAMED_SENTINEL'] AND NOT (umoptions @> ARRAY['secret.other=UNCHANGED']) FROM pg_catalog.pg_user_mappings WHERE srvname = 'durable_b2_endpoint';" "t" || return 1 + run_sql_capture "SET ROLE durable_b2_endpoint_probe; + CREATE SERVER durable_b2_secrets FOREIGN DATA WRAPPER pg_durable_fdw OPTIONS (auth_scheme 'none'); + CREATE USER MAPPING FOR CURRENT_USER SERVER durable_b2_secrets OPTIONS (\"secret.key\" 'NAME_ONLY_SENTINEL'); + ALTER SERVER durable_b2_endpoint OPTIONS (SET auth_scheme 'none', DROP base_url);" >/dev/null || return 1 + assert_sql_equals "SELECT count(*) = 2 FROM pg_catalog.pg_foreign_server WHERE srvname IN ('durable_b2_secrets', 'durable_b2_endpoint') AND srvoptions = ARRAY['auth_scheme=none'];" "t" || return 1 + assert_sql_equals "SELECT umoptions = ARRAY['secret.key=NAME_ONLY_SENTINEL'] FROM pg_catalog.pg_user_mappings WHERE srvname = 'durable_b2_secrets';" "t" || return 1 + assert_sql_equals "SELECT df.secret('durable_b2_endpoint', 'key') = jsonb_build_object('server', 'durable_b2_endpoint', 'key', 'key');" "t" || return 1 + assert_sql_equals "SELECT ((df.with_http_options( + df.http('https://api.github.com/', 'POST'), + jsonb_build_object('secret_bindings', jsonb_build_object('form', jsonb_build_object('password', df.secret('durable_b2_endpoint', 'key'))), + 'form_fields', jsonb_build_object('ordinary', 'literal')) + )::jsonb->>'query')::jsonb->'secret_bindings'->'form'->'password'->>'key') = 'key';" "t" || return 1 + run_sql_capture "DROP SERVER durable_b2_endpoint, durable_b2_secrets CASCADE; + DROP OWNED BY durable_b2_endpoint_probe; + DROP ROLE durable_b2_endpoint_probe;" >/dev/null +} + if [ "$HAS_COMPAT_PREV" = true ]; then run_test "B2: Pre-upgrade data survives ALTER EXTENSION UPDATE" test_b2_data_survives_upgrade run_test "B2: Pre-upgrade instance remains queryable" test_b2_pre_upgrade_instance_after_upgrade @@ -1037,6 +1239,8 @@ if [ "$HAS_COMPAT_PREV" = true ]; then run_test "B2: Loop dependency and unified API survive upgrade" test_b2_loop_dependency_survives_upgrade run_test "B2: New data and execution after upgrade" test_b2_new_data_after_upgrade run_test "B2: df.grant_usage() works and df.debug_connection() is gone after upgrade" test_b2_grant_usage_after_upgrade + run_test "B2: HTTP OIDs, grants and dependent views survive upgrade" test_b2_http_api_after_upgrade + run_test "B2: Endpoint FDW, validator and delegated catalog DDL work after upgrade" test_b2_endpoint_catalog_after_upgrade fi # ============================================================================ diff --git a/sql/pg_durable--0.2.7--0.2.8.sql b/sql/pg_durable--0.2.7--0.2.8.sql index e23a62b6..7e7a7789 100644 --- a/sql/pg_durable--0.2.7--0.2.8.sql +++ b/sql/pg_durable--0.2.7--0.2.8.sql @@ -15,3 +15,171 @@ CREATE FUNCTION df."loop"( ) RETURNS TEXT LANGUAGE c AS 'MODULE_PATHNAME', 'loop_with_policy_wrapper'; + +-- HTTP options are additive; existing function ABIs, OIDs and ACLs stay unchanged. +CREATE FUNCTION df."with_http_options"( + "fut" TEXT, + "options" jsonb +) RETURNS TEXT +LANGUAGE c +AS 'MODULE_PATHNAME', 'with_http_options_wrapper'; + +CREATE FUNCTION df.endpoint_option_validator( + "options" pg_catalog.text[], + "catalog" pg_catalog.oid +) RETURNS pg_catalog.void +LANGUAGE c STRICT +AS 'MODULE_PATHNAME', 'endpoint_option_validator_wrapper'; + +CREATE FOREIGN DATA WRAPPER pg_durable_fdw + NO HANDLER VALIDATOR df.endpoint_option_validator; +REVOKE ALL ON FOREIGN DATA WRAPPER pg_durable_fdw FROM PUBLIC; + +CREATE TYPE df.http_endpoint AS (server pg_catalog.text, path pg_catalog.text); + +CREATE FUNCTION df.endpoint("server" pg_catalog.text, "path" pg_catalog.text) +RETURNS df.http_endpoint +LANGUAGE c IMMUTABLE STRICT PARALLEL SAFE +AS 'MODULE_PATHNAME', 'endpoint_wrapper'; + +CREATE FUNCTION df.http( + "url" df.http_endpoint, + "method" pg_catalog.text DEFAULT 'POST', + "body" pg_catalog.text DEFAULT NULL, + "headers" pg_catalog.jsonb DEFAULT NULL, + "timeout_seconds" pg_catalog.int4 DEFAULT 30 +) RETURNS pg_catalog.text +LANGUAGE c +AS 'MODULE_PATHNAME', 'http_endpoint_wrapper'; + +CREATE FUNCTION df.http_multipart( + "url" df.http_endpoint, + "method" pg_catalog.text DEFAULT 'POST', + "parts" pg_catalog.jsonb DEFAULT NULL, + "headers" pg_catalog.jsonb DEFAULT NULL, + "timeout_seconds" pg_catalog.int4 DEFAULT 30 +) RETURNS pg_catalog.text +LANGUAGE c +AS 'MODULE_PATHNAME', 'http_multipart_endpoint_wrapper'; + +REVOKE EXECUTE ON FUNCTION df.http(df.http_endpoint, text, text, jsonb, integer) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION df.http_multipart(df.http_endpoint, text, jsonb, jsonb, integer) FROM PUBLIC; + +CREATE FUNCTION df.secret("server" pg_catalog.text, "key" pg_catalog.text) +RETURNS pg_catalog.jsonb +LANGUAGE c IMMUTABLE STRICT PARALLEL SAFE +AS 'MODULE_PATHNAME', 'secret_wrapper'; + +CREATE OR REPLACE FUNCTION df.grant_usage( + p_role TEXT, + include_http boolean DEFAULT false, + with_grant boolean DEFAULT false +) +RETURNS VOID +LANGUAGE plpgsql +SET search_path = pg_catalog, pg_temp +AS $fn$ +DECLARE + grant_opt TEXT := ''; +BEGIN + IF with_grant THEN + grant_opt := ' WITH GRANT OPTION'; + END IF; + + -- Schema access — the access gate for ordinary df.* functions (see header). + EXECUTE pg_catalog.format('GRANT USAGE ON SCHEMA df TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + + -- df.http() — opt-in because it makes outbound network requests. + IF include_http THEN + EXECUTE pg_catalog.format('GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + EXECUTE pg_catalog.format('GRANT EXECUTE ON FUNCTION df.http(df.http_endpoint, text, text, jsonb, integer) TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + -- df.http_multipart() shares the same opt-in (HTTP egress is one privilege). + EXECUTE pg_catalog.format('GRANT EXECUTE ON FUNCTION df.http_multipart(text, text, jsonb, jsonb, integer) TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + EXECUTE pg_catalog.format('GRANT EXECUTE ON FUNCTION df.http_multipart(df.http_endpoint, text, jsonb, jsonb, integer) TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + END IF; + + -- Admin helpers and system-wide metrics — with_grant => true marks a + -- pg_durable admin, so it also grants df.metrics() (cluster-wide aggregate + -- counts). + IF with_grant THEN + EXECUTE pg_catalog.format('GRANT EXECUTE ON FUNCTION df.grant_usage(text, boolean, boolean) TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + EXECUTE pg_catalog.format('GRANT EXECUTE ON FUNCTION df.revoke_usage(text) TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + EXECUTE pg_catalog.format('GRANT EXECUTE ON FUNCTION df.metrics() TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + END IF; + + -- Table privileges + EXECUTE pg_catalog.format('GRANT SELECT ON df.instances TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + EXECUTE pg_catalog.format('GRANT UPDATE (status, updated_at) ON df.instances TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + EXECUTE pg_catalog.format('GRANT SELECT ON df.nodes TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + EXECUTE pg_catalog.format('GRANT INSERT (id, label, root_node, submitted_by, database) ON df.instances TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + EXECUTE pg_catalog.format('GRANT INSERT (id, instance_id, node_type, query, result_name, left_node, right_node, submitted_by, database) ON df.nodes TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + EXECUTE pg_catalog.format('GRANT SELECT, INSERT, UPDATE, DELETE ON df.vars TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + + RAISE NOTICE 'pg_durable: granted df usage privileges to "%"', p_role; +END; +$fn$; + +CREATE OR REPLACE FUNCTION df.revoke_usage(p_role TEXT) +RETURNS VOID +LANGUAGE plpgsql +SET search_path = pg_catalog, pg_temp +AS $fn$ +BEGIN + -- Mirror of df.grant_usage(): undo exactly what it grants. Revoking schema + -- USAGE is the access gate that locks the role out of ordinary df.* + -- functions; the sensitive functions and table privileges are undone below. + -- CASCADE also removes any sub-grants the role made via WITH GRANT OPTION. + + -- Sensitive functions (granted explicitly by grant_usage()). A delegated + -- admin may lack privilege on some of these (e.g. df.http); skip those. + BEGIN + EXECUTE pg_catalog.format('REVOKE EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) FROM %I CASCADE', p_role); + EXCEPTION WHEN insufficient_privilege THEN + NULL; + END; + BEGIN + EXECUTE pg_catalog.format('REVOKE EXECUTE ON FUNCTION df.http(df.http_endpoint, text, text, jsonb, integer) FROM %I CASCADE', p_role); + EXCEPTION WHEN insufficient_privilege THEN + NULL; + END; + BEGIN + EXECUTE pg_catalog.format('REVOKE EXECUTE ON FUNCTION df.metrics() FROM %I CASCADE', p_role); + EXCEPTION WHEN insufficient_privilege THEN + NULL; + END; + BEGIN + EXECUTE pg_catalog.format('REVOKE EXECUTE ON FUNCTION df.http_multipart(text, text, jsonb, jsonb, integer) FROM %I CASCADE', p_role); + EXCEPTION WHEN insufficient_privilege THEN + NULL; + END; + BEGIN + EXECUTE pg_catalog.format('REVOKE EXECUTE ON FUNCTION df.http_multipart(df.http_endpoint, text, jsonb, jsonb, integer) FROM %I CASCADE', p_role); + EXCEPTION WHEN insufficient_privilege THEN + NULL; + END; + BEGIN + EXECUTE pg_catalog.format('REVOKE EXECUTE ON FUNCTION df.grant_usage(text, boolean, boolean) FROM %I CASCADE', p_role); + EXCEPTION WHEN insufficient_privilege THEN + NULL; + END; + BEGIN + EXECUTE pg_catalog.format('REVOKE EXECUTE ON FUNCTION df.revoke_usage(text) FROM %I CASCADE', p_role); + EXCEPTION WHEN insufficient_privilege THEN + NULL; + END; + + -- Table privileges. + -- Column-level revokes must match the column-level grants from grant_usage(). + EXECUTE pg_catalog.format('REVOKE SELECT, INSERT, UPDATE, DELETE ON df.vars FROM %I CASCADE', p_role); + EXECUTE pg_catalog.format('REVOKE INSERT (id, instance_id, node_type, query, result_name, left_node, right_node, submitted_by, database) ON df.nodes FROM %I CASCADE', p_role); + EXECUTE pg_catalog.format('REVOKE SELECT ON df.nodes FROM %I CASCADE', p_role); + EXECUTE pg_catalog.format('REVOKE INSERT (id, label, root_node, submitted_by, database) ON df.instances FROM %I CASCADE', p_role); + EXECUTE pg_catalog.format('REVOKE UPDATE (status, updated_at) ON df.instances FROM %I CASCADE', p_role); + EXECUTE pg_catalog.format('REVOKE SELECT ON df.instances FROM %I CASCADE', p_role); + + -- Schema access — the access gate for all ordinary df.* functions. + EXECUTE pg_catalog.format('REVOKE USAGE ON SCHEMA df FROM %I CASCADE', p_role); + + RAISE NOTICE 'pg_durable: revoked df usage privileges granted by "%" from "%"', current_user, p_role; +END; +$fn$; diff --git a/src/activities/execute_http.rs b/src/activities/execute_http.rs index 874d1511..65197cb7 100644 --- a/src/activities/execute_http.rs +++ b/src/activities/execute_http.rs @@ -17,24 +17,42 @@ use std::sync::Arc; use std::time::Duration; use sqlx::PgPool; +use tokio::sync::Semaphore; use crate::types::HttpConfig; /// Activity name for registration and scheduling pub const NAME: &str = "pg_durable::activity::execute-http"; -/// Check that `submitted_by` holds EXECUTE privilege on `df.http()`. +/// Check the HTTP privilege required by the request's destination and body mode. /// /// This closes the bypass path where a user crafts a raw Durofut JSON and /// passes it directly to `df.start()`, inserting an HTTP node without going /// through the DSL guard in `df.http()`. -async fn check_http_privilege(pool: &PgPool, submitted_by: &str) -> Result<(), String> { +pub(crate) async fn check_http_privilege( + pool: &PgPool, + submitted_by: &str, + endpoint: bool, + multipart: bool, +) -> Result<(), String> { + let signature = match (multipart, endpoint) { + (false, false) => "df.http(text,text,text,jsonb,integer)", + (false, true) => "df.http(df.http_endpoint,text,text,jsonb,integer)", + (true, false) => "df.http_multipart(text,text,jsonb,jsonb,integer)", + (true, true) => "df.http_multipart(df.http_endpoint,text,jsonb,jsonb,integer)", + }; + let function = if multipart { + "df.http_multipart" + } else { + "df.http" + }; let has_priv: Option = sqlx::query_scalar( - "SELECT has_function_privilege($1::regrole, \ - 'df.http(text,text,text,jsonb,integer)'::regprocedure, \ - 'EXECUTE')", + "SELECT COALESCE(pg_catalog.has_function_privilege( + role.oid, pg_catalog.to_regprocedure($2)::pg_catalog.oid, 'EXECUTE'), false) + FROM pg_catalog.pg_roles AS role WHERE role.rolname OPERATOR(pg_catalog.=) $1", ) .bind(submitted_by) + .bind(signature) .fetch_optional(pool) .await .map_err(|e| format!("HTTP privilege check failed for role '{submitted_by}': {e}"))?; @@ -42,8 +60,8 @@ async fn check_http_privilege(pool: &PgPool, submitted_by: &str) -> Result<(), S match has_priv { Some(true) => Ok(()), _ => Err(format!( - "Blocked: role '{submitted_by}' does not have EXECUTE privilege on df.http(). \ - Grant EXECUTE ON FUNCTION df.http(text,text,text,jsonb,integer) TO {submitted_by} to allow HTTP requests." + "Blocked: role '{submitted_by}' does not have EXECUTE privilege on {function}() for this request. \ + Required function: {signature}. Use df.grant_usage with include_http => true to allow HTTP requests." )), } } @@ -86,6 +104,7 @@ pub(crate) fn build_client(timeout: Duration) -> Result pub async fn execute( ctx: ActivityContext, pool: Arc, + semaphore: Arc, config_json: String, ) -> Result { let config: HttpConfig = @@ -123,7 +142,7 @@ pub async fn execute( // differential can separate what we approve from what we request. // --- Privilege check (Layer 0): submitted_by must have EXECUTE on df.http() --- - check_http_privilege(&pool, audit_user) + check_http_privilege(&pool, audit_user, config.endpoint.is_some(), false) .await .inspect_err(|_| { ctx.trace_info(format!( @@ -131,26 +150,62 @@ pub async fn execute( )); })?; - let request_url = crate::ssrf::parse_request_url(&config.url).inspect_err(|_| { + config.secret_options.validate( + config.body.is_some(), + &config.method, + false, + config.headers.as_ref(), + )?; + let mut catalog = crate::endpoints::EndpointCatalog::new(audit_user, &semaphore); + let mut prepared = crate::endpoints::prepare_request( + &mut catalog, + config.endpoint.as_deref(), + &config.url, + config.headers.as_ref(), + ) + .await + .inspect_err(|_| { ctx.trace_info(format!( "HTTP BLOCKED (malformed) url={safe_url} submitted_by={audit_user}" )); })?; + let request_url = &prepared.url; + let safe_url = if config.endpoint.is_some() { + crate::redact::redact_url(request_url.as_str()) + } else { + safe_url + }; // --- Scheme validation (always enforced, regardless of feature flag) --- - crate::ssrf::validate_scheme(&request_url).inspect_err(|_| { + crate::ssrf::validate_scheme(request_url).inspect_err(|_| { ctx.trace_info(format!( "HTTP BLOCKED (scheme) url={safe_url} submitted_by={audit_user}" )); })?; // --- Azure endpoint allow-list (blocks all bare IPs + non-Azure domains) --- - crate::ssrf::validate_allowlist(&request_url).inspect_err(|_| { + crate::ssrf::validate_allowlist(request_url).inspect_err(|_| { ctx.trace_info(format!( "HTTP BLOCKED (allowlist) url={safe_url} submitted_by={audit_user}" )); })?; + let resolved = config + .secret_options + .resolve(&mut catalog, &mut prepared, config.headers.as_ref()) + .await?; + catalog.close().await?; + let safe_url = if config + .secret_options + .secret_bindings + .as_ref() + .is_some_and(|bindings| !bindings.query.is_empty()) + { + crate::redact::redact_url(prepared.url.as_str()) + } else { + safe_url + }; + let request_url = prepared.url; let start = std::time::Instant::now(); ctx.trace_info(format!( "HTTP {} {safe_url} submitted_by={audit_user}", @@ -174,6 +229,9 @@ pub async fn execute( if let Some(headers) = &config.headers { if let Some(obj) = headers.as_object() { for (key, value) in obj { + if resolved.form_body.is_some() && key.eq_ignore_ascii_case("content-type") { + continue; + } if let Some(v) = value.as_str() { request = request.header(key, v); } @@ -181,8 +239,20 @@ pub async fn execute( } } + if let Some((name, value)) = prepared.credential_header { + request = request.header(name, value); + } + request = request.headers(resolved.headers); + // Add body (for POST/PUT/PATCH) - if let Some(body) = &config.body { + if let Some(body) = resolved.form_body { + request = request + .header( + reqwest::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(body); + } else if let Some(body) = &config.body { request = request.body(body.clone()); } diff --git a/src/activities/execute_multipart.rs b/src/activities/execute_multipart.rs index 7e4edb33..0ca206dd 100644 --- a/src/activities/execute_multipart.rs +++ b/src/activities/execute_multipart.rs @@ -20,38 +20,14 @@ use std::sync::Arc; use std::time::Duration; use sqlx::PgPool; +use tokio::sync::Semaphore; -use crate::activities::execute_http::build_client; +use crate::activities::execute_http::{build_client, check_http_privilege}; use crate::types::MultipartConfig; /// Activity name for registration and scheduling pub const NAME: &str = "pg_durable::activity::execute-multipart"; -/// Check that `submitted_by` holds EXECUTE privilege on `df.http_multipart()`. -/// -/// Mirrors `execute_http::check_http_privilege` — closes the bypass path where -/// a user crafts a raw Durofut JSON and passes it directly to `df.start()`, -/// inserting an HTTP_MULTIPART node without going through the DSL guard. -async fn check_multipart_privilege(pool: &PgPool, submitted_by: &str) -> Result<(), String> { - let has_priv: Option = sqlx::query_scalar( - "SELECT has_function_privilege($1::regrole, \ - 'df.http_multipart(text,text,jsonb,jsonb,integer)'::regprocedure, \ - 'EXECUTE')", - ) - .bind(submitted_by) - .fetch_optional(pool) - .await - .map_err(|e| format!("HTTP privilege check failed for role '{submitted_by}': {e}"))?; - - match has_priv { - Some(true) => Ok(()), - _ => Err(format!( - "Blocked: role '{submitted_by}' does not have EXECUTE privilege on df.http_multipart(). \ - Grant EXECUTE ON FUNCTION df.http_multipart(text,text,jsonb,jsonb,integer) TO {submitted_by} to allow multipart HTTP requests." - )), - } -} - /// Decode a part's `data_b64` payload, tolerating ASCII whitespace. /// /// PostgreSQL's `encode(bytea, 'base64')` follows RFC 2045 §6.8 and breaks its @@ -80,6 +56,7 @@ fn decode_part_data(data_b64: &str) -> Result, base64::DecodeError> { pub async fn execute( ctx: ActivityContext, pool: Arc, + semaphore: Arc, config_json: String, ) -> Result { let config: MultipartConfig = serde_json::from_str(&config_json) @@ -102,7 +79,7 @@ pub async fn execute( // 3. DNS resolver (SsrfSafeResolver): catches DNS rebinding. // --- Privilege check (Layer 0) --- - check_multipart_privilege(&pool, audit_user) + check_http_privilege(&pool, audit_user, config.endpoint.is_some(), true) .await .inspect_err(|_| { ctx.trace_info(format!( @@ -110,26 +87,59 @@ pub async fn execute( )); })?; - let request_url = crate::ssrf::parse_request_url(&config.url).inspect_err(|_| { + config + .secret_options + .validate(false, &config.method, true, config.headers.as_ref())?; + let mut catalog = crate::endpoints::EndpointCatalog::new(audit_user, &semaphore); + let mut prepared = crate::endpoints::prepare_request( + &mut catalog, + config.endpoint.as_deref(), + &config.url, + config.headers.as_ref(), + ) + .await + .inspect_err(|_| { ctx.trace_info(format!( "HTTP_MULTIPART BLOCKED (malformed) url={safe_url} submitted_by={audit_user}" )); })?; + let request_url = &prepared.url; + let safe_url = if config.endpoint.is_some() { + crate::redact::redact_url(request_url.as_str()) + } else { + safe_url + }; // --- Scheme validation (always enforced) --- - crate::ssrf::validate_scheme(&request_url).inspect_err(|_| { + crate::ssrf::validate_scheme(request_url).inspect_err(|_| { ctx.trace_info(format!( "HTTP_MULTIPART BLOCKED (scheme) url={safe_url} submitted_by={audit_user}" )); })?; // --- Azure endpoint allow-list --- - crate::ssrf::validate_allowlist(&request_url).inspect_err(|_| { + crate::ssrf::validate_allowlist(request_url).inspect_err(|_| { ctx.trace_info(format!( "HTTP_MULTIPART BLOCKED (allowlist) url={safe_url} submitted_by={audit_user}" )); })?; + let resolved = config + .secret_options + .resolve(&mut catalog, &mut prepared, config.headers.as_ref()) + .await?; + catalog.close().await?; + let safe_url = if config + .secret_options + .secret_bindings + .as_ref() + .is_some_and(|bindings| !bindings.query.is_empty()) + { + crate::redact::redact_url(prepared.url.as_str()) + } else { + safe_url + }; + let request_url = prepared.url; let start = std::time::Instant::now(); ctx.trace_info(format!( "HTTP_MULTIPART {} {safe_url} ({} parts) submitted_by={audit_user}", @@ -172,6 +182,11 @@ pub async fn execute( } } + if let Some((name, value)) = prepared.credential_header { + request = request.header(name, value); + } + request = request.headers(resolved.headers); + // Build the multipart form from base64-encoded parts. let mut form = reqwest::multipart::Form::new(); for part in &config.parts { diff --git a/src/activities/execute_sql.rs b/src/activities/execute_sql.rs index 62b11c9e..56012877 100644 --- a/src/activities/execute_sql.rs +++ b/src/activities/execute_sql.rs @@ -209,24 +209,12 @@ pub async fn execute( // Acquire a permit from the user-connection semaphore. The permit is held // for the entire SQL execution and released automatically when dropped. - let timeout = get_execution_acquire_timeout(); - let limit = get_max_user_connections(); - let _permit = match tokio::time::timeout(timeout, semaphore.acquire()).await { - Ok(Ok(permit)) => permit, - Ok(Err(_)) => { - return Err(format!( - "pg_durable: connection limit reached (max_user_connections={limit}). \ - Semaphore closed unexpectedly." - )); - } - Err(_) => { - return Err(format!( - "pg_durable: connection limit reached (max_user_connections={limit}). \ - Timed out after {}s waiting for an available execution slot.", - timeout.as_secs() - )); - } - }; + let _permit = crate::types::acquire_execution_permit( + &semaphore, + get_execution_acquire_timeout(), + get_max_user_connections(), + ) + .await?; let mut conn = connect_as_user(&input.submitted_by, input.database.as_deref()).await?; diff --git a/src/dsl.rs b/src/dsl.rs index 13ce8851..53b4a5b9 100644 --- a/src/dsl.rs +++ b/src/dsl.rs @@ -487,6 +487,72 @@ pub fn race(a: &str, b: &str) -> String { .to_json() } +/// Applies HTTP options to a single HTTP or HTTP_MULTIPART node. +#[pg_extern(schema = "df")] +pub fn with_http_options(fut: &str, options: Option) -> String { + use std::{collections::HashSet, sync::LazyLock}; + static ALLOWED_KEYS: LazyLock> = LazyLock::new(|| { + let allowed = [ + // Keep alphabetical to simplify merge conflicts + "form_fields", + "secret_bindings", + ]; + debug_assert!(allowed.is_sorted()); + HashSet::from_iter(allowed) + }); + let node = Durofut::try_from_json(fut).unwrap_or_else(|_| { + pgrx::error!("df.with_http_options(): expected an HTTP or HTTP_MULTIPART node") + }); + + if !matches!(node.node_type.as_str(), "HTTP" | "HTTP_MULTIPART") + || node.left_node.is_some() + || node.right_node.is_some() + || node.condition_node.is_some() + || !node.extra_nodes.is_empty() + { + pgrx::error!("df.with_http_options(): expected a single HTTP or HTTP_MULTIPART node"); + } + + let valid_config = match (node.node_type.as_str(), node.query.as_deref()) { + ("HTTP", Some(query)) => serde_json::from_str::(query).is_ok(), + ("HTTP_MULTIPART", Some(query)) => { + serde_json::from_str::(query).is_ok() + } + _ => false, + }; + if !valid_config { + pgrx::error!("df.with_http_options(): HTTP node config is malformed"); + } + + if let Some(options) = options { + let Some(map) = options.0.as_object() else { + pgrx::error!("df.with_http_options(): options must be a JSON object"); + }; + for key in map.keys() { + if !ALLOWED_KEYS.contains(key.as_str()) { + pgrx::error!("df.with_http_options(): unrecognised option '{key}'."); + } + } + if !map.is_empty() { + let config: serde_json::Value = serde_json::from_str(node.query.as_deref().unwrap()) + .expect("Validated HTTP configuration"); + let bindings = map + .get("secret_bindings") + .or_else(|| config.get("secret_bindings")) + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let form = map + .get("form_fields") + .or_else(|| config.get("form_fields")) + .cloned(); + return crate::secrets::configure_bindings(fut, bindings, form) + .unwrap_or_else(|error| pgrx::error!("df.with_http_options(): {}", error)); + } + } + + fut.to_string() +} + /// Creates an HTTP request node. /// Makes an HTTP request to the specified URL and returns the response. /// @@ -508,6 +574,37 @@ pub fn http( body: default!(Option<&str>, "NULL"), headers: default!(Option, "NULL"), timeout_seconds: default!(i32, "30"), +) -> String { + http_node(url, method, body, headers, timeout_seconds, None) +} + +#[pg_extern(name = "http", schema = "df", requires = ["create_endpoint_type"])] +pub fn http_endpoint( + url: pgrx::composite_type!("df.http_endpoint"), + method: default!(&str, "'POST'"), + body: default!(Option<&str>, "NULL"), + headers: default!(Option, "NULL"), + timeout_seconds: default!(i32, "30"), +) -> String { + let endpoint = crate::endpoints::EndpointReference::from_tuple(url) + .unwrap_or_else(|error| pgrx::error!("{}", error)); + http_node( + &endpoint.path, + method, + body, + headers, + timeout_seconds, + Some(&endpoint.server), + ) +} + +fn http_node( + url: &str, + method: &str, + body: Option<&str>, + headers: Option, + timeout_seconds: i32, + endpoint: Option<&str>, ) -> String { // Fail early when no http feature is compiled in — df.nodes can be inserted // by hand, so we also enforce this at execution time, but blocking at DSL @@ -524,7 +621,7 @@ pub fn http( // here surfaces the error before df.start() is ever called. // Skip the check when the URL contains variable placeholders ({...}) — // substitution happens at execution time so the scheme is not yet known. - if !url.contains('{') { + if endpoint.is_none() && !url.contains('{') { if let Err(e) = crate::ssrf::precheck_url_scheme(url) { pgrx::error!("{}", e); } @@ -543,13 +640,16 @@ pub fn http( pgrx::error!("Timeout must be positive"); } - let config = serde_json::json!({ + let mut config = serde_json::json!({ "url": url, "method": method_upper, "body": body, "headers": headers.as_ref().map(|h| &h.0), "timeout_seconds": timeout_seconds }); + if let Some(server) = endpoint { + config["endpoint"] = serde_json::Value::String(server.into()); + } Durofut { node_type: "HTTP".to_string(), @@ -594,6 +694,37 @@ pub fn http_multipart( parts: default!(Option, "NULL"), headers: default!(Option, "NULL"), timeout_seconds: default!(i32, "30"), +) -> String { + http_multipart_node(url, method, parts, headers, timeout_seconds, None) +} + +#[pg_extern(name = "http_multipart", schema = "df", requires = ["create_endpoint_type"])] +pub fn http_multipart_endpoint( + url: pgrx::composite_type!("df.http_endpoint"), + method: default!(&str, "'POST'"), + parts: default!(Option, "NULL"), + headers: default!(Option, "NULL"), + timeout_seconds: default!(i32, "30"), +) -> String { + let endpoint = crate::endpoints::EndpointReference::from_tuple(url) + .unwrap_or_else(|error| pgrx::error!("{}", error)); + http_multipart_node( + &endpoint.path, + method, + parts, + headers, + timeout_seconds, + Some(&endpoint.server), + ) +} + +fn http_multipart_node( + url: &str, + method: &str, + parts: Option, + headers: Option, + timeout_seconds: i32, + endpoint: Option<&str>, ) -> String { // Fail early when no http feature is compiled in — same guard as df.http. if !crate::ssrf::http_enabled() { @@ -605,7 +736,7 @@ pub fn http_multipart( // Validate URL scheme at DSL time (skip when URL contains variable // placeholders — substitution happens at execution time). Mirrors df.http. - if !url.contains('{') { + if endpoint.is_none() && !url.contains('{') { if let Err(e) = crate::ssrf::precheck_url_scheme(url) { pgrx::error!("{}", e); } @@ -640,13 +771,16 @@ pub fn http_multipart( }; let _ = parts_arr; // shape validated; activity re-parses from the JSON below - let config = serde_json::json!({ + let mut config = serde_json::json!({ "url": url, "method": method_upper, "parts": parts_value, "headers": headers.as_ref().map(|h| &h.0), "timeout_seconds": timeout_seconds }); + if let Some(server) = endpoint { + config["endpoint"] = serde_json::Value::String(server.into()); + } Durofut { node_type: "HTTP_MULTIPART".to_string(), diff --git a/src/endpoints.rs b/src/endpoints.rs new file mode 100644 index 00000000..7b0a5ebc --- /dev/null +++ b/src/endpoints.rs @@ -0,0 +1,1446 @@ +use std::collections::BTreeMap; + +use pgrx::prelude::*; +use reqwest::header::{HeaderName, HeaderValue, AUTHORIZATION}; +use sqlx::Connection; +use tokio::sync::{Semaphore, SemaphorePermit}; +use url::Url; + +pub const FDW_NAME: &str = "pg_durable_fdw"; + +pgrx::extension_sql!( + "CREATE TYPE df.http_endpoint AS (server pg_catalog.text, path pg_catalog.text);", + name = "create_endpoint_type", + requires = [df] +); + +pub struct EndpointReference { + pub server: String, + pub path: String, +} + +fn validate_endpoint_path(path: &str) -> Result<(), String> { + if !path.starts_with('/') + || path.starts_with("//") + || path.contains(['\\', '#']) + || path + .chars() + .any(|character| character.is_control() || character.is_whitespace()) + { + return Err("Endpoint path must start with one slash and contain no backslash, fragment or whitespace".into()); + } + for segment in path.split('?').next().unwrap_or_default().split('/') { + let decoded = percent_encoding::percent_decode_str(segment).collect::>(); + if decoded == b"." + || decoded == b".." + || decoded.contains(&b'/') + || decoded.contains(&b'\\') + { + return Err( + "Endpoint path cannot contain traversal segments or encoded separators".into(), + ); + } + } + Ok(()) +} + +impl EndpointReference { + pub fn from_tuple(value: pgrx::composite_type!("df.http_endpoint")) -> Result { + let reference = Self { + server: value + .get_by_name::("server") + .map_err(|_| "Invalid endpoint server field")? + .ok_or("Endpoint server must not be NULL")?, + path: value + .get_by_name::("path") + .map_err(|_| "Invalid endpoint path field")? + .ok_or("Endpoint path must not be NULL")?, + }; + reference.validate()?; + Ok(reference) + } + + fn validate(&self) -> Result<(), String> { + if self.server.is_empty() || self.server.chars().any(char::is_control) { + return Err( + "Endpoint server name must be nonempty and contain no control characters".into(), + ); + } + validate_endpoint_path(&self.path) + } +} + +#[pg_extern(schema = "df", immutable, parallel_safe, requires = ["create_endpoint_type"])] +pub fn endpoint(server: &str, path: &str) -> pgrx::composite_type!('static, "df.http_endpoint") { + let reference = EndpointReference { + server: server.into(), + path: path.into(), + }; + reference + .validate() + .unwrap_or_else(|error| pgrx::error!("{}", error)); + let mut tuple = PgHeapTuple::new_composite_type("df.http_endpoint") + .expect("Endpoint type must be installed"); + tuple + .set_by_name("server", reference.server) + .expect("Endpoint server field must exist"); + tuple + .set_by_name("path", reference.path) + .expect("Endpoint path field must exist"); + tuple +} + +pub fn set_execution_context( + config: &mut serde_json::Value, + submitted_by: &str, + database: Option<&str>, +) { + config["submitted_by"] = serde_json::Value::String(submitted_by.into()); + if config.get("endpoint").is_some() + || config.get("secret_bindings").is_some() + || config.get("form_fields").is_some() + { + config["database"] = database.map_or(serde_json::Value::Null, |database| { + serde_json::Value::String(database.into()) + }); + } +} + +fn compose_endpoint_url(base: &Url, path: &str) -> Result { + validate_endpoint_path(path)?; + let prefix = base.as_str().strip_suffix('/').unwrap_or(base.as_str()); + let composed = + Url::parse(&format!("{prefix}{path}")).map_err(|_| "Invalid endpoint request URL")?; + let base_path = base.path().strip_suffix('/').unwrap_or(base.path()); + if composed.origin() != base.origin() || !composed.path().starts_with(&format!("{base_path}/")) + { + return Err("Endpoint path cannot escape its base URL".into()); + } + Ok(composed) +} + +pub struct EndpointRequest { + pub url: Url, + pub credential_header: Option<(HeaderName, HeaderValue)>, +} + +fn prepare_endpoint_request( + endpoint: ResolvedEndpoint, + path: &str, + headers: Option<&serde_json::Value>, +) -> Result { + let mut url = compose_endpoint_url(&endpoint.base_url, path)?; + let credential_name = match &endpoint.auth { + EndpointAuth::Bearer(_) => Some(&AUTHORIZATION), + EndpointAuth::Header { name, .. } => Some(name), + _ => None, + }; + if let Some(headers) = headers.and_then(serde_json::Value::as_object) { + for name in headers.keys() { + if name.eq_ignore_ascii_case("host") + || credential_name + .is_some_and(|credential| name.eq_ignore_ascii_case(credential.as_str())) + { + return Err( + "Request headers cannot override endpoint routing or authentication".into(), + ); + } + } + } + let credential_header = match endpoint.auth { + EndpointAuth::None => None, + EndpointAuth::Bearer(value) => Some((AUTHORIZATION, value)), + EndpointAuth::Header { name, value } => Some((name, value)), + EndpointAuth::Query(query) => { + let credential_url = Url::parse(&format!("https://endpoint.invalid/?{query}")) + .map_err(|_| "Invalid endpoint credential query")?; + let names = credential_url + .query_pairs() + .map(|(name, _)| name.into_owned()) + .collect::>(); + if url + .query_pairs() + .any(|(name, _)| names.contains(name.as_ref())) + { + return Err("Request query cannot override endpoint credential parameters".into()); + } + let combined = match url.query().filter(|query| !query.is_empty()) { + Some(existing) => format!("{existing}&{query}"), + None => query, + }; + url.set_query(Some(&combined)); + None + } + }; + Ok(EndpointRequest { + url, + credential_header, + }) +} + +pub async fn prepare_request( + catalog: &mut EndpointCatalog<'_>, + server: Option<&str>, + url: &str, + headers: Option<&serde_json::Value>, +) -> Result { + match server { + Some(server) => { + validate_endpoint_path(url)?; + let endpoint = catalog.resolve_endpoint(server).await?; + prepare_endpoint_request(endpoint, url, headers) + } + None => Ok(EndpointRequest { + url: crate::ssrf::parse_request_url(url)?, + credential_header: None, + }), + } +} + +#[derive(Clone)] +pub enum AuthScheme { + None, + Bearer, + Header(HeaderName), + Query, +} + +#[derive(Clone)] +pub struct EndpointConfig { + pub base_url: Option, + pub auth_scheme: AuthScheme, +} + +fn parse_options(options: &[String]) -> Result, String> { + let mut parsed = BTreeMap::new(); + for option in options { + let (name, value) = option + .split_once('=') + .ok_or("Invalid endpoint option: expected name=value")?; + if parsed.insert(name, value).is_some() { + return Err("Duplicate endpoint option".into()); + } + } + Ok(parsed) +} + +fn required<'a>(options: &BTreeMap<&str, &'a str>, name: &str) -> Result<&'a str, String> { + options + .get(name) + .copied() + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("Endpoint option '{name}' is required and must not be empty")) +} + +impl EndpointConfig { + pub fn from_options(options: &[String]) -> Result { + let options = parse_options(options)?; + if options + .keys() + .any(|name| !matches!(*name, "base_url" | "auth_scheme" | "header_name")) + { + return Err( + "Unsupported endpoint server option; allowed: base_url, auth_scheme, header_name" + .into(), + ); + } + let base_url = if options.contains_key("base_url") { + let raw_url = required(&options, "base_url")?; + if raw_url + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + || raw_url.contains(['{', '}', '\\']) + { + return Err("Endpoint base_url must be a literal HTTPS URL".into()); + } + let base_url = Url::parse(raw_url).map_err(|_| "Invalid endpoint base_url")?; + if base_url.scheme() != "https" + || base_url.host_str().is_none() + || !base_url.username().is_empty() + || base_url.password().is_some() + || base_url.query().is_some() + || base_url.fragment().is_some() + { + return Err( + "Endpoint base_url must be HTTPS without userinfo, query or fragment".into(), + ); + } + Some(base_url) + } else { + None + }; + let auth_scheme = match required(&options, "auth_scheme")? { + "none" => AuthScheme::None, + "bearer" => AuthScheme::Bearer, + "query" => AuthScheme::Query, + "header" => { + let name = HeaderName::from_bytes(required(&options, "header_name")?.as_bytes()) + .map_err(|_| "Invalid endpoint header_name")?; + if matches!( + name.as_str(), + "host" + | "content-type" + | "content-length" + | "transfer-encoding" + | "connection" + | "proxy-authorization" + | "proxy-authenticate" + | "te" + | "trailer" + | "upgrade" + | "keep-alive" + ) { + return Err( + "Endpoint header_name cannot control HTTP routing or framing".into(), + ); + } + AuthScheme::Header(name) + } + "managed-identity" => { + return Err("Managed identity is not supported in this version".into()) + } + _ => { + return Err( + "Unsupported endpoint auth_scheme; allowed: none, bearer, header, query".into(), + ) + } + }; + if !matches!(auth_scheme, AuthScheme::Header(_)) && options.contains_key("header_name") { + return Err("Endpoint header_name requires auth_scheme 'header'".into()); + } + if base_url.is_none() && !matches!(auth_scheme, AuthScheme::None) { + return Err("Endpoint base_url is required unless auth_scheme is 'none'".into()); + } + Ok(Self { + base_url, + auth_scheme, + }) + } +} + +fn validate_mapping_options(options: &[String]) -> Result<(), String> { + let options = parse_options(options)?; + for (name, value) in options { + if let Some(key) = name.strip_prefix(crate::secrets::SECRET_OPTION_PREFIX) { + crate::secrets::validate_secret_key(key)?; + continue; + } + if value.is_empty() { + return Err("Endpoint credential options must not be empty".into()); + } + match name { + "token" | "header_value" => { + HeaderValue::from_str(value).map_err(|_| "Invalid endpoint credential header value")?; + if name == "token" && (!value.is_ascii() || value.chars().any(char::is_whitespace)) { + return Err("Endpoint bearer token must be ASCII without whitespace".into()); + } + } + "query_string" => { + let query = value.strip_prefix('?').unwrap_or(value); + if query.is_empty() || query.chars().any(|character| character.is_whitespace() || character.is_control()) || query.contains(['#', '{', '}']) { + return Err("Endpoint query_string must be a nonempty encoded query without a fragment".into()); + } + let parsed = Url::parse(&format!("https://endpoint.invalid/?{query}")) + .map_err(|_| "Invalid endpoint query_string")?; + if parsed.query() != Some(query) { + return Err("Endpoint query_string must already be URL-encoded".into()); + } + } + _ => return Err("Unsupported endpoint user mapping option; allowed: token, header_value, query_string, secret.".into()), + } + } + Ok(()) +} + +#[pg_extern(schema = "df")] +pub fn endpoint_option_validator(options: Vec, catalog: pg_sys::Oid) { + let result = if catalog == pg_sys::ForeignServerRelationId { + EndpointConfig::from_options(&options).map(|_| ()) + } else if catalog == pg_sys::UserMappingRelationId { + validate_mapping_options(&options) + } else if catalog == pg_sys::ForeignDataWrapperRelationId { + if options.is_empty() { + Ok(()) + } else { + Err("pg_durable_fdw does not accept wrapper options".into()) + } + } else { + Err("pg_durable_fdw does not support foreign tables or column options".into()) + }; + if let Err(error) = result { + pgrx::error!("{}", error); + } +} + +pgrx::extension_sql!( + r#" +CREATE FOREIGN DATA WRAPPER pg_durable_fdw + NO HANDLER VALIDATOR df.endpoint_option_validator; +REVOKE ALL ON FOREIGN DATA WRAPPER pg_durable_fdw FROM PUBLIC; +"#, + name = "create_endpoint_fdw", + requires = [endpoint_option_validator] +); + +pub enum EndpointAuth { + None, + Bearer(HeaderValue), + Header { + name: HeaderName, + value: HeaderValue, + }, + Query(String), +} + +pub struct ResolvedEndpoint { + pub base_url: Url, + pub auth: EndpointAuth, +} + +fn resolve_auth(config: AuthScheme, mapping: &[String]) -> Result { + validate_mapping_options(mapping)?; + let mapping = parse_options(mapping)?; + match config { + AuthScheme::None => Ok(EndpointAuth::None), + AuthScheme::Bearer => { + let mut value = + HeaderValue::from_str(&format!("Bearer {}", required(&mapping, "token")?)) + .map_err(|_| "Invalid endpoint bearer token")?; + value.set_sensitive(true); + Ok(EndpointAuth::Bearer(value)) + } + AuthScheme::Header(name) => { + let mut value = HeaderValue::from_str(required(&mapping, "header_value")?) + .map_err(|_| "Invalid endpoint credential header value")?; + value.set_sensitive(true); + Ok(EndpointAuth::Header { name, value }) + } + AuthScheme::Query => { + let value = required(&mapping, "query_string")?; + Ok(EndpointAuth::Query( + value.strip_prefix('?').unwrap_or(value).to_owned(), + )) + } + } +} + +struct CatalogServer { + oid: i64, + config: EndpointConfig, + mapping: Option>, +} + +pub struct EndpointCatalog<'a> { + submitted_by: &'a str, + database: Option<&'a str>, + semaphore: &'a Semaphore, + connection: Option, + permit: Option>, + servers: BTreeMap, +} + +impl<'a> EndpointCatalog<'a> { + pub fn new(submitted_by: &'a str, semaphore: &'a Semaphore) -> Self { + Self { + submitted_by, + database: None, + semaphore, + connection: None, + permit: None, + servers: BTreeMap::new(), + } + } + + async fn connection(&mut self) -> Result<&mut sqlx::PgConnection, String> { + if self.connection.is_none() { + let permit = crate::types::acquire_execution_permit( + self.semaphore, + crate::types::get_execution_acquire_timeout(), + crate::types::get_max_user_connections(), + ) + .await?; + let mut connection = crate::types::connect_as_user(self.submitted_by, self.database) + .await + .map_err(|error| format!("Endpoint catalog connection failed: {error}"))?; + sqlx::query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY") + .execute(&mut connection) + .await + .map_err(|_| "Endpoint catalog snapshot failed")?; + let identity_matches: bool = sqlx::query_scalar( + "SELECT CURRENT_USER::pg_catalog.text OPERATOR(pg_catalog.=) $1 + AND SESSION_USER::pg_catalog.text OPERATOR(pg_catalog.=) $1", + ) + .bind(self.submitted_by) + .fetch_one(&mut connection) + .await + .map_err(|_| "Endpoint catalog identity check failed")?; + if !identity_matches { + return Err(format!( + "Endpoint catalog identity differs from submitting role {:?}", + self.submitted_by + )); + } + let installed: bool = sqlx::query_scalar( + "SELECT EXISTS ( + SELECT 1 FROM pg_catalog.pg_foreign_data_wrapper AS wrapper + JOIN pg_catalog.pg_depend AS dependency + ON dependency.classid = 'pg_catalog.pg_foreign_data_wrapper'::pg_catalog.regclass + AND dependency.objid = wrapper.oid + AND dependency.refclassid = 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependency.deptype = 'e' + JOIN pg_catalog.pg_extension AS extension ON extension.oid = dependency.refobjid + WHERE wrapper.fdwname = $1 AND extension.extname = 'pg_durable' + )", + ) + .bind(FDW_NAME) + .fetch_one(&mut connection) + .await + .map_err(|_| "Endpoint wrapper lookup failed")?; + if !installed { + return Err( + "Endpoint support is not installed; update the pg_durable extension schema" + .into(), + ); + } + self.connection = Some(connection); + self.permit = Some(permit); + } + Ok(self.connection.as_mut().unwrap()) + } + + async fn load_server(&mut self, server: &str) -> Result<&CatalogServer, String> { + if !self.servers.contains_key(server) { + let connection = self + .connection() + .await + .map_err(|error| format!("Endpoint server {server:?}: {error}"))?; + let endpoint: Option<(i64, bool, bool, Option>)> = sqlx::query_as( + "SELECT server.oid::pg_catalog.int8, + wrapper.fdwname = $2, + pg_catalog.has_server_privilege(server.oid, 'USAGE'), + server.srvoptions + FROM pg_catalog.pg_foreign_server AS server + JOIN pg_catalog.pg_foreign_data_wrapper AS wrapper ON wrapper.oid = server.srvfdw + WHERE server.srvname = $1", + ) + .bind(server) + .bind(FDW_NAME) + .fetch_optional(connection) + .await + .map_err(|_| "Endpoint server lookup failed")?; + let (oid, correct_wrapper, permitted, options) = + endpoint.ok_or("Endpoint server does not exist")?; + if !correct_wrapper { + return Err("Endpoint server must use pg_durable_fdw".into()); + } + if !permitted { + return Err("Permission denied: endpoint server USAGE is required".into()); + } + let config = EndpointConfig::from_options(options.as_deref().unwrap_or_default())?; + self.servers.insert( + server.to_owned(), + CatalogServer { + oid, + config, + mapping: None, + }, + ); + } + Ok(self.servers.get(server).unwrap()) + } + + async fn load_mapping(&mut self, server: &str) -> Result<&[String], String> { + let entry = self.load_server(server).await?; + let oid = entry.oid; + if entry.mapping.is_none() { + let mapping = load_user_mapping(self.connection().await?, oid).await?; + self.servers.get_mut(server).unwrap().mapping = Some(mapping); + } + Ok(self + .servers + .get(server) + .unwrap() + .mapping + .as_deref() + .unwrap()) + } + + pub async fn resolve_named_secrets( + &mut self, + server: &str, + ) -> Result, String> { + let mapping = self.load_mapping(server).await?; + let options = parse_options(mapping)?; + Ok(options + .into_iter() + .filter_map(|(name, value)| { + name.strip_prefix(crate::secrets::SECRET_OPTION_PREFIX) + .map(|key| (key.to_owned(), value.to_owned())) + }) + .collect()) + } + + pub async fn resolve_endpoint(&mut self, server: &str) -> Result { + let config = self.load_server(server).await?.config.clone(); + let base_url = config.base_url.ok_or_else(|| { + format!( + "Endpoint server {server:?} has no base_url; it can only be used for named secrets" + ) + })?; + let auth = if matches!(config.auth_scheme, AuthScheme::None) { + EndpointAuth::None + } else { + resolve_auth(config.auth_scheme, self.load_mapping(server).await?)? + }; + Ok(ResolvedEndpoint { base_url, auth }) + } + + pub async fn close(mut self) -> Result<(), String> { + if let Some(connection) = self.connection.take() { + connection + .close() + .await + .map_err(|_| "Endpoint catalog connection close failed")?; + } + drop(self.permit.take()); + Ok(()) + } +} + +async fn load_user_mapping( + connection: &mut sqlx::PgConnection, + server_oid: i64, +) -> Result, String> { + let mapping: Option>> = sqlx::query_scalar( + "SELECT mapping.umoptions + FROM pg_catalog.pg_user_mappings AS mapping + WHERE mapping.srvid::pg_catalog.int8 = $1 + AND mapping.umuser = ( + SELECT role.oid FROM pg_catalog.pg_roles AS role + WHERE role.rolname = CURRENT_USER + )", + ) + .bind(server_oid) + .fetch_optional(&mut *connection) + .await + .map_err(|_| "Endpoint user mapping lookup failed")?; + let mapping = mapping.ok_or( + "Endpoint user mapping for the submitting role is required; PUBLIC mappings are unsupported", + )?; + let mapping = mapping.ok_or("Endpoint credential options are missing or inaccessible")?; + validate_mapping_options(&mapping)?; + Ok(mapping) +} + +#[cfg(test)] +mod unit_tests { + use super::*; + + #[test] + fn endpoint_named_secret_options_are_opaque() { + let values = options(&[ + "token=ENDPOINT_TOKEN", + "secret.api_key=abc==def", + "secret.empty=", + "secret.json={\"nested\":123}", + "secret.token=separate-token", + "secret.Mixed.Key=literal ${secret:other.key}\nvalue", + ]); + validate_mapping_options(&values).unwrap(); + let parsed = parse_options(&values).unwrap(); + assert_eq!(parsed["secret.api_key"], "abc==def"); + assert_eq!(parsed["secret.empty"], ""); + assert_eq!(parsed["secret.json"], r#"{"nested":123}"#); + for invalid in [ + "secret.=PRIVATE_VALUE", + "secret.bad\nname=PRIVATE_VALUE", + "secrets=PRIVATE_VALUE", + "resource=PRIVATE_VALUE", + ] { + let error = validate_mapping_options(&options(&[invalid])).unwrap_err(); + assert!(!error.contains("PRIVATE_VALUE")); + } + } + + #[test] + fn endpoint_execution_context_preserves_legacy_inputs() { + for input in [ + r#"{"url":"https://api.github.com/{path}","method":"GET","body":"${secret:literal.value}","headers":{"Z":"{last}","A":"$first"},"timeout_seconds":30}"#, + r#"{"url":"https://api.github.com/upload","method":"POST","parts":[{"name":"file","data_b64":"$payload.body"}],"headers":null,"timeout_seconds":30}"#, + ] { + let mut expected: serde_json::Value = serde_json::from_str(input).unwrap(); + expected["submitted_by"] = serde_json::Value::String("caller".into()); + let mut actual: serde_json::Value = serde_json::from_str(input).unwrap(); + set_execution_context(&mut actual, "caller", Some("other_database")); + assert_eq!(actual.to_string(), expected.to_string()); + assert!(actual.get("database").is_none()); + } + let mut config = serde_json::json!({"endpoint":"fixed_{server}","url":"/{path}","database":"forged","submitted_by":"forged"}); + set_execution_context(&mut config, "caller", Some("trusted_database")); + assert_eq!(config["database"], "trusted_database"); + assert_eq!(config["submitted_by"], "caller"); + assert_eq!(config["endpoint"], "fixed_{server}"); + set_execution_context(&mut config, "caller", None); + assert!(config["database"].is_null()); + } + + #[test] + fn endpoint_reference_validation() { + let reference = EndpointReference { + server: "server.with,\"punctuation".into(), + path: "/items/{item}?version=1".into(), + }; + reference.validate().unwrap(); + for server in ["", "invalid\nserver"] { + assert!(EndpointReference { + server: server.into(), + path: "/".into() + } + .validate() + .is_err()); + } + } + + #[test] + fn endpoint_path_cannot_change_authority_or_escape_prefix() { + let base = Url::parse("https://api.github.com/prefix/").unwrap(); + assert_eq!( + compose_endpoint_url(&base, "/items?q=1").unwrap().as_str(), + "https://api.github.com/prefix/items?q=1" + ); + for path in [ + "https://evil.test/x", + "//evil.test/x", + "/\\evil.test/x", + "/../escape", + "/%2e%2e/escape", + "/.%2E/escape", + "/%2Fescape", + "/%5cescape", + "/data#fragment", + "/data\n", + ] { + assert!(compose_endpoint_url(&base, path).is_err(), "{path}"); + } + } + + #[test] + fn endpoint_authentication_cannot_be_overridden() { + let bearer = || ResolvedEndpoint { + base_url: Url::parse("https://api.github.com/").unwrap(), + auth: EndpointAuth::Bearer(HeaderValue::from_static("Bearer PRIVATE_TOKEN")), + }; + for headers in [ + serde_json::json!({"hOsT":"evil.test"}), + serde_json::json!({"authorization":"other"}), + ] { + assert!(prepare_endpoint_request(bearer(), "/", Some(&headers)).is_err()); + } + let request = prepare_endpoint_request(bearer(), "/", None).unwrap(); + let (name, value) = request.credential_header.unwrap(); + let built = reqwest::Client::builder() + .no_proxy() + .build() + .unwrap() + .get(request.url) + .header(name, value) + .build() + .unwrap(); + assert_eq!(built.headers()[AUTHORIZATION], "Bearer PRIVATE_TOKEN"); + let query = || ResolvedEndpoint { + base_url: Url::parse("https://api.github.com/").unwrap(), + auth: EndpointAuth::Query("sig=PRIVATE%2BVALUE&sv=1".into()), + }; + assert!(prepare_endpoint_request(query(), "/?%73ig=override", None).is_err()); + let request = prepare_endpoint_request(query(), "/?page=2", None).unwrap(); + assert_eq!(request.url.query(), Some("page=2&sig=PRIVATE%2BVALUE&sv=1")); + assert!(!crate::redact::redact_url(request.url.as_str()).contains("PRIVATE")); + } + + fn options(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn endpoint_valid_options() { + let secrets_only = EndpointConfig::from_options(&options(&["auth_scheme=none"])).unwrap(); + assert!(secrets_only.base_url.is_none()); + assert!(matches!(secrets_only.auth_scheme, AuthScheme::None)); + for scheme in ["none", "bearer", "query"] { + assert!(EndpointConfig::from_options(&options(&[ + "base_url=https://api.github.com/v1", + &format!("auth_scheme={scheme}") + ])) + .is_ok()); + } + assert!(EndpointConfig::from_options(&options(&[ + "base_url=https://api.github.com", + "auth_scheme=header", + "header_name=x-api-key" + ])) + .is_ok()); + assert!(validate_mapping_options(&options(&["token=secret=="])).is_ok()); + assert!( + validate_mapping_options(&options(&["query_string=?sig=abc%2Fdef%3D&sv=1"])).is_ok() + ); + } + + #[test] + fn endpoint_rejects_invalid_server_options() { + for invalid in [ + vec![], + vec!["auth_scheme=bearer"], + vec!["auth_scheme=query"], + vec!["auth_scheme=header", "header_name=x-api-key"], + vec!["auth_scheme=none", "base_url="], + vec!["auth_scheme=none", "base_url=not-a-url"], + vec!["auth_scheme=none", "header_name=x-api-key"], + vec!["base_url=https://api.github.com"], + vec!["base_url=http://api.github.com", "auth_scheme=none"], + vec![ + "base_url=https://user:secret@api.github.com", + "auth_scheme=none", + ], + vec![ + "base_url=https://api.github.com/?secret=value", + "auth_scheme=none", + ], + vec![ + "base_url=https://api.github.com/#secret", + "auth_scheme=none", + ], + vec!["base_url=https://{host}", "auth_scheme=none"], + vec![ + "base_url=https://api.github.com", + "auth_scheme=managed-identity", + ], + vec![ + "base_url=https://api.github.com", + "auth_scheme=none", + "resource=https://vault.azure.net", + ], + vec![ + "base_url=https://api.github.com", + "auth_scheme=none", + "header_name=x-api-key", + ], + vec![ + "base_url=https://api.github.com", + "auth_scheme=header", + "header_name=Host", + ], + vec!["base_url=https://api.github.com", "auth_scheme=header"], + vec![ + "base_url=https://api.github.com", + "auth_scheme=none", + "auth_scheme=bearer", + ], + ] { + assert!( + EndpointConfig::from_options(&options(&invalid)).is_err(), + "{invalid:?}" + ); + } + } + + #[test] + fn endpoint_invalid_credentials_do_not_leak() { + for invalid in [ + "token=TOP_SECRET\r\nInjected: true", + "header_value=TOP_SECRET\n", + "query_string=TOP_SECRET#fragment", + "query_string=TOP_SECRET value", + "unknown=TOP_SECRET", + "token=", + "query_string=?", + ] { + let error = validate_mapping_options(&options(&[invalid])).unwrap_err(); + assert!(!error.contains("TOP_SECRET")); + } + } +} + +#[cfg(any(test, feature = "pg_test"))] +#[pg_schema] +mod tests { + use super::*; + + async fn resolve_endpoint( + submitted_by: &str, + database: Option<&str>, + server: &str, + ) -> Result { + let semaphore = Semaphore::new(1); + let mut catalog = EndpointCatalog::new(submitted_by, &semaphore); + catalog.database = database; + let endpoint = catalog.resolve_endpoint(server).await?; + catalog.close().await?; + Ok(endpoint) + } + + async fn resolve_named_secrets( + submitted_by: &str, + database: Option<&str>, + server: &str, + ) -> Result, String> { + let semaphore = Semaphore::new(1); + let mut catalog = EndpointCatalog::new(submitted_by, &semaphore); + catalog.database = database; + let values = catalog.resolve_named_secrets(server).await?; + catalog.close().await?; + Ok(values) + } + + #[pg_test] + fn endpoint_catalog_snapshot_and_admission() { + use std::future::Future; + use std::task::Poll; + + let admin = Spi::get_one::("SELECT CURRENT_USER::text") + .unwrap() + .unwrap(); + let database = Spi::get_one::("SELECT current_database()::text") + .unwrap() + .unwrap(); + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + let mut connection = crate::types::connect_as_user(&admin, Some(&database)).await.unwrap(); + sqlx::raw_sql(r#" + CREATE ROLE endpoint_snapshot_user LOGIN; + CREATE SERVER endpoint_snapshot FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://old.azurewebsites.net', auth_scheme 'bearer'); + CREATE SERVER endpoint_snapshot_other FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (auth_scheme 'none'); + GRANT USAGE ON FOREIGN SERVER endpoint_snapshot, endpoint_snapshot_other TO endpoint_snapshot_user; + CREATE USER MAPPING FOR endpoint_snapshot_user SERVER endpoint_snapshot + OPTIONS (token 'OLD_TOKEN', "secret.generation" 'OLD_NAMED'); + CREATE USER MAPPING FOR endpoint_snapshot_user SERVER endpoint_snapshot_other + OPTIONS ("secret.generation" 'OLD_OTHER'); + "#).execute(&mut connection).await.unwrap(); + + let semaphore = Semaphore::new(1); + let occupied = crate::types::acquire_execution_permit( + &semaphore, std::time::Duration::from_secs(30), 1, + ).await.unwrap(); + let mut catalog = EndpointCatalog::new("endpoint_snapshot_user", &semaphore); + catalog.database = Some(&database); + { + let waiting = catalog.load_server("endpoint_snapshot"); + tokio::pin!(waiting); + std::future::poll_fn(|context| { + assert!(waiting.as_mut().poll(context).is_pending()); + Poll::Ready(()) + }).await; + let connected: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_catalog.pg_stat_activity WHERE usename = 'endpoint_snapshot_user'", + ).fetch_one(&mut connection).await.unwrap(); + assert_eq!(connected, 0); + drop(occupied); + assert_eq!(waiting.await.unwrap().config.base_url.as_ref().unwrap().as_str(), "https://old.azurewebsites.net/"); + } + assert_eq!(semaphore.available_permits(), 0); + let settings: (String, String) = sqlx::query_as( + "SELECT current_setting('transaction_isolation'), current_setting('transaction_read_only')", + ).fetch_one(catalog.connection().await.unwrap()).await.unwrap(); + assert_eq!(settings, ("repeatable read".into(), "on".into())); + sqlx::raw_sql(r#" + BEGIN; + ALTER SERVER endpoint_snapshot OPTIONS (SET base_url 'https://new.azurewebsites.net'); + ALTER USER MAPPING FOR endpoint_snapshot_user SERVER endpoint_snapshot + OPTIONS (SET token 'NEW_TOKEN', SET "secret.generation" 'NEW_NAMED'); + ALTER USER MAPPING FOR endpoint_snapshot_user SERVER endpoint_snapshot_other + OPTIONS (SET "secret.generation" 'NEW_OTHER'); + COMMIT; + "#).execute(&mut connection).await.unwrap(); + + let endpoint = catalog.resolve_endpoint("endpoint_snapshot").await.unwrap(); + assert_eq!(endpoint.base_url.as_str(), "https://old.azurewebsites.net/"); + assert!(matches!(endpoint.auth, EndpointAuth::Bearer(value) if value == "Bearer OLD_TOKEN")); + assert_eq!(catalog.resolve_named_secrets("endpoint_snapshot").await.unwrap()["generation"], "OLD_NAMED"); + assert_eq!(catalog.resolve_named_secrets("endpoint_snapshot_other").await.unwrap()["generation"], "OLD_OTHER"); + let connected: i64 = sqlx::query_scalar( + "SELECT count(*) FROM pg_catalog.pg_stat_activity WHERE usename = 'endpoint_snapshot_user'", + ).fetch_one(&mut connection).await.unwrap(); + assert_eq!(connected, 1); + for multipart in [false, true] { + let mut binding_options = serde_json::json!({ + "secret_bindings": { + "headers": {"X-Generation": {"server": "endpoint_snapshot", "key": "generation"}}, + "query": {"generation": {"server": "endpoint_snapshot_other", "key": "generation"}} + } + }); + if !multipart { + binding_options["secret_bindings"]["form"] = serde_json::json!({ + "generation": {"server": "endpoint_snapshot", "key": "generation"} + }); + } + let options: crate::secrets::SecretOptions = serde_json::from_value(binding_options).unwrap(); + options.validate(false, "POST", multipart, None).unwrap(); + let mut request = prepare_request(&mut catalog, Some("endpoint_snapshot"), "/items", None).await.unwrap(); + let resolved = options.resolve(&mut catalog, &mut request, None).await.unwrap(); + assert_eq!(request.url.host_str(), Some("old.azurewebsites.net")); + assert_eq!(request.credential_header.as_ref().unwrap().1, "Bearer OLD_TOKEN"); + assert_eq!(resolved.headers["x-generation"], "OLD_NAMED"); + assert_eq!(request.url.query(), Some("generation=OLD_OTHER")); + assert_eq!(resolved.form_body.as_deref(), if multipart { None } else { Some("generation=OLD_NAMED") }); + } + catalog.close().await.unwrap(); + assert_eq!(semaphore.available_permits(), 1); + + let mut next = EndpointCatalog::new("endpoint_snapshot_user", &semaphore); + next.database = Some(&database); + let endpoint = next.resolve_endpoint("endpoint_snapshot").await.unwrap(); + assert_eq!(endpoint.base_url.as_str(), "https://new.azurewebsites.net/"); + assert!(matches!(endpoint.auth, EndpointAuth::Bearer(value) if value == "Bearer NEW_TOKEN")); + assert_eq!(next.resolve_named_secrets("endpoint_snapshot").await.unwrap()["generation"], "NEW_NAMED"); + assert_eq!(next.resolve_named_secrets("endpoint_snapshot_other").await.unwrap()["generation"], "NEW_OTHER"); + drop(next); + assert_eq!(semaphore.available_permits(), 1); + + let occupied = semaphore.acquire().await.unwrap(); + let mut unused = EndpointCatalog::new("endpoint_snapshot_user", &semaphore); + let mut request = prepare_request(&mut unused, None, "https://api.github.com/", None).await.unwrap(); + let options: crate::secrets::SecretOptions = serde_json::from_value( + serde_json::json!({"form_fields":{"literal":"$result"}}), + ).unwrap(); + let resolved = options.resolve(&mut unused, &mut request, None).await.unwrap(); + assert_eq!(resolved.form_body.as_deref(), Some("literal=%24result")); + assert!(unused.connection.is_none()); + unused.close().await.unwrap(); + drop(occupied); + + { + let mut failed = EndpointCatalog::new("endpoint_snapshot_user", &semaphore); + failed.database = Some(&database); + assert!(failed.resolve_endpoint("endpoint_snapshot_missing").await.is_err()); + } + assert_eq!(semaphore.available_permits(), 1); + sqlx::raw_sql(r#" + DROP SERVER endpoint_snapshot, endpoint_snapshot_other CASCADE; + DROP ROLE endpoint_snapshot_user; + "#).execute(&mut connection).await.unwrap(); + }); + } + + #[pg_test] + fn endpoint_helper_is_lookup_free() { + let reference = endpoint("missing_server", "/items/{item}"); + assert_eq!( + reference + .get_by_name::("server") + .unwrap() + .as_deref(), + Some("missing_server") + ); + assert_eq!( + reference.get_by_name::("path").unwrap().as_deref(), + Some("/items/{item}") + ); + assert_eq!( + Spi::get_one::( + "SELECT pg_catalog.pg_typeof(df.endpoint('missing_server', '/'))::text" + ) + .unwrap() + .as_deref(), + Some("df.http_endpoint") + ); + } + + #[cfg(any( + feature = "http-allow-azure-domains", + feature = "http-allow-test-domains", + feature = "http-allow-all" + ))] + #[pg_test] + fn endpoint_constructors_preserve_body_and_node_types() { + let http = crate::dsl::http_endpoint( + endpoint("missing_server", "/items/{item}"), + "POST", + Some("${secret:literal.value}"), + None, + 30, + ); + let multipart = crate::dsl::http_multipart_endpoint( + endpoint("missing_server", "/items/{item}"), + "POST", + Some(pgrx::JsonB( + serde_json::json!([{"name":"file","data_b64":"aGVsbG8="}]), + )), + None, + 30, + ); + for (json, node_type) in [(http, "HTTP"), (multipart, "HTTP_MULTIPART")] { + let node = crate::types::Durofut::from_json(&json); + assert_eq!(node.node_type, node_type); + let config: serde_json::Value = + serde_json::from_str(node.query.as_ref().unwrap()).unwrap(); + assert_eq!(config["endpoint"], "missing_server"); + assert_eq!(config["url"], "/items/{item}"); + if node_type == "HTTP" { + assert_eq!(config["body"], "${secret:literal.value}"); + } + } + for constructor in ["df.http", "df.http_multipart"] { + let extra = if constructor == "df.http" { + "" + } else { + ", parts => '[{\"name\":\"file\",\"data_b64\":\"aA==\"}]'::jsonb" + }; + let typed: String = Spi::get_one(&format!("SELECT {constructor}(df.endpoint('missing_server', '/items'), method => 'POST'{extra})")).unwrap().unwrap(); + let node = crate::types::Durofut::from_json(&typed); + let config: serde_json::Value = + serde_json::from_str(node.query.as_deref().unwrap()).unwrap(); + assert_eq!(config["endpoint"], "missing_server"); + for raw in [ + "https://api.github.com/items", + r#"{"type":"pg_durable.endpoint","server":"missing_server","path":"/items"}"#, + ] { + let raw_node: String = Spi::get_one(&format!( + "SELECT {constructor}('{raw}', method => 'POST'{extra})" + )) + .unwrap() + .unwrap(); + let node = crate::types::Durofut::from_json(&raw_node); + let config: serde_json::Value = + serde_json::from_str(node.query.as_deref().unwrap()).unwrap(); + assert_eq!(config["url"], raw); + assert!(config.get("endpoint").is_none()); + } + Spi::run(&format!("PREPARE endpoint_text_probe(text) AS SELECT {constructor}($1, method => 'POST'{extra})")).unwrap(); + let prepared: String = Spi::get_one(r#"EXECUTE endpoint_text_probe('{"type":"pg_durable.endpoint","server":"missing_server","path":"/items"}')"#).unwrap().unwrap(); + Spi::run("DEALLOCATE endpoint_text_probe").unwrap(); + let node = crate::types::Durofut::from_json(&prepared); + let config: serde_json::Value = + serde_json::from_str(node.query.as_deref().unwrap()).unwrap(); + assert!(config.get("endpoint").is_none()); + } + Spi::run(r#" + DO $test$ + DECLARE + destination df.http_endpoint; + rejected boolean; + BEGIN + FOREACH destination IN ARRAY ARRAY[ + ROW(NULL, '/')::df.http_endpoint, + ROW('server', NULL)::df.http_endpoint, + ROW('', '/')::df.http_endpoint, + ROW('server', '//other.example/path')::df.http_endpoint, + ROW('server', 'https://other.example/path')::df.http_endpoint, + ROW('server', '/../escape')::df.http_endpoint, + NULL::df.http_endpoint + ] LOOP + rejected := false; + BEGIN + PERFORM df.http(destination, 'POST'); + EXCEPTION WHEN OTHERS THEN + rejected := true; + END; + IF NOT rejected THEN RAISE EXCEPTION 'Invalid HTTP endpoint accepted'; END IF; + rejected := false; + BEGIN + PERFORM df.http_multipart(destination, parts => '[{"name":"file","data_b64":"aA=="}]'); + EXCEPTION WHEN OTHERS THEN + rejected := true; + END; + IF NOT rejected THEN RAISE EXCEPTION 'Invalid multipart endpoint accepted'; END IF; + END LOOP; + END $test$; + "#).unwrap(); + } + + #[pg_test] + fn endpoint_http_grants() { + let admin = Spi::get_one::("SELECT CURRENT_USER::text") + .unwrap() + .unwrap(); + let database = Spi::get_one::("SELECT current_database()::text") + .unwrap() + .unwrap(); + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + let options = sqlx::postgres::PgConnectOptions::new() + .username(&admin).database(&database) + .host(&crate::types::get_host()).port(crate::types::get_port()); + let pool = sqlx::postgres::PgPoolOptions::new().max_connections(1).connect_with(options).await.unwrap(); + sqlx::raw_sql(r#" + CREATE ROLE endpoint_grant_raw LOGIN; + CREATE ROLE endpoint_grant_typed LOGIN; + CREATE ROLE endpoint_grant_user LOGIN; + CREATE ROLE endpoint_grant_admin LOGIN; + GRANT EXECUTE ON FUNCTION df.http(text,text,text,jsonb,integer), df.http_multipart(text,text,jsonb,jsonb,integer) TO endpoint_grant_raw; + GRANT EXECUTE ON FUNCTION df.http(df.http_endpoint,text,text,jsonb,integer), df.http_multipart(df.http_endpoint,text,jsonb,jsonb,integer) TO endpoint_grant_typed; + "#).execute(&pool).await.unwrap(); + for multipart in [false, true] { + for endpoint in [false, true] { + assert_eq!(crate::activities::execute_http::check_http_privilege(&pool, "endpoint_grant_raw", endpoint, multipart).await.is_ok(), !endpoint); + assert_eq!(crate::activities::execute_http::check_http_privilege(&pool, "endpoint_grant_typed", endpoint, multipart).await.is_ok(), endpoint); + assert!(crate::activities::execute_http::check_http_privilege(&pool, "endpoint_grant_user", endpoint, multipart).await.is_err()); + } + } + sqlx::raw_sql(r#" + SELECT df.grant_usage('endpoint_grant_admin', include_http => true, with_grant => true); + SET ROLE endpoint_grant_admin; + SELECT df.grant_usage('endpoint_grant_user', include_http => true); + SELECT df.grant_usage('endpoint_grant_user', include_http => false); + RESET ROLE; + "#).execute(&pool).await.unwrap(); + for multipart in [false, true] { + for endpoint in [false, true] { + crate::activities::execute_http::check_http_privilege(&pool, "endpoint_grant_user", endpoint, multipart).await.unwrap(); + } + } + sqlx::raw_sql(r#" + SET ROLE endpoint_grant_admin; + SELECT df.revoke_usage('endpoint_grant_user'); + RESET ROLE; + "#).execute(&pool).await.unwrap(); + for multipart in [false, true] { + for endpoint in [false, true] { + assert!(crate::activities::execute_http::check_http_privilege(&pool, "endpoint_grant_user", endpoint, multipart).await.is_err()); + } + } + sqlx::raw_sql(r#" + DROP OWNED BY endpoint_grant_raw, endpoint_grant_typed, endpoint_grant_user, endpoint_grant_admin; + DROP ROLE endpoint_grant_raw, endpoint_grant_typed, endpoint_grant_user, endpoint_grant_admin; + "#).execute(&pool).await.unwrap(); + pool.close().await; + }); + } + + #[pg_test] + fn endpoint_catalog_permissions() { + let admin = Spi::get_one::("SELECT CURRENT_USER::text") + .unwrap() + .unwrap(); + let database = Spi::get_one::("SELECT pg_catalog.current_database()::text") + .unwrap() + .unwrap(); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let mut connection = crate::types::connect_as_user(&admin, Some(&database)) + .await + .unwrap(); + sqlx::raw_sql( + r#" + DROP ROLE IF EXISTS "endpoint Alice", endpoint_bob; + CREATE ROLE "endpoint Alice" LOGIN; + CREATE ROLE endpoint_bob LOGIN; + SELECT df.grant_usage('endpoint Alice'); + SELECT df.grant_usage('endpoint_bob'); + CREATE SERVER endpoint_test FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://api.github.com', auth_scheme 'bearer'); + GRANT USAGE ON FOREIGN SERVER endpoint_test TO "endpoint Alice", endpoint_bob; + GRANT USAGE ON FOREIGN DATA WRAPPER pg_durable_fdw TO "endpoint Alice"; + CREATE SERVER endpoint_none FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (auth_scheme 'none'); + GRANT USAGE ON FOREIGN SERVER endpoint_none TO "endpoint Alice"; + CREATE FOREIGN DATA WRAPPER endpoint_other_fdw; + CREATE SERVER endpoint_other FOREIGN DATA WRAPPER endpoint_other_fdw; + GRANT USAGE ON FOREIGN SERVER endpoint_other TO "endpoint Alice"; + "#, + ) + .execute(&mut connection) + .await + .unwrap(); + let mut alice = crate::types::connect_as_user("endpoint Alice", Some(&database)) + .await + .unwrap(); + let mut bob = crate::types::connect_as_user("endpoint_bob", Some(&database)) + .await + .unwrap(); + + let error = resolve_endpoint("endpoint Alice", Some("endpoint_missing_database"), "endpoint_test") + .await.err().unwrap(); + assert!(error.contains("Endpoint catalog connection failed")); + assert!(error.contains("endpoint_test")); + assert!(error.contains("endpoint_missing_database")); + assert!(error.contains("endpoint Alice")); + assert!(error.contains("does not exist")); + + for statement in [ + "CREATE SERVER endpoint_invalid FOREIGN DATA WRAPPER pg_durable_fdw OPTIONS (base_url 'https://api.github.com', auth_scheme 'managed-identity')", + "CREATE SERVER endpoint_invalid FOREIGN DATA WRAPPER pg_durable_fdw OPTIONS (base_url 'https://api.github.com', auth_scheme 'none', scope 'TOP_SECRET')", + "CREATE SERVER endpoint_invalid FOREIGN DATA WRAPPER pg_durable_fdw OPTIONS (base_url 'https://api.github.com?key=TOP_SECRET', auth_scheme 'none')", + "CREATE USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (header_value E'TOP_SECRET\\r\\n')", + "CREATE USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (unknown 'TOP_SECRET')", + ] { + let error = sqlx::raw_sql(statement).execute(&mut alice).await.unwrap_err(); + assert!(!error.to_string().contains("TOP_SECRET")); + } + + sqlx::raw_sql( + "CREATE USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (token 'ALICE_TOKEN=='); + CREATE SERVER endpoint_owned FOREIGN DATA WRAPPER pg_durable_fdw OPTIONS (base_url 'https://api.github.com', auth_scheme 'none'); + ALTER SERVER endpoint_owned OPTIONS (ADD header_name 'x-api-key', SET auth_scheme 'header');", + ) + .execute(&mut alice) + .await + .unwrap(); + assert!(sqlx::raw_sql("ALTER SERVER endpoint_owned OPTIONS (DROP header_name)") + .execute(&mut alice).await.is_err()); + assert!(sqlx::raw_sql("ALTER SERVER endpoint_owned OPTIONS (DROP base_url)") + .execute(&mut alice).await.is_err()); + assert!(sqlx::raw_sql("CREATE FOREIGN TABLE endpoint_table (value text) SERVER endpoint_owned") + .execute(&mut alice).await.is_err()); + + sqlx::raw_sql("CREATE USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (token 'BOB_TOKEN')") + .execute(&mut bob).await.unwrap(); + let visible: bool = sqlx::query_scalar( + "SELECT umoptions IS NOT NULL FROM pg_catalog.pg_user_mappings WHERE srvname = 'endpoint_test' AND usename = CURRENT_USER", + ).fetch_one(&mut alice).await.unwrap(); + assert!(visible); + let peer_visible: bool = sqlx::query_scalar( + "SELECT umoptions IS NOT NULL FROM pg_catalog.pg_user_mappings WHERE srvname = 'endpoint_test' AND usename = 'endpoint_bob'", + ).fetch_one(&mut alice).await.unwrap(); + assert!(!peer_visible); + + let resolved = resolve_endpoint("endpoint Alice", Some(&database), "endpoint_test").await.unwrap(); + assert_eq!(resolved.base_url.as_str(), "https://api.github.com/"); + match resolved.auth { + EndpointAuth::Bearer(value) => { + assert_eq!(value, "Bearer ALICE_TOKEN=="); + assert!(value.is_sensitive()); + } + _ => panic!("Expected bearer authentication"), + } + let resolved = resolve_endpoint("endpoint_bob", Some(&database), "endpoint_test").await.unwrap(); + assert!(matches!(resolved.auth, EndpointAuth::Bearer(value) if value == "Bearer BOB_TOKEN")); + + sqlx::raw_sql(r#"ALTER ROLE "endpoint Alice" NOINHERIT; + GRANT endpoint_bob TO "endpoint Alice"; + ALTER ROLE "endpoint Alice" SET role = 'endpoint_bob';"#) + .execute(&mut connection).await.unwrap(); + let mut changed_identity = crate::types::connect_as_user("endpoint Alice", Some(&database)) + .await.unwrap(); + let effective_role: String = sqlx::query_scalar("SELECT CURRENT_USER::text") + .fetch_one(&mut changed_identity).await.unwrap(); + assert_eq!(effective_role, "endpoint_bob"); + drop(changed_identity); + for error in [ + resolve_endpoint("endpoint Alice", Some(&database), "endpoint_test").await.err().unwrap(), + resolve_named_secrets("endpoint Alice", Some(&database), "endpoint_test").await.err().unwrap(), + ] { + assert!(error.contains("identity differs from submitting role")); + assert!(!error.contains("BOB_TOKEN")); + } + sqlx::raw_sql(r#"ALTER ROLE "endpoint Alice" RESET role; + REVOKE endpoint_bob FROM "endpoint Alice"; + ALTER ROLE "endpoint Alice" INHERIT;"#) + .execute(&mut connection).await.unwrap(); + + sqlx::raw_sql("ALTER USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (SET token 'ROTATED_TOKEN')") + .execute(&mut alice).await.unwrap(); + let resolved = resolve_endpoint("endpoint Alice", Some(&database), "endpoint_test").await.unwrap(); + assert!(matches!(resolved.auth, EndpointAuth::Bearer(value) if value == "Bearer ROTATED_TOKEN")); + + sqlx::raw_sql(r#"ALTER USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (ADD "secret.key" 'ALICE_SECRET', ADD "secret.empty" '', ADD "secret.token" 'NAMED_TOKEN')"#) + .execute(&mut alice).await.unwrap(); + sqlx::raw_sql(r#"ALTER USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (ADD "secret.key" 'BOB_SECRET')"#) + .execute(&mut bob).await.unwrap(); + let values = resolve_named_secrets("endpoint Alice", Some(&database), "endpoint_test").await.unwrap(); + assert_eq!(values["key"], "ALICE_SECRET"); + assert_eq!(values["empty"], ""); + assert_eq!(values["token"], "NAMED_TOKEN"); + assert_eq!(resolve_named_secrets("endpoint_bob", Some(&database), "endpoint_test").await.unwrap()["key"], "BOB_SECRET"); + sqlx::raw_sql(r#"ALTER USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (ADD "secret.Mixed.Key" 'abc=={"nested":true}')"#) + .execute(&mut alice).await.unwrap(); + let values = resolve_named_secrets("endpoint Alice", Some(&database), "endpoint_test").await.unwrap(); + assert_eq!(values["key"], "ALICE_SECRET"); + assert_eq!(values["Mixed.Key"], "abc=={\"nested\":true}"); + sqlx::raw_sql(r#"ALTER USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (SET "secret.key" 'ROTATED_SECRET', DROP "secret.Mixed.Key")"#) + .execute(&mut alice).await.unwrap(); + let values = resolve_named_secrets("endpoint Alice", Some(&database), "endpoint_test").await.unwrap(); + assert_eq!(values["key"], "ROTATED_SECRET"); + assert_eq!(values["empty"], ""); + assert_eq!(values["token"], "NAMED_TOKEN"); + assert!(!values.contains_key("Mixed.Key")); + let resolved = resolve_endpoint("endpoint Alice", Some(&database), "endpoint_test").await.unwrap(); + assert!(matches!(resolved.auth, EndpointAuth::Bearer(value) if value == "Bearer ROTATED_TOKEN")); + for statement in [ + r#"ALTER USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (ADD "secret." 'DO_NOT_ECHO')"#, + r#"ALTER USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (ADD secrets '{"key":"DO_NOT_ECHO"}')"#, + r#"ALTER USER MAPPING FOR CURRENT_USER SERVER endpoint_test OPTIONS (ADD scope 'DO_NOT_ECHO')"#, + r#"ALTER SERVER endpoint_owned OPTIONS (ADD "secret.key" 'DO_NOT_ECHO')"#, + ] { + let error = sqlx::raw_sql(statement).execute(&mut alice).await.unwrap_err(); + assert!(!error.to_string().contains("DO_NOT_ECHO")); + } + + sqlx::raw_sql("REVOKE USAGE ON FOREIGN SERVER endpoint_test FROM \"endpoint Alice\"") + .execute(&mut connection).await.unwrap(); + let error = resolve_endpoint("endpoint Alice", Some(&database), "endpoint_test").await.err().unwrap(); + assert!(error.contains("USAGE")); + assert!(resolve_named_secrets("endpoint Alice", Some(&database), "endpoint_test").await.err().unwrap().contains("USAGE")); + sqlx::raw_sql("GRANT USAGE ON FOREIGN SERVER endpoint_test TO \"endpoint Alice\"") + .execute(&mut connection).await.unwrap(); + sqlx::raw_sql("DROP USER MAPPING FOR CURRENT_USER SERVER endpoint_test") + .execute(&mut alice).await.unwrap(); + sqlx::raw_sql("CREATE USER MAPPING FOR PUBLIC SERVER endpoint_test OPTIONS (token 'PUBLIC_TOKEN')") + .execute(&mut connection).await.unwrap(); + let error = resolve_endpoint("endpoint Alice", Some(&database), "endpoint_test").await.err().unwrap(); + assert!(error.contains("PUBLIC mappings are unsupported")); + + sqlx::raw_sql("CREATE USER MAPPING FOR CURRENT_USER SERVER endpoint_test") + .execute(&mut alice).await.unwrap(); + let error = resolve_endpoint("endpoint Alice", Some(&database), "endpoint_test").await.err().unwrap(); + assert!(error.contains("missing or inaccessible")); + + sqlx::raw_sql("CREATE USER MAPPING FOR CURRENT_USER SERVER endpoint_owned OPTIONS (header_value 'HEADER_TOKEN')") + .execute(&mut alice).await.unwrap(); + let resolved = resolve_endpoint("endpoint Alice", Some(&database), "endpoint_owned").await.unwrap(); + assert!(matches!(resolved.auth, EndpointAuth::Header { name, value } if name == "x-api-key" && value == "HEADER_TOKEN" && value.is_sensitive())); + sqlx::raw_sql( + "ALTER SERVER endpoint_owned OPTIONS (SET auth_scheme 'query', DROP header_name); + ALTER USER MAPPING FOR CURRENT_USER SERVER endpoint_owned OPTIONS (DROP header_value, ADD query_string '?sig=abc%2Fdef%3D&sv=1');", + ).execute(&mut alice).await.unwrap(); + let resolved = resolve_endpoint("endpoint Alice", Some(&database), "endpoint_owned").await.unwrap(); + assert!(matches!(resolved.auth, EndpointAuth::Query(value) if value == "sig=abc%2Fdef%3D&sv=1")); + sqlx::raw_sql("ALTER SERVER endpoint_owned OPTIONS (SET auth_scheme 'bearer')") + .execute(&mut alice).await.unwrap(); + assert!(resolve_endpoint("endpoint Alice", Some(&database), "endpoint_owned").await.err().unwrap().contains("'token' is required")); + let error = resolve_endpoint("endpoint Alice", Some(&database), "endpoint_none").await.err().unwrap(); + assert!(error.contains("has no base_url")); + assert!(error.contains("endpoint_none")); + assert!(resolve_named_secrets("endpoint Alice", Some(&database), "endpoint_none").await.err().unwrap().contains("mapping for the submitting role is required")); + sqlx::raw_sql(r#"CREATE USER MAPPING FOR CURRENT_USER SERVER endpoint_none OPTIONS ("secret.key" 'NONE_SECRET')"#) + .execute(&mut alice).await.unwrap(); + assert_eq!(resolve_named_secrets("endpoint Alice", Some(&database), "endpoint_none").await.unwrap()["key"], "NONE_SECRET"); + assert!(resolve_named_secrets("endpoint_bob", Some(&database), "endpoint_none").await.err().unwrap().contains("USAGE")); + for statement in [ + "ALTER SERVER endpoint_none OPTIONS (SET auth_scheme 'bearer')", + "ALTER SERVER endpoint_none OPTIONS (ADD base_url 'http://api.github.com')", + "ALTER SERVER endpoint_none OPTIONS (ADD base_url '')", + ] { + assert!(sqlx::raw_sql(statement).execute(&mut connection).await.is_err()); + } + sqlx::raw_sql("ALTER SERVER endpoint_none OPTIONS (ADD base_url 'https://api.github.com')") + .execute(&mut connection).await.unwrap(); + let resolved = resolve_endpoint("endpoint Alice", Some(&database), "endpoint_none").await.unwrap(); + assert_eq!(resolved.base_url.as_str(), "https://api.github.com/"); + assert!(matches!(resolved.auth, EndpointAuth::None)); + sqlx::raw_sql("ALTER SERVER endpoint_none OPTIONS (DROP base_url)") + .execute(&mut connection).await.unwrap(); + assert_eq!(resolve_named_secrets("endpoint Alice", Some(&database), "endpoint_none").await.unwrap()["key"], "NONE_SECRET"); + assert!(resolve_endpoint("endpoint Alice", Some(&database), "endpoint_missing").await.err().unwrap().contains("does not exist")); + assert!(resolve_endpoint("endpoint Alice", Some(&database), "endpoint_other").await.err().unwrap().contains("must use pg_durable_fdw")); + + sqlx::raw_sql("ALTER EXTENSION pg_durable DROP FOREIGN DATA WRAPPER pg_durable_fdw") + .execute(&mut connection).await.unwrap(); + assert!(resolve_endpoint("endpoint Alice", Some(&database), "endpoint_none").await.err().unwrap().contains("update the pg_durable extension schema")); + sqlx::raw_sql("ALTER EXTENSION pg_durable ADD FOREIGN DATA WRAPPER pg_durable_fdw") + .execute(&mut connection).await.unwrap(); + + drop(alice); + drop(bob); + sqlx::raw_sql( + r#" + DROP SERVER endpoint_test, endpoint_none, endpoint_owned, endpoint_other CASCADE; + DROP FOREIGN DATA WRAPPER endpoint_other_fdw; + DROP OWNED BY "endpoint Alice", endpoint_bob; + DROP ROLE "endpoint Alice", endpoint_bob; + "#, + ).execute(&mut connection).await.unwrap(); + }); + } +} diff --git a/src/explain.rs b/src/explain.rs index 3f828515..f004c4c3 100644 --- a/src/explain.rs +++ b/src/explain.rs @@ -674,6 +674,9 @@ fn format_node_display(node: &ExplainNode) -> String { .map(|cfg| { let method = cfg["method"].as_str().unwrap_or("POST"); let url = cfg["url"].as_str().unwrap_or("?"); + if let Some(server) = cfg["endpoint"].as_str() { + return (method.to_string(), format!("endpoint {server:?} {url}")); + } // Truncate long URLs let display_url = if url.len() > 40 { format!("{}...", &url[..37]) diff --git a/src/lib.rs b/src/lib.rs index e9125d94..48074724 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,12 +66,14 @@ pub static LOG_WORKFLOW_SQL: GucSetting = GucSetting::::new(true); pub mod activities; pub mod client; pub mod dsl; +pub mod endpoints; pub mod explain; pub mod monitoring; pub mod node_status; pub mod orchestrations; pub mod redact; pub mod registry; +pub mod secrets; pub mod ssrf; pub mod types; pub mod worker; @@ -150,7 +152,7 @@ pub extern "C-unwind" fn _PG_init() { GucRegistry::define_int_guc( c"pg_durable.max_user_connections", - c"Maximum number of concurrent user-execution connections for SQL node execution", + c"Maximum number of concurrent user connections for SQL execution and HTTP credential catalogs", c"", &MAX_USER_CONNECTIONS, 1, @@ -172,7 +174,7 @@ pub extern "C-unwind" fn _PG_init() { GucRegistry::define_int_guc( c"pg_durable.execution_acquire_timeout", - c"Seconds to wait for an available execution slot before failing a SQL node", + c"Seconds to wait for a user connection slot before failing SQL execution or HTTP credential lookup", c"", &EXECUTION_ACQUIRE_TIMEOUT, 1, @@ -549,8 +551,10 @@ BEGIN -- df.http() — opt-in because it makes outbound network requests. IF include_http THEN EXECUTE pg_catalog.format('GRANT EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + EXECUTE pg_catalog.format('GRANT EXECUTE ON FUNCTION df.http(df.http_endpoint, text, text, jsonb, integer) TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; -- df.http_multipart() shares the same opt-in (HTTP egress is one privilege). EXECUTE pg_catalog.format('GRANT EXECUTE ON FUNCTION df.http_multipart(text, text, jsonb, jsonb, integer) TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; + EXECUTE pg_catalog.format('GRANT EXECUTE ON FUNCTION df.http_multipart(df.http_endpoint, text, jsonb, jsonb, integer) TO %I', p_role) OPERATOR(pg_catalog.||) grant_opt; END IF; -- Admin helpers and system-wide metrics — with_grant => true marks a @@ -595,6 +599,11 @@ BEGIN EXCEPTION WHEN insufficient_privilege THEN NULL; END; + BEGIN + EXECUTE pg_catalog.format('REVOKE EXECUTE ON FUNCTION df.http(df.http_endpoint, text, text, jsonb, integer) FROM %I CASCADE', p_role); + EXCEPTION WHEN insufficient_privilege THEN + NULL; + END; BEGIN EXECUTE pg_catalog.format('REVOKE EXECUTE ON FUNCTION df.metrics() FROM %I CASCADE', p_role); EXCEPTION WHEN insufficient_privilege THEN @@ -605,6 +614,11 @@ BEGIN EXCEPTION WHEN insufficient_privilege THEN NULL; END; + BEGIN + EXECUTE pg_catalog.format('REVOKE EXECUTE ON FUNCTION df.http_multipart(df.http_endpoint, text, jsonb, jsonb, integer) FROM %I CASCADE', p_role); + EXCEPTION WHEN insufficient_privilege THEN + NULL; + END; BEGIN EXECUTE pg_catalog.format('REVOKE EXECUTE ON FUNCTION df.grant_usage(text, boolean, boolean) FROM %I CASCADE', p_role); EXCEPTION WHEN insufficient_privilege THEN @@ -660,8 +674,10 @@ END $$; -- functions explicitly to authorized roles; df.metrics() is granted to -- with_grant => true admins or by a direct administrator GRANT. REVOKE EXECUTE ON FUNCTION df.http(text, text, text, jsonb, integer) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION df.http(df.http_endpoint, text, text, jsonb, integer) FROM PUBLIC; REVOKE EXECUTE ON FUNCTION df.metrics() FROM PUBLIC; REVOKE EXECUTE ON FUNCTION df.http_multipart(text, text, jsonb, jsonb, integer) FROM PUBLIC; +REVOKE EXECUTE ON FUNCTION df.http_multipart(df.http_endpoint, text, jsonb, jsonb, integer) FROM PUBLIC; REVOKE EXECUTE ON FUNCTION df.grant_usage(text, boolean, boolean) FROM PUBLIC; REVOKE EXECUTE ON FUNCTION df.revoke_usage(text) FROM PUBLIC; "#, @@ -669,7 +685,9 @@ REVOKE EXECUTE ON FUNCTION df.revoke_usage(text) FROM PUBLIC; requires = [ "create_tables", dsl::http, + dsl::http_endpoint, dsl::http_multipart, + dsl::http_multipart_endpoint, monitoring::metrics ] ); diff --git a/src/orchestrations/execute_function_graph.rs b/src/orchestrations/execute_function_graph.rs index 6b86b001..70f44c41 100644 --- a/src/orchestrations/execute_function_graph.rs +++ b/src/orchestrations/execute_function_graph.rs @@ -1963,7 +1963,11 @@ async fn execute_http_node( } // Inject audit context from the function node - config["submitted_by"] = serde_json::Value::String(node.submitted_by.clone()); + crate::endpoints::set_execution_context( + &mut config, + &node.submitted_by, + node.database.as_deref(), + ); let final_config = config.to_string(); let url = config["url"].as_str().unwrap_or("?"); @@ -2068,7 +2072,11 @@ async fn execute_http_multipart_node( } // Inject audit context from the function node. - config["submitted_by"] = serde_json::Value::String(node.submitted_by.clone()); + crate::endpoints::set_execution_context( + &mut config, + &node.submitted_by, + node.database.as_deref(), + ); let final_config = config.to_string(); let url = config["url"].as_str().unwrap_or("?"); diff --git a/src/registry.rs b/src/registry.rs index 1eee1517..c50b9561 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -14,7 +14,9 @@ use crate::orchestrations; /// Create the activity registry with all registered activities pub fn create_activity_registry(pool: Arc, semaphore: Arc) -> ActivityRegistry { - let sql_semaphore = semaphore; + let sql_semaphore = semaphore.clone(); + let http_semaphore = semaphore.clone(); + let multipart_semaphore = semaphore; let graph_pool = pool.clone(); let transaction_graph_pool = pool.clone(); let status_pool = pool.clone(); @@ -50,11 +52,13 @@ pub fn create_activity_registry(pool: Arc, semaphore: Arc) -> }) .register(activities::execute_http::NAME, move |ctx: ActivityContext, config_json: String| { let pool = http_pool.clone(); - async move { activities::execute_http::execute(ctx, pool, config_json).await } + let semaphore = http_semaphore.clone(); + async move { activities::execute_http::execute(ctx, pool, semaphore, config_json).await } }) .register(activities::execute_multipart::NAME, move |ctx: ActivityContext, config_json: String| { let pool = multipart_pool.clone(); - async move { activities::execute_multipart::execute(ctx, pool, config_json).await } + let semaphore = multipart_semaphore.clone(); + async move { activities::execute_multipart::execute(ctx, pool, semaphore, config_json).await } }) .build() } diff --git a/src/secrets.rs b/src/secrets.rs new file mode 100644 index 00000000..29338f2e --- /dev/null +++ b/src/secrets.rs @@ -0,0 +1,624 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use pgrx::prelude::*; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const SECRET_OPTION_PREFIX: &str = "secret."; + +pub fn validate_secret_key(key: &str) -> Result<(), String> { + if key.is_empty() || key.contains('=') || key.chars().any(char::is_control) { + return Err("Secret keys must be nonempty without control characters or '='".into()); + } + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SecretReference { + pub server: String, + pub key: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub prefix: String, +} + +impl SecretReference { + fn validate(&self) -> Result<(), String> { + if self.server.is_empty() || self.server.chars().any(char::is_control) { + return Err( + "Secret references require a nonempty server name without control characters" + .into(), + ); + } + validate_secret_key(&self.key) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SecretBindings { + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub query: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub form: BTreeMap, +} + +pub(crate) fn credential_header_name(name: &str) -> Result { + let name = reqwest::header::HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| "Invalid credential header name")?; + if matches!( + name.as_str(), + "host" + | "content-type" + | "content-length" + | "transfer-encoding" + | "connection" + | "proxy-authorization" + | "proxy-authenticate" + | "te" + | "trailer" + | "upgrade" + | "keep-alive" + ) { + return Err("Credential headers cannot control HTTP routing or framing".into()); + } + Ok(name) +} + +impl SecretBindings { + pub fn validate(&self) -> Result<(), String> { + let mut headers = BTreeSet::new(); + for (name, reference) in &self.headers { + reference.validate()?; + let name = credential_header_name(name)?; + if !headers.insert(name.as_str().to_owned()) { + return Err("Duplicate secret header binding (case-insensitive)".into()); + } + reqwest::header::HeaderValue::from_str(&reference.prefix) + .map_err(|_| "Invalid secret header prefix")?; + } + for (name, reference) in self.query.iter().chain(&self.form) { + reference.validate()?; + if name.is_empty() || name.chars().any(char::is_control) { + return Err( + "Secret field names must be nonempty without control characters".into(), + ); + } + if !reference.prefix.is_empty() { + return Err("Secret prefixes are supported only in header bindings".into()); + } + } + Ok(()) + } +} + +#[pg_extern(schema = "df", immutable, parallel_safe)] +pub fn secret(server: &str, key: &str) -> pgrx::JsonB { + let reference = SecretReference { + server: server.into(), + key: key.into(), + prefix: String::new(), + }; + reference + .validate() + .unwrap_or_else(|error| pgrx::error!("{}", error)); + pgrx::JsonB(serde_json::to_value(reference).expect("Secret reference serialization failed")) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SecretOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub secret_bindings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub form_fields: Option>, +} + +pub struct ResolvedBindings { + pub headers: reqwest::header::HeaderMap, + pub form_body: Option, +} + +impl SecretOptions { + fn form_mode(&self) -> bool { + self.form_fields.is_some() + || self + .secret_bindings + .as_ref() + .is_some_and(|bindings| !bindings.form.is_empty()) + } + + pub fn validate( + &self, + has_body: bool, + method: &str, + multipart: bool, + headers: Option<&Value>, + ) -> Result<(), String> { + if let Some(bindings) = &self.secret_bindings { + bindings.validate()?; + } + if self.form_mode() { + if multipart || has_body { + return Err( + "Form fields cannot be combined with a raw body or multipart request".into(), + ); + } + if !matches!(method, "POST" | "PUT" | "PATCH") { + return Err("Form fields require POST, PUT or PATCH".into()); + } + for name in self.form_fields.iter().flat_map(|fields| fields.keys()) { + if name.is_empty() || name.chars().any(char::is_control) { + return Err( + "Form field names must be nonempty without control characters".into(), + ); + } + if self + .secret_bindings + .as_ref() + .is_some_and(|bindings| bindings.form.contains_key(name)) + { + return Err( + "A form field cannot have both an ordinary value and a secret binding" + .into(), + ); + } + } + for (name, value) in headers.and_then(Value::as_object).into_iter().flatten() { + if name.eq_ignore_ascii_case("content-length") + || name.eq_ignore_ascii_case("transfer-encoding") + { + return Err("Form requests cannot override HTTP body framing".into()); + } + if name.eq_ignore_ascii_case("content-type") + && !value.as_str().is_some_and(|value| { + value.eq_ignore_ascii_case("application/x-www-form-urlencoded") + }) + { + return Err( + "Form requests require application/x-www-form-urlencoded Content-Type" + .into(), + ); + } + } + } + Ok(()) + } + + fn validate_destinations( + &self, + request: &crate::endpoints::EndpointRequest, + headers: Option<&Value>, + ) -> Result<(), String> { + let Some(bindings) = &self.secret_bindings else { + return Ok(()); + }; + bindings.validate()?; + let active = + !bindings.headers.is_empty() || !bindings.query.is_empty() || !bindings.form.is_empty(); + for name in headers + .and_then(Value::as_object) + .into_iter() + .flat_map(|headers| headers.keys()) + { + if (active && name.eq_ignore_ascii_case("host")) + || bindings + .headers + .keys() + .any(|secret_name| name.eq_ignore_ascii_case(secret_name)) + { + return Err( + "Secret bindings conflict with ordinary request headers or Host".into(), + ); + } + } + if let Some((name, _)) = &request.credential_header { + if bindings + .headers + .keys() + .any(|secret_name| secret_name.eq_ignore_ascii_case(name.as_str())) + { + return Err("Secret binding cannot override endpoint authentication".into()); + } + } + if request + .url + .query_pairs() + .any(|(name, _)| bindings.query.contains_key(name.as_ref())) + { + return Err("Secret binding conflicts with an existing query parameter".into()); + } + Ok(()) + } + + pub async fn resolve( + &self, + endpoint_catalog: &mut crate::endpoints::EndpointCatalog<'_>, + request: &mut crate::endpoints::EndpointRequest, + headers: Option<&Value>, + ) -> Result { + self.validate_destinations(request, headers)?; + let mut catalog = BTreeMap::new(); + if let Some(bindings) = &self.secret_bindings { + let servers = bindings + .headers + .values() + .chain(bindings.query.values()) + .chain(bindings.form.values()) + .map(|reference| reference.server.as_str()) + .collect::>(); + for server in servers { + catalog.insert( + server.to_owned(), + endpoint_catalog + .resolve_named_secrets(server) + .await + .map_err(|error| format!("Secret server {server:?}: {error}"))?, + ); + } + } + self.materialize(request, &catalog) + } + + fn materialize( + &self, + request: &mut crate::endpoints::EndpointRequest, + catalog: &BTreeMap>, + ) -> Result { + let lookup = + |slot: &str, name: &str, reference: &SecretReference| -> Result<&str, String> { + catalog + .get(&reference.server) + .and_then(|values| values.get(&reference.key)) + .map(String::as_str) + .ok_or_else(|| { + format!( + "Referenced secret key is missing: {slot}[{name:?}], server {:?}, key {:?}", + reference.server, reference.key + ) + }) + }; + let mut headers = reqwest::header::HeaderMap::new(); + let mut fields = self.form_fields.clone().unwrap_or_default(); + if let Some(bindings) = &self.secret_bindings { + for (name, reference) in &bindings.headers { + let header_name = credential_header_name(name)?; + let mut value = reqwest::header::HeaderValue::from_str(&format!( + "{}{}", + reference.prefix, + lookup("headers", name, reference)? + )) + .map_err(|_| { + format!( + "Resolved secret is not a valid HTTP header value: headers[{name:?}], server {:?}, key {:?}", + reference.server, reference.key + ) + })?; + value.set_sensitive(true); + headers.insert(header_name, value); + } + if !bindings.query.is_empty() { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for (name, reference) in &bindings.query { + serializer.append_pair(name, lookup("query", name, reference)?); + } + let encoded = serializer.finish(); + let query = match request.url.query().filter(|query| !query.is_empty()) { + Some(existing) => format!("{existing}&{encoded}"), + None => encoded, + }; + request.url.set_query(Some(&query)); + } + for (name, reference) in &bindings.form { + fields.insert(name.clone(), lookup("form", name, reference)?.to_owned()); + } + } + let form_body = if self.form_mode() { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.extend_pairs(&fields); + Some(serializer.finish()) + } else { + None + }; + Ok(ResolvedBindings { headers, form_body }) + } +} + +pub fn configure_bindings( + request: &str, + bindings: Value, + form: Option, +) -> Result { + let bindings: SecretBindings = serde_json::from_value(bindings).map_err(|_| { + "Invalid secret bindings: expected header/query/form maps of secret references" + })?; + bindings.validate()?; + let mut node: Value = + serde_json::from_str(request).map_err(|_| "Secret bindings require an HTTP node")?; + let multipart = match node.get("node_type").and_then(Value::as_str) { + Some("HTTP") => false, + Some("HTTP_MULTIPART") => true, + _ => return Err("Secret bindings require a single HTTP or HTTP_MULTIPART node".into()), + }; + let mut config: Value = serde_json::from_str( + node.get("query") + .and_then(Value::as_str) + .ok_or("HTTP node has no request configuration")?, + ) + .map_err(|_| "Invalid HTTP request configuration")?; + if !config.is_object() { + return Err("Invalid HTTP request configuration".into()); + } + let form_fields: Option> = form + .map(serde_json::from_value) + .transpose() + .map_err(|_| "Form fields must be an object of string values")?; + let options = SecretOptions { + secret_bindings: Some(bindings), + form_fields, + }; + options.validate( + config.get("body").is_some_and(|body| !body.is_null()), + config.get("method").and_then(Value::as_str).unwrap_or(""), + multipart, + config.get("headers"), + )?; + config["secret_bindings"] = + serde_json::to_value(options.secret_bindings).map_err(|_| "Invalid secret bindings")?; + if let Some(fields) = options.form_fields { + config["form_fields"] = serde_json::to_value(fields).map_err(|_| "Invalid form fields")?; + } + node["query"] = Value::String(config.to_string()); + Ok(node.to_string()) +} + +#[cfg(test)] +mod unit_tests { + use super::*; + use serde_json::json; + + fn request() -> String { + json!({"node_type":"HTTP","query":json!({"url":"https://api.github.com/","method":"POST","body":null}).to_string()}).to_string() + } + + #[test] + fn secret_bindings_encode_without_rescanning_data() { + let options: SecretOptions = serde_json::from_value(json!({ + "secret_bindings": { + "headers":{"Authorization":{"server":"foo","key":"token","prefix":"Bearer "}}, + "query":{"api_key":{"server":"foo","key":"delimiters"}}, + "form":{"password":{"server":"foo","key":"delimiters"}} + }, + "form_fields":{"payload":"${secret:foo.other} $result {var}", "empty":""} + })) + .unwrap(); + let mut request = crate::endpoints::EndpointRequest { + url: url::Url::parse("https://api.github.com/?sig=existing%2Bvalue").unwrap(), + credential_header: None, + }; + options.validate(false, "POST", false, None).unwrap(); + options.validate_destinations(&request, None).unwrap(); + let catalog = BTreeMap::from([( + "foo".into(), + BTreeMap::from([ + ("token".into(), "${secret:foo.other}".into()), + ("delimiters".into(), "a&b+c= %\"\n\u{00e9}".into()), + ]), + )]); + let resolved = options.materialize(&mut request, &catalog).unwrap(); + assert_eq!( + resolved.headers["authorization"], + "Bearer ${secret:foo.other}" + ); + assert!(resolved.headers["authorization"].is_sensitive()); + assert!(request + .url + .query() + .unwrap() + .starts_with("sig=existing%2Bvalue&")); + let query: BTreeMap<_, _> = request.url.query_pairs().into_owned().collect(); + assert_eq!(query["api_key"], "a&b+c= %\"\n\u{00e9}"); + let form: BTreeMap<_, _> = + url::form_urlencoded::parse(resolved.form_body.unwrap().as_bytes()) + .into_owned() + .collect(); + assert_eq!(form["password"], query["api_key"]); + assert_eq!(form["payload"], "${secret:foo.other} $result {var}"); + assert_eq!(form["empty"], ""); + } + + #[test] + fn secret_bindings_reject_transport_conflicts() { + let options: SecretOptions = serde_json::from_value(json!({"secret_bindings":{"headers":{"X-Key":{"server":"foo","key":"bar"}},"query":{"key":{"server":"foo","key":"bar"}}}})).unwrap(); + let mut request = crate::endpoints::EndpointRequest { + url: url::Url::parse("https://api.github.com/?%6bey=ordinary").unwrap(), + credential_header: None, + }; + assert!(options.validate_destinations(&request, None).is_err()); + request.url.set_query(None); + assert!(options + .validate_destinations(&request, Some(&json!({"x-key":"ordinary"}))) + .is_err()); + assert!(options + .validate_destinations(&request, Some(&json!({"Host":"other"}))) + .is_err()); + request.credential_header = Some(( + reqwest::header::HeaderName::from_static("x-key"), + reqwest::header::HeaderValue::from_static("endpoint"), + )); + assert!(options.validate_destinations(&request, None).is_err()); + let form: SecretOptions = serde_json::from_value(json!({"form_fields":{}})).unwrap(); + assert!(form.validate(true, "POST", false, None).is_err()); + assert!(form.validate(false, "POST", true, None).is_err()); + assert!(form.validate(false, "GET", false, None).is_err()); + assert!(form + .validate( + false, + "POST", + false, + Some(&json!({"Content-Type":"application/json"})) + ) + .is_err()); + assert!(form + .validate(false, "POST", false, Some(&json!({"Content-Length":"0"}))) + .is_err()); + } + + #[test] + fn secret_bindings_errors_do_not_echo_values() { + for key in ["", "PRIVATE_VALUE\n", "PRIVATE_VALUE=other"] { + assert!(!validate_secret_key(key) + .unwrap_err() + .contains("PRIVATE_VALUE")); + } + let options: SecretOptions = serde_json::from_value( + json!({"secret_bindings":{"headers":{"x-key":{"server":"foo","key":"bar"}}}}), + ) + .unwrap(); + let mut request = crate::endpoints::EndpointRequest { + url: url::Url::parse("https://api.github.com/").unwrap(), + credential_header: None, + }; + let catalog = BTreeMap::from([( + "foo".into(), + BTreeMap::from([("bar".into(), "PRIVATE_VALUE\r\n".into())]), + )]); + let error = options.materialize(&mut request, &catalog).err().unwrap(); + assert!(!error.contains("PRIVATE_VALUE")); + assert!(error.contains(r#"headers["x-key"], server "foo", key "bar""#)); + for slot in ["headers", "query", "form"] { + let options: SecretOptions = serde_json::from_value(json!({ + "secret_bindings": {slot: {"x-key": {"server": "missing\nserver", "key": "absent"}}} + })) + .unwrap(); + let error = options.materialize(&mut request, &catalog).err().unwrap(); + assert!(error.contains(&format!("{slot}[\"x-key\"]"))); + assert!(error.contains(r#"server "missing\nserver", key "absent""#)); + assert!(!error.contains('\n')); + assert!(!error.contains("PRIVATE_VALUE")); + } + } + + #[test] + fn secret_bindings_preserve_literal_form_data() { + let data = json!({"payload":"${secret:foo.bar} $result {var}","descriptor":"{\"server\":\"other\",\"key\":\"private\"}"}); + let configured = configure_bindings( + &request(), + json!({"form":{"client_secret":{"server":"foo","key":"bar"}}}), + Some(data.clone()), + ) + .unwrap(); + let node: Value = serde_json::from_str(&configured).unwrap(); + let config: Value = serde_json::from_str(node["query"].as_str().unwrap()).unwrap(); + assert_eq!(config["form_fields"], data); + assert_eq!( + config["secret_bindings"]["form"]["client_secret"]["key"], + "bar" + ); + } + + #[test] + fn secret_bindings_reject_ambiguous_or_unsafe_shapes() { + for bindings in [ + json!({"body":{}}), + json!({"headers":{"Host":{"server":"foo","key":"bar"}}}), + json!({"headers":{"X-Key":{"server":"foo","key":"bar"},"x-key":{"server":"foo","key":"bar"}}}), + json!({"headers":{"X-Key":{"server":"foo","key":"bar","prefix":"bad\r\n"}}}), + json!({"query":{"key":{"server":"foo","key":"bar","prefix":"prefix"}}}), + json!({"form":{"key":"${secret:foo.bar}"}}), + json!({"form":{"key":{"server":"foo","key":""}}}), + ] { + assert!(configure_bindings(&request(), bindings, None).is_err()); + } + assert!(configure_bindings( + &request(), + json!({"form":{"key":{"server":"foo","key":"bar"}}}), + Some(json!({"key":"data"})) + ) + .is_err()); + assert!(configure_bindings( + &request(), + json!({}), + Some(json!({"key":{"server":"foo","key":"bar"}})) + ) + .is_err()); + } + + #[test] + fn secret_binding_serialization_is_canonical() { + let first: Value = serde_json::from_str( + r#"{"query":{"second":{"server":"foo","key":"b"},"first":{"key":"a","server":"foo"}}}"#, + ) + .unwrap(); + let second: Value = serde_json::from_str( + r#"{"query":{"first":{"server":"foo","key":"a"},"second":{"key":"b","server":"foo"}}}"#, + ) + .unwrap(); + assert_eq!( + configure_bindings(&request(), first, None).unwrap(), + configure_bindings(&request(), second, None).unwrap() + ); + } +} + +#[cfg(any(test, feature = "pg_test"))] +#[pg_schema] +mod tests { + use super::*; + + #[pg_test] + fn secret_reference_is_lookup_free() { + assert_eq!( + secret("missing.server", "key.\\\"").0, + serde_json::json!({"server":"missing.server","key":"key.\\\""}) + ); + } + + #[cfg(any( + feature = "http-allow-azure-domains", + feature = "http-allow-test-domains", + feature = "http-allow-all" + ))] + #[pg_test] + fn secret_options_preserve_data_and_noop_bytes() { + let request = crate::dsl::http("https://api.github.com", "POST", None, None, 30); + assert_eq!(crate::dsl::with_http_options(&request, None), request); + assert_eq!( + crate::dsl::with_http_options(&request, Some(pgrx::JsonB(serde_json::json!({})))), + request + ); + let options = serde_json::json!({ + "secret_bindings":{"form":{"password":secret("missing", "key").0}}, + "form_fields":{"payload":"${secret:missing.key} $result {variable}"} + }); + let configured = crate::dsl::with_http_options(&request, Some(pgrx::JsonB(options))); + let node = crate::types::Durofut::from_json(&configured); + let mut config: Value = serde_json::from_str(node.query.as_ref().unwrap()).unwrap(); + crate::endpoints::set_execution_context(&mut config, "caller", Some("trusted")); + assert_eq!(config["database"], "trusted"); + assert_eq!( + config["form_fields"]["payload"], + "${secret:missing.key} $result {variable}" + ); + let typed: crate::types::HttpConfig = serde_json::from_value(config).unwrap(); + assert_eq!( + typed.secret_options.secret_bindings.unwrap().form["password"].key, + "key" + ); + let replaced = crate::dsl::with_http_options( + &configured, + Some(pgrx::JsonB( + serde_json::json!({"form_fields":{"payload":"replacement"}}), + )), + ); + let replaced_node = crate::types::Durofut::from_json(&replaced); + let replaced_config: Value = + serde_json::from_str(replaced_node.query.as_ref().unwrap()).unwrap(); + assert_eq!( + replaced_config["secret_bindings"]["form"]["password"]["key"], + "key" + ); + assert_eq!(replaced_config["form_fields"]["payload"], "replacement"); + } +} diff --git a/src/types.rs b/src/types.rs index c0237d10..7d16933b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -71,6 +71,25 @@ pub fn get_execution_acquire_timeout() -> Duration { Duration::from_secs(crate::EXECUTION_ACQUIRE_TIMEOUT.get() as u64) } +pub async fn acquire_execution_permit( + semaphore: &tokio::sync::Semaphore, + timeout: Duration, + limit: u32, +) -> Result, String> { + match tokio::time::timeout(timeout, semaphore.acquire()).await { + Ok(Ok(permit)) => Ok(permit), + Ok(Err(_)) => Err(format!( + "pg_durable: connection limit reached (max_user_connections={limit}). \ + Semaphore closed unexpectedly." + )), + Err(_) => Err(format!( + "pg_durable: connection limit reached (max_user_connections={limit}). \ + Timed out after {}s waiting for an available execution slot.", + timeout.as_secs() + )), + } +} + /// Get the transaction_mode => 'new' launch-slot timeout as a Duration. pub fn get_new_transaction_start_timeout() -> Duration { Duration::from_secs(crate::NEW_TRANSACTION_START_TIMEOUT.get() as u64) @@ -1350,6 +1369,10 @@ pub(crate) fn string_map_to_json( #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HttpConfig { pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database: Option, pub method: String, #[serde(default)] pub body: Option, @@ -1360,6 +1383,8 @@ pub struct HttpConfig { /// Role that called df.start() (audit trail) #[serde(default)] pub submitted_by: Option, + #[serde(flatten)] + pub secret_options: crate::secrets::SecretOptions, } fn default_http_timeout() -> u64 { @@ -1385,6 +1410,10 @@ pub struct MultipartPart { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MultipartConfig { pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database: Option, pub method: String, pub parts: Vec, #[serde(default)] @@ -1394,6 +1423,8 @@ pub struct MultipartConfig { /// Role that called df.start() (audit trail) #[serde(default)] pub submitted_by: Option, + #[serde(flatten)] + pub secret_options: crate::secrets::SecretOptions, } // ============================================================================ @@ -1789,6 +1820,48 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn execution_admission_waits_and_releases() { + use std::future::Future; + use std::task::Poll; + + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let semaphore = tokio::sync::Semaphore::new(1); + let timeout = Duration::from_secs(30); + let first = acquire_execution_permit(&semaphore, timeout, 1) + .await + .unwrap(); + let waiting = acquire_execution_permit(&semaphore, timeout, 1); + tokio::pin!(waiting); + std::future::poll_fn(|context| { + assert!(waiting.as_mut().poll(context).is_pending()); + Poll::Ready(()) + }) + .await; + assert_eq!(semaphore.available_permits(), 0); + assert!(acquire_execution_permit(&semaphore, Duration::ZERO, 1) + .await + .err() + .unwrap() + .contains("Timed out after 0s")); + drop(first); + let second = waiting.await.unwrap(); + assert_eq!(semaphore.available_permits(), 0); + drop(second); + assert_eq!(semaphore.available_permits(), 1); + semaphore.close(); + assert!(acquire_execution_permit(&semaphore, timeout, 1) + .await + .err() + .unwrap() + .contains("Semaphore closed unexpectedly")); + }); + } + #[test] fn loop_config_defaults_to_fail_fast() { let config: LoopConfig = serde_json::from_str("{}").unwrap(); diff --git a/tests/e2e/sql/47_http_dsl_disabled.sql b/tests/e2e/sql/47_http_dsl_disabled.sql index 2c7283f6..a5a802a4 100644 --- a/tests/e2e/sql/47_http_dsl_disabled.sql +++ b/tests/e2e/sql/47_http_dsl_disabled.sql @@ -31,6 +31,29 @@ BEGIN RAISE EXCEPTION 'TEST FAILED: df.http() should raise at DSL time when HTTP is disabled'; END IF; + caught := false; + BEGIN + PERFORM df.http(df.endpoint('missing_server', '/path'), 'GET'); + EXCEPTION WHEN OTHERS THEN + IF SQLERRM ILIKE '%df.http() is disabled%' THEN + caught := true; + ELSE + RAISE; + END IF; + END; + IF NOT caught THEN RAISE EXCEPTION 'TEST FAILED: typed HTTP should be disabled'; END IF; + caught := false; + BEGIN + PERFORM df.http_multipart(df.endpoint('missing_server', '/path'), parts => '[{"name":"file","data_b64":"aA=="}]'); + EXCEPTION WHEN OTHERS THEN + IF SQLERRM ILIKE '%df.http_multipart() is disabled%' THEN + caught := true; + ELSE + RAISE; + END IF; + END; + IF NOT caught THEN RAISE EXCEPTION 'TEST FAILED: typed multipart should be disabled'; END IF; + RAISE NOTICE 'TEST PASSED: http_dsl_disabled_raises'; END $$; diff --git a/tests/e2e/sql/69_http_options.sql b/tests/e2e/sql/69_http_options.sql new file mode 100644 index 00000000..14bf08c5 --- /dev/null +++ b/tests/e2e/sql/69_http_options.sql @@ -0,0 +1,160 @@ +-- Copyright (c) Microsoft Corporation. +-- Licensed under the PostgreSQL License. + +-- Tests: df.with_http_options. +SELECT df.grant_usage('df_e2e_user', include_http => true); +SET SESSION AUTHORIZATION df_e2e_user; + +DO $$ +DECLARE + request_node TEXT; + named_node TEXT; + option_value JSONB; + expected_error TEXT; + actual_error TEXT; +BEGIN + FOREACH request_node IN ARRAY ARRAY[ + df.http('https://httpbingo.org/{path}', 'POST', '$response {value}', '{"Authorization":"${secret:server.token}"}', 15), + df.http_multipart('https://httpbingo.org/post', 'POST', '[{"name":"field","data_b64":"$response.body"}]') + ] LOOP + IF df.with_http_options(request_node, NULL) IS DISTINCT FROM request_node + OR df.with_http_options(request_node, '{}') IS DISTINCT FROM request_node + OR df.with_http_options(df.with_http_options(request_node, '{}'), '{}') IS DISTINCT FROM request_node THEN + RAISE EXCEPTION 'TEST FAILED: empty options changed the node'; + END IF; + + named_node := df.as(request_node, 'response'); + IF df.with_http_options(named_node, '{}') IS DISTINCT FROM named_node + OR df.as(df.with_http_options(request_node, '{}'), 'response') IS DISTINCT FROM named_node THEN + RAISE EXCEPTION 'TEST FAILED: options changed result naming'; + END IF; + + FOR option_value, expected_error IN + SELECT * FROM (VALUES + ('{"retry":3}'::jsonb, 'unrecognised option ''retry'''), + ('{"response":"metadata"}'::jsonb, 'unrecognised option ''response'''), + ('[]'::jsonb, 'options must be a JSON object'), + ('null'::jsonb, 'options must be a JSON object'), + ('true'::jsonb, 'options must be a JSON object'), + ('42'::jsonb, 'options must be a JSON object'), + ('"value"'::jsonb, 'options must be a JSON object') + ) AS cases(option_value, expected_error) + LOOP + actual_error := NULL; + BEGIN + PERFORM df.with_http_options(request_node, option_value); + EXCEPTION WHEN others THEN + actual_error := SQLERRM; + END; + IF actual_error IS NULL OR strpos(actual_error, expected_error) = 0 THEN + RAISE EXCEPTION 'TEST FAILED: expected %, got %', expected_error, actual_error; + END IF; + END LOOP; + END LOOP; + + request_node := ' { "result_name": "response", "query": "{\"url\":\"https://httpbingo.org/{path}\",\"method\":\"GET\",\"body\":\"$response ${secret:server.token}\"}", "node_type": "HTTP" } '; + IF df.with_http_options(request_node, '{}') IS DISTINCT FROM request_node THEN + RAISE EXCEPTION 'TEST FAILED: options reserialized the original graph text'; + END IF; + + FOREACH request_node IN ARRAY ARRAY[ + 'SELECT 1', '{', 'null', '{}', df.sql('SELECT 1'), df.sleep(1), + df.http('https://httpbingo.org/get') ~> 'SELECT 1', + '{"node_type":"HTTP"}', + '{"node_type":"HTTP","query":"not JSON"}', + '{"node_type":"HTTP_MULTIPART","query":"[]"}', + '{"node_type":"HTTP","query":"{}","left_node":{"node_type":"SQL","query":"SELECT 1"}}' + ] LOOP + actual_error := NULL; + BEGIN + PERFORM df.with_http_options(request_node, '{}'); + EXCEPTION WHEN others THEN + actual_error := SQLERRM; + END; + IF actual_error IS NULL OR actual_error NOT LIKE 'df.with_http_options(): %' THEN + RAISE EXCEPTION 'TEST FAILED: expected invalid-node rejection, got %', actual_error; + END IF; + END LOOP; + + RAISE NOTICE 'TEST PASSED: HTTP options validation and byte preservation'; +END $$; + +CREATE TEMP TABLE _test_http_options (instance_id TEXT, node_type TEXT, query TEXT); + +INSERT INTO _test_http_options +SELECT df.start( + df.with_http_options(request_node, '{}') |=> 'response' + ~> 'SELECT ($response::jsonb->>''status'')::integer as status', + 'test-http-options' + ), request_node::jsonb->>'node_type', request_node::jsonb->>'query' +FROM (VALUES + (df.http('https://httpbingo.org/get', 'GET')), + (df.http_multipart('https://httpbingo.org/post', 'POST', '[{"name":"field","data_b64":"aGk="}]')) +) AS requests(request_node); + +DO $$ +DECLARE + test_case RECORD; + status TEXT; +BEGIN + FOR test_case IN SELECT * FROM _test_http_options LOOP + SELECT df.await_instance(test_case.instance_id) INTO status; + IF status IS DISTINCT FROM 'completed' THEN + RAISE EXCEPTION 'TEST FAILED: % with options ended with %', test_case.node_type, status; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM df.nodes AS node + WHERE node.instance_id = test_case.instance_id + AND node.node_type = test_case.node_type + AND node.query = test_case.query + AND node.result_name = 'response' + ) THEN + RAISE EXCEPTION 'TEST FAILED: stored HTTP config or result name changed'; + END IF; + END LOOP; +END $$; + +DROP TABLE _test_http_options; +RESET SESSION AUTHORIZATION; + +DROP ROLE IF EXISTS http_options_denied; +CREATE ROLE http_options_denied LOGIN; +SELECT df.grant_usage('http_options_denied'); +SET SESSION AUTHORIZATION http_options_denied; + +CREATE TEMP TABLE _test_http_options_denied (instance_id TEXT, node_type TEXT); +INSERT INTO _test_http_options_denied +SELECT df.start(df.with_http_options( + jsonb_build_object('node_type', node_type, 'query', config)::text, '{}' + ), 'test-http-options-denied'), node_type +FROM (VALUES + ('HTTP', '{"url":"https://api.github.com/","method":"GET","timeout_seconds":1}'), + ('HTTP_MULTIPART', '{"url":"https://api.github.com/","method":"POST","parts":[{"name":"field","data_b64":"aGk="}],"timeout_seconds":1}') +) AS requests(node_type, config); + +DO $$ +DECLARE + test_case RECORD; + status TEXT; + node_result TEXT; +BEGIN + FOR test_case IN SELECT * FROM _test_http_options_denied LOOP + SELECT df.await_instance(test_case.instance_id) INTO status; + SELECT node.result::text INTO node_result + FROM df.nodes AS node + WHERE node.instance_id = test_case.instance_id AND node.node_type = test_case.node_type; + + IF status IS DISTINCT FROM 'failed' + OR node_result IS NULL + OR node_result NOT LIKE '%does not have EXECUTE privilege%' THEN + RAISE EXCEPTION 'TEST FAILED: options bypassed HTTP privilege checks: %, %', status, node_result; + END IF; + END LOOP; +END $$; + +DROP TABLE _test_http_options_denied; +RESET SESSION AUTHORIZATION; +DROP OWNED BY http_options_denied; +DROP ROLE http_options_denied; + +SELECT 'TEST PASSED' AS result; diff --git a/tests/e2e/sql/72_endpoint_catalog.sql b/tests/e2e/sql/72_endpoint_catalog.sql new file mode 100644 index 00000000..3375ff6b --- /dev/null +++ b/tests/e2e/sql/72_endpoint_catalog.sql @@ -0,0 +1,108 @@ +RESET SESSION AUTHORIZATION; +DROP SERVER IF EXISTS ec_server CASCADE; +DROP ROLE IF EXISTS ec_owner, ec_caller; +CREATE ROLE ec_owner LOGIN; +CREATE ROLE ec_caller LOGIN; +SELECT df.grant_usage('ec_owner', include_http => true, with_grant => true); +SELECT df.grant_usage('ec_caller', include_http => true); + +DO $$ +BEGIN + IF pg_catalog.has_foreign_data_wrapper_privilege('ec_owner', 'pg_durable_fdw', 'USAGE') THEN + RAISE EXCEPTION 'TEST FAILED: endpoint creation was granted implicitly'; + END IF; +END $$; + +GRANT USAGE ON FOREIGN DATA WRAPPER pg_durable_fdw TO ec_owner; +SET SESSION AUTHORIZATION ec_owner; +CREATE SERVER ec_server FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://api.github.com', auth_scheme 'none'); +ALTER SERVER ec_server OPTIONS (SET auth_scheme 'header', ADD header_name 'x-api-key'); +GRANT USAGE ON FOREIGN SERVER ec_server TO ec_caller; + +DO $$ +DECLARE + rejected BOOLEAN := false; +BEGIN + BEGIN + ALTER SERVER ec_server OPTIONS (DROP header_name); + EXCEPTION WHEN OTHERS THEN + IF SQLERRM NOT LIKE '%header_name%required%' THEN RAISE; END IF; + rejected := true; + END; + IF NOT rejected THEN RAISE EXCEPTION 'TEST FAILED: invalid merged options accepted'; END IF; + + rejected := false; + BEGIN + ALTER SERVER ec_server OPTIONS (SET auth_scheme 'managed-identity', DROP header_name); + EXCEPTION WHEN OTHERS THEN + IF SQLERRM NOT LIKE '%not supported in this version%' THEN RAISE; END IF; + rejected := true; + END; + IF NOT rejected THEN RAISE EXCEPTION 'TEST FAILED: managed identity enabled without controls'; END IF; + + rejected := false; + BEGIN + ALTER SERVER ec_server OPTIONS (ADD resource 'https://vault.azure.net'); + EXCEPTION WHEN OTHERS THEN + IF SQLERRM NOT LIKE '%Unsupported endpoint server option%' THEN RAISE; END IF; + rejected := true; + END; + IF NOT rejected THEN RAISE EXCEPTION 'TEST FAILED: unknown server option accepted'; END IF; +END $$; + +RESET SESSION AUTHORIZATION; +SET SESSION AUTHORIZATION ec_caller; +CREATE USER MAPPING FOR CURRENT_USER SERVER ec_server OPTIONS (header_value 'CATALOG_SENTINEL'); +ALTER USER MAPPING FOR CURRENT_USER SERVER ec_server OPTIONS (SET header_value 'ROTATED_SENTINEL'); + +DO $$ +DECLARE + rejected BOOLEAN := false; +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_user_mappings + WHERE srvname = 'ec_server' AND usename = CURRENT_USER + AND umoptions = ARRAY['header_value=ROTATED_SENTINEL'] + ) THEN + RAISE EXCEPTION 'TEST FAILED: caller cannot read its rotated mapping'; + END IF; + BEGIN + ALTER USER MAPPING FOR CURRENT_USER SERVER ec_server OPTIONS (SET header_value E'SHOULD_NOT_LEAK\r\n'); + EXCEPTION WHEN OTHERS THEN + IF SQLERRM LIKE '%SHOULD_NOT_LEAK%' OR SQLERRM NOT LIKE '%Invalid endpoint credential header value%' THEN RAISE; END IF; + rejected := true; + END; + IF NOT rejected THEN RAISE EXCEPTION 'TEST FAILED: invalid header credential accepted'; END IF; +END $$; + +RESET SESSION AUTHORIZATION; +SET SESSION AUTHORIZATION ec_owner; +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_user_mappings + WHERE srvname = 'ec_server' AND usename = 'ec_caller' AND umoptions IS NOT NULL + ) THEN + RAISE EXCEPTION 'TEST FAILED: server owner can read another role mapping'; + END IF; +END $$; + +REVOKE USAGE ON FOREIGN SERVER ec_server FROM ec_caller; +RESET SESSION AUTHORIZATION; +SET SESSION AUTHORIZATION ec_caller; +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_user_mappings + WHERE srvname = 'ec_server' AND usename = CURRENT_USER AND umoptions IS NOT NULL + ) THEN + RAISE EXCEPTION 'TEST FAILED: revoked caller still sees credential options'; + END IF; +END $$; + +RESET SESSION AUTHORIZATION; +DROP SERVER ec_server CASCADE; +DROP OWNED BY ec_owner, ec_caller; +DROP ROLE ec_owner, ec_caller; +SELECT 'TEST PASSED' AS result; \ No newline at end of file diff --git a/tests/e2e/sql/73_endpoint_http.sql b/tests/e2e/sql/73_endpoint_http.sql new file mode 100644 index 00000000..82e45c20 --- /dev/null +++ b/tests/e2e/sql/73_endpoint_http.sql @@ -0,0 +1,184 @@ +RESET SESSION AUTHORIZATION; +DROP SERVER IF EXISTS eh_none, eh_bearer, eh_header, eh_query, eh_missing, eh_denied, eh_blocked CASCADE; +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'eh_no_http') THEN + DROP OWNED BY eh_no_http; + END IF; +END $$; +DROP ROLE IF EXISTS eh_no_http; +CREATE ROLE eh_no_http LOGIN; +SELECT df.grant_usage('eh_no_http'); +SELECT df.grant_usage('df_e2e_user', include_http => true); + +CREATE SERVER eh_none FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://httpbingo.org/status', auth_scheme 'none'); +CREATE SERVER eh_bearer FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://httpbingo.org', auth_scheme 'bearer'); +CREATE SERVER eh_header FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://httpbingo.org', auth_scheme 'header', header_name 'x-api-key'); +CREATE SERVER eh_query FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://httpbingo.org', auth_scheme 'query'); +CREATE SERVER eh_missing FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://httpbingo.org', auth_scheme 'bearer'); +CREATE SERVER eh_denied FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://httpbingo.org', auth_scheme 'none'); +CREATE SERVER eh_blocked FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://127.0.0.1', auth_scheme 'none'); +GRANT USAGE ON FOREIGN SERVER eh_none, eh_bearer, eh_header, eh_query, eh_missing, eh_blocked TO df_e2e_user; +GRANT USAGE ON FOREIGN SERVER eh_none TO eh_no_http; +CREATE ROLE eh_raw_http LOGIN; +CREATE ROLE eh_typed_http LOGIN; +CREATE ROLE eh_revoked_http LOGIN; +SELECT df.grant_usage('eh_raw_http'); +SELECT df.grant_usage('eh_typed_http'); +SELECT df.grant_usage('eh_revoked_http', include_http => true); +GRANT EXECUTE ON FUNCTION df.http(text,text,text,jsonb,integer), df.http_multipart(text,text,jsonb,jsonb,integer) TO eh_raw_http; +GRANT EXECUTE ON FUNCTION df.http(df.http_endpoint,text,text,jsonb,integer), df.http_multipart(df.http_endpoint,text,jsonb,jsonb,integer) TO eh_typed_http; +GRANT USAGE ON FOREIGN SERVER eh_none TO eh_raw_http, eh_typed_http, eh_revoked_http; +CREATE USER MAPPING FOR df_e2e_user SERVER eh_bearer OPTIONS (token 'ENDPOINT_PRIVATE_BEARER'); +CREATE USER MAPPING FOR df_e2e_user SERVER eh_header OPTIONS (header_value 'ENDPOINT_PRIVATE_HEADER'); +CREATE USER MAPPING FOR df_e2e_user SERVER eh_query OPTIONS (query_string 'sig=ENDPOINT_PRIVATE_QUERY&sv=1'); + +CREATE TEMP TABLE _endpoint_http_cases (instance_id text, expected text, error_pattern text); +GRANT SELECT, INSERT ON _endpoint_http_cases TO df_e2e_user, eh_no_http, eh_raw_http, eh_typed_http, eh_revoked_http; +CREATE TEMP TABLE _endpoint_revoked_nodes (node text); +GRANT SELECT, INSERT ON _endpoint_revoked_nodes TO eh_revoked_http; + +SET SESSION AUTHORIZATION eh_revoked_http; +INSERT INTO _endpoint_revoked_nodes VALUES + (df.http('https://httpbingo.org/status/204', 'GET')), + (df.http(df.endpoint('eh_none', '/204'), 'GET')), + (df.http_multipart('https://httpbingo.org/status/204', parts => '[{"name":"file","data_b64":"aA=="}]')), + (df.http_multipart(df.endpoint('eh_none', '/204'), parts => '[{"name":"file","data_b64":"aA=="}]')); +RESET SESSION AUTHORIZATION; +SELECT df.revoke_usage('eh_revoked_http'); +SELECT df.grant_usage('eh_revoked_http'); +SET SESSION AUTHORIZATION eh_revoked_http; +INSERT INTO _endpoint_http_cases SELECT df.start(node, 'endpoint-helper-revoked'), 'failed', '%EXECUTE%' FROM _endpoint_revoked_nodes; +RESET SESSION AUTHORIZATION; + +SET SESSION AUTHORIZATION df_e2e_user; +SELECT df.setvar('endpoint_status_code', '204'); +SELECT df.setvar('endpoint_bad_path', '..'); + +DO $$ +DECLARE + server_name text; + request_path text; + request_node text; + config jsonb; +BEGIN + FOREACH server_name IN ARRAY ARRAY['eh_none', 'eh_bearer', 'eh_header', 'eh_query'] LOOP + request_path := CASE WHEN server_name = 'eh_none' THEN '/{endpoint_status_code}' ELSE '/status/{endpoint_status_code}' END; + request_node := df.http(df.endpoint(server_name, request_path), 'GET'); + config := (request_node::jsonb->>'query')::jsonb; + IF config->>'endpoint' IS DISTINCT FROM server_name OR config->>'url' IS DISTINCT FROM request_path THEN + RAISE EXCEPTION 'TEST FAILED: endpoint construction changed reference fields'; + END IF; + IF df.explain(request_node) NOT LIKE '%' || server_name || '%' THEN + RAISE EXCEPTION 'TEST FAILED: endpoint missing from explain output'; + END IF; + INSERT INTO _endpoint_http_cases VALUES (df.start(request_node, 'endpoint-http-' || server_name), 'completed', NULL); + request_node := df.http_multipart(df.endpoint(server_name, request_path), parts => '[{"name":"file","data_b64":"aGVsbG8="}]'); + INSERT INTO _endpoint_http_cases VALUES (df.start(request_node, 'endpoint-multipart-' || server_name), 'completed', NULL); + END LOOP; +END $$; + +INSERT INTO _endpoint_http_cases VALUES + (df.start(df.http('{"type":"pg_durable.endpoint","server":"eh_bearer","path":"/status/204"}'::text, 'GET'), 'endpoint-text-json'), 'failed', '%malformed URL%'), + (df.start(df.http_multipart('{"type":"pg_durable.endpoint","server":"eh_bearer","path":"/status/204"}'::text, + parts => '[{"name":"file","data_b64":"aA=="}]'), 'endpoint-text-json-multipart'), 'failed', '%malformed URL%'), + (df.start(df.http(df.endpoint('eh_missing', '/status/204'), 'GET'), 'endpoint-missing-mapping'), 'failed', '%mapping%required%'), + (df.start(df.http(df.endpoint('eh_denied', '/status/204'), 'GET'), 'endpoint-server-denied'), 'failed', '%USAGE%'), + (df.start(df.http_multipart(df.endpoint('eh_denied', '/status/204'), parts => '[{"name":"file","data_b64":"aA=="}]'), 'endpoint-multipart-denied'), 'failed', '%USAGE%'), + (df.start(df.http(df.endpoint('eh_none', '/{endpoint_bad_path}/escape'), 'GET'), 'endpoint-substituted-traversal'), 'failed', '%traversal%'), + (df.start(df.http(df.endpoint('eh_bearer', '/status/204'), 'GET', headers => '{"authorization":"override"}'), 'endpoint-header-override'), 'failed', '%override%'), + (df.start(df.http_multipart(df.endpoint('eh_header', '/status/204'), parts => '[{"name":"file","data_b64":"aA=="}]', headers => '{"X-API-KEY":"override"}'), 'endpoint-multipart-override'), 'failed', '%override%'), + (df.start(df.http(df.endpoint('eh_query', '/status/204?%73ig=override'), 'GET'), 'endpoint-query-override'), 'failed', '%override%'), + (df.start(df.http(df.endpoint('eh_none', '/204'), 'GET', headers => '{"Host":"evil.example"}'), 'endpoint-host-override'), 'failed', '%override%'), + (df.start(df.http(df.endpoint('eh_blocked', '/'), 'GET'), 'endpoint-ssrf'), 'failed', '%bare IP%'), + (df.start(df.http(df.endpoint('eh_bearer', '/status/400'), 'GET'), 'endpoint-client-error'), 'completed', NULL); + +RESET SESSION AUTHORIZATION; +SET SESSION AUTHORIZATION eh_raw_http; +INSERT INTO _endpoint_http_cases VALUES + (df.start(df.http('https://httpbingo.org/status/204', 'GET'), 'endpoint-raw-grant'), 'completed', NULL), + (df.start(df.http_multipart('https://httpbingo.org/status/204', parts => '[{"name":"file","data_b64":"aA=="}]'), 'endpoint-raw-multipart-grant'), 'completed', NULL), + (df.start('{"node_type":"HTTP","query":"{\"endpoint\":\"eh_none\",\"url\":\"/204\",\"method\":\"GET\"}"}', 'endpoint-forged-raw-grant'), 'failed', '%EXECUTE%df.http()%'), + (df.start('{"node_type":"HTTP_MULTIPART","query":"{\"endpoint\":\"eh_none\",\"url\":\"/204\",\"method\":\"POST\",\"parts\":[{\"name\":\"file\",\"data_b64\":\"aA==\"}]}"}', 'endpoint-forged-raw-multipart-grant'), 'failed', '%EXECUTE%df.http_multipart()%'); +RESET SESSION AUTHORIZATION; +SET SESSION AUTHORIZATION eh_typed_http; +INSERT INTO _endpoint_http_cases VALUES + (df.start(df.http(df.endpoint('eh_none', '/204'), 'GET'), 'endpoint-typed-grant'), 'completed', NULL), + (df.start(df.http_multipart(df.endpoint('eh_none', '/204'), parts => '[{"name":"file","data_b64":"aA=="}]'), 'endpoint-typed-multipart-grant'), 'completed', NULL), + (df.start('{"node_type":"HTTP","query":"{\"url\":\"https://httpbingo.org/status/204\",\"method\":\"GET\"}"}', 'endpoint-forged-typed-grant'), 'failed', '%EXECUTE%df.http()%'), + (df.start('{"node_type":"HTTP_MULTIPART","query":"{\"url\":\"https://httpbingo.org/status/204\",\"method\":\"POST\",\"parts\":[{\"name\":\"file\",\"data_b64\":\"aA==\"}]}"}', 'endpoint-forged-typed-multipart-grant'), 'failed', '%EXECUTE%df.http_multipart()%'); +RESET SESSION AUTHORIZATION; +SET SESSION AUTHORIZATION eh_no_http; +INSERT INTO _endpoint_http_cases VALUES + (df.start('{"node_type":"HTTP","query":"{\"endpoint\":\"eh_none\",\"url\":\"/204\",\"method\":\"GET\"}"}', 'endpoint-forged-no-http'), 'failed', '%EXECUTE%df.http()%'), + (df.start('{"node_type":"HTTP_MULTIPART","query":"{\"endpoint\":\"eh_none\",\"url\":\"/204\",\"method\":\"POST\",\"parts\":[{\"name\":\"file\",\"data_b64\":\"aA==\"}]}"}', 'endpoint-forged-no-multipart'), 'failed', '%EXECUTE%df.http_multipart()%'); +RESET SESSION AUTHORIZATION; + +DO $$ +DECLARE + test_case record; + actual_status text; + attempts integer; + result_text text; + terminal_count integer; + leaked boolean; + engine_schema text := df.duroxide_schema(); +BEGIN + FOR test_case IN SELECT * FROM _endpoint_http_cases LOOP + attempts := 0; + LOOP + actual_status := df.status(test_case.instance_id); + EXIT WHEN actual_status IN ('completed', 'failed', 'cancelled') OR attempts >= 600; + PERFORM pg_sleep(0.1); + attempts := attempts + 1; + END LOOP; + result_text := df.result(test_case.instance_id); + IF test_case.expected = 'failed' THEN + SELECT result::text INTO result_text FROM df.nodes + WHERE instance_id = test_case.instance_id AND node_type IN ('HTTP', 'HTTP_MULTIPART'); + END IF; + IF actual_status IS DISTINCT FROM test_case.expected THEN + RAISE EXCEPTION 'TEST FAILED: endpoint instance % expected %, got %, result %', test_case.instance_id, test_case.expected, actual_status, result_text; + END IF; + IF test_case.error_pattern IS NOT NULL AND COALESCE(result_text, '') NOT LIKE test_case.error_pattern THEN + RAISE EXCEPTION 'TEST FAILED: endpoint error did not match %: %', test_case.error_pattern, result_text; + END IF; + IF test_case.expected = 'completed' AND ((result_text::jsonb->>'status')::integer IN (204, 400)) IS NOT TRUE THEN + RAISE EXCEPTION 'TEST FAILED: non-echoing endpoint returned unexpected result %', result_text; + END IF; + attempts := 0; + LOOP + EXECUTE format('SELECT count(*) FROM %I.history WHERE instance_id = $1 AND event_data::jsonb->>''type'' IN (''OrchestrationCompleted'', ''OrchestrationFailed'')', engine_schema) + INTO terminal_count USING test_case.instance_id; + EXIT WHEN terminal_count > 0 OR attempts >= 300; + PERFORM pg_sleep(0.1); + attempts := attempts + 1; + END LOOP; + IF terminal_count = 0 THEN RAISE EXCEPTION 'TEST FAILED: endpoint history not persisted'; END IF; + EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I.history WHERE instance_id = $1 AND event_data::text LIKE ''%%ENDPOINT_PRIVATE_%%'')', engine_schema) + INTO leaked USING test_case.instance_id; + IF leaked THEN RAISE EXCEPTION 'TEST FAILED: endpoint credential in durable history'; END IF; + EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I.executions AS execution WHERE instance_id = $1 AND row_to_json(execution)::text LIKE ''%%ENDPOINT_PRIVATE_%%'')', engine_schema) + INTO leaked USING test_case.instance_id; + IF leaked THEN RAISE EXCEPTION 'TEST FAILED: endpoint credential in execution state'; END IF; + IF EXISTS (SELECT 1 FROM df.nodes AS node WHERE instance_id = test_case.instance_id AND row_to_json(node)::text LIKE '%ENDPOINT_PRIVATE_%') THEN + RAISE EXCEPTION 'TEST FAILED: endpoint credential in node state'; + END IF; + END LOOP; +END $$; + +SET SESSION AUTHORIZATION df_e2e_user; +SELECT df.unsetvar('endpoint_status_code'); +SELECT df.unsetvar('endpoint_bad_path'); +RESET SESSION AUTHORIZATION; +DROP TABLE _endpoint_http_cases, _endpoint_revoked_nodes; +DROP SERVER eh_none, eh_bearer, eh_header, eh_query, eh_missing, eh_denied, eh_blocked CASCADE; +DROP OWNED BY eh_no_http, eh_raw_http, eh_typed_http, eh_revoked_http; +DROP ROLE eh_no_http, eh_raw_http, eh_typed_http, eh_revoked_http; +SELECT 'TEST PASSED' AS result; \ No newline at end of file diff --git a/tests/e2e/sql/74_secret_bindings.sql b/tests/e2e/sql/74_secret_bindings.sql new file mode 100644 index 00000000..bf7c2597 --- /dev/null +++ b/tests/e2e/sql/74_secret_bindings.sql @@ -0,0 +1,175 @@ +RESET SESSION AUTHORIZATION; +DROP DATABASE IF EXISTS _test_binding_sql_target; +CREATE DATABASE _test_binding_sql_target TEMPLATE template0; +DROP SERVER IF EXISTS sb_service, sb_endpoint, sb_denied, sb_nomap, sb_auth CASCADE; +DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'sb_no_http') THEN DROP OWNED BY sb_no_http; END IF; +END $$; +DROP ROLE IF EXISTS sb_no_http; +CREATE ROLE sb_no_http LOGIN; +GRANT CONNECT ON DATABASE _test_binding_sql_target TO df_e2e_user, sb_no_http; +SELECT df.grant_usage('sb_no_http'); +SELECT df.grant_usage('df_e2e_user', include_http => true); +CREATE SERVER sb_service FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (auth_scheme 'none'); +CREATE SERVER sb_endpoint FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://httpbingo.org', auth_scheme 'none'); +CREATE SERVER sb_denied FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (auth_scheme 'none'); +CREATE SERVER sb_nomap FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (auth_scheme 'none'); +CREATE SERVER sb_auth FOREIGN DATA WRAPPER pg_durable_fdw + OPTIONS (base_url 'https://httpbingo.org', auth_scheme 'bearer'); +GRANT USAGE ON FOREIGN SERVER sb_service, sb_endpoint, sb_nomap, sb_auth TO df_e2e_user; +GRANT USAGE ON FOREIGN SERVER sb_service TO sb_no_http; +CREATE USER MAPPING FOR df_e2e_user SERVER sb_service OPTIONS + ("secret.private" 'BINDING_PRIVATE_CREDENTIAL', "secret.probe" 'a&b+c= %', + "secret.empty" '', "secret.invalid_header" E'BINDING_PRIVATE_BAD\r\n'); +CREATE USER MAPPING FOR df_e2e_user SERVER sb_auth OPTIONS (token 'BINDING_PRIVATE_ENDPOINT'); +CREATE USER MAPPING FOR sb_no_http SERVER sb_service OPTIONS ("secret.private" 'BINDING_PRIVATE_OTHER'); +CREATE TEMP TABLE _binding_cases(instance_id text, expected text, error_pattern text, echo boolean DEFAULT false); +GRANT SELECT, INSERT ON _binding_cases TO df_e2e_user, sb_no_http; +SET SESSION AUTHORIZATION df_e2e_user; + +ALTER USER MAPPING FOR CURRENT_USER SERVER sb_service OPTIONS (ADD "secret.new_key" 'BINDING_PRIVATE_ADDED'); +ALTER USER MAPPING FOR CURRENT_USER SERVER sb_service OPTIONS (SET "secret.private" 'BINDING_PRIVATE_ROTATED'); +ALTER USER MAPPING FOR CURRENT_USER SERVER sb_service OPTIONS (DROP "secret.new_key"); + +DO $$ +DECLARE + server_name text; + bindings jsonb; + request_node text; + multipart_node text; + target_probe text := $probe$SELECT 1 / (pg_catalog.current_database() = '_test_binding_sql_target' + AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_durable'))::integer$probe$; +BEGIN + FOREACH server_name IN ARRAY ARRAY[NULL, 'sb_endpoint', 'sb_auth'] LOOP + IF server_name IS NULL THEN + request_node := df.http('https://httpbingo.org/status/204', 'POST'); + multipart_node := df.http_multipart('https://httpbingo.org/status/204', parts => '[{"name":"file","data_b64":"aGVsbG8="}]'); + ELSE + request_node := df.http(df.endpoint(server_name, '/status/204'), 'POST'); + multipart_node := df.http_multipart(df.endpoint(server_name, '/status/204'), parts => '[{"name":"file","data_b64":"aGVsbG8="}]'); + END IF; + bindings := jsonb_build_object( + 'headers', jsonb_build_object('X-Key', df.secret('sb_service', 'private') || '{"prefix":"Key "}'::jsonb), + 'query', jsonb_build_object('key', df.secret('sb_service', 'private'))); + request_node := df.with_http_options(request_node, + jsonb_build_object('secret_bindings', bindings || jsonb_build_object('form', jsonb_build_object('password', df.secret('sb_service', 'private'))), + 'form_fields', jsonb_build_object('payload', '${secret:sb_service.private} $missing {missing}'))); + INSERT INTO _binding_cases VALUES (df.start(request_node, 'secret-form'), 'completed', NULL, false); + INSERT INTO _binding_cases VALUES (df.start(target_probe ~> request_node, 'secret-form-other-database', + database => '_test_binding_sql_target'), 'completed', NULL, false); + request_node := df.with_http_options(multipart_node, + jsonb_build_object('secret_bindings', bindings)); + INSERT INTO _binding_cases VALUES (df.start(request_node, 'secret-multipart'), 'completed', NULL, false); + INSERT INTO _binding_cases VALUES (df.start(target_probe ~> request_node, 'secret-multipart-other-database', + database => '_test_binding_sql_target'), 'completed', NULL, false); + END LOOP; +END $$; + +INSERT INTO _binding_cases VALUES + (df.start(df.http(df.endpoint('sb_service', '/status/204'), 'GET'), 'binding-url-less-endpoint'), 'failed', '%has no base_url%', false), + (df.start(df.http_multipart(df.endpoint('sb_service', '/status/204'), parts => '[{"name":"file","data_b64":"aGVsbG8="}]'), + 'binding-url-less-multipart-endpoint'), 'failed', '%has no base_url%', false), + (df.start(df.with_http_options(df.http('https://httpbingo.org/status/204', 'GET'), + jsonb_build_object('secret_bindings', jsonb_build_object('headers', jsonb_build_object('X-Key', df.secret('sb_service', 'missing'))))), 'binding-missing-key'), 'failed', '%secret key is missing%', false), + (df.start(df.with_http_options(df.http('https://httpbingo.org/status/204', 'GET'), + jsonb_build_object('secret_bindings', jsonb_build_object('query', jsonb_build_object('key', df.secret('sb_denied', 'private'))))), 'binding-denied-server', database => '_test_binding_sql_target'), 'failed', '%USAGE%', false), + (df.start(df.with_http_options(df.http('https://httpbingo.org/status/204', 'GET'), + jsonb_build_object('secret_bindings', jsonb_build_object('query', jsonb_build_object('key', df.secret('sb_nomap', 'private'))))), 'binding-no-mapping', database => '_test_binding_sql_target'), 'failed', '%mapping%required%', false), + (df.start(df.http(df.endpoint('sb_denied', '/status/204'), 'GET'), 'binding-endpoint-denied-other-database', + database => '_test_binding_sql_target'), 'failed', '%USAGE%', false), + (df.start(df.with_http_options(df.http('https://httpbingo.org/status/204', 'GET'), + jsonb_build_object('secret_bindings', jsonb_build_object('headers', jsonb_build_object('X-Key', df.secret('sb_service', 'invalid_header'))))), 'binding-invalid-header'), 'failed', '%not a valid HTTP header%', false), + (df.start(df.with_http_options(df.http('https://httpbingo.org/status/204?%6bey=ordinary', 'GET'), + jsonb_build_object('secret_bindings', jsonb_build_object('query', jsonb_build_object('key', df.secret('sb_service', 'private'))))), 'binding-query-conflict'), 'failed', '%conflicts%query%', false), + (df.start(df.with_http_options(df.http('https://httpbingo.org/status/204', 'GET', headers => '{"x-key":"ordinary"}'), + jsonb_build_object('secret_bindings', jsonb_build_object('headers', jsonb_build_object('X-Key', df.secret('sb_service', 'private'))))), 'binding-header-conflict'), 'failed', '%conflict%headers%', false), + (df.start(df.with_http_options(df.http(df.endpoint('sb_auth', '/status/204'), 'GET'), + jsonb_build_object('secret_bindings', jsonb_build_object('headers', jsonb_build_object('Authorization', df.secret('sb_service', 'private'))))), 'binding-endpoint-conflict'), 'failed', '%override endpoint%', false), + (df.start(df.with_http_options(df.http('https://127.0.0.1', 'GET'), + jsonb_build_object('secret_bindings', jsonb_build_object('query', jsonb_build_object('key', df.secret('sb_service', 'private'))))), 'binding-blocked-destination'), 'failed', '%bare IP%', false); + +INSERT INTO _binding_cases VALUES (df.start(df.with_http_options( + df.http('https://httpbingo.org/anything?ordinary=%24%7Bsecret%3Asb_service.private%7D', 'POST', headers => '{"X-Literal":"${secret:sb_service.private}"}'), + jsonb_build_object('secret_bindings', jsonb_build_object( + 'headers', jsonb_build_object('X-Probe', df.secret('sb_service', 'probe') || '{"prefix":"Key "}'::jsonb), + 'query', jsonb_build_object('probe', df.secret('sb_service', 'probe')), + 'form', jsonb_build_object('password', df.secret('sb_service', 'probe'), 'empty', df.secret('sb_service', 'empty'))), + 'form_fields', jsonb_build_object('payload', '${secret:sb_service.private} $missing {missing}', 'descriptor', '{"server":"sb_service","key":"private"}'))), + 'binding-echo-public-probe'), 'completed', NULL, true); + +RESET SESSION AUTHORIZATION; +SET SESSION AUTHORIZATION sb_no_http; +INSERT INTO _binding_cases VALUES (df.start( + '{"node_type":"HTTP","query":"{\"url\":\"https://httpbingo.org/status/204\",\"method\":\"GET\",\"secret_bindings\":{\"headers\":{\"X-Key\":{\"server\":\"sb_service\",\"key\":\"private\"}}}}"}', + 'binding-forged-no-http', database => '_test_binding_sql_target'), 'failed', '%EXECUTE%df.http()%', false); +RESET SESSION AUTHORIZATION; + +DO $$ +DECLARE + test_case record; + actual text; + result_text text; + body jsonb; + attempts integer; + terminal_count integer; + leaked boolean; + engine_schema text := df.duroxide_schema(); +BEGIN + FOR test_case IN SELECT * FROM _binding_cases LOOP + actual := df.await_instance(test_case.instance_id, 60); + SELECT result::text INTO result_text FROM df.nodes WHERE instance_id = test_case.instance_id AND node_type IN ('HTTP','HTTP_MULTIPART'); + IF actual IS DISTINCT FROM test_case.expected THEN + RAISE EXCEPTION 'TEST FAILED: binding expected %, got %, result %', test_case.expected, actual, result_text; + END IF; + IF test_case.error_pattern IS NOT NULL AND COALESCE(result_text,'') NOT LIKE test_case.error_pattern THEN + RAISE EXCEPTION 'TEST FAILED: binding error did not match %: %', test_case.error_pattern, result_text; + END IF; + IF actual = 'completed' THEN + IF (result_text::jsonb->>'status')::integer IS DISTINCT FROM (CASE WHEN test_case.echo THEN 200 ELSE 204 END) THEN + RAISE EXCEPTION 'TEST FAILED: unexpected HTTP result %', result_text; + END IF; + IF test_case.echo THEN + body := (result_text::jsonb->>'body')::jsonb; + IF body->'form'->'password'->>0 IS DISTINCT FROM 'a&b+c= %' + OR body->'form'->'empty'->>0 IS DISTINCT FROM '' + OR body->'form'->'payload'->>0 IS DISTINCT FROM '${secret:sb_service.private} $missing {missing}' + OR body->'form'->'descriptor'->>0 IS DISTINCT FROM '{"server":"sb_service","key":"private"}' + OR body->'args'->'probe'->>0 IS DISTINCT FROM 'a&b+c= %' + OR body->'args'->'ordinary'->>0 IS DISTINCT FROM '${secret:sb_service.private}' + OR body->'headers'->'X-Probe'->>0 IS DISTINCT FROM 'Key a&b+c= %' + OR body->'headers'->'X-Literal'->>0 IS DISTINCT FROM '${secret:sb_service.private}' THEN + RAISE EXCEPTION 'TEST FAILED: binding encoding or literal data changed: %', body; + END IF; + END IF; + END IF; + attempts := 0; + LOOP + EXECUTE format('SELECT count(*) FROM %I.history WHERE instance_id = $1 AND event_data::jsonb->>''type'' IN (''OrchestrationCompleted'',''OrchestrationFailed'')', engine_schema) + INTO terminal_count USING test_case.instance_id; + EXIT WHEN terminal_count > 0 OR attempts >= 300; + PERFORM pg_sleep(0.1); + attempts := attempts + 1; + END LOOP; + IF terminal_count = 0 THEN RAISE EXCEPTION 'TEST FAILED: binding terminal history missing'; END IF; + EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I.history WHERE instance_id = $1 AND event_data::text LIKE ''%%BINDING_PRIVATE_%%'')', engine_schema) + INTO leaked USING test_case.instance_id; + IF leaked THEN RAISE EXCEPTION 'TEST FAILED: credential in binding history'; END IF; + EXECUTE format('SELECT EXISTS (SELECT 1 FROM %I.executions AS execution WHERE instance_id = $1 AND row_to_json(execution)::text LIKE ''%%BINDING_PRIVATE_%%'')', engine_schema) + INTO leaked USING test_case.instance_id; + IF leaked THEN RAISE EXCEPTION 'TEST FAILED: credential in binding execution state'; END IF; + IF EXISTS (SELECT 1 FROM df.nodes node WHERE instance_id = test_case.instance_id AND row_to_json(node)::text LIKE '%BINDING_PRIVATE_%') THEN + RAISE EXCEPTION 'TEST FAILED: credential in binding node state'; + END IF; + END LOOP; +END $$; + +DROP TABLE _binding_cases; +DROP SERVER sb_service, sb_endpoint, sb_denied, sb_nomap, sb_auth CASCADE; +DROP OWNED BY sb_no_http; +DROP ROLE sb_no_http; +DROP DATABASE _test_binding_sql_target; +SELECT 'TEST PASSED' AS result;