Repository navigation
Expand file tree
/
Copy pathinit.write.template.sql
More file actions
1708 lines (1616 loc) · 97.7 KB
/
Copy pathinit.write.template.sql
File metadata and controls
1708 lines (1616 loc) · 97.7 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
-- ─────────────────────────────────────────────────────────────────────────────
-- init.write.sql — the write half: ingress, guards, and optional tenant scoping.
--
-- ⚠️ **Appended to `init.core.template.sql`, never run alone.** It needs the reader role,
-- the publication and the DDL triggers that file creates:
--
-- cat init.core.template.sql init.write.template.sql | envsubst | psql -v ON_ERROR_STOP=1 …
--
-- Everything below is **defined, not applied**. These are functions a DBA calls per
-- table; loading this file grants nobody anything beyond the writer role's CONNECT and
-- USAGE. Making a table writable is a deliberate act:
--
-- SELECT zebridge_grant_edge_writes('public.orders');
-- SELECT zebridge_install_write_guards('public.orders', 'updated_at', 'deleted_at');
--
-- Multi-tenant is not a build flag, it is two more calls, both live without a restart:
--
-- INSERT INTO zebridge_user_tenants (principal, tenant_id) VALUES ('alice', 'acme');
-- SELECT zebridge_enable('public.orders', tenant_col => 'tenant_id', dry_run => false);
-- -- the catalogue row it writes is what routes the *subject*
--
-- ⚠️ `zebridge_scope_publication_to_one_tenant()` is a different shape entirely — it pins
-- a publication to ONE tenant value, for one-bridge-per-tenant deployments. It is not the
-- multi-tenant path above.
-- ─────────────────────────────────────────────────────────────────────────────
-- ---------------------------------------------------------
-- The WRITE role — separate from the read role on purpose
-- ---------------------------------------------------------
--
-- ${POSTGRES_READER_USER} stays exactly what it is: SELECT + REPLICATION, physically
-- unable to write. The ingress path (NATS -> bridge -> PostgreSQL) uses a *second*
-- connection under this role, so adding the write feature never widens the privileges
-- of the read path.
--
-- It is created with **no table privileges at all**. That is the backstop that makes
-- "the bridge is a dumb dispatcher" safe rather than merely simple: even a bug in
-- subject parsing cannot reach a table the DBA has not explicitly opened, because the
-- role has no path to one. Grants are per table, via
-- zebridge_grant_edge_writes() below — never ALL TABLES, and never by default.
--
-- Two attributes are spelled out even though they are the defaults, because each fails
-- **open** if it is ever wrong:
-- NOBYPASSRLS - with BYPASSRLS every row-level policy becomes decorative
-- NOREPLICATION- the write role must not be able to read the WAL
--
-- A third rule cannot be expressed here and belongs in review: this role must **not own
-- any table**. Table owners are exempt from row-level security unless the table is set
-- to FORCE ROW LEVEL SECURITY, so an owning writer silently bypasses every policy.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${POSTGRES_WRITER_USER}') THEN
CREATE USER ${POSTGRES_WRITER_USER} WITH PASSWORD '${POSTGRES_WRITER_PASSWORD}'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS;
END IF;
-- The writer's connection BUDGET, DB-enforced (outside the IF: applies on
-- re-apply to existing roles). Inventory: one per mutation listener lane
-- (ZB_INGRESS_LANES, at most 8) + sweeper 1 + enrollment permits ≤4
-- (http_server caps them, connect_timeout=3 bounds a hang) + margin. 20 total,
-- with the reader's 10 still under a third of the cluster's default 100: the
-- bridge cannot starve it even through a bug — the budget is a rule in PG, not
-- prose. The bridge checks the arithmetic at every boot (preflight's 🔌 line).
ALTER ROLE ${POSTGRES_WRITER_USER} CONNECTION LIMIT 20;
END;
$$;
GRANT CONNECT ON DATABASE ${TARGET_DB} TO ${POSTGRES_WRITER_USER};
GRANT USAGE ON SCHEMA public TO ${POSTGRES_WRITER_USER};
-- Read-only catalogue access (moved here from init.core: that file must never name
-- this role, or the read-only profile cannot apply on a fresh cluster).
GRANT SELECT ON public.zebridge_catalogue TO ${POSTGRES_WRITER_USER};
-- Bookkeeping the bridge records THROUGH THE READER by design (its width budget, the
-- refusal mirror, zebridge_generations) so a read-only deployment can record it at
-- all. When DATABASE_READER_URL is a hot standby the server refuses every write
-- there, and the bridge sends the same bookkeeping over DATABASE_WRITER_URL instead
-- (NOTES §10cz) — so the writer needs exactly the reader's bookkeeping privileges.
GRANT SELECT, INSERT, DELETE ON public.zebridge_generations TO ${POSTGRES_WRITER_USER};
GRANT UPDATE (has_full, retired_at, has_checkpoint, ckpt_lower, obj_bytes, full_sorted, ckpt_sorted) ON public.zebridge_generations TO ${POSTGRES_WRITER_USER}; -- §10gf/§10gq, the reader's column grant
GRANT SELECT ON public.zebridge_limits TO ${POSTGRES_WRITER_USER}; -- the registration reads MIN and its previous row around the call
GRANT EXECUTE ON FUNCTION public.zebridge_register_limits(text, name, integer, boolean) TO ${POSTGRES_WRITER_USER};
GRANT EXECUTE ON FUNCTION public.zebridge_set_suspended(text, text) TO ${POSTGRES_WRITER_USER};
GRANT EXECUTE ON FUNCTION public.zebridge_clear_suspensions() TO ${POSTGRES_WRITER_USER};
GRANT EXECUTE ON FUNCTION public.zebridge_observe_incarnation(text, oid) TO ${POSTGRES_WRITER_USER};
-- Deliberately NOT granted: SELECT/INSERT/UPDATE on any table, and no ALTER DEFAULT
-- PRIVILEGES. A new table is not edge-writable until someone says so.
-- Open one table to edge writes. Idempotent; call it once per table.
--
-- SELECT is required alongside INSERT/UPDATE because the last-write-wins guard
-- (`WHERE stored.version < EXCLUDED.version`) reads the existing row.
--
-- zebridge_ddl_events is refused by name: it is the bridge's own DDL tracker, and a
-- client able to insert into it could forge a schema, a suspension or a tombstone for
-- every other client. That is the one table where "can write my own rows" becomes
-- "can control every replica".
-- opens one table to edge writes — see PROTOCOL.md §7.4
CREATE OR REPLACE FUNCTION public.zebridge_grant_edge_writes(tbl regclass)
RETURNS void AS $$
DECLARE
tbl_name text := tbl::text;
rel_name text := (SELECT relname FROM pg_class WHERE oid = tbl);
BEGIN
-- ⚠️ Every bridge-owned table, not just the DDL transport. Each is a
-- different escalation, and the second one was proven exploitable on 2026-08-28
-- (NOTES §10aq) before the ingress path learned to refuse it by name:
--
-- zebridge_ddl_events a forged row publishes a fabricated schema, suspension
-- or tombstone to EVERY client
-- zebridge_gc_watermark the offline window for every client — one principal
-- dragged it back six years with a single mutation
-- zebridge_user_tenants the principal->tenant roster: a forged row is
-- cross-tenant privilege escalation
-- zebridge_invites the enrollment table. bridge_writer holds UPDATE so
-- redemption can stamp used_at — and UPDATE covers every
-- column, so a known code could be un-spent, un-expired,
-- or re-pointed at another principal, tenant and role
--
-- This does not revoke what the BRIDGE itself needs (the sweeper writes the
-- watermark as bridge_writer, and must keep that grant). It refuses to hand those
-- tables to the EDGE, which is a different question and the one that was wrong.
-- The whole zebridge_ namespace (zebridge_enable refuses a user table with that prefix), so a
-- table a later release adds is covered without anyone remembering a list: it used to be
-- five names, in both spellings of the regclass text.
IF lower(left(rel_name, 9)) = 'zebridge_' THEN
RAISE EXCEPTION 'refusing to grant edge writes on %: this is a bridge-owned '
'table — a forged row here reaches every client', tbl_name;
END IF;
-- Mutation receipts, by schema: the parent, every monthly partition (present or
-- future) and the policy row. A forged receipt silences a write that never ran.
IF (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.oid = tbl) = 'zebridge_private' THEN
RAISE EXCEPTION 'refusing to grant edge writes on %: zebridge_private holds the bridge''s '
'mutation receipts', tbl_name;
END IF;
-- DELETE as well as SELECT/INSERT/UPDATE, for two paths that both need it:
--
-- * a table with **no tombstone column** deletes physically — that is the documented
-- weaker guarantee, and it is an actual DELETE;
-- * the **tombstone sweeper** (src/gc.zig) reaps soft-deleted rows past
-- GC_THRESHOLD_MS, and runs as this role precisely so it is not the admin.
--
-- Marginal risk is small: this role can already UPDATE any row it may write, and RLS
-- bounds which rows those are. Withholding DELETE did not prevent data loss, it only
-- made the sweeper fail with `permission denied` — silently, in a sidecar nobody reads.
EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON %s TO ${POSTGRES_WRITER_USER}', tbl);
RAISE NOTICE 'edge writes enabled on % for ${POSTGRES_WRITER_USER}', tbl;
END;
$$ LANGUAGE plpgsql;
-- lets the tombstone sweeper reap a table that keeps tombstones but takes no edge writes — see PROTOCOL.md §7.5
--
-- A table can keep tombstones without being writable from the edge: a loader that
-- soft-deletes and revives rows (examples/08-map/load_fuel.py), so a returning client
-- learns what went. Those tombstones must be reaped too, and the sweeper runs as this
-- role: without SELECT and DELETE it fails with `permission denied` on every pass, and
-- the tombstones pile up for ever. zebridge_enable calls this for a read-only table with
-- a tombstone_col; a writable one already has both (zebridge_grant_edge_writes).
--
-- Under row-level security (a tenant-scoped read-only table: the reader's SELECT policy
-- only), the grant alone reaps nothing — the writer sees no row. So the sweeper's session,
-- and only it (zb.principal = 'zb_sweeper', as the tombstone guard knows it), gets its
-- own SELECT and DELETE policies. No INSERT, no UPDATE: the table stays read-only.
CREATE OR REPLACE FUNCTION public.zebridge_grant_sweeper_reaps(tbl regclass)
RETURNS void AS $$
BEGIN
EXECUTE format('GRANT SELECT, DELETE ON %s TO ${POSTGRES_WRITER_USER}', tbl);
IF (SELECT relrowsecurity FROM pg_class WHERE oid = tbl) THEN
EXECUTE format('DROP POLICY IF EXISTS zb_sweeper_read ON %s', tbl);
EXECUTE format('CREATE POLICY zb_sweeper_read ON %s FOR SELECT TO ${POSTGRES_WRITER_USER} '
'USING (coalesce(current_setting(''zb.principal'', true), '''') = ''zb_sweeper'')', tbl);
EXECUTE format('DROP POLICY IF EXISTS zb_sweeper_reap ON %s', tbl);
EXECUTE format('CREATE POLICY zb_sweeper_reap ON %s FOR DELETE TO ${POSTGRES_WRITER_USER} '
'USING (coalesce(current_setting(''zb.principal'', true), '''') = ''zb_sweeper'')', tbl);
END IF;
RAISE NOTICE 'the sweeper may reap % (SELECT, DELETE for ${POSTGRES_WRITER_USER})', tbl;
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------
-- Write guards — making the version column true for EVERY writer
-- ---------------------------------------------------------
--
-- Last-write-wins compares `stored.version < incoming.version`, and the bridge stamps the
-- version on every write it applies. The bridge is not the only writer. A cron job, a psql
-- session, another service, or an ORM path that forgets can run
--
-- UPDATE users SET name = 'x' WHERE id = 1; -- updated_at untouched
--
-- and the row changes while the version does not. The stored version then no longer
-- describes the row, so a client's OLDER edit beats a NEWER server write — silently, and
-- "correctly" by the rule. See PROTOCOL.md §7.3.
--
-- A trigger fixes it without a migration: it changes no shape and breaks no query, which
-- is what makes it usable on a database you do not control.
-- Stamp the version column when the statement did not — see PROTOCOL.md §7.3
CREATE OR REPLACE FUNCTION public.zebridge_bump_version()
RETURNS trigger AS $$
DECLARE
col text := TG_ARGV[0];
BEGIN
-- Only when this statement left the version alone. A writer that DID set it — the
-- bridge, which sends the client's value and clamps it (§7.3), or a well-behaved ORM —
-- keeps what it wrote. Overwriting unconditionally would make the version
-- server-assigned and quietly discard the value the client was told to send back.
IF (to_jsonb(OLD) -> col) IS NOT DISTINCT FROM (to_jsonb(NEW) -> col) THEN
-- jsonb round trip because plpgsql cannot assign to a column named at runtime.
-- One conversion per updated row, on tables the operator opted in.
NEW := jsonb_populate_record(NEW, jsonb_build_object(col, now()));
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Turn a physical DELETE into a tombstone — see PROTOCOL.md §7.5
CREATE OR REPLACE FUNCTION public.zebridge_soft_delete()
RETURNS trigger AS $$
DECLARE
tombstone text := TG_ARGV[0];
version text := TG_ARGV[1];
BEGIN
-- The sweeper MUST get through, or tombstones accumulate forever and the reaping this
-- whole design depends on can never happen. It identifies itself the same way every
-- other writer does — `zb.principal`, the setting the RLS policies read — so no new
-- mechanism is needed here.
IF coalesce(current_setting('zb.principal', true), '') = 'zb_sweeper' THEN
RETURN OLD;
END IF;
-- Located by `ctid`, not by primary key: the key columns are not known here without a
-- catalog lookup per row, and the tuple's physical address is stable for the statement
-- that is deleting it.
--
-- The version is stamped too, not just the tombstone. A soft delete that did not move
-- the version would be refused by the next edge write's LWW guard — the row would read
-- as deleted while still accepting edits.
EXECUTE format(
'UPDATE %I.%I SET %I = now(), %I = now() WHERE ctid = $1',
TG_TABLE_SCHEMA, TG_TABLE_NAME, tombstone, version
) USING OLD.ctid;
-- Suppress the DELETE. The UPDATE above is what reaches CDC, so clients see the
-- tombstone rather than a physical removal they cannot overrule.
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Attach both to one table — see PROTOCOL.md §7.3
--
-- The column names must match this table's zebridge_catalogue row. zebridge_enable writes
-- both in one transaction; called by hand, nothing cross-checks them: name a
-- different column here and the bridge and the database disagree about what "version"
-- means, with no error. `zebridge_audit_write_guards()` reports what is actually attached
-- so the two can be compared.
-- Tenant guard: every row leaves with a legal tenant token, or the write is refused.
--
-- Attached per table by `zebridge_install_write_guards`, which passes the column name as a
-- trigger argument. It fills in a missing tenant and refuses an unusable one — because the
-- bridge copies the column straight into `cdc.<tenant>.<table>.<op>` without interpreting it.
--
-- 1. **absence** — omitted, NULL, or empty → **the writer's own tenant**, looked up from
-- `zebridge_user_tenants` by `zb.principal`. This is the correction that matters: a
-- tenanted client that forgets the column must NOT have its (sensitive) row silently
-- published to everyone. Omission means "mine", never "open" — and only while
-- "mine" is one tenant: a member of several (§10fn) must name it, the omission
-- is refused rather than guessed.
--
-- ⚠️ **Fail-CLOSED on no derivable tenant.** If `zb.principal` is unset or unmapped,
-- there is no tenant to stamp and the write is REJECTED — a row on a tenant-routed
-- table with no tenant cannot be routed, so producing one is worse than refusing. An
-- admin seeding such a table must state the tenant, exactly as a client does.
--
-- The OPEN tenant (`${OPEN_TENANT}`) is no longer a coalesce target — it is simply the
-- tenant a principal *mapped to it* carries, so its rows land in `CDC_PUBLIC` and are
-- read by everyone. "Open" is a mapping, not a default: to write open data you are a
-- member of the open tenant, audited in the one table like every other writer.
--
-- 2. **malformed** — a real value with a NATS subject metacharacter (`.`, space, `*`, `>`)
-- → RAISES. A dot splits the token and the row routes to a subject no consumer
-- receives. Not corrected: a caller that sent `evil.acme` meant something, and
-- rerouting it silently is worse than refusing.
--
-- 3. **valid** — passed through untouched; `zb_tenant_write` (RLS `WITH CHECK`) is what
-- confirms the writer may actually write that tenant, so a supplied value is validated,
-- not trusted.
--
-- ⚠️ `set_config('zb.principal', …, true)` MUST share the write's transaction (the bridge's
-- pipeline does). In autocommit each statement is its own transaction and the setting is
-- gone by the INSERT — the lookup then finds nothing and this fails closed.
--
-- ⚠️ Runs BEFORE the NOT NULL check (Postgres fires BEFORE-row triggers first), so the
-- column stays NOT NULL with no DEFAULT and this still satisfies it.
--
-- ⚠️ The jsonb round trip is how a dynamically-named column is written — plain plpgsql
-- cannot assign `NEW.<variable>`. Same trick as `zebridge_bump_version()`; no extension, and
-- a generated per-table function would need nested dollar-quoting, which `envsubst`
-- (the bridge-init pipe) destroys.
CREATE OR REPLACE FUNCTION public.zebridge_guard_tenant()
RETURNS trigger AS $$
DECLARE
col text := TG_ARGV[0];
val text;
mine text;
n bigint;
who text := current_setting('zb.principal', true);
BEGIN
EXECUTE format('SELECT ($1).%I::text', col) USING NEW INTO val;
IF val IS NULL OR val = '' THEN
-- Derive from identity — exact only when the principal belongs to ONE tenant.
-- A member of several (§10fn) must say which: guessing one would route the row
-- to a tenant the writer did not name, so the omission is refused, not filled.
SELECT min(tenant_id), count(*) INTO mine, n FROM public.zebridge_user_tenants
WHERE principal = who;
IF n > 1 THEN
RAISE EXCEPTION 'no tenant for a write to %: principal % belongs to % tenants, '
'so % must be supplied explicitly.',
TG_TABLE_NAME, quote_literal(who), n, col
USING ERRCODE = 'check_violation';
END IF;
IF mine IS NULL THEN
RAISE EXCEPTION 'no tenant for a write to %: principal % is unset or not in '
'zebridge_user_tenants, and a tenant-routed row cannot be left unrouted. '
'Set zb.principal in the transaction, or supply % explicitly.',
TG_TABLE_NAME, coalesce(quote_literal(who), 'NULL'), col
USING ERRCODE = 'check_violation';
END IF;
NEW := jsonb_populate_record(NEW, jsonb_build_object(col, mine));
RETURN NEW;
END IF;
-- A tenant is a NATS subject token, measured in BYTES (octet_length, not length): no `.`, `*`,
-- `>`, space or control character, and at most ${MAX_TENANT_BYTES} bytes. The bridge drops a row
-- that gets past this (counted: bridge_dropped_events_total{reason="tenant_unusable"}), so this
-- trigger is what keeps the replica from silently missing it. Existing rows are not rechecked by
-- a trigger; `bridge --diagnose` lists them.
IF val ~ '[\x01-\x20\x7f.*>]' THEN
RAISE EXCEPTION 'tenant %=% on % contains a NATS subject metacharacter '
'(. * > space or a control character); it would route to a subject no consumer receives',
col, quote_literal(val), TG_TABLE_NAME USING ERRCODE = 'check_violation';
END IF;
IF octet_length(val) > ${MAX_TENANT_BYTES} THEN
RAISE EXCEPTION 'tenant %=% on % is % bytes; a tenant is at most ${MAX_TENANT_BYTES} bytes '
'(measured in bytes, not characters)',
col, quote_literal(left(val, 24) || '…'), TG_TABLE_NAME, octet_length(val)
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END $$ LANGUAGE plpgsql;
-- A parent's tombstone while a live child still references it (§10dn). Every foreign
-- key pointing at this table is looked up in pg_constraint; a referencing row counts
-- as live unless the child's own tombstone column (from the catalogue) is set. Raised
-- as foreign_key_violation (23503): the bridge turns the failed UPDATE into a
-- `rejected` verdict naming the child table, exactly as a physical delete would have
-- been refused by NO ACTION. Fresh tombstones only: an already-tombstoned row's
-- update (a version bump, a reap-time touch) passes.
CREATE OR REPLACE FUNCTION public.zebridge_tombstone_children_guard()
RETURNS trigger AS $$
DECLARE
tomb text := TG_ARGV[0];
fk record;
cond text;
i int;
child_tomb text;
n bigint;
BEGIN
IF (to_jsonb(NEW) ->> tomb) IS NULL OR (to_jsonb(OLD) ->> tomb) IS NOT NULL THEN
RETURN NEW;
END IF;
FOR fk IN SELECT con.conname, con.conrelid, con.conkey, con.confkey
FROM pg_constraint con
WHERE con.confrelid = TG_RELID AND con.contype = 'f'
LOOP
cond := '';
FOR i IN 1..array_length(fk.conkey, 1) LOOP
cond := cond || CASE WHEN i > 1 THEN ' AND ' ELSE '' END
|| format('%I = %L',
(SELECT attname FROM pg_attribute WHERE attrelid = fk.conrelid AND attnum = fk.conkey[i]),
to_jsonb(OLD) ->> (SELECT attname FROM pg_attribute WHERE attrelid = TG_RELID AND attnum = fk.confkey[i]));
END LOOP;
SELECT c.tombstone_col INTO child_tomb
FROM public.zebridge_catalogue c
WHERE c.tbl = (SELECT relname FROM pg_class WHERE oid = fk.conrelid);
IF child_tomb IS NOT NULL THEN
cond := cond || format(' AND %I IS NULL', child_tomb);
END IF;
EXECUTE format('SELECT count(*) FROM %s WHERE %s', fk.conrelid::regclass, cond) INTO n;
IF n > 0 THEN
RAISE EXCEPTION USING
ERRCODE = 'foreign_key_violation',
MESSAGE = format('cannot tombstone %s: %s live row(s) in %s still reference it (constraint %s) — delete the children first',
TG_TABLE_NAME, n, fk.conrelid::regclass, fk.conname);
END IF;
END LOOP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION public.zebridge_install_write_guards(
tbl regclass,
version_col text,
tombstone_col text DEFAULT NULL,
tenant_col text DEFAULT NULL
)
RETURNS void AS $$
DECLARE
t text := tbl::text;
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = tbl
AND attname = version_col AND attnum > 0 AND NOT attisdropped) THEN
RAISE EXCEPTION '% has no column %', t, version_col;
END IF;
EXECUTE format('DROP TRIGGER IF EXISTS zebridge_bump_version_t ON %s', t);
EXECUTE format(
'CREATE TRIGGER zebridge_bump_version_t BEFORE UPDATE ON %s '
'FOR EACH ROW EXECUTE FUNCTION public.zebridge_bump_version(%L)', t, version_col
);
RAISE NOTICE 'version guard on %: % is stamped even when a writer forgets', t, version_col;
EXECUTE format('DROP TRIGGER IF EXISTS zebridge_soft_delete_t ON %s', t);
IF tombstone_col IS NOT NULL THEN
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = tbl
AND attname = tombstone_col AND attnum > 0 AND NOT attisdropped) THEN
RAISE EXCEPTION '% has no column %', t, tombstone_col;
END IF;
EXECUTE format(
'CREATE TRIGGER zebridge_soft_delete_t BEFORE DELETE ON %s '
'FOR EACH ROW EXECUTE FUNCTION public.zebridge_soft_delete(%L, %L)',
t, tombstone_col, version_col
);
RAISE NOTICE 'delete guard on %: DELETE writes % instead of removing the row', t, tombstone_col;
-- §10dn: a tombstone is a DELETE on every replica, and a replica cannot delete a
-- parent whose children are still there (its foreign keys have no cascade). So
-- the tombstone is refused HERE, where the truth lives, while a live child still
-- references the row: the client gets `rejected`, and deletes the children first.
EXECUTE format('DROP TRIGGER IF EXISTS zebridge_tombstone_children_t ON %s', t);
EXECUTE format(
'CREATE TRIGGER zebridge_tombstone_children_t BEFORE UPDATE OF %I ON %s '
'FOR EACH ROW EXECUTE FUNCTION public.zebridge_tombstone_children_guard(%L)',
tombstone_col, t, tombstone_col
);
ELSE
-- No tombstone column means deletes are physical for this table — the documented
-- weaker guarantee, not an oversight. Saying so beats silence.
RAISE NOTICE 'no tombstone for %: deletes stay physical, so an offline client can resurrect a row', t;
END IF;
-- The tenant guard, when the table is tenant-routed. One shared function,
-- `zebridge_guard_tenant()`, with the column name passed as a trigger argument.
EXECUTE format('DROP TRIGGER IF EXISTS zebridge_guard_tenant_t ON %s', t);
IF tenant_col IS NOT NULL THEN
IF NOT EXISTS (SELECT 1 FROM pg_attribute WHERE attrelid = tbl
AND attname = tenant_col AND attnum > 0 AND NOT attisdropped) THEN
RAISE EXCEPTION '% has no column %', t, tenant_col;
END IF;
EXECUTE format(
'CREATE TRIGGER zebridge_guard_tenant_t BEFORE INSERT OR UPDATE ON %s '
'FOR EACH ROW EXECUTE FUNCTION public.zebridge_guard_tenant(%L)', t, tenant_col
);
RAISE NOTICE 'tenant guard on %: % filled from the writer''s tenant when omitted (fail-closed if unmapped), malformed or longer than ${MAX_TENANT_BYTES} bytes refused', t, tenant_col;
END IF;
END;
$$ LANGUAGE plpgsql;
-- ── Registers (COOPERATIVE_EDITING.md) ───────────────────────────────────────
-- A register column is one jsonb object of named registers, each {"v": value, "t": stamp,
-- "w": writer}. The row's last-writer-wins decides whether a write is ACCEPTED; for a
-- register column it would then replace the whole document with what its writer last saw
-- — a late write from an editor who was offline rolled back registers written meanwhile
-- by others, accepted, with no verdict saying so (measured in example 15). So on every
-- accepted UPDATE the column is merged instead, register by register: the newest edit of
-- each field survives, whoever wrote the row last — Cassandra's per-cell timestamps,
-- Figma's per-property last-writer-wins, on a plain jsonb column.
--
-- zebridge_merge_registers is mergeRegisters (libzb core.zig, which zb-client-ts runs as WebAssembly), pinned
-- by the same fixtures: per key the later `t` wins, an equal `t` breaks on `w`, a key on
-- one side only is kept, a value without `t` counts as the oldest. Stamps and writers are
-- compared byte by byte (COLLATE "C"), as JavaScript and Zig compare strings — a locale
-- collation could order two stamps differently. Registers only gain: removing one is
-- writing it again with a newer `t` and an empty `v`.
CREATE OR REPLACE FUNCTION public.zebridge_merge_registers(a jsonb, b jsonb) RETURNS jsonb AS $$
SELECT COALESCE(jsonb_object_agg(k, CASE
WHEN rb IS NULL THEN ra
WHEN ra IS NULL THEN rb
WHEN COALESCE(rb ->> 't', '') COLLATE "C" > COALESCE(ra ->> 't', '') COLLATE "C" THEN rb
WHEN COALESCE(rb ->> 't', '') = COALESCE(ra ->> 't', '')
AND COALESCE(rb ->> 'w', '') COLLATE "C" > COALESCE(ra ->> 'w', '') COLLATE "C" THEN rb
ELSE ra END), '{}'::jsonb)
FROM (SELECT ea.key AS k, ea.value AS ra, NULL::jsonb AS rb
FROM jsonb_each(CASE WHEN jsonb_typeof(a) = 'object' THEN a ELSE '{}'::jsonb END) ea
WHERE NOT (CASE WHEN jsonb_typeof(b) = 'object' THEN b ELSE '{}'::jsonb END) ? ea.key
UNION ALL
SELECT eb.key, (CASE WHEN jsonb_typeof(a) = 'object' THEN a ELSE '{}'::jsonb END) -> eb.key, eb.value
FROM jsonb_each(CASE WHEN jsonb_typeof(b) = 'object' THEN b ELSE '{}'::jsonb END) eb) m
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
-- The trigger: TG_ARGV names the register columns. BEFORE INSERT, and BEFORE UPDATE OF
-- those columns, so a write that does not set one (part A's plain columns) costs nothing;
-- a write the version guard refuses (stale) never reaches it — the row's rule still
-- decides WHETHER, this decides WHAT.
--
-- A register column holds a JSON object of registers, or NULL. Anything else (an array, a
-- string, a number) is refused: merged, it would count as an empty document, so the write
-- would change nothing and still be reported applied. SQLSTATE 22023 is permanent to the
-- bridge, so the client gets `rejected` at once, with this message as the detail.
CREATE OR REPLACE FUNCTION public.zebridge_merge_register_cols() RETURNS trigger AS $$
DECLARE
col text;
val jsonb;
patch jsonb := '{}'::jsonb;
BEGIN
FOREACH col IN ARRAY TG_ARGV LOOP
val := to_jsonb(NEW) -> col;
IF jsonb_typeof(val) NOT IN ('object', 'null') THEN
RAISE EXCEPTION '%.%: a register column holds a JSON object of registers (got %)', TG_TABLE_NAME, col, jsonb_typeof(val)
USING ERRCODE = '22023';
END IF;
IF TG_OP = 'UPDATE' THEN
patch := patch || jsonb_build_object(col, public.zebridge_merge_registers(to_jsonb(OLD) -> col, val));
END IF;
END LOOP;
IF TG_OP = 'UPDATE' THEN
NEW := jsonb_populate_record(NEW, patch);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Installs the merge on `cols` (each a jsonb column); idempotent, replaces the column list.
CREATE OR REPLACE FUNCTION public.zebridge_install_register_merge(tbl regclass, cols name[]) RETURNS void AS $$
DECLARE
c name;
typ text;
BEGIN
IF cols IS NULL OR cardinality(cols) = 0 THEN
EXECUTE format('DROP TRIGGER IF EXISTS zebridge_merge_registers_t ON %s', tbl);
RETURN;
END IF;
FOREACH c IN ARRAY cols LOOP
SELECT format_type(atttypid, atttypmod) INTO typ
FROM pg_attribute WHERE attrelid = tbl AND attname = c AND NOT attisdropped;
IF typ IS NULL THEN
RAISE EXCEPTION '%: no column %', tbl, c;
ELSIF typ <> 'jsonb' THEN
RAISE EXCEPTION '%.%: a register column is jsonb, not %', tbl, c, typ;
END IF;
END LOOP;
EXECUTE format('DROP TRIGGER IF EXISTS zebridge_merge_registers_t ON %s', tbl);
EXECUTE format('CREATE TRIGGER zebridge_merge_registers_t BEFORE INSERT OR UPDATE OF %s ON %s '
'FOR EACH ROW EXECUTE FUNCTION public.zebridge_merge_register_cols(%s)',
(SELECT string_agg(quote_ident(x), ', ') FROM unnest(cols) x), tbl,
(SELECT string_agg(quote_literal(x), ', ') FROM unnest(cols) x));
END;
$$ LANGUAGE plpgsql;
-- Take the guards off again — the counterpart, and needed more often than it looks
--
-- ⚠️ Once the delete guard is on, **nobody but the sweeper can physically delete from this
-- table**. An admin running `DELETE FROM t WHERE …` in psql gets a tombstone, not a
-- removal. That is the whole point, and it is also a genuine surprise the first time it
-- happens during a cleanup. Two ways out, in order of preference:
--
-- SELECT set_config('zb.principal', 'zb_sweeper', false); -- for this session only
-- SELECT zebridge_remove_write_guards('public.t'); -- permanently
CREATE OR REPLACE FUNCTION public.zebridge_remove_write_guards(tbl regclass)
RETURNS void AS $$
DECLARE
t text := tbl::text;
BEGIN
EXECUTE format('DROP TRIGGER IF EXISTS zebridge_bump_version_t ON %s', t);
EXECUTE format('DROP TRIGGER IF EXISTS zebridge_soft_delete_t ON %s', t);
RAISE NOTICE 'write guards removed from %: versions are only as true as their writers again', t;
END;
$$ LANGUAGE plpgsql;
-- Scope WRITES by tenant, without pinning the publication — see PROTOCOL.md §7.4
--
-- ⚠️ The companion to `zebridge_scope_publication_to_one_tenant`, and the difference is the whole
-- point. That one adds a publication row filter (`WHERE tenant = 'acme'`), which pins the
-- publication — and therefore the bridge — to **one** tenant. This one does not touch the
-- publication at all, so a single bridge can carry many tenants and route them by subject
-- (the catalogue's `tenant_col` → `cdc.<tenant>.<table>.<op>`).
--
-- ⚠️ **RLS does not bound reads.** It bounds what the writer role may write and what a
-- snapshot may select. What stops one tenant *reading* another's rows is the CDC subject
-- plus the NATS permission on it. Enabling this and granting clients `cdc.>` gives you
-- airtight writes and no read isolation whatsoever.
CREATE OR REPLACE FUNCTION public.zebridge_scope_writes_by_tenant(tbl regclass, tenant_col name)
RETURNS void AS $$
BEGIN
-- NOT NULL because a replica-identity index requires it, and because a nullable tenant
-- is a row no policy matches and no subject can carry.
IF NOT EXISTS (SELECT 1 FROM pg_attribute
WHERE attrelid = tbl AND attname = tenant_col
AND attnum > 0 AND NOT attisdropped AND attnotnull) THEN
RAISE EXCEPTION 'tenant column %.% must exist and be NOT NULL', tbl, tenant_col;
END IF;
-- ⚠️ The tenant column must be inside the REPLICA IDENTITY, or the table is refused
-- with `tenant_not_in_replica_identity` and nothing replicates (init.core).
PERFORM public.zebridge_tenant_replica_identity(tbl, tenant_col);
EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', tbl);
-- The writer may touch only rows whose tenant is mapped to the principal it declared.
-- `USING` bounds what it can see and change; `WITH CHECK` bounds what it can leave
-- behind — both are needed, or a writer can move a row into another tenant.
--
-- ⚠️ **Open bar on the OPEN tenant (`${OPEN_TENANT}`): every principal may write it**, in
-- both clauses, even one mapped to no tenant but its own. This is the write half of the
-- open-data model — the open tenant is a shared pool anyone may post to and anyone may
-- edit (it is also read by everyone via CDC_PUBLIC). Integrity on *private* tenants is
-- untouched: alice still cannot write or delete a `globex` row. The carve-out only widens
-- reach to the tenant that is public by definition, so it grants nothing that reading the
-- open feed did not already expose.
--
-- ⚠️ It is in USING as well as WITH CHECK on purpose. Edge writes are upserts
-- (INSERT … ON CONFLICT DO UPDATE); updating an existing open row needs USING to see it,
-- so a WITH-CHECK-only carve-out would let a restricted user CREATE an open row but fail
-- to UPDATE one — a half-open state worse than either choice.
EXECUTE format('DROP POLICY IF EXISTS zb_tenant_write ON %s', tbl);
EXECUTE format(
'CREATE POLICY zb_tenant_write ON %s FOR ALL TO %I'
' USING (%I IN (SELECT tenant_id FROM public.zebridge_user_tenants'
' WHERE principal = (SELECT current_setting(''zb.principal'', true))) OR %I = ''${OPEN_TENANT}'')'
' WITH CHECK (%I IN (SELECT tenant_id FROM public.zebridge_user_tenants'
' WHERE principal = (SELECT current_setting(''zb.principal'', true))) OR %I = ''${OPEN_TENANT}'')',
tbl, '${POSTGRES_WRITER_USER}', tenant_col, tenant_col, tenant_col, tenant_col);
-- Reads: CDC stays subject-scoped regardless (RLS cannot see it — see
-- zebridge_scope_reads_by_tenant's own comment, init.core.template.sql). Snapshots
-- now ARE RLS-scoped, via the same function a read-only deployment would call
-- directly — defined once in core so it needs nothing from this file.
PERFORM public.zebridge_scope_reads_by_tenant(tbl, tenant_col);
RAISE NOTICE 'writes and scoped reads on % are now scoped by %; CDC stays scoped by the subject, not RLS — zebridge_enable records %.% in zebridge_catalogue, and a running bridge reloads the catalogue row live',
tbl, tenant_col, tbl, tenant_col;
END;
$$ LANGUAGE plpgsql;
-- Answers "which tables are guarded, and on which columns?" — compare against the catalogue
CREATE OR REPLACE FUNCTION public.zebridge_audit_write_guards()
RETURNS TABLE (tbl text, version_guard boolean, delete_guard boolean, detail text) AS $$
SELECT c.relname::text,
EXISTS (SELECT 1 FROM pg_trigger g WHERE g.tgrelid = c.oid
AND g.tgname = 'zebridge_bump_version_t'),
EXISTS (SELECT 1 FROM pg_trigger g WHERE g.tgrelid = c.oid
AND g.tgname = 'zebridge_soft_delete_t'),
coalesce((SELECT string_agg(pg_get_triggerdef(g.oid), ' | ')
FROM pg_trigger g WHERE g.tgrelid = c.oid
AND g.tgname LIKE 'zebridge_%'), '')
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relkind = 'r'
-- Prefix test rather than `zebridge_is_internal_table()`: a LANGUAGE sql body is
-- validated when it is CREATEd, so calling a function declared later in this file
-- fails the whole script. Caught by scripts/scenarios/render.py, which applies the
-- rendered SQL to a scratch database rather than trusting that it parses.
AND c.relname NOT LIKE 'zebridge%'
ORDER BY c.relname;
$$ LANGUAGE sql STABLE;
-- ---------------------------------------------------------
-- Per-tenant scoping — ONE entry point, on purpose
-- ---------------------------------------------------------
--
-- ⚠️ **Use `zebridge_scope_publication_to_one_tenant()` and nothing else** to put a tenant-scoped
-- table into a publication. A bare `ALTER PUBLICATION ... ADD TABLE` is the failure this
-- exists to prevent: it publishes the table with no row filter and no policy, and every
-- subscriber of that publication then receives every tenant's rows. Nothing errors, and
-- nothing in the bridge can detect it — the bridge is a pass-through and is meant to stay
-- one.
--
-- The function does the four things that must not drift apart, in the order they must
-- happen:
--
-- 1. refuses a nullable or absent tenant column (a NULL tenant belongs to nobody, and a
-- nullable column cannot carry a replica identity)
-- 2. moves the REPLICA IDENTITY to (tenant_col, <pk>) if the tenant is not already in it
-- — without this PostgreSQL refuses every UPDATE and DELETE on the table once the
-- publication carries a filter, including the application's own
-- 3. enables RLS and creates two policies: writes bounded by the principal→tenant
-- mapping, reads left wide for `bridge_reader` (one session serves all tenants, so it
-- has no principal to resolve — read scoping is the publication's job)
-- 4. only then adds the table to the publication, with the filter
--
-- Called once per (table, tenant): the per-table work is idempotent, the publication entry
-- is per-tenant. Each publication needs its own replication slot and its own bridge
-- instance.
--
-- ⚠️ `bridge_reader` must NOT be given BYPASSRLS to make step 3 work. That is a *role*
-- attribute covering every table in the database, and this file already grants the reader
-- SELECT on all of them — including tables added later by someone who never heard of the
-- bridge. The per-table `zb_reader_all` policy is the same capability, scoped, and it
-- fails closed for anything nobody considered.
-- Which principal may act for which tenant. Referenced by the write policy above; for
-- *writes* the bridge never reads it and never learns what a tenant is — it sets
-- `zb.principal` from the subject token it already trusts, and PostgreSQL resolves the
-- rest.
--
-- For *reads* it is a second source: this table is replicated (below) so the bridge can
-- mirror it live into `$KV.tenants.<principal>`, letting a fresh consumer holding only a
-- principal ID ask NATS for its own tenant (PROTOCOL.md "The Connection Flow", Step 0;
-- NOTES.md §1.12 part 3) instead of guessing or hardcoding it. The bridge still never
-- resolves a tenant *for routing purposes* from this table — that stays the subject
-- token's job — this is purely a client-facing lookup, mirrored from the same source of
-- truth as the write policy above so the two cannot drift apart.
-- principal → tenant, the mapping RLS resolves against — see PROTOCOL.md §7.4
CREATE TABLE IF NOT EXISTS public.zebridge_user_tenants (
principal text NOT NULL,
-- A tenant is a NATS subject token (§10fo): no `.`, `*`, `>` or space — the
-- application-table guard checks a row's value, this checks the roster's, and
-- the invite below the DBA's, so a wildcard can never become a stream name, a
-- KV entry or a JWT tag.
tenant_id text NOT NULL CHECK (tenant_id <> '' AND tenant_id !~ '[\x01-\x20\x7f.*>]' AND octet_length(tenant_id) <= ${MAX_TENANT_BYTES}),
PRIMARY KEY (principal, tenant_id)
);
ALTER TABLE public.zebridge_user_tenants DROP CONSTRAINT IF EXISTS zebridge_user_tenants_tenant_id_check;
ALTER TABLE public.zebridge_user_tenants ADD CONSTRAINT zebridge_user_tenants_tenant_id_check CHECK (tenant_id <> '' AND tenant_id !~ '[\x01-\x20\x7f.*>]' AND octet_length(tenant_id) <= ${MAX_TENANT_BYTES});
GRANT SELECT ON public.zebridge_user_tenants TO ${POSTGRES_READER_USER}, ${POSTGRES_WRITER_USER};
-- A restricted leaf owns a principal namespace <leaf_id>.<device>. Register
-- its explicit tenant list before issuing leaf-scoped invites or link credentials.
-- This table is admin-owned; the bridge can only read it. A leaf id starts with
-- ${LEAF_PREFIX} (grammar.json leaf_prefix) and no plain principal may: a principal
-- named like a leaf would sit above <leaf_id>.<device> in every wildcard grant.
-- A leaf id must also never have been used as a hub principal.
CREATE TABLE IF NOT EXISTS public.zebridge_leaf_scopes (
leaf_id text PRIMARY KEY,
tenant_ids text[] NOT NULL CHECK (cardinality(tenant_ids) > 0
AND array_position(tenant_ids, NULL) IS NULL
AND array_position(tenant_ids, '') IS NULL
AND array_to_string(tenant_ids, '') ~ '^[A-Za-z0-9_-]+$'),
nats_url text,
nats_ws_url text
);
ALTER TABLE public.zebridge_leaf_scopes DROP CONSTRAINT IF EXISTS zebridge_leaf_scopes_leaf_id_check;
ALTER TABLE public.zebridge_leaf_scopes ADD CONSTRAINT zebridge_leaf_scopes_leaf_id_check
CHECK (leaf_id ~ '^${LEAF_PREFIX}[A-Za-z0-9_-]+$' AND length(leaf_id) <= 64);
GRANT SELECT ON public.zebridge_leaf_scopes TO ${POSTGRES_READER_USER}, ${POSTGRES_WRITER_USER};
CREATE OR REPLACE FUNCTION public.zebridge_check_leaf_membership(p_principal text, p_tenant text)
RETURNS void LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
DECLARE scope text;
BEGIN
PERFORM pg_advisory_xact_lock(hashtextextended(split_part(p_principal, '.', 1), 0));
IF p_principal ~ '^${LEAF_PREFIX}[A-Za-z0-9_-]+[.][A-Za-z0-9_-]+$' THEN
scope := split_part(p_principal, '.', 1);
PERFORM 1 FROM public.zebridge_leaf_scopes
WHERE leaf_id = scope AND p_tenant = ANY(tenant_ids) FOR SHARE;
IF NOT FOUND THEN
RAISE EXCEPTION 'principal % is outside its registered leaf tenant list', p_principal
USING ERRCODE = '23514';
END IF;
ELSIF p_principal !~ '^[A-Za-z0-9_-]+$' OR p_principal ~ '^${LEAF_PREFIX}' THEN
RAISE EXCEPTION 'invalid or reserved principal namespace %', p_principal USING ERRCODE = '23514';
END IF;
END;
$$;
REVOKE ALL ON FUNCTION public.zebridge_check_leaf_membership(text, text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.zebridge_check_leaf_membership(text, text) TO ${POSTGRES_WRITER_USER};
CREATE OR REPLACE FUNCTION public.zebridge_guard_leaf_membership()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
BEGIN
PERFORM public.zebridge_check_leaf_membership(NEW.principal, NEW.tenant_id);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS zebridge_guard_leaf_membership_t ON public.zebridge_user_tenants;
CREATE TRIGGER zebridge_guard_leaf_membership_t BEFORE INSERT OR UPDATE ON public.zebridge_user_tenants
FOR EACH ROW EXECUTE FUNCTION public.zebridge_guard_leaf_membership();
-- The keys a principal enrolled with (§10cl). /enroll used to mint from the pubkey
-- and FORGET it — which made any future hard kill (the NATS account revocation list,
-- keyed by user pubkey) impossible to retrofit: you cannot revoke a key you never
-- recorded. One row per enrollment; a principal may hold several (laptop, phone,
-- re-enrollments after expiry), so this is one-to-many and the PUBKEY is the key.
-- Written by the enroll CTE in the same transaction as the invite redemption; kept
-- after revocation (rows are the audit trail AND the future revocation list's input —
-- `bridge --revoke` does not delete them).
CREATE TABLE IF NOT EXISTS public.zebridge_principal_keys (
user_pubkey text PRIMARY KEY,
principal text NOT NULL,
tenant_id text NOT NULL,
enrolled_at timestamptz NOT NULL DEFAULT now(),
-- Stamped by `bridge --revoke` (§10cm). PG is the source of truth for the FULL
-- revocation map: the account JWT's `revocations` is re-derived from every row
-- with a revoked_at, so revoking B can never silently un-revoke A.
revoked_at timestamptz
);
GRANT SELECT ON public.zebridge_principal_keys TO ${POSTGRES_READER_USER}, ${POSTGRES_WRITER_USER};
-- INSERT for enrollment, UPDATE for the ON CONFLICT arm (a re-enrolled key refreshes
-- its ownership row). Never DELETE: the rows outlive revocation on purpose.
GRANT INSERT, UPDATE ON public.zebridge_principal_keys TO ${POSTGRES_WRITER_USER};
-- §10kn: `bridge --revoke <p> --purge` asks the principal's devices to delete their local
-- replica and identity. A row here means the latest revocation of <p> asked for it: the
-- bridge adds `"purge": true` to the ban, and `/renew` answers a revoked key with it. A
-- plain `--revoke` deletes the row. Written only by the admin command; read by both roles.
-- By principal, not by key: a principal minted without a recorded key is purged too.
CREATE TABLE IF NOT EXISTS public.zebridge_purges (
principal text PRIMARY KEY,
requested_at timestamptz NOT NULL DEFAULT now()
);
GRANT SELECT ON public.zebridge_purges TO ${POSTGRES_READER_USER}, ${POSTGRES_WRITER_USER};
-- A revoked principal is dead for good (§10dm, §10kb). `bridge --revoke` deletes its
-- mappings and stamps its keys; /enroll then refuses any invite for the name and /renew
-- refuses its keys. A mapping put back by hand would still reopen the door: writes pass
-- the tenant guard again, and once the 2 h ban on VERDICTS ages out, the revoked
-- device's unexpired JWT works until it expires. So the door itself refuses: no mapping
-- for a principal with a revoked key. The fix is a new principal name.
CREATE OR REPLACE FUNCTION public.zebridge_refuse_revoked_mapping()
RETURNS trigger AS $$
BEGIN
IF EXISTS (SELECT 1 FROM public.zebridge_principal_keys
WHERE principal = NEW.principal AND revoked_at IS NOT NULL) THEN
RAISE EXCEPTION 'principal % was revoked and cannot be mapped again', NEW.principal
USING HINT = 'A revoked name is dead for good: invite the person under a new principal.';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public, pg_catalog;
DROP TRIGGER IF EXISTS zebridge_refuse_revoked_mapping_t ON public.zebridge_user_tenants;
CREATE TRIGGER zebridge_refuse_revoked_mapping_t
BEFORE INSERT OR UPDATE OF principal ON public.zebridge_user_tenants
FOR EACH ROW EXECUTE FUNCTION public.zebridge_refuse_revoked_mapping();
-- Enrollment invites — the pump-starter (NOTES: the JWT mint flow). One row per
-- invitation: a high-entropy single-use code (122 random bits, generated here when the
-- INSERT omits it) the operator hands out out-of-band;
-- presenting it to the bridge's /enroll endpoint IS the authentication (a one-time
-- password), and the row carries everything the mint needs: principal and tenant.
-- Every invite makes a CLIENT: the bridge holds the client signing key only, and
-- services get their creds offline (--mint-responder). The principal CHECK is the naming police — the narrow waist between the
-- identity world and the routing world ([A-Za-z0-9_-] is the intersection of NATS
-- subject-token, KV-key and template-expansion alphabets, measured).
CREATE TABLE IF NOT EXISTS public.zebridge_invites (
code text PRIMARY KEY DEFAULT replace(gen_random_uuid()::text, '-', ''),
principal text NOT NULL CHECK (principal ~ '^[A-Za-z0-9_-]+$'),
tenant_id text NOT NULL CHECK (tenant_id <> '' AND tenant_id !~ '[\x01-\x20\x7f.*>]' AND octet_length(tenant_id) <= ${MAX_TENANT_BYTES}),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL DEFAULT now() + interval '1 days',
used_at timestamptz
);
-- The role column is gone: nothing read it, and a value other than 'client' was accepted
-- and ignored. An existing table loses it here.
ALTER TABLE public.zebridge_invites DROP CONSTRAINT IF EXISTS zebridge_invites_principal_check;
ALTER TABLE public.zebridge_invites ADD CONSTRAINT zebridge_invites_principal_check
CHECK ((principal ~ '^[A-Za-z0-9_-]+$' AND principal !~ '^${LEAF_PREFIX}')
OR principal ~ '^${LEAF_PREFIX}[A-Za-z0-9_-]+[.][A-Za-z0-9_-]+$');
DROP TRIGGER IF EXISTS zebridge_guard_leaf_membership_t ON public.zebridge_invites;
CREATE TRIGGER zebridge_guard_leaf_membership_t BEFORE INSERT OR UPDATE OF principal, tenant_id ON public.zebridge_invites
FOR EACH ROW EXECUTE FUNCTION public.zebridge_guard_leaf_membership();
ALTER TABLE public.zebridge_invites DROP COLUMN IF EXISTS role;
ALTER TABLE public.zebridge_invites DROP CONSTRAINT IF EXISTS zebridge_invites_tenant_id_check;
ALTER TABLE public.zebridge_invites ADD CONSTRAINT zebridge_invites_tenant_id_check CHECK (tenant_id <> '' AND tenant_id !~ '[\x01-\x20\x7f.*>]' AND octet_length(tenant_id) <= ${MAX_TENANT_BYTES});
-- The defaults too: CREATE TABLE IF NOT EXISTS leaves an older table as it was, and an
-- invite inserted without a code then failed ("null value in column code").
ALTER TABLE public.zebridge_invites ALTER COLUMN code SET DEFAULT replace(gen_random_uuid()::text, '-', '');
ALTER TABLE public.zebridge_invites ALTER COLUMN expires_at SET DEFAULT now() + interval '1 days';
-- The bridge redeems invites over its WRITER connection: read the row, stamp
-- used_at, and register the principal→tenant mapping in the same transaction.
GRANT SELECT, UPDATE ON public.zebridge_invites TO ${POSTGRES_WRITER_USER};
GRANT INSERT ON public.zebridge_user_tenants TO ${POSTGRES_WRITER_USER};
-- Creating an invite without the admin — see SECURITY.md §1.6. An app's backend, after its
-- own sign-in, asks for an invite for the principal and tenant it chose; `bridge --invite`
-- calls the same function. Its login is the DBA's to create, then
-- Changing a scope cannot strand existing memberships or reuse a principal
-- namespace whose old credentials may still be valid. Retire/revoke devices first.
CREATE OR REPLACE FUNCTION public.zebridge_guard_leaf_scope()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public AS $$
DECLARE lid text;
BEGIN
lid := CASE WHEN TG_OP = 'DELETE' THEN OLD.leaf_id ELSE NEW.leaf_id END;
PERFORM pg_advisory_xact_lock(hashtextextended(lid, 0));
IF TG_OP <> 'DELETE' AND (
EXISTS (SELECT 1 FROM public.zebridge_principal_keys WHERE principal = NEW.leaf_id) OR
EXISTS (SELECT 1 FROM public.zebridge_invites WHERE principal = NEW.leaf_id) OR
EXISTS (SELECT 1 FROM public.zebridge_user_tenants WHERE principal = NEW.leaf_id)) THEN
RAISE EXCEPTION 'leaf id % has a hub-principal history; choose a fresh id', NEW.leaf_id USING ERRCODE = '23514';
END IF;
IF EXISTS (SELECT 1 FROM public.zebridge_user_tenants
WHERE split_part(principal, '.', 1) = lid AND strpos(principal, '.') > 0
AND (TG_OP = 'DELETE' OR NOT tenant_id = ANY(NEW.tenant_ids))) OR
(TG_OP = 'UPDATE' AND NEW.leaf_id <> OLD.leaf_id) THEN
RAISE EXCEPTION 'leaf scope % still has incompatible memberships; revoke them before changing it', lid USING ERRCODE = '23514';
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS zebridge_guard_leaf_scope_t ON public.zebridge_leaf_scopes;
CREATE TRIGGER zebridge_guard_leaf_scope_t BEFORE INSERT OR UPDATE OR DELETE ON public.zebridge_leaf_scopes
FOR EACH ROW EXECUTE FUNCTION public.zebridge_guard_leaf_scope();
-- `GRANT zebridge_inviter TO <login>`: the group role has no login and no password here.
-- It may call this function and nothing else — no table, so it cannot read codes, spend
-- one, or touch an invite it did not make. The function refuses a revoked principal
-- (revocation is for good) and a lifetime over 30 days. Revoking stays an admin act.
CREATE OR REPLACE FUNCTION public.zebridge_invite(p_principal text, p_tenant text, p_valid_for interval DEFAULT interval '1 day')
RETURNS text
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public
AS $$
DECLARE
v_code text;
BEGIN
IF p_valid_for IS NULL OR p_valid_for <= interval '0' OR p_valid_for > interval '30 days' THEN
RAISE EXCEPTION 'an invite lasts more than 0 and at most 30 days, not %', p_valid_for
USING ERRCODE = '22023';
END IF;
IF EXISTS (SELECT 1 FROM public.zebridge_principal_keys k
WHERE k.principal = p_principal AND k.revoked_at IS NOT NULL) THEN
RAISE EXCEPTION 'principal % was revoked, which is for good: invite a new principal', p_principal
USING ERRCODE = '42501';
END IF;
INSERT INTO public.zebridge_invites (principal, tenant_id, expires_at)
VALUES (p_principal, p_tenant, now() + p_valid_for)
RETURNING code INTO v_code;
RETURN v_code;
END;
$$;
REVOKE ALL ON FUNCTION public.zebridge_invite(text, text, interval) FROM PUBLIC;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'zebridge_inviter') THEN
CREATE ROLE zebridge_inviter NOLOGIN;
END IF;
END;
$$;
GRANT USAGE ON SCHEMA public TO zebridge_inviter;
GRANT EXECUTE ON FUNCTION public.zebridge_invite(text, text, interval) TO zebridge_inviter;
-- ⚠️ Never published as ordinary CDC — see the matching exemption in
-- `zebridge_publication_guard()` (init.core.template.sql). The bridge special-cases this
-- relation's name in its WAL loop and diverts every row into a `$KV.tenants.<principal>`
-- put instead, exactly as it already does for `zebridge_ddl_events` → `$KV.schemas.*`.
-- Publishing it as `cdc.zebridge_user_tenants.*` would hand every client with a broad CDC
-- grant the full principal→tenant roster — the disclosure the KV bucket's principal-first
-- key order exists to prevent.
-- Attached to every ZeBridge publication that ALREADY exists; the ones created
-- later get it from `zebridge_create_publication`, which knows this table by name.
-- Between the two, the install order stops mattering — core then write then
-- create, or core then create then write, and the table lands either way. That is
-- the whole point: this file used to attach it to one name rendered from the
-- environment, so a publication made any other way silently had no tenant roster
-- and PROTOCOL Step 0 failed for every one of its clients.
--
-- A ZeBridge publication is recognised by the one table they all carry and
-- nothing else does: zebridge_ddl_events.
DO $$
DECLARE p record;
BEGIN
FOR p IN SELECT DISTINCT pubname FROM pg_publication_tables
WHERE schemaname = 'public' AND tablename = 'zebridge_ddl_events'
LOOP
IF NOT EXISTS (
SELECT 1 FROM pg_publication_tables
WHERE pubname = p.pubname AND schemaname = 'public'
AND tablename = 'zebridge_user_tenants'
) THEN
EXECUTE format('ALTER PUBLICATION %I ADD TABLE public.zebridge_user_tenants', p.pubname);
RAISE NOTICE 'zebridge: attached zebridge_user_tenants to publication %', p.pubname;
END IF;
END LOOP;
END $$;
-- ⚠️ Dollar-quote with a bare double-dollar ONLY — never a named tag (dollar, letters,
-- dollar). This file is rendered by `envsubst`, which reads such a tag as a variable
-- reference and substitutes it away, breaking the quoting and truncating every definition
-- after it. Measured: the whole file rendered down to one surviving function, and psql
-- reported no error. A bare double-dollar is not a valid identifier, so it survives.
--
-- The same rule forces the nested DDL below to use doubled single quotes rather than a
-- second dollar-quoted string — this comment cannot even show you the syntax to avoid.
-- the only supported way to publish a tenant-scoped table — see PROTOCOL.md §7.4, NOTES.md §1.8
CREATE OR REPLACE FUNCTION public.zebridge_scope_publication_to_one_tenant(
tbl regclass,
tenant_col name,
tenant_value text,
publication name
) RETURNS void AS $$
BEGIN
IF tbl::text IN ('zebridge_ddl_events','public.zebridge_ddl_events') THEN