-- Copyright (c) Microsoft Corporation. -- Licensed under the PostgreSQL License. -- pg_durable upgrade: 0.2.4 → 0.2.5 -- -- UPGRADE ORDERING: this release changes duroxide-recorded orchestration history. -- Non-root df.loop() nodes run as child sub-orchestrations; replay-recorded result -- and variable maps use canonical key ordering; root/non-root loops share one -- body/condition policy; and the execute-subtree orchestration that runs parallel -- branches now hosts loops too, with a wider input envelope. duroxide replays by -- exact input and scheduling equality, so: -- * EVERY in-flight JOIN (&) or RACE (|) branch fails under this binary, because -- its recorded execute-subtree input uses the pre-0.2.5 shape. This is not -- limited to workflows carrying multiple variables or named results. -- * In-flight root and non-root df.loop() instances may fail with a -- nondeterminism error. -- The engine fails closed, but the df.instances row may remain pending/running. -- Operators that require in-flight continuity should quiesce and drain before -- upgrading; operators that accept failed/recreated work can upgrade directly and -- clean up stale instances afterward. See docs/upgrade-testing.md, -- "Loop and sub-orchestration replay compatibility". -- No DDL is required for these runtime changes: status_details already exists. -- -- Adds df.http_multipart() for multipart/form-data requests (file uploads, -- form posts). It builds an HTTP_MULTIPART node whose payload is a JSON array -- of parts (name / filename / content_type / data_b64); the execute_multipart -- activity base64-decodes each part and posts a reqwest::multipart::Form. The -- existing df.http() path is unchanged. -- -- Security: df.http_multipart() is gated identically to df.http() — PUBLIC -- EXECUTE is revoked at upgrade time, and df.grant_usage() grants it only when -- include_http => true (HTTP egress is one privilege). The execute_multipart -- activity runs the same 4-layer gate as execute_http (privilege, scheme, -- allow-list, SSRF-safe DNS resolver, no redirects). -- -- See docs/upgrade-testing.md for the upgrade-script and backward-compatibility -- requirements (Scenario A / B1 / B2). -- ============================================================================ -- Register the new df.http_multipart() C function. -- -- pgrx emits this for fresh installs from the #[pg_extern] in src/dsl.rs; the -- upgrade script must create it explicitly so pre-existing installs gain the -- function on ALTER EXTENSION UPDATE. The C symbol http_multipart_wrapper is -- compiled into the new .so (matching the dsl::http_multipart Rust function). -- ============================================================================ CREATE FUNCTION df."http_multipart"( "url" TEXT, "method" TEXT DEFAULT 'POST', "parts" jsonb DEFAULT NULL, "headers" jsonb DEFAULT NULL, "timeout_seconds" INT DEFAULT 30 ) RETURNS TEXT LANGUAGE c AS 'MODULE_PATHNAME', 'http_multipart_wrapper'; -- ============================================================================ -- Admit the HTTP_MULTIPART node type into the schema constraints and the -- df.ensure_durofut() validator. These mirror VALID_NODE_TYPES in src/types.rs -- (the Rust constant is the canonical source). The constraints are NOT VALID, -- so re-adding them does not rewrite existing rows. -- ============================================================================ ALTER TABLE df.nodes DROP CONSTRAINT nodes_node_type_chk; ALTER TABLE df.nodes ADD CONSTRAINT nodes_node_type_chk CHECK (node_type OPERATOR(pg_catalog.=) ANY (ARRAY['SQL', 'THEN', 'IF', 'JOIN', 'LOOP', 'BREAK', 'RACE', 'SLEEP', 'WAIT_SCHEDULE', 'HTTP', 'HTTP_MULTIPART', 'SIGNAL'])) NOT VALID; ALTER TABLE df.nodes DROP CONSTRAINT nodes_structure_chk; ALTER TABLE df.nodes ADD CONSTRAINT nodes_structure_chk CHECK ( CASE WHEN node_type OPERATOR(pg_catalog.=) ANY (ARRAY['SQL', 'SLEEP', 'WAIT_SCHEDULE', 'BREAK', 'HTTP', 'HTTP_MULTIPART', 'SIGNAL']) THEN left_node IS NULL AND right_node IS NULL AND query IS NOT NULL WHEN node_type OPERATOR(pg_catalog.=) 'THEN' THEN left_node IS NOT NULL AND right_node IS NOT NULL AND query IS NULL WHEN node_type OPERATOR(pg_catalog.=) 'IF' THEN left_node IS NOT NULL AND right_node IS NOT NULL AND query IS NOT NULL WHEN node_type OPERATOR(pg_catalog.=) 'LOOP' THEN left_node IS NOT NULL AND right_node IS NULL WHEN node_type OPERATOR(pg_catalog.=) 'JOIN' THEN left_node IS NOT NULL AND right_node IS NOT NULL WHEN node_type OPERATOR(pg_catalog.=) 'RACE' THEN left_node IS NOT NULL AND right_node IS NOT NULL AND query IS NULL ELSE FALSE END ) NOT VALID; -- Helper to ensure a value is a durofut (returns JSON string) -- Rejects JSON with unknown node_type values. -- NOTE: The valid node type list here must be kept in sync with -- VALID_NODE_TYPES in src/types.rs (the Rust constant is the canonical source). -- search_path omits df on purpose: the body only touches pg_catalog builtins -- (jsonb, ->>, <>) and the schema-qualified df.sql(), so df is not needed. -- Including df here makes pgspot deem the path insecure (an upgrade script has -- no CREATE SCHEMA df, so df is not provably extension-owned), which re-enables -- CVE-2018-1058-style search_path hijack warnings (PS005/PS001/PS017). The -- fresh-install DDL in src/lib.rs matches for upgrade parity (Scenario A). CREATE OR REPLACE FUNCTION df.ensure_durofut(val text) RETURNS text AS $$ DECLARE node_type_val text; BEGIN -- Try to parse as JSON to check if it's already a durofut BEGIN node_type_val := (val::jsonb)->>'node_type'; IF node_type_val IS NOT NULL THEN -- Has a node_type - validate it IF node_type_val NOT IN ('SQL', 'THEN', 'IF', 'JOIN', 'LOOP', 'BREAK', 'RACE', 'SLEEP', 'WAIT_SCHEDULE', 'HTTP', 'HTTP_MULTIPART', 'SIGNAL') THEN RAISE EXCEPTION 'Unknown node_type ''%''. Valid types: SQL, THEN, IF, JOIN, LOOP, BREAK, RACE, SLEEP, WAIT_SCHEDULE, HTTP, HTTP_MULTIPART, SIGNAL', node_type_val; END IF; RETURN val; END IF; EXCEPTION WHEN invalid_text_representation THEN -- Not valid JSON, treat as SQL NULL; WHEN raise_exception THEN -- Re-raise our validation error RAISE; WHEN OTHERS THEN -- Not valid JSON, treat as SQL NULL; END; -- It's plain SQL, wrap it RETURN df.sql(val); END; $$ LANGUAGE plpgsql IMMUTABLE SET search_path = pg_catalog, pg_temp; -- ============================================================================ -- Grant/revoke plumbing for df.http_multipart(). -- -- grant_usage() / revoke_usage() are re-emitted in full so upgraded installs -- match fresh 0.2.5 installs (see src/lib.rs). The signature is unchanged -- (df.grant_usage(text, boolean, boolean)); http_multipart rides on the -- existing include_http flag. -- ============================================================================ 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; -- 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; 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.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.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$; -- df.http_multipart() is sensitive (network access), so revoke PostgreSQL's -- default PUBLIC EXECUTE — same treatment as df.http(). df.grant_usage() -- re-grants it explicitly to authorized roles (include_http => true). REVOKE EXECUTE ON FUNCTION df.http_multipart(text, text, jsonb, jsonb, integer) FROM PUBLIC; -- ============================================================================ -- Add df.start(transaction_mode): choose which transaction the start runs in. -- -- transaction_mode => 'caller' (the default) is the historical behaviour: the -- start joins the caller's transaction and is rolled back with it. -- transaction_mode => 'new' persists and enqueues the durable function on a -- separate PostgreSQL session, so it commits independently and survives a -- rollback of the caller's transaction (the PostgreSQL equivalent of Oracle's -- PRAGMA AUTONOMOUS_TRANSACTION). Nothing about the started function changes; -- only the commit boundary of the start itself does. -- -- The three-argument df.start() is dropped and replaced by a four-argument -- one bound to a new C symbol (start_v2_wrapper). Both cannot coexist: with -- defaults on label/database in each, a three-argument call such as -- df.start(fut, label, database) matches both and PostgreSQL raises "function -- is not unique". Dropping leaves exactly one df.start at every arity. -- -- Scenario B1 (new .so, un-upgraded schema) is preserved without the overload: -- src/dsl.rs keeps the three-argument Rust fn start() and therefore the -- start_wrapper symbol, marked #[pg_extern(sql = false)] so it contributes no -- DDL. Pre-0.2.5 schemas declare df.start(text, text, text) against that -- symbol and keep resolving to it, running in 'caller' mode; they simply do -- not expose transaction_mode. Because sql = false emits nothing, a fresh -- 0.2.5 install has only the four-argument df.start, which is what this script -- produces too (Scenario A). -- -- The CREATE FUNCTION block is the pgrx-generated fresh-install DDL for -- src/dsl.rs::start_v2 copied verbatim, so the Scenario A snapshot matches a -- fresh 0.2.5 install. New df.* functions retain PostgreSQL's default PUBLIC -- EXECUTE (gated by USAGE ON SCHEMA df), so no explicit GRANT is needed. -- ============================================================================ DROP FUNCTION IF EXISTS df.start(text, text, text); -- pg_durable::dsl::start CREATE FUNCTION df."start"( "fut" TEXT, /* &str */ "label" TEXT DEFAULT NULL, /* core::option::Option<&str> */ "database" TEXT DEFAULT NULL, /* core::option::Option<&str> */ "transaction_mode" TEXT DEFAULT 'caller' /* &str */ ) RETURNS TEXT /* alloc::string::String */ LANGUAGE c /* Rust */ AS 'MODULE_PATHNAME', 'start_v2_wrapper';