-
Notifications
You must be signed in to change notification settings - Fork 69
Expand file tree
/
Copy pathlib.rs
More file actions
3111 lines (2754 loc) · 118 KB
/
Copy pathlib.rs
File metadata and controls
3111 lines (2754 loc) · 118 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) Microsoft Corporation.
// Licensed under the PostgreSQL License.
//! pg_durable - Durable SQL Functions for PostgreSQL
//!
//! This extension provides durable, fault-tolerant function execution within PostgreSQL
//! using the Duroxide runtime for persistence.
use pgrx::guc::*;
use pgrx::prelude::*;
use std::ffi::CString;
// ============================================================================
// GUC Definitions
// ============================================================================
pub static WORKER_ROLE: GucSetting<Option<CString>> =
GucSetting::<Option<CString>>::new(Some(c"postgres"));
pub static DATABASE: GucSetting<Option<CString>> =
GucSetting::<Option<CString>>::new(Some(c"postgres"));
pub static MAX_MANAGEMENT_CONNECTIONS: GucSetting<i32> = GucSetting::<i32>::new(6);
pub static MAX_DUROXIDE_CONNECTIONS: GucSetting<i32> = GucSetting::<i32>::new(10);
pub static MAX_USER_CONNECTIONS: GucSetting<i32> = GucSetting::<i32>::new(10);
pub static EXECUTION_ACQUIRE_TIMEOUT: GucSetting<i32> = GucSetting::<i32>::new(30);
/// When `false` (default), pg_durable rejects any instance whose `submitted_by`
/// role is a PostgreSQL superuser. Set to `true` only when superuser durable
/// functions are explicitly desired. See docs/superuser_guc.md.
pub static ENABLE_SUPERUSER_INSTANCES: GucSetting<bool> = GucSetting::<bool>::new(false);
// Module declarations
pub mod activities;
pub mod client;
pub mod dsl;
pub mod explain;
pub mod monitoring;
pub mod orchestrations;
pub mod registry;
pub mod ssrf;
pub mod types;
pub mod worker;
// Re-export key types for tests
pub use types::Durofut;
/// Monotonically increasing schema version written to `duroxide._worker_ready`
/// by the background worker after successful initialization. Increment whenever
/// a new binary introduces new duroxide-pg migration scripts or any other
/// BGW-applied duroxide schema change.
pub const WORKER_SCHEMA_VERSION: i32 = 1;
::pgrx::pg_module_magic!(name, version);
// ============================================================================
// Background Worker Registration
// ============================================================================
#[pg_guard]
pub extern "C-unwind" fn _PG_init() {
if unsafe { !pgrx::pg_sys::process_shared_preload_libraries_in_progress } {
pgrx::error!(
"pg_durable must be loaded via shared_preload_libraries.\n\nHINT: Add 'pg_durable' to shared_preload_libraries in postgresql.conf and restart the server."
);
}
GucRegistry::define_string_guc(
c"pg_durable.worker_role",
c"PostgreSQL role used by the pg_durable background worker",
c"",
&WORKER_ROLE,
GucContext::Postmaster,
GucFlags::default(),
);
GucRegistry::define_string_guc(
c"pg_durable.database",
c"PostgreSQL database used by the pg_durable background worker",
c"",
&DATABASE,
GucContext::Postmaster,
GucFlags::default(),
);
GucRegistry::define_int_guc(
c"pg_durable.max_management_connections",
c"Maximum number of connections in the background worker management pool (lifecycle, graph loading, status updates)",
c"",
&MAX_MANAGEMENT_CONNECTIONS,
1,
1000,
GucContext::Postmaster,
GucFlags::default(),
);
GucRegistry::define_int_guc(
c"pg_durable.max_duroxide_connections",
c"Maximum number of connections in the duroxide provider pool (orchestration state + listener)",
c"",
&MAX_DUROXIDE_CONNECTIONS,
1,
1000,
GucContext::Postmaster,
GucFlags::default(),
);
GucRegistry::define_int_guc(
c"pg_durable.max_user_connections",
c"Maximum number of concurrent user-execution connections for SQL node execution",
c"",
&MAX_USER_CONNECTIONS,
1,
1000,
GucContext::Postmaster,
GucFlags::default(),
);
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"",
&EXECUTION_ACQUIRE_TIMEOUT,
1,
3600,
GucContext::Postmaster,
GucFlags::default(),
);
GucRegistry::define_bool_guc(
c"pg_durable.enable_superuser_instances",
c"Allow pg_durable instances whose submitted_by role is a PostgreSQL superuser",
c"Disabled by default to prevent superuser execution-identity forgery via RLS-bypassing roles. Requires server restart to change.",
&ENABLE_SUPERUSER_INSTANCES,
GucContext::Postmaster,
GucFlags::SUPERUSER_ONLY,
);
worker::register_background_worker();
}
// ============================================================================
// Schema Declaration
// ============================================================================
// Create both extension-owned schemas as the very first statements of the
// install script. `bootstrap` guarantees this runs before every other extension
// object, including the redundant `CREATE SCHEMA IF NOT EXISTS df` that pgrx
// emits for the `#[pg_schema] mod df` entity below.
extension_sql!(
r#"
CREATE SCHEMA df;
CREATE SCHEMA _duroxide;
-- Returns the name of the duroxide provider schema selected for this install.
-- Fresh installs return '_duroxide'. The body is version-specific: the upgrade
-- script pg_durable--0.2.2--0.2.3.sql replaces it to return 'duroxide' for
-- installs that originated on pg_durable <= 0.2.2 (which keep the legacy
-- 'duroxide' schema). Both backend sessions and the background worker call
-- df.duroxide_schema() to discover which schema to use, falling back to
-- 'duroxide' when the helper is absent (installs predating it).
CREATE FUNCTION df.duroxide_schema() RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE
SET search_path = pg_catalog, pg_temp
AS $$ SELECT '_duroxide'::text $$;
"#,
name = "bootstrap_schemas",
bootstrap
);
/// The 'df' schema contains all pg_durable functions (df = durable functions).
/// pgrx requires this entity so that `#[pg_extern(schema = "df")]` functions can
/// resolve their target schema. It emits a redundant `CREATE SCHEMA IF NOT
/// EXISTS df` that no-ops after the bootstrap block above has already created df.
#[pg_schema]
mod df {}
// ============================================================================
// Table Definitions
// ============================================================================
extension_sql!(
r#"
-- Table to store function nodes (SQL steps, THEN chains, etc.)
CREATE TABLE df.nodes (
id VARCHAR(8) NOT NULL,
instance_id VARCHAR(8) NOT NULL,
node_type TEXT NOT NULL,
query TEXT,
result_name TEXT,
left_node VARCHAR(8),
right_node VARCHAR(8),
status TEXT DEFAULT 'pending',
result JSONB,
error TEXT,
submitted_by REGROLE,
database TEXT,
created_at TIMESTAMPTZ DEFAULT pg_catalog.now(),
updated_at TIMESTAMPTZ DEFAULT pg_catalog.now()
);
COMMENT ON COLUMN df.nodes.submitted_by IS
'Effective role (current_user) at df.start() time - used for connection authentication and SQL execution';
-- Table to store function instances
CREATE TABLE df.instances (
id VARCHAR(8) PRIMARY KEY,
label TEXT,
root_node VARCHAR(8) NOT NULL,
status TEXT DEFAULT 'pending',
submitted_by REGROLE NOT NULL,
database TEXT,
created_at TIMESTAMPTZ DEFAULT pg_catalog.now(),
updated_at TIMESTAMPTZ DEFAULT pg_catalog.now(),
completed_at TIMESTAMPTZ
);
COMMENT ON COLUMN df.instances.submitted_by IS
'Effective role (current_user) at df.start() time - used for connection authentication and SQL execution';
-- Index for status-filtered listing, newest-first
-- (df.list_instances() WHERE status = $1 ORDER BY created_at DESC). Also serves the
-- pending-instance scan via the leading status column. The trailing id prepares the
-- access path for the keyset pagination planned for df.list_instances
-- (ORDER BY created_at DESC, id ASC); df.list_instances() does not order by id yet,
-- so this does not change the current result ordering.
-- NOTE: keep these two index definitions byte-identical to the 0.2.3->0.2.4 upgrade
-- script (sql/pg_durable--0.2.3--0.2.4.sql) until 0.2.4 is released -- Scenario A
-- compares pg_get_indexdef() across the fresh-install and upgrade paths.
CREATE INDEX idx_instances_status ON df.instances(status, created_at DESC, id);
-- Index for unfiltered listing, newest-first (df.list_instances() ORDER BY created_at DESC).
-- The trailing id prepares the access path for the same future keyset pagination
-- (ORDER BY created_at DESC, id ASC); it does not affect the current ordering.
CREATE INDEX idx_instances_created_at ON df.instances(created_at DESC, id);
-- Index for finding nodes by instance
CREATE INDEX idx_nodes_instance ON df.nodes(instance_id);
-- Table to store workflow variables (captured at df.start())
-- Per-user scoping: each user has their own variable namespace.
CREATE TABLE df.vars (
name TEXT NOT NULL,
value TEXT,
owner REGROLE NOT NULL DEFAULT pg_catalog.quote_ident(current_user)::pg_catalog.regrole,
PRIMARY KEY (owner, name)
);
-- Sentinel table: the background worker writes its epoch_id here after
-- initialising. If the extension is DROP-ed and re-CREATEd between
-- two poll ticks the epoch row disappears, so the worker detects the
-- recreation even though the extension is always "present" in pg_extension.
CREATE TABLE df._worker_epoch (
epoch_id UUID PRIMARY KEY,
started_at TIMESTAMPTZ DEFAULT pg_catalog.now(),
last_seen_at TIMESTAMPTZ DEFAULT pg_catalog.now()
);
ALTER TABLE df.instances
ADD CONSTRAINT instances_id_format_chk
-- Operators (OPERATOR(pg_catalog.<op>)) and functions (e.g. pg_catalog.now)
-- are schema-qualified throughout this install DDL so name resolution never
-- depends on the session search_path -- closing the CVE-2018-1058 vector
-- (a malicious schema shadowing `=`, `~`, etc.). Enforced by the pgspot CI
-- gate (scripts/pgspot-gate.sh).
CHECK (id OPERATOR(pg_catalog.~) '^[0-9a-f]{8}$') NOT VALID,
ADD CONSTRAINT instances_root_node_format_chk
CHECK (root_node OPERATOR(pg_catalog.~) '^[0-9a-f]{8}$') NOT VALID,
ADD CONSTRAINT instances_status_chk
CHECK (status OPERATOR(pg_catalog.=) ANY (ARRAY['pending', 'running', 'completed', 'failed', 'cancelled'])) NOT VALID,
-- Supports the composite FK from df.nodes that ties node identity to the instance row.
ADD CONSTRAINT instances_identity_key
UNIQUE (id, submitted_by);
ALTER TABLE df.nodes
ADD CONSTRAINT nodes_instance_id_present_chk
CHECK (instance_id IS NOT NULL) NOT VALID,
ADD CONSTRAINT nodes_submitted_by_present_chk
CHECK (submitted_by IS NOT NULL) NOT VALID,
ADD CONSTRAINT nodes_id_format_chk
CHECK (id OPERATOR(pg_catalog.~) '^[0-9a-f]{8}$') NOT VALID,
ADD CONSTRAINT nodes_instance_id_format_chk
CHECK (instance_id OPERATOR(pg_catalog.~) '^[0-9a-f]{8}$') NOT VALID,
ADD CONSTRAINT nodes_left_node_format_chk
CHECK (left_node IS NULL OR left_node OPERATOR(pg_catalog.~) '^[0-9a-f]{8}$') NOT VALID,
ADD CONSTRAINT nodes_right_node_format_chk
CHECK (right_node IS NULL OR right_node OPERATOR(pg_catalog.~) '^[0-9a-f]{8}$') NOT VALID,
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', 'SIGNAL'])) NOT VALID,
ADD CONSTRAINT nodes_result_name_chk
CHECK (result_name IS NULL OR result_name OPERATOR(pg_catalog.~) '^[A-Za-z_][A-Za-z0-9_]*$') NOT VALID,
ADD CONSTRAINT nodes_status_chk
CHECK (status OPERATOR(pg_catalog.=) ANY (ARRAY['pending', 'running', 'completed', 'failed'])) NOT VALID,
ADD CONSTRAINT nodes_result_status_chk
CHECK (result IS NULL OR status OPERATOR(pg_catalog.=) ANY (ARRAY['completed', 'failed'])) NOT VALID,
ADD CONSTRAINT nodes_structure_chk
CHECK (
CASE
WHEN node_type OPERATOR(pg_catalog.=) ANY (ARRAY['SQL', 'SLEEP', 'WAIT_SCHEDULE', 'BREAK', 'HTTP', '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,
-- Composite primary key: node IDs only need to be unique per instance, so
-- the random 8-hex node ID is never the sole uniqueness guarantee (issue
-- #129). The same-instance foreign keys below reference (instance_id, id),
-- which this primary key satisfies.
ADD CONSTRAINT nodes_pkey
PRIMARY KEY (instance_id, id);
ALTER TABLE df.nodes
ADD CONSTRAINT nodes_instance_identity_fkey
FOREIGN KEY (instance_id, submitted_by)
REFERENCES df.instances (id, submitted_by)
DEFERRABLE INITIALLY DEFERRED NOT VALID,
ADD CONSTRAINT nodes_left_node_same_instance_fkey
FOREIGN KEY (instance_id, left_node)
REFERENCES df.nodes (instance_id, id)
DEFERRABLE INITIALLY DEFERRED NOT VALID,
ADD CONSTRAINT nodes_right_node_same_instance_fkey
FOREIGN KEY (instance_id, right_node)
REFERENCES df.nodes (instance_id, id)
DEFERRABLE INITIALLY DEFERRED NOT VALID;
ALTER TABLE df.instances
ADD CONSTRAINT instances_root_node_same_instance_fkey
FOREIGN KEY (id, root_node)
REFERENCES df.nodes (instance_id, id)
DEFERRABLE INITIALLY DEFERRED NOT VALID;
"#,
name = "create_tables",
requires = [df]
);
// ============================================================================
// Row-Level Security Policies & Grants
// ============================================================================
extension_sql!(
r#"
-- Enable RLS on df.instances (no FORCE — superuser/table-owner bypasses RLS)
ALTER TABLE df.instances ENABLE ROW LEVEL SECURITY;
CREATE POLICY instances_user_isolation ON df.instances
FOR ALL
USING (submitted_by OPERATOR(pg_catalog.=) pg_catalog.quote_ident(current_user)::pg_catalog.regrole)
WITH CHECK (submitted_by OPERATOR(pg_catalog.=) pg_catalog.quote_ident(current_user)::pg_catalog.regrole);
-- Enable RLS on df.nodes
ALTER TABLE df.nodes ENABLE ROW LEVEL SECURITY;
CREATE POLICY nodes_user_isolation ON df.nodes
FOR ALL
USING (submitted_by OPERATOR(pg_catalog.=) pg_catalog.quote_ident(current_user)::pg_catalog.regrole)
WITH CHECK (submitted_by OPERATOR(pg_catalog.=) pg_catalog.quote_ident(current_user)::pg_catalog.regrole);
-- Enable RLS on df.vars (per-user variable isolation)
ALTER TABLE df.vars ENABLE ROW LEVEL SECURITY;
CREATE POLICY vars_user_isolation ON df.vars
FOR ALL
USING (owner OPERATOR(pg_catalog.=) pg_catalog.quote_ident(current_user)::pg_catalog.regrole)
WITH CHECK (owner OPERATOR(pg_catalog.=) pg_catalog.quote_ident(current_user)::pg_catalog.regrole);
-- No automatic PUBLIC grants — admins call df.grant_usage('role') after
-- CREATE EXTENSION (or see USER_GUIDE.md "Privilege Grants" for manual GRANTs).
-- Helper: grant all required df privileges to a role in one call. Additive
-- only (never REVOKEs); call df.revoke_usage() first to downgrade. SECURITY
-- INVOKER with EXECUTE revoked from PUBLIC, so the caller must hold the
-- underlying privileges WITH GRANT OPTION (superusers and with_grant => true
-- admins do). See USER_GUIDE.md "Privilege Grants" for full details.
--
-- Access gate: schema USAGE makes the ordinary df.* functions callable (they
-- keep PostgreSQL's default PUBLIC EXECUTE). Sensitive functions (df.http,
-- df.metrics, df.grant_usage, df.revoke_usage) have PUBLIC EXECUTE revoked at
-- install time and are granted explicitly below when appropriate — keep a new
-- private function private the same way (REVOKE ... FROM PUBLIC in
-- rls_and_grants, then grant it here).
-- include_http => true also grants EXECUTE on df.http() (opt-in: network).
-- with_grant => true marks a pg_durable admin: grants everything WITH GRANT
-- OPTION, lets the role call df.grant_usage()/
-- df.revoke_usage() for others, and grants df.metrics()
-- (system-wide aggregate counts).
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;
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$;
-- Revoke everything df.grant_usage() grants (same authorization model).
-- format(%I) quotes identifiers; SECURITY INVOKER caps it at the caller's
-- own privileges.
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.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$;
-- Validate that the worker role is a superuser.
-- The background worker must bypass RLS to manage all users' instances/nodes.
-- If the worker role is not a superuser, workflows will silently fail because
-- RLS will filter out rows the worker needs to read/update.
DO $$
DECLARE
wrole TEXT;
is_super BOOLEAN;
BEGIN
wrole := pg_catalog.current_setting('pg_durable.worker_role', true);
IF wrole IS NULL OR wrole OPERATOR(pg_catalog.=) '' THEN
wrole := 'postgres';
END IF;
SELECT rolsuper INTO is_super FROM pg_catalog.pg_roles WHERE rolname OPERATOR(pg_catalog.=) wrole;
IF is_super IS NULL THEN
RAISE WARNING 'pg_durable: worker role "%" does not exist. The background worker will not be able to process workflows. Create the role as a superuser before using pg_durable.', wrole;
ELSIF NOT is_super THEN
RAISE WARNING 'pg_durable: worker role "%" is not a superuser. The background worker must be a superuser to bypass RLS and manage all users'' instances. Grant superuser or BYPASSRLS to this role.', wrole;
END IF;
END $$;
-- df.http(), df.metrics(), df.grant_usage() and df.revoke_usage() are sensitive
-- (network access / system-wide monitoring / privilege management), so revoke
-- PostgreSQL's default PUBLIC EXECUTE. df.grant_usage() re-grants the helper
-- 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.metrics() FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION df.grant_usage(text, boolean, boolean) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION df.revoke_usage(text) FROM PUBLIC;
"#,
name = "rls_and_grants",
requires = ["create_tables", dsl::http, monitoring::metrics]
);
// ============================================================================
// Extension Validation (must run before duroxide schema creation)
// ============================================================================
// In production builds, validate that the extension is created in the database
// the background worker will connect to. In pgrx test builds the test database
// name is chosen by pgrx and won't match the worker's target database, so we
// skip the check (unit tests don't need the background worker).
#[cfg(not(any(test, feature = "pg_test")))]
extension_sql!(
r#"
-- Validate that CREATE EXTENSION is run in the correct database
-- The background worker connects to one specific database (determined by
-- the pg_durable.database GUC, defaults to "postgres").
-- The extension must be created in that database for workflows to execute.
DO $$
DECLARE
current_db TEXT;
target_db TEXT;
BEGIN
-- Get the current database
SELECT pg_catalog.current_database() INTO current_db;
-- Get the target database that the background worker will connect to
SELECT df.target_database() INTO target_db;
IF current_db OPERATOR(pg_catalog.<>) target_db THEN
RAISE EXCEPTION 'pg_durable extension must be created in database "%" (currently in "%"). The background worker only processes functions in the database specified by the pg_durable.database GUC (defaults to "postgres").', target_db, current_db
USING HINT = 'Connect to the correct database and run: CREATE EXTENSION pg_durable;';
END IF;
END $$;
"#,
name = "validate_database",
requires = [df, target_database]
);
#[cfg(any(test, feature = "pg_test"))]
extension_sql!(
r#"
-- Test build: skip database validation.
-- pgrx creates a test database whose name differs from the background worker's
-- target database. The worker won't run in the test database; unit tests that
-- exercise duroxide use direct tokio runtimes instead.
DO $$
BEGIN
RAISE NOTICE 'pg_durable: database validation skipped (test build)';
END $$;
"#,
name = "validate_database",
requires = [df]
);
// ============================================================================
// SQL Operators
// ============================================================================
extension_sql!(
r#"
-- Operator ~> for sequencing: a ~> b means "run a, then run b"
CREATE OPERATOR ~> (
FUNCTION = df.seq,
LEFTARG = text,
RIGHTARG = text
);
-- Operator |=> for naming: fut |=> 'name' means "name this result as $name"
CREATE OR REPLACE FUNCTION df.as_op(fut text, name text) RETURNS text AS $$
SELECT df.as(fut, name);
$$ LANGUAGE SQL IMMUTABLE SET search_path = pg_catalog, df, pg_temp;
CREATE OPERATOR |=> (
FUNCTION = df.as_op,
LEFTARG = text,
RIGHTARG = text
);
-- Operator & for parallel join: a & b means "run a and b in parallel, wait for both"
CREATE OPERATOR & (
FUNCTION = df.join,
LEFTARG = text,
RIGHTARG = text
);
-- Operator | for race: a | b means "run a and b in parallel, first wins"
CREATE OPERATOR | (
FUNCTION = df.race,
LEFTARG = text,
RIGHTARG = text
);
-- Operators ?> and !> for if-then-else: cond ?> then_branch !> else_branch
-- We need helper functions to build the if node incrementally
-- Helper: cond ?> then creates a partial if (stores condition and then branch)
CREATE OR REPLACE FUNCTION df.if_then_op(condition text, then_branch text) RETURNS text AS $$
DECLARE
cond_fut jsonb;
then_fut jsonb;
result_obj jsonb;
BEGIN
-- Ensure both are durofuts
cond_fut := df.ensure_durofut(condition)::jsonb;
then_fut := df.ensure_durofut(then_branch)::jsonb;
-- Return a special marker object for the partial if
result_obj := jsonb_build_object(
'_partial_if', true,
'condition', cond_fut,
'then_branch', then_fut
);
RETURN result_obj::text;
END;
$$ LANGUAGE plpgsql IMMUTABLE SET search_path = pg_catalog, df, pg_temp;
-- Helper: partial_if !> else completes the if node
CREATE OR REPLACE FUNCTION df.if_else_op(partial_if text, else_branch text) RETURNS text AS $$
DECLARE
partial jsonb;
else_fut text;
cond_text text;
then_text text;
BEGIN
partial := partial_if::jsonb;
-- Check if it's a partial if
IF partial->>'_partial_if' IS NULL THEN
RAISE EXCEPTION 'Invalid if-then-else: left side of !> must be a ?> expression';
END IF;
cond_text := partial->'condition'::text;
then_text := partial->'then_branch'::text;
else_fut := df.ensure_durofut(else_branch);
-- Now call the real df.if function
RETURN df.if(cond_text, then_text, else_fut);
END;
$$ LANGUAGE plpgsql IMMUTABLE SET search_path = pg_catalog, df, pg_temp;
-- 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).
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', 'SIGNAL') THEN
RAISE EXCEPTION 'Unknown node_type ''%''. Valid types: SQL, THEN, IF, JOIN, LOOP, BREAK, RACE, SLEEP, WAIT_SCHEDULE, HTTP, 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, df, pg_temp;
CREATE OPERATOR ?> (
FUNCTION = df.if_then_op,
LEFTARG = text,
RIGHTARG = text
);
CREATE OPERATOR !> (
FUNCTION = df.if_else_op,
LEFTARG = text,
RIGHTARG = text
);
-- Operator @> for loop: @> body means "repeat body forever"
-- This is a PREFIX operator with lowest precedence
CREATE OR REPLACE FUNCTION df.loop_prefix_op(body text) RETURNS text AS $$
SELECT df.loop(body);
$$ LANGUAGE SQL IMMUTABLE SET search_path = pg_catalog, df, pg_temp;
CREATE OPERATOR @> (
FUNCTION = df.loop_prefix_op,
RIGHTARG = text
);
"#,
name = "create_operators",
requires = [
dsl::then_fn,
dsl::as_named,
dsl::join,
dsl::race,
dsl::if_fn,
dsl::loop_fn
]
);
// ============================================================================
// Tests
// ============================================================================
#[cfg(any(test, feature = "pg_test"))]
#[pg_schema]
mod tests {
use crate::Durofut;
use pgrx::prelude::*;
// ========================================================================
// Test Helpers for Integration Tests
// ========================================================================
/// Ensure the Duroxide store exists and is ready
fn ensure_store_ready() -> Result<String, String> {
use crate::types::{
backend_duroxide_schema, new_backend_provider, postgres_connection_string,
};
use std::time::{Duration, Instant};
let pg_conn_str = postgres_connection_string();
let schema = backend_duroxide_schema();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("Failed to create runtime: {e}"))?;
// Try to initialize the store (creates schema if it doesn't exist)
rt.block_on(async {
let start = Instant::now();
let timeout = Duration::from_secs(10);
loop {
match new_backend_provider(&pg_conn_str, schema).await {
Ok(_) => return Ok(format!("{pg_conn_str} (schema: {schema})")),
Err(e) => {
if start.elapsed() > timeout {
return Err(format!(
"Failed to initialize store after {}s: {}",
timeout.as_secs(),
e
));
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
}
})
}
/// Wait for a durable function to complete, polling Duroxide status
fn poll_until_terminal(instance_id: &str, timeout_secs: u64) -> Result<String, String> {
use crate::types::{
backend_duroxide_schema, new_backend_provider, postgres_connection_string,
};
use duroxide::Client;
use std::time::{Duration, Instant};
// Ensure store is ready first
let _ = ensure_store_ready()?;
let pg_conn_str = postgres_connection_string();
let schema = backend_duroxide_schema();
let start = Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("Failed to create runtime: {e}"))?;
rt.block_on(async {
let store = new_backend_provider(&pg_conn_str, schema).await?;
let client = Client::new(store);
loop {
if let Ok(info) = client.get_instance_info(instance_id).await {
match info.status.as_str() {
"Completed" | "ContinuedAsNew" => {
return Ok(info.output.unwrap_or_default());
}
"Failed" | "Canceled" => {
return Err(format!(
"{}: {}",
info.status,
info.output.unwrap_or_default()
));
}
_ => {} // Still running
}
}
// Instance not found yet - continue polling
if start.elapsed() > timeout {
// Get final status for better error message
let final_status = client
.get_instance_info(instance_id)
.await
.map(|i| i.status)
.unwrap_or_else(|_| "unknown".to_string());
return Err(format!(
"Timeout after {timeout_secs}s, status: {final_status}"
));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
}
/// Get the current status from Duroxide
fn get_duroxide_status(instance_id: &str) -> Option<String> {
use crate::types::{
backend_duroxide_schema, new_backend_provider, postgres_connection_string,
};
use duroxide::Client;
let _ = ensure_store_ready().ok()?;
let pg_conn_str = postgres_connection_string();
let schema = backend_duroxide_schema();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.ok()?;
rt.block_on(async {
let store = new_backend_provider(&pg_conn_str, schema).await.ok()?;
let client = Client::new(store);
client
.get_instance_info(instance_id)
.await
.ok()
.map(|i| i.status)
})
}
fn sql_literal(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn test_database_connection_string() -> String {
use crate::types::{get_host, get_port};
let database = Spi::get_one::<String>("SELECT pg_catalog.current_database()::text")
.expect("current_database query should succeed")
.expect("current_database should return a value");
// Connect as the current session role rather than the worker role GUC
// (which defaults to "postgres"). The pgrx test cluster's superuser is
// the OS account, so "postgres" may not exist; the current role always
// does and can log in.
let role = Spi::get_one::<String>("SELECT current_user::text")
.expect("current_user query should succeed")
.expect("current_user should return a value");
format!(
"postgres://{}@{}:{}/{}",
role,
get_host(),
get_port(),
database
)
}
async fn delete_prune_test_rows(pool: &sqlx::PgPool, id_list: &str) {
let mut tx = pool.begin().await.expect("begin cleanup transaction");
sqlx::query("SET CONSTRAINTS ALL DEFERRED")
.execute(&mut *tx)
.await
.expect("defer cleanup constraints");
sqlx::query(&format!(
"DELETE FROM df.nodes WHERE instance_id IN ({id_list})"
))
.execute(&mut *tx)
.await
.expect("clean prune test nodes");
sqlx::query(&format!("DELETE FROM df.instances WHERE id IN ({id_list})"))
.execute(&mut *tx)
.await
.expect("clean prune test instances");
tx.commit().await.expect("commit cleanup");
}
// ========================================================================
// Unit Tests - DSL Node Creation
// ========================================================================
#[pg_test]
fn test_sql_creates_valid_durofut() {
let json = crate::dsl::sql("SELECT 1");
let fut = Durofut::from_json(&json);
assert_eq!(fut.node_type, "SQL");
assert!(fut.query.is_some());
}
#[pg_test]
fn test_seq_creates_then_node() {
let a = crate::dsl::sql("SELECT 1");
let b = crate::dsl::sql("SELECT 2");
let then_json = crate::dsl::then_fn(&a, &b);
let then_fut = Durofut::from_json(&then_json);
assert_eq!(then_fut.node_type, "THEN");
assert!(then_fut.left_node.is_some());
assert!(then_fut.right_node.is_some());
}
#[pg_test]
fn test_as_named_sets_result_name() {
let sql_json = crate::dsl::sql("SELECT 1");
let named_json = crate::dsl::as_named(&sql_json, "my_result");
let named_fut = Durofut::from_json(&named_json);
assert_eq!(named_fut.result_name, Some("my_result".to_string()));
}
#[pg_test]
fn test_sleep_creates_valid_node() {
let json = crate::dsl::sleep(60);
let fut = Durofut::from_json(&json);
assert_eq!(fut.node_type, "SLEEP");
assert_eq!(fut.query, Some("60".to_string()));
}
#[pg_test]
fn test_sleep_node_is_recognized_as_durofut() {
// This test verifies that SLEEP nodes created by df.sleep() can be
// properly recognized and deserialized by Durofut::ensure()
let sleep_json = crate::dsl::sleep(1);
// Test that is_durofut recognizes it
assert!(
Durofut::is_durofut(&sleep_json),
"Durofut::is_durofut should recognize SLEEP node JSON: {}",
sleep_json
);
// Test that ensure doesn't wrap it in SQL
let ensured = Durofut::ensure(&sleep_json);
assert_eq!(
ensured.node_type, "SLEEP",
"Durofut::ensure should preserve SLEEP, not wrap as SQL"
);
assert_eq!(ensured.query, Some("1".to_string()));
}
#[pg_test]
fn test_wait_for_schedule_valid_cron() {
let json = crate::dsl::wait_for_schedule("*/5 * * * *");
let fut = Durofut::from_json(&json);
assert_eq!(fut.node_type, "WAIT_SCHEDULE");
// The node stores only the cron expression; the next tick is computed at
// execution time, so there must be no pre-computed wait baked in at DSL time.
let config: serde_json::Value =
serde_json::from_str(fut.query.as_ref().expect("query must be set")).unwrap();
assert_eq!(
config["cron_expr"].as_str(),
Some("*/5 * * * *"),
"cron_expr should be preserved"
);
assert!(
config.get("wait_seconds").is_none(),
"config must not pre-compute wait_seconds at DSL time"
);
assert!(
config.get("target_timestamp").is_none(),