@hasna/loops 0.4.28 → 0.4.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +84 -1
  2. package/README.md +226 -49
  3. package/dist/api/index.d.ts +20 -19
  4. package/dist/api/index.js +6846 -1087
  5. package/dist/cli/index.js +2471 -885
  6. package/dist/cli/safe-error-context.d.ts +1 -0
  7. package/dist/daemon/daemon.d.ts +1 -0
  8. package/dist/daemon/index.js +1786 -493
  9. package/dist/generated/storage-kit/index.d.ts +1 -1
  10. package/dist/generated/storage-kit/mode.d.ts +6 -12
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.js +4778 -1016
  13. package/dist/lib/advancement.d.ts +52 -0
  14. package/dist/lib/agent-adapter.d.ts +20 -2
  15. package/dist/lib/auth/route-policy.d.ts +14 -0
  16. package/dist/lib/auth/tenant-auth.d.ts +38 -0
  17. package/dist/lib/cloud/mode.d.ts +2 -8
  18. package/dist/lib/cloud/storage.d.ts +1 -2
  19. package/dist/lib/cloud/transport.d.ts +4 -18
  20. package/dist/lib/errors.d.ts +43 -1
  21. package/dist/lib/format.d.ts +2 -2
  22. package/dist/lib/goal/runner.d.ts +36 -3
  23. package/dist/lib/health.d.ts +1 -1
  24. package/dist/lib/labels.d.ts +4 -0
  25. package/dist/lib/loop-status.d.ts +4 -0
  26. package/dist/lib/migration.d.ts +4 -17
  27. package/dist/lib/mode.d.ts +2 -5
  28. package/dist/lib/mode.js +27 -36
  29. package/dist/lib/route/index.d.ts +2 -2
  30. package/dist/lib/route/throttle.d.ts +7 -0
  31. package/dist/lib/route/types.d.ts +3 -0
  32. package/dist/lib/run-completion.d.ts +18 -0
  33. package/dist/lib/scheduler.d.ts +5 -11
  34. package/dist/lib/storage/contract.d.ts +15 -1
  35. package/dist/lib/storage/index.d.ts +1 -1
  36. package/dist/lib/storage/index.js +3864 -457
  37. package/dist/lib/storage/pg-executor.d.ts +10 -5
  38. package/dist/lib/storage/postgres-loop-storage.d.ts +50 -24
  39. package/dist/lib/storage/postgres-schema.d.ts +8 -0
  40. package/dist/lib/storage/postgres-schema.js +1323 -1
  41. package/dist/lib/storage/postgres.d.ts +3 -1
  42. package/dist/lib/storage/postgres.js +1353 -27
  43. package/dist/lib/storage/provider-credentials.d.ts +84 -0
  44. package/dist/lib/storage/shared-database-transfer.d.ts +136 -0
  45. package/dist/lib/storage/sqlite.d.ts +15 -2
  46. package/dist/lib/storage/sqlite.js +1299 -217
  47. package/dist/lib/storage/tenant-backfill-s3.d.ts +53 -0
  48. package/dist/lib/storage/tenant-backfill.d.ts +40 -0
  49. package/dist/lib/store/index.d.ts +11 -8
  50. package/dist/lib/store.d.ts +52 -7
  51. package/dist/lib/store.js +1266 -217
  52. package/dist/lib/templates.d.ts +11 -0
  53. package/dist/lib/workflow-events.d.ts +6 -0
  54. package/dist/lib/workflow-provenance.d.ts +8 -0
  55. package/dist/lib/workflow-runner.d.ts +52 -4
  56. package/dist/mcp/index.js +1961 -651
  57. package/dist/runner/index.d.ts +20 -2
  58. package/dist/runner/index.js +2113 -177
  59. package/dist/sdk/http.d.ts +211 -3
  60. package/dist/sdk/http.js +301 -0
  61. package/dist/sdk/index.d.ts +7 -2
  62. package/dist/sdk/index.js +1959 -711
  63. package/dist/serve/index.d.ts +14 -0
  64. package/dist/serve/index.js +12323 -1743
  65. package/dist/types.d.ts +64 -3
  66. package/docs/AUTOMATION_RUNTIME_DESIGN.md +32 -32
  67. package/docs/CUTOVER-RUNBOOK.md +158 -31
  68. package/docs/DEPLOYMENT_MODES.md +78 -56
  69. package/docs/RUNTIME_BOUNDARY.md +26 -26
  70. package/docs/SHARED-DATABASE-TRANSFER.md +95 -0
  71. package/docs/SHARED_KIT_EXTRACTION_INVENTORY.md +631 -0
  72. package/docs/TRANSCRIPT_LOOP_PATTERNS.md +3 -3
  73. package/docs/UNIFIED_PRODUCT_CONTRACT.md +365 -0
  74. package/docs/USAGE.md +143 -52
  75. package/docs/workflows/transcript-feedback-to-loops.json +2 -2
  76. package/package.json +7 -3
  77. package/dist/lib/storage/pg-runner-claim.d.ts +0 -40
@@ -2,6 +2,7 @@
2
2
  // src/lib/storage/postgres-schema.ts
3
3
  import { createHash } from "crypto";
4
4
  var POSTGRES_MIGRATION_LEDGER_TABLE = "open_loops_schema_migrations";
5
+ var POSTGRES_MIGRATION_ADVISORY_LOCK_SQL = "SELECT pg_advisory_xact_lock(1330466384, 1280262987)";
5
6
  function checksumStorageSql(sql) {
6
7
  const normalized = sql.trim().replace(/\r\n/g, `
7
8
  `);
@@ -10,6 +11,283 @@ function checksumStorageSql(sql) {
10
11
  function migration(id, sql) {
11
12
  return Object.freeze({ id, sql: sql.trim(), checksum: checksumStorageSql(sql) });
12
13
  }
14
+ var POSTGRES_TENANT_BOOTSTRAP_ROLES_SQL = `
15
+ DO $bootstrap_roles$
16
+ DECLARE role_name TEXT;
17
+ BEGIN
18
+ FOREACH role_name IN ARRAY ARRAY[
19
+ 'open_loops_owner', 'open_loops_migrator',
20
+ 'open_loops_runtime', 'open_loops_authenticator'
21
+ ] LOOP
22
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname=role_name) THEN
23
+ EXECUTE format(
24
+ 'CREATE ROLE %I INHERIT NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS',
25
+ role_name
26
+ );
27
+ ELSIF EXISTS (
28
+ SELECT 1 FROM pg_roles WHERE rolname=role_name AND rolcanlogin
29
+ ) THEN
30
+ RAISE EXCEPTION 'reserved OpenLoops database role % is LOGIN; detach or replace that credential with provider authority before tenant enforcement',
31
+ role_name;
32
+ ELSIF EXISTS (
33
+ SELECT 1 FROM pg_roles
34
+ WHERE rolname=role_name
35
+ AND (NOT rolinherit OR rolsuper OR rolcreatedb OR
36
+ rolcreaterole OR rolreplication OR rolbypassrls)
37
+ ) THEN
38
+ EXECUTE format(
39
+ 'ALTER ROLE %I INHERIT NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS',
40
+ role_name
41
+ );
42
+ END IF;
43
+ END LOOP;
44
+ END
45
+ $bootstrap_roles$;
46
+ `.trim();
47
+ var POSTGRES_TENANT_CLUSTER_ROLE_EXCLUSIVITY_SQL = `
48
+ DO $cluster_role_exclusivity$
49
+ DECLARE cross_database_dependency RECORD;
50
+ BEGIN
51
+ SELECT role.rolname AS role_name,
52
+ COALESCE(database.datname, dependency.dbid::text) AS database_name
53
+ INTO cross_database_dependency
54
+ FROM pg_shdepend dependency
55
+ JOIN pg_roles role
56
+ ON role.oid=dependency.refobjid
57
+ AND dependency.refclassid='pg_authid'::regclass
58
+ LEFT JOIN pg_database database ON database.oid=dependency.dbid
59
+ WHERE role.rolname IN (
60
+ 'open_loops_owner', 'open_loops_migrator',
61
+ 'open_loops_runtime', 'open_loops_authenticator'
62
+ )
63
+ AND dependency.dbid NOT IN (
64
+ 0,
65
+ (SELECT oid FROM pg_database WHERE datname=current_database())
66
+ )
67
+ LIMIT 1;
68
+ IF FOUND THEN
69
+ RAISE EXCEPTION 'reserved OpenLoops role % has a dependency in database %; use a dedicated cluster or remove the cross-database dependency before tenant enforcement',
70
+ cross_database_dependency.role_name,
71
+ cross_database_dependency.database_name;
72
+ END IF;
73
+
74
+ SELECT role.rolname AS role_name, database.datname AS database_name
75
+ INTO cross_database_dependency
76
+ FROM pg_database database
77
+ JOIN pg_roles role ON role.oid=database.datdba
78
+ WHERE role.rolname IN (
79
+ 'open_loops_owner', 'open_loops_migrator',
80
+ 'open_loops_runtime', 'open_loops_authenticator'
81
+ )
82
+ AND database.datname<>current_database()
83
+ LIMIT 1;
84
+ IF FOUND THEN
85
+ RAISE EXCEPTION 'reserved OpenLoops role % owns database %; role names must be exclusive to the dedicated OpenLoops cluster',
86
+ cross_database_dependency.role_name,
87
+ cross_database_dependency.database_name;
88
+ END IF;
89
+ END
90
+ $cluster_role_exclusivity$;
91
+ `.trim();
92
+ var POSTGRES_TENANT_BOOTSTRAP_MEMBERSHIPS_SQL = `
93
+ DO $bootstrap_memberships$
94
+ DECLARE bootstrap_name NAME := session_user;
95
+ DECLARE bootstrap_superuser BOOLEAN;
96
+ DECLARE relevant_membership_count BIGINT;
97
+ DECLARE safe_privileged_role_count BIGINT;
98
+ BEGIN
99
+ IF bootstrap_name IN (
100
+ 'open_loops_owner', 'open_loops_migrator',
101
+ 'open_loops_runtime', 'open_loops_authenticator'
102
+ ) THEN
103
+ RAISE EXCEPTION 'tenant enforcement bootstrap login must be distinct from OpenLoops database roles';
104
+ END IF;
105
+ SELECT rolsuper INTO bootstrap_superuser FROM pg_roles WHERE rolname=bootstrap_name;
106
+
107
+ -- PostgreSQL 16 grants CREATEROLE creators membership in newly created roles,
108
+ -- but createrole_self_grant defaults to empty: ADMIN can be true while
109
+ -- INHERIT and SET are false. Only a superuser can revoke the implicit row,
110
+ -- whose grantor is PostgreSQL's bootstrap superuser. A non-superuser runner
111
+ -- must therefore receive exact memberships during provider bootstrap.
112
+ IF bootstrap_superuser THEN
113
+ EXECUTE format(
114
+ 'REVOKE open_loops_owner, open_loops_migrator, open_loops_runtime, open_loops_authenticator FROM %I',
115
+ bootstrap_name
116
+ );
117
+ EXECUTE format(
118
+ 'GRANT open_loops_owner, open_loops_migrator TO %I WITH ADMIN FALSE, INHERIT TRUE, SET TRUE',
119
+ bootstrap_name
120
+ );
121
+ END IF;
122
+
123
+ SELECT count(*),
124
+ count(DISTINCT granted.rolname) FILTER (
125
+ WHERE granted.rolname IN ('open_loops_owner', 'open_loops_migrator')
126
+ AND NOT membership.admin_option
127
+ AND membership.inherit_option
128
+ AND membership.set_option
129
+ )
130
+ INTO relevant_membership_count, safe_privileged_role_count
131
+ FROM pg_auth_members membership
132
+ JOIN pg_roles granted ON granted.oid=membership.roleid
133
+ JOIN pg_roles member ON member.oid=membership.member
134
+ WHERE member.rolname=bootstrap_name
135
+ AND granted.rolname IN (
136
+ 'open_loops_owner', 'open_loops_migrator',
137
+ 'open_loops_runtime', 'open_loops_authenticator'
138
+ );
139
+
140
+ IF NOT bootstrap_superuser AND (
141
+ pg_has_role(bootstrap_name, 'open_loops_runtime', 'MEMBER') OR
142
+ pg_has_role(bootstrap_name, 'open_loops_authenticator', 'MEMBER') OR
143
+ relevant_membership_count <> 2 OR safe_privileged_role_count <> 2
144
+ ) THEN
145
+ RAISE EXCEPTION 'non-superuser tenant enforcement requires pre-provisioned owner/migrator memberships with ADMIN FALSE, INHERIT TRUE, SET TRUE and no runtime/authenticator membership';
146
+ END IF;
147
+ IF NOT bootstrap_superuser AND EXISTS (
148
+ SELECT 1
149
+ FROM pg_roles service_login
150
+ WHERE service_login.rolcanlogin
151
+ AND NOT service_login.rolsuper
152
+ AND service_login.rolname<>bootstrap_name
153
+ AND (
154
+ pg_has_role(service_login.oid, 'open_loops_runtime', 'MEMBER') OR
155
+ pg_has_role(service_login.oid, 'open_loops_authenticator', 'MEMBER')
156
+ )
157
+ ) THEN
158
+ RAISE EXCEPTION 'non-superuser tenant enforcement must run before runtime/authenticator service login memberships are provisioned';
159
+ END IF;
160
+ IF bootstrap_superuser AND (
161
+ relevant_membership_count <> 2 OR safe_privileged_role_count <> 2
162
+ ) THEN
163
+ RAISE EXCEPTION 'tenant enforcement did not normalize bootstrap role memberships';
164
+ END IF;
165
+ END
166
+ $bootstrap_memberships$;
167
+ `.trim();
168
+ var POSTGRES_TENANT_PRIVILEGED_MEMBERSHIPS_SQL = `
169
+ DO $privileged_role_membership_acl$
170
+ DECLARE unsafe_membership RECORD;
171
+ BEGIN
172
+ SELECT granted.rolname AS granted_role, member.rolname AS member_role
173
+ INTO unsafe_membership
174
+ FROM pg_auth_members membership
175
+ JOIN pg_roles granted ON granted.oid=membership.roleid
176
+ JOIN pg_roles member ON member.oid=membership.member
177
+ WHERE (granted.rolname IN ('open_loops_owner', 'open_loops_migrator')
178
+ OR member.rolname IN (
179
+ 'open_loops_owner', 'open_loops_migrator',
180
+ 'open_loops_runtime', 'open_loops_authenticator'
181
+ ))
182
+ AND NOT (
183
+ granted.rolname IN ('open_loops_owner', 'open_loops_migrator')
184
+ AND member.rolname=session_user
185
+ AND NOT membership.admin_option
186
+ AND membership.inherit_option
187
+ AND membership.set_option
188
+ )
189
+ LIMIT 1;
190
+ IF FOUND THEN
191
+ RAISE EXCEPTION 'unsafe privileged role membership % to %; remove it with the original grantor or provider authority before tenant enforcement',
192
+ unsafe_membership.granted_role,
193
+ unsafe_membership.member_role;
194
+ END IF;
195
+ END
196
+ $privileged_role_membership_acl$;
197
+ `.trim();
198
+ var POSTGRES_TENANT_SERVICE_ROLE_MEMBERSHIPS_SQL = `
199
+ DO $service_role_memberships$
200
+ DECLARE membership RECORD;
201
+ BEGIN
202
+ FOR membership IN
203
+ SELECT granted.rolname AS granted_role, member.rolname AS member_role
204
+ FROM pg_auth_members relation
205
+ JOIN pg_roles granted ON granted.oid=relation.roleid
206
+ JOIN pg_roles member ON member.oid=relation.member
207
+ WHERE member.rolname IN ('open_loops_runtime', 'open_loops_authenticator')
208
+ LOOP
209
+ EXECUTE format('REVOKE %I FROM %I', membership.granted_role, membership.member_role);
210
+ END LOOP;
211
+ END
212
+ $service_role_memberships$;
213
+ `.trim();
214
+ var POSTGRES_TENANT_UNSAFE_SERVICE_MEMBERSHIPS_SQL = `
215
+ DO $unsafe_service_members$
216
+ DECLARE unsafe_member RECORD;
217
+ BEGIN
218
+ FOR unsafe_member IN
219
+ SELECT granted.rolname AS granted_role, member.rolname AS member_role,
220
+ member.rolcanlogin AS member_can_login
221
+ FROM pg_auth_members membership
222
+ JOIN pg_roles granted ON granted.oid = membership.roleid
223
+ JOIN pg_roles member ON member.oid = membership.member
224
+ WHERE granted.rolname IN ('open_loops_runtime', 'open_loops_authenticator')
225
+ AND (membership.admin_option OR NOT membership.inherit_option OR NOT membership.set_option OR
226
+ NOT member.rolcanlogin OR NOT member.rolinherit OR
227
+ member.rolsuper OR member.rolbypassrls OR member.rolcreatedb OR
228
+ member.rolcreaterole OR member.rolreplication OR
229
+ pg_has_role(member.oid, 'open_loops_owner', 'MEMBER') OR
230
+ pg_has_role(member.oid, 'open_loops_migrator', 'MEMBER') OR
231
+ EXISTS (
232
+ SELECT 1 FROM pg_auth_members other_membership
233
+ WHERE other_membership.member = member.oid
234
+ AND other_membership.roleid <> granted.oid
235
+ ) OR
236
+ EXISTS (
237
+ SELECT 1 FROM pg_auth_members downstream_membership
238
+ WHERE downstream_membership.roleid = member.oid
239
+ ) OR
240
+ (granted.rolname = 'open_loops_runtime' AND
241
+ pg_has_role(member.oid, 'open_loops_authenticator', 'MEMBER')) OR
242
+ (granted.rolname = 'open_loops_authenticator' AND
243
+ pg_has_role(member.oid, 'open_loops_runtime', 'MEMBER')))
244
+ LOOP
245
+ IF unsafe_member.member_can_login THEN
246
+ RAISE EXCEPTION 'unsafe service login membership for % in %; normalize it with provider authority before tenant enforcement',
247
+ unsafe_member.member_role,
248
+ unsafe_member.granted_role;
249
+ END IF;
250
+ EXECUTE format('REVOKE %I FROM %I', unsafe_member.granted_role, unsafe_member.member_role);
251
+ END LOOP;
252
+ END
253
+ $unsafe_service_members$;
254
+ `.trim();
255
+ var POSTGRES_TENANT_SERVICE_MEMBER_CLEANUP_SQL = `
256
+ DO $service_member_acl$
257
+ DECLARE service_member RECORD;
258
+ BEGIN
259
+ FOR service_member IN
260
+ SELECT DISTINCT member.oid AS member_oid, member.rolname AS member_role
261
+ FROM pg_auth_members membership
262
+ JOIN pg_roles granted ON granted.oid = membership.roleid
263
+ JOIN pg_roles member ON member.oid = membership.member
264
+ WHERE granted.rolname IN ('open_loops_runtime', 'open_loops_authenticator')
265
+ AND member.rolcanlogin
266
+ LOOP
267
+ IF service_member.member_oid IN (
268
+ (SELECT oid FROM pg_roles WHERE rolname=session_user),
269
+ (SELECT datdba FROM pg_database WHERE datname=current_database()),
270
+ (SELECT nspowner FROM pg_namespace WHERE nspname='public')
271
+ ) THEN
272
+ RAISE EXCEPTION 'provider/bootstrap login % must never be processed as a service login',
273
+ service_member.member_role;
274
+ END IF;
275
+ IF EXISTS (
276
+ SELECT 1
277
+ FROM pg_shdepend dependency
278
+ WHERE dependency.refclassid = 'pg_authid'::regclass
279
+ AND dependency.refobjid = service_member.member_oid
280
+ AND dependency.deptype = 'o'
281
+ AND dependency.dbid IN (0, (SELECT oid FROM pg_database WHERE datname = current_database()))
282
+ ) THEN
283
+ RAISE EXCEPTION 'service login % owns database objects; reassign or remove them before tenant enforcement',
284
+ service_member.member_role;
285
+ END IF;
286
+ EXECUTE format('DROP OWNED BY %I', service_member.member_role);
287
+ END LOOP;
288
+ END
289
+ $service_member_acl$;
290
+ `.trim();
13
291
  var POSTGRES_STORAGE_MIGRATIONS = Object.freeze([
14
292
  migration("0001_core_runtime", `
15
293
  CREATE TABLE IF NOT EXISTS loops (
@@ -355,6 +633,1042 @@ CREATE INDEX IF NOT EXISTS idx_workflow_work_items_machine ON workflow_work_item
355
633
  `),
356
634
  migration("0007_work_item_gate_deaths", `
357
635
  ALTER TABLE workflow_work_items ADD COLUMN IF NOT EXISTS gate_deaths INTEGER NOT NULL DEFAULT 0;
636
+ `),
637
+ migration("0011_workflow_run_provenance", `
638
+ ALTER TABLE workflow_runs ADD COLUMN IF NOT EXISTS workflow_definition_hash TEXT;
639
+ `),
640
+ migration("0012_loop_labels", `
641
+ ALTER TABLE loops ADD COLUMN IF NOT EXISTS labels_json JSONB NOT NULL DEFAULT '[]'::jsonb;
642
+ `),
643
+ migration("0008_tenant_prepare", `
644
+ CREATE TABLE tenants (
645
+ id TEXT PRIMARY KEY,
646
+ slug TEXT NOT NULL UNIQUE,
647
+ name TEXT NOT NULL,
648
+ status TEXT NOT NULL CHECK (status IN ('active', 'suspended')),
649
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
650
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
651
+ );
652
+
653
+ CREATE TABLE principals (
654
+ id TEXT PRIMARY KEY,
655
+ kind TEXT NOT NULL CHECK (kind IN ('human', 'service', 'machine')),
656
+ display_name TEXT NOT NULL,
657
+ status TEXT NOT NULL CHECK (status IN ('active', 'suspended')),
658
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
659
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
660
+ );
661
+
662
+ CREATE TABLE tenant_memberships (
663
+ tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
664
+ principal_id TEXT NOT NULL REFERENCES principals(id) ON DELETE CASCADE,
665
+ status TEXT NOT NULL CHECK (status IN ('active', 'suspended')),
666
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
667
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
668
+ PRIMARY KEY (tenant_id, principal_id)
669
+ );
670
+
671
+ CREATE TABLE tenant_roles (
672
+ name TEXT PRIMARY KEY CHECK (name IN ('admin', 'operator', 'member', 'readonly', 'service', 'worker'))
673
+ );
674
+ INSERT INTO tenant_roles(name) VALUES ('admin'), ('operator'), ('member'), ('readonly'), ('service'), ('worker');
675
+
676
+ CREATE TABLE tenant_membership_roles (
677
+ tenant_id TEXT NOT NULL,
678
+ principal_id TEXT NOT NULL,
679
+ role TEXT NOT NULL REFERENCES tenant_roles(name),
680
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
681
+ PRIMARY KEY (tenant_id, principal_id, role),
682
+ FOREIGN KEY (tenant_id, principal_id) REFERENCES tenant_memberships(tenant_id, principal_id) ON DELETE CASCADE
683
+ );
684
+
685
+ CREATE TABLE IF NOT EXISTS api_keys (
686
+ kid TEXT PRIMARY KEY,
687
+ app TEXT NOT NULL,
688
+ agent TEXT,
689
+ scopes JSONB NOT NULL,
690
+ token_hash TEXT NOT NULL UNIQUE,
691
+ issued_at TIMESTAMPTZ NOT NULL,
692
+ expires_at TIMESTAMPTZ,
693
+ revoked_at TIMESTAMPTZ,
694
+ revoked_reason TEXT,
695
+ last_used_at TIMESTAMPTZ,
696
+ created_by TEXT,
697
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
698
+ );
699
+ ALTER TABLE api_keys ADD COLUMN tenant_id TEXT;
700
+ ALTER TABLE api_keys ADD COLUMN principal_id TEXT;
701
+ ALTER TABLE api_keys ADD COLUMN token_kind TEXT;
702
+ ALTER TABLE api_keys ADD COLUMN disabled_at TIMESTAMPTZ;
703
+
704
+ ALTER TABLE loops ADD COLUMN tenant_id TEXT;
705
+ ALTER TABLE loop_runs ADD COLUMN tenant_id TEXT;
706
+ ALTER TABLE daemon_lease ADD COLUMN tenant_id TEXT;
707
+ ALTER TABLE workflow_specs ADD COLUMN tenant_id TEXT;
708
+ ALTER TABLE workflow_runs ADD COLUMN tenant_id TEXT;
709
+ ALTER TABLE workflow_invocations ADD COLUMN tenant_id TEXT;
710
+ ALTER TABLE workflow_work_items ADD COLUMN tenant_id TEXT;
711
+ ALTER TABLE workflow_step_runs ADD COLUMN tenant_id TEXT;
712
+ ALTER TABLE workflow_events ADD COLUMN tenant_id TEXT;
713
+ ALTER TABLE goals ADD COLUMN tenant_id TEXT;
714
+ ALTER TABLE goal_plan_nodes ADD COLUMN tenant_id TEXT;
715
+ ALTER TABLE goal_runs ADD COLUMN tenant_id TEXT;
716
+ ALTER TABLE runner_machines ADD COLUMN tenant_id TEXT;
717
+ ALTER TABLE runner_leases ADD COLUMN tenant_id TEXT;
718
+ ALTER TABLE audit_events ADD COLUMN tenant_id TEXT;
719
+ ALTER TABLE audit_events ADD COLUMN principal_id TEXT;
720
+ ALTER TABLE audit_events ADD COLUMN request_id TEXT;
721
+ ALTER TABLE audit_events ADD COLUMN operation_id TEXT;
722
+ ALTER TABLE audit_events ADD COLUMN decision TEXT;
723
+ ALTER TABLE audit_events ADD COLUMN deny_reason TEXT;
724
+ ALTER TABLE run_receipts ADD COLUMN tenant_id TEXT;
725
+
726
+ CREATE TABLE tenant_row_assignments (
727
+ table_name TEXT NOT NULL,
728
+ row_id TEXT NOT NULL,
729
+ tenant_id TEXT NOT NULL REFERENCES tenants(id),
730
+ PRIMARY KEY (table_name, row_id),
731
+ CHECK (table_name IN (
732
+ 'loops', 'loop_runs', 'daemon_lease', 'workflow_specs', 'workflow_runs',
733
+ 'workflow_invocations', 'workflow_work_items', 'workflow_step_runs', 'workflow_events',
734
+ 'goals', 'goal_plan_nodes', 'goal_runs', 'runner_machines', 'runner_leases',
735
+ 'audit_events', 'run_receipts'
736
+ ))
737
+ );
738
+
739
+ CREATE TABLE api_key_tenant_bindings (
740
+ kid TEXT PRIMARY KEY REFERENCES api_keys(kid) ON DELETE CASCADE,
741
+ tenant_id TEXT NOT NULL,
742
+ principal_id TEXT NOT NULL,
743
+ token_kind TEXT NOT NULL CHECK (token_kind IN ('api_key', 'service', 'machine')),
744
+ FOREIGN KEY (tenant_id, principal_id) REFERENCES tenant_memberships(tenant_id, principal_id)
745
+ );
746
+
747
+ CREATE TABLE preauth_audit_events (
748
+ id TEXT PRIMARY KEY,
749
+ request_id TEXT NOT NULL,
750
+ operation_id TEXT NOT NULL,
751
+ deny_reason TEXT NOT NULL,
752
+ metadata_json JSONB NOT NULL,
753
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
754
+ );
755
+ `),
756
+ migration("0009_tenant_backfill", `
757
+ DO $tenant_backfill$
758
+ DECLARE
759
+ target_table TEXT;
760
+ id_column TEXT;
761
+ missing_count BIGINT;
762
+ mapping_count BIGINT;
763
+ BEGIN
764
+ FOR target_table, id_column IN
765
+ SELECT * FROM (VALUES
766
+ ('loops', 'id'), ('loop_runs', 'id'), ('daemon_lease', 'id'),
767
+ ('workflow_specs', 'id'), ('workflow_runs', 'id'), ('workflow_invocations', 'id'),
768
+ ('workflow_work_items', 'id'), ('workflow_step_runs', 'id'), ('workflow_events', 'id'),
769
+ ('goals', 'id'), ('goal_plan_nodes', 'id'), ('goal_runs', 'id'),
770
+ ('runner_machines', 'id'), ('runner_leases', 'id'), ('audit_events', 'id'),
771
+ ('run_receipts', 'run_id')
772
+ ) AS targets(table_name, row_id_column)
773
+ LOOP
774
+ EXECUTE format(
775
+ 'UPDATE %I target SET tenant_id = assignment.tenant_id FROM tenant_row_assignments assignment WHERE assignment.table_name = $1 AND assignment.row_id = target.%I',
776
+ target_table, id_column
777
+ ) USING target_table;
778
+ EXECUTE format('SELECT count(*) FROM %I WHERE tenant_id IS NULL', target_table) INTO missing_count;
779
+ IF missing_count > 0 THEN
780
+ RAISE EXCEPTION 'tenant backfill incomplete for %: % rows have no explicit assignment', target_table, missing_count;
781
+ END IF;
782
+ EXECUTE format('SELECT count(*) FROM tenant_row_assignments WHERE table_name = $1') INTO mapping_count USING target_table;
783
+ EXECUTE format('SELECT count(*) FROM %I', target_table) INTO missing_count;
784
+ IF mapping_count <> missing_count THEN
785
+ RAISE EXCEPTION 'tenant assignment cardinality mismatch for %: mappings %, rows %', target_table, mapping_count, missing_count;
786
+ END IF;
787
+ END LOOP;
788
+
789
+ UPDATE api_keys key
790
+ SET tenant_id = binding.tenant_id,
791
+ principal_id = binding.principal_id,
792
+ token_kind = binding.token_kind
793
+ FROM api_key_tenant_bindings binding
794
+ WHERE binding.kid = key.kid;
795
+ SELECT count(*) INTO missing_count
796
+ FROM api_keys
797
+ WHERE tenant_id IS NULL OR principal_id IS NULL OR token_kind IS NULL
798
+ OR agent IS DISTINCT FROM principal_id;
799
+ IF missing_count > 0 THEN
800
+ RAISE EXCEPTION 'api key tenant backfill incomplete: % keys have no exact principal binding', missing_count;
801
+ END IF;
802
+
803
+ IF EXISTS (
804
+ SELECT 1 FROM loop_runs child JOIN loops parent ON parent.id = child.loop_id WHERE child.tenant_id <> parent.tenant_id
805
+ UNION ALL SELECT 1 FROM workflow_runs child JOIN workflow_specs parent ON parent.id = child.workflow_id WHERE child.tenant_id <> parent.tenant_id
806
+ UNION ALL SELECT 1 FROM workflow_work_items child JOIN workflow_invocations parent ON parent.id = child.invocation_id WHERE child.tenant_id <> parent.tenant_id
807
+ UNION ALL SELECT 1 FROM workflow_step_runs child JOIN workflow_runs parent ON parent.id = child.workflow_run_id WHERE child.tenant_id <> parent.tenant_id
808
+ UNION ALL SELECT 1 FROM workflow_events child JOIN workflow_runs parent ON parent.id = child.workflow_run_id WHERE child.tenant_id <> parent.tenant_id
809
+ UNION ALL SELECT 1 FROM goal_plan_nodes child JOIN goals parent ON parent.id = child.goal_id WHERE child.tenant_id <> parent.tenant_id
810
+ UNION ALL SELECT 1 FROM goal_runs child JOIN goals parent ON parent.id = child.goal_id WHERE child.tenant_id <> parent.tenant_id
811
+ UNION ALL SELECT 1 FROM runner_leases child JOIN runner_machines parent ON parent.id = child.runner_id WHERE child.tenant_id <> parent.tenant_id
812
+ ) THEN
813
+ RAISE EXCEPTION 'tenant backfill contains cross-tenant parent/child relationships';
814
+ END IF;
815
+ END
816
+ $tenant_backfill$;
817
+ `),
818
+ migration("0010_tenant_enforce", `
819
+ ${POSTGRES_TENANT_BOOTSTRAP_ROLES_SQL}
820
+
821
+ ${POSTGRES_TENANT_CLUSTER_ROLE_EXCLUSIVITY_SQL}
822
+
823
+ ${POSTGRES_TENANT_BOOTSTRAP_MEMBERSHIPS_SQL}
824
+
825
+ ${POSTGRES_TENANT_SERVICE_ROLE_MEMBERSHIPS_SQL}
826
+
827
+ ${POSTGRES_TENANT_PRIVILEGED_MEMBERSHIPS_SQL}
828
+
829
+ ${POSTGRES_TENANT_UNSAFE_SERVICE_MEMBERSHIPS_SQL}
830
+
831
+ ${POSTGRES_TENANT_SERVICE_MEMBER_CLEANUP_SQL}
832
+
833
+ DO $service_role_acl$
834
+ DECLARE namespace RECORD;
835
+ DECLARE database_grantee RECORD;
836
+ DECLARE object_grantee RECORD;
837
+ BEGIN
838
+ FOR database_grantee IN
839
+ SELECT DISTINCT grantee.rolname AS grantee_role
840
+ FROM pg_database database
841
+ CROSS JOIN LATERAL aclexplode(COALESCE(database.datacl, acldefault('d', database.datdba))) acl
842
+ JOIN pg_roles grantee ON grantee.oid=acl.grantee
843
+ WHERE database.datname=current_database()
844
+ AND acl.grantee<>database.datdba
845
+ LOOP
846
+ EXECUTE format('REVOKE ALL PRIVILEGES ON DATABASE %I FROM %I', current_database(), database_grantee.grantee_role);
847
+ END LOOP;
848
+ EXECUTE format(
849
+ 'REVOKE ALL PRIVILEGES ON DATABASE %I FROM PUBLIC',
850
+ current_database()
851
+ );
852
+ FOR namespace IN
853
+ SELECT oid, nspname
854
+ FROM pg_namespace
855
+ WHERE nspname NOT IN ('pg_catalog', 'information_schema')
856
+ AND nspname NOT LIKE 'pg_toast%'
857
+ AND nspname NOT LIKE 'pg_temp_%'
858
+ LOOP
859
+ FOR object_grantee IN
860
+ SELECT class.relkind, class.relname,
861
+ CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE format('%I', grantee.rolname) END AS grantee_sql
862
+ FROM pg_class class
863
+ CROSS JOIN LATERAL aclexplode(class.relacl) acl
864
+ LEFT JOIN pg_roles grantee ON grantee.oid = acl.grantee
865
+ WHERE class.relnamespace = namespace.oid
866
+ AND class.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
867
+ AND (acl.grantee = 0 OR grantee.oid IS NOT NULL)
868
+ LOOP
869
+ IF object_grantee.relkind = 'S' THEN
870
+ EXECUTE format(
871
+ 'REVOKE ALL PRIVILEGES ON SEQUENCE %I.%I FROM %s',
872
+ namespace.nspname,
873
+ object_grantee.relname,
874
+ object_grantee.grantee_sql
875
+ );
876
+ ELSE
877
+ EXECUTE format(
878
+ 'REVOKE ALL PRIVILEGES ON TABLE %I.%I FROM %s',
879
+ namespace.nspname,
880
+ object_grantee.relname,
881
+ object_grantee.grantee_sql
882
+ );
883
+ END IF;
884
+ END LOOP;
885
+ FOR object_grantee IN
886
+ SELECT proc.oid::regprocedure AS function_signature,
887
+ CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE format('%I', grantee.rolname) END AS grantee_sql
888
+ FROM pg_proc proc
889
+ CROSS JOIN LATERAL aclexplode(COALESCE(proc.proacl, acldefault('f', proc.proowner))) acl
890
+ LEFT JOIN pg_roles grantee ON grantee.oid = acl.grantee
891
+ WHERE proc.pronamespace = namespace.oid
892
+ AND (acl.grantee = 0 OR grantee.oid IS NOT NULL)
893
+ LOOP
894
+ EXECUTE format(
895
+ 'REVOKE ALL PRIVILEGES ON FUNCTION %s FROM %s',
896
+ object_grantee.function_signature,
897
+ object_grantee.grantee_sql
898
+ );
899
+ END LOOP;
900
+ FOR object_grantee IN
901
+ SELECT CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE format('%I', grantee.rolname) END AS grantee_sql
902
+ FROM pg_namespace ns
903
+ CROSS JOIN LATERAL aclexplode(ns.nspacl) acl
904
+ LEFT JOIN pg_roles grantee ON grantee.oid = acl.grantee
905
+ WHERE ns.oid = namespace.oid
906
+ AND (acl.grantee = 0 OR grantee.oid IS NOT NULL)
907
+ LOOP
908
+ EXECUTE format('REVOKE ALL PRIVILEGES ON SCHEMA %I FROM %s', namespace.nspname, object_grantee.grantee_sql);
909
+ END LOOP;
910
+ END LOOP;
911
+ END
912
+ $service_role_acl$;
913
+
914
+ DO $service_connect$
915
+ DECLARE service_member RECORD;
916
+ BEGIN
917
+ FOR service_member IN
918
+ SELECT DISTINCT member.rolname AS member_role
919
+ FROM pg_auth_members membership
920
+ JOIN pg_roles granted ON granted.oid = membership.roleid
921
+ JOIN pg_roles member ON member.oid = membership.member
922
+ WHERE granted.rolname IN ('open_loops_runtime', 'open_loops_authenticator')
923
+ AND member.rolcanlogin
924
+ LOOP
925
+ EXECUTE format('GRANT CONNECT ON DATABASE %I TO %I', current_database(), service_member.member_role);
926
+ END LOOP;
927
+ END
928
+ $service_connect$;
929
+
930
+ DO $service_role_cross_memberships$
931
+ DECLARE membership RECORD;
932
+ BEGIN
933
+ FOR membership IN
934
+ SELECT granted.rolname AS granted_role, member.rolname AS member_role
935
+ FROM pg_auth_members relation
936
+ JOIN pg_roles granted ON granted.oid=relation.roleid
937
+ JOIN pg_roles member ON member.oid=relation.member
938
+ WHERE (member.rolname='open_loops_runtime' AND
939
+ granted.rolname IN ('open_loops_owner', 'open_loops_migrator', 'open_loops_authenticator'))
940
+ OR (member.rolname='open_loops_authenticator' AND
941
+ granted.rolname IN ('open_loops_owner', 'open_loops_migrator', 'open_loops_runtime'))
942
+ LOOP
943
+ EXECUTE format('REVOKE %I FROM %I', membership.granted_role, membership.member_role);
944
+ END LOOP;
945
+ END
946
+ $service_role_cross_memberships$;
947
+ REVOKE CREATE ON SCHEMA public FROM PUBLIC;
948
+ REVOKE ALL ON SCHEMA public FROM open_loops_runtime, open_loops_authenticator;
949
+ GRANT USAGE ON SCHEMA public TO open_loops_runtime, open_loops_authenticator;
950
+ GRANT USAGE, CREATE ON SCHEMA public TO open_loops_owner, open_loops_migrator;
951
+
952
+ ALTER TABLE loop_runs DROP CONSTRAINT loop_runs_loop_id_fkey;
953
+ ALTER TABLE workflow_runs DROP CONSTRAINT workflow_runs_workflow_id_fkey;
954
+ ALTER TABLE workflow_runs DROP CONSTRAINT workflow_runs_loop_id_fkey;
955
+ ALTER TABLE workflow_runs DROP CONSTRAINT workflow_runs_loop_run_id_fkey;
956
+ ALTER TABLE workflow_work_items DROP CONSTRAINT workflow_work_items_invocation_id_fkey;
957
+ ALTER TABLE workflow_work_items DROP CONSTRAINT workflow_work_items_workflow_id_fkey;
958
+ ALTER TABLE workflow_work_items DROP CONSTRAINT workflow_work_items_loop_id_fkey;
959
+ ALTER TABLE workflow_work_items DROP CONSTRAINT workflow_work_items_workflow_run_id_fkey;
960
+ ALTER TABLE workflow_step_runs DROP CONSTRAINT workflow_step_runs_workflow_run_id_fkey;
961
+ ALTER TABLE workflow_events DROP CONSTRAINT workflow_events_workflow_run_id_fkey;
962
+ ALTER TABLE goal_plan_nodes DROP CONSTRAINT goal_plan_nodes_goal_id_fkey;
963
+ ALTER TABLE goal_runs DROP CONSTRAINT goal_runs_goal_id_fkey;
964
+ ALTER TABLE runner_leases DROP CONSTRAINT runner_leases_runner_id_fkey;
965
+ ALTER TABLE runner_leases DROP CONSTRAINT runner_leases_loop_run_id_fkey;
966
+ ALTER TABLE runner_leases DROP CONSTRAINT runner_leases_workflow_run_id_fkey;
967
+ ALTER TABLE runner_leases DROP CONSTRAINT runner_leases_check;
968
+
969
+ ALTER TABLE loops DROP CONSTRAINT loops_pkey;
970
+ ALTER TABLE loop_runs DROP CONSTRAINT loop_runs_pkey;
971
+ ALTER TABLE loop_runs DROP CONSTRAINT loop_runs_loop_id_scheduled_for_key;
972
+ ALTER TABLE daemon_lease DROP CONSTRAINT daemon_lease_pkey;
973
+ ALTER TABLE workflow_specs DROP CONSTRAINT workflow_specs_pkey;
974
+ ALTER TABLE workflow_runs DROP CONSTRAINT workflow_runs_pkey;
975
+ ALTER TABLE workflow_invocations DROP CONSTRAINT workflow_invocations_pkey;
976
+ ALTER TABLE workflow_work_items DROP CONSTRAINT workflow_work_items_pkey;
977
+ ALTER TABLE workflow_work_items DROP CONSTRAINT workflow_work_items_route_key_idempotency_key_key;
978
+ ALTER TABLE workflow_step_runs DROP CONSTRAINT workflow_step_runs_pkey;
979
+ ALTER TABLE workflow_step_runs DROP CONSTRAINT workflow_step_runs_workflow_run_id_step_id_key;
980
+ ALTER TABLE workflow_events DROP CONSTRAINT workflow_events_pkey;
981
+ ALTER TABLE workflow_events DROP CONSTRAINT workflow_events_workflow_run_id_sequence_key;
982
+ ALTER TABLE goals DROP CONSTRAINT goals_pkey;
983
+ ALTER TABLE goal_plan_nodes DROP CONSTRAINT goal_plan_nodes_pkey;
984
+ ALTER TABLE goal_plan_nodes DROP CONSTRAINT goal_plan_nodes_plan_id_key_key;
985
+ ALTER TABLE goal_runs DROP CONSTRAINT goal_runs_pkey;
986
+ ALTER TABLE runner_machines DROP CONSTRAINT runner_machines_pkey;
987
+ ALTER TABLE runner_leases DROP CONSTRAINT runner_leases_pkey;
988
+ ALTER TABLE run_receipts DROP CONSTRAINT run_receipts_pkey;
989
+ ALTER TABLE audit_events DROP CONSTRAINT audit_events_pkey;
990
+
991
+ DROP INDEX idx_workflows_name_active;
992
+ DROP INDEX idx_workflow_runs_idempotency;
993
+ DROP INDEX idx_workflow_invocations_dedupe;
994
+ DROP INDEX idx_runner_leases_active_loop_run;
995
+ DROP INDEX idx_runner_leases_active_workflow_run;
996
+
997
+ CREATE OR REPLACE FUNCTION public.open_loops_current_tenant_id() RETURNS TEXT
998
+ LANGUAGE sql STABLE PARALLEL SAFE SET search_path = pg_catalog
999
+ RETURN NULLIF(pg_catalog.current_setting('open_loops.tenant_id', true), '');
1000
+ ALTER FUNCTION public.open_loops_current_tenant_id() OWNER TO open_loops_owner;
1001
+ DO $tenant_function_acl$
1002
+ DECLARE grantee RECORD;
1003
+ BEGIN
1004
+ FOR grantee IN
1005
+ SELECT rolname
1006
+ FROM pg_roles
1007
+ WHERE rolname NOT IN ('open_loops_owner', 'open_loops_runtime')
1008
+ LOOP
1009
+ EXECUTE format(
1010
+ 'REVOKE ALL ON FUNCTION public.open_loops_current_tenant_id() FROM %I',
1011
+ grantee.rolname
1012
+ );
1013
+ END LOOP;
1014
+ END
1015
+ $tenant_function_acl$;
1016
+ REVOKE ALL ON FUNCTION public.open_loops_current_tenant_id() FROM PUBLIC;
1017
+ GRANT EXECUTE ON FUNCTION public.open_loops_current_tenant_id() TO open_loops_owner;
1018
+ GRANT EXECUTE ON FUNCTION public.open_loops_current_tenant_id() TO open_loops_runtime;
1019
+
1020
+ DO $tenant_defaults$
1021
+ DECLARE table_name TEXT;
1022
+ BEGIN
1023
+ FOREACH table_name IN ARRAY ARRAY[
1024
+ 'loops', 'loop_runs', 'daemon_lease', 'workflow_specs', 'workflow_runs',
1025
+ 'workflow_invocations', 'workflow_work_items', 'workflow_step_runs', 'workflow_events',
1026
+ 'goals', 'goal_plan_nodes', 'goal_runs', 'runner_machines', 'runner_leases', 'run_receipts'
1027
+ ] LOOP
1028
+ EXECUTE format('ALTER TABLE %I ALTER COLUMN tenant_id SET DEFAULT public.open_loops_current_tenant_id()', table_name);
1029
+ END LOOP;
1030
+ END
1031
+ $tenant_defaults$;
1032
+
1033
+ ALTER TABLE loops ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id);
1034
+ ALTER TABLE loop_runs ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id), ADD UNIQUE (tenant_id, loop_id, scheduled_for);
1035
+ ALTER TABLE daemon_lease ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id);
1036
+ ALTER TABLE workflow_specs ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id);
1037
+ ALTER TABLE workflow_runs ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id);
1038
+ ALTER TABLE workflow_invocations ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id);
1039
+ ALTER TABLE workflow_work_items ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id), ADD UNIQUE (tenant_id, route_key, idempotency_key);
1040
+ ALTER TABLE workflow_step_runs ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id), ADD UNIQUE (tenant_id, workflow_run_id, step_id);
1041
+ ALTER TABLE workflow_events ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id), ADD UNIQUE (tenant_id, workflow_run_id, sequence);
1042
+ ALTER TABLE goals ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id);
1043
+ ALTER TABLE goal_plan_nodes ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id), ADD UNIQUE (tenant_id, plan_id, key);
1044
+ ALTER TABLE goal_runs ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id);
1045
+ ALTER TABLE runner_machines ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id);
1046
+ ALTER TABLE runner_leases ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id);
1047
+ ALTER TABLE run_receipts ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, run_id);
1048
+ ALTER TABLE audit_events ALTER COLUMN tenant_id SET NOT NULL, ADD PRIMARY KEY (tenant_id, id);
1049
+ ALTER TABLE api_keys
1050
+ ALTER COLUMN tenant_id SET NOT NULL,
1051
+ ALTER COLUMN principal_id SET NOT NULL,
1052
+ ALTER COLUMN token_kind SET NOT NULL,
1053
+ ADD FOREIGN KEY (tenant_id, principal_id) REFERENCES tenant_memberships(tenant_id, principal_id),
1054
+ ADD CHECK (app = 'loops'),
1055
+ ADD CHECK (token_kind IN ('api_key', 'service', 'machine')),
1056
+ ADD CHECK (jsonb_typeof(scopes) = 'array');
1057
+
1058
+ DO $tenant_foreign_keys$
1059
+ DECLARE table_name TEXT;
1060
+ BEGIN
1061
+ FOREACH table_name IN ARRAY ARRAY[
1062
+ 'loops', 'loop_runs', 'daemon_lease', 'workflow_specs', 'workflow_runs',
1063
+ 'workflow_invocations', 'workflow_work_items', 'workflow_step_runs', 'workflow_events',
1064
+ 'goals', 'goal_plan_nodes', 'goal_runs', 'runner_machines', 'runner_leases',
1065
+ 'audit_events', 'run_receipts'
1066
+ ] LOOP
1067
+ EXECUTE format(
1068
+ 'ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (tenant_id) REFERENCES tenants(id)',
1069
+ table_name,
1070
+ table_name || '_tenant_id_fkey'
1071
+ );
1072
+ END LOOP;
1073
+ END
1074
+ $tenant_foreign_keys$;
1075
+
1076
+ ALTER TABLE loop_runs ADD FOREIGN KEY (tenant_id, loop_id) REFERENCES loops(tenant_id, id) ON DELETE CASCADE;
1077
+ ALTER TABLE workflow_runs ADD FOREIGN KEY (tenant_id, workflow_id) REFERENCES workflow_specs(tenant_id, id) ON DELETE CASCADE;
1078
+ ALTER TABLE workflow_runs ADD FOREIGN KEY (tenant_id, loop_id) REFERENCES loops(tenant_id, id) ON DELETE SET NULL (loop_id);
1079
+ ALTER TABLE workflow_runs ADD FOREIGN KEY (tenant_id, loop_run_id) REFERENCES loop_runs(tenant_id, id) ON DELETE SET NULL (loop_run_id);
1080
+ ALTER TABLE workflow_invocations ADD FOREIGN KEY (tenant_id, workflow_id) REFERENCES workflow_specs(tenant_id, id) ON DELETE SET NULL (workflow_id);
1081
+ ALTER TABLE workflow_work_items ADD FOREIGN KEY (tenant_id, invocation_id) REFERENCES workflow_invocations(tenant_id, id) ON DELETE CASCADE;
1082
+ ALTER TABLE workflow_work_items ADD FOREIGN KEY (tenant_id, workflow_id) REFERENCES workflow_specs(tenant_id, id) ON DELETE SET NULL (workflow_id);
1083
+ ALTER TABLE workflow_work_items ADD FOREIGN KEY (tenant_id, loop_id) REFERENCES loops(tenant_id, id) ON DELETE SET NULL (loop_id);
1084
+ ALTER TABLE workflow_work_items ADD FOREIGN KEY (tenant_id, workflow_run_id) REFERENCES workflow_runs(tenant_id, id) ON DELETE SET NULL (workflow_run_id);
1085
+ ALTER TABLE workflow_step_runs ADD FOREIGN KEY (tenant_id, workflow_run_id) REFERENCES workflow_runs(tenant_id, id) ON DELETE CASCADE;
1086
+ ALTER TABLE workflow_events ADD FOREIGN KEY (tenant_id, workflow_run_id) REFERENCES workflow_runs(tenant_id, id) ON DELETE CASCADE;
1087
+ ALTER TABLE goal_plan_nodes ADD FOREIGN KEY (tenant_id, goal_id) REFERENCES goals(tenant_id, id) ON DELETE CASCADE;
1088
+ ALTER TABLE goal_runs ADD FOREIGN KEY (tenant_id, goal_id) REFERENCES goals(tenant_id, id) ON DELETE CASCADE;
1089
+ ALTER TABLE runner_leases ADD FOREIGN KEY (tenant_id, runner_id) REFERENCES runner_machines(tenant_id, id) ON DELETE CASCADE;
1090
+ ALTER TABLE runner_leases ADD FOREIGN KEY (tenant_id, loop_run_id) REFERENCES loop_runs(tenant_id, id) ON DELETE CASCADE;
1091
+ ALTER TABLE runner_leases ADD FOREIGN KEY (tenant_id, workflow_run_id) REFERENCES workflow_runs(tenant_id, id) ON DELETE CASCADE;
1092
+ ALTER TABLE runner_leases ADD CONSTRAINT runner_leases_exactly_one_run CHECK (num_nonnulls(loop_run_id, workflow_run_id) = 1);
1093
+ ALTER TABLE run_receipts ADD FOREIGN KEY (tenant_id, loop_id) REFERENCES loops(tenant_id, id);
1094
+
1095
+ ALTER TABLE loop_runs ADD FOREIGN KEY (tenant_id, goal_run_id) REFERENCES goal_runs(tenant_id, id) ON DELETE SET NULL (goal_run_id);
1096
+ ALTER TABLE workflow_runs ADD FOREIGN KEY (tenant_id, invocation_id) REFERENCES workflow_invocations(tenant_id, id) ON DELETE SET NULL (invocation_id);
1097
+ ALTER TABLE workflow_runs ADD FOREIGN KEY (tenant_id, work_item_id) REFERENCES workflow_work_items(tenant_id, id) ON DELETE SET NULL (work_item_id);
1098
+ ALTER TABLE workflow_runs ADD FOREIGN KEY (tenant_id, goal_run_id) REFERENCES goal_runs(tenant_id, id) ON DELETE SET NULL (goal_run_id);
1099
+ ALTER TABLE workflow_step_runs ADD FOREIGN KEY (tenant_id, goal_run_id) REFERENCES goal_runs(tenant_id, id) ON DELETE SET NULL (goal_run_id);
1100
+ ALTER TABLE goals ADD FOREIGN KEY (tenant_id, loop_id) REFERENCES loops(tenant_id, id) ON DELETE SET NULL (loop_id);
1101
+ ALTER TABLE goals ADD FOREIGN KEY (tenant_id, loop_run_id) REFERENCES loop_runs(tenant_id, id) ON DELETE SET NULL (loop_run_id);
1102
+ ALTER TABLE goals ADD FOREIGN KEY (tenant_id, workflow_id) REFERENCES workflow_specs(tenant_id, id) ON DELETE SET NULL (workflow_id);
1103
+ ALTER TABLE goals ADD FOREIGN KEY (tenant_id, workflow_run_id) REFERENCES workflow_runs(tenant_id, id) ON DELETE SET NULL (workflow_run_id);
1104
+ ALTER TABLE goal_runs ADD FOREIGN KEY (tenant_id, loop_id) REFERENCES loops(tenant_id, id) ON DELETE SET NULL (loop_id);
1105
+ ALTER TABLE goal_runs ADD FOREIGN KEY (tenant_id, loop_run_id) REFERENCES loop_runs(tenant_id, id) ON DELETE SET NULL (loop_run_id);
1106
+ ALTER TABLE goal_runs ADD FOREIGN KEY (tenant_id, workflow_id) REFERENCES workflow_specs(tenant_id, id) ON DELETE SET NULL (workflow_id);
1107
+ ALTER TABLE goal_runs ADD FOREIGN KEY (tenant_id, workflow_run_id) REFERENCES workflow_runs(tenant_id, id) ON DELETE SET NULL (workflow_run_id);
1108
+
1109
+ CREATE UNIQUE INDEX idx_workflows_name_active ON workflow_specs(tenant_id, name) WHERE status = 'active';
1110
+ CREATE UNIQUE INDEX idx_workflow_runs_idempotency ON workflow_runs(tenant_id, workflow_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
1111
+ CREATE UNIQUE INDEX idx_workflow_invocations_dedupe ON workflow_invocations(tenant_id, source_kind, source_dedupe_key) WHERE source_dedupe_key IS NOT NULL;
1112
+ CREATE UNIQUE INDEX idx_runner_leases_active_loop_run ON runner_leases(tenant_id, loop_run_id) WHERE loop_run_id IS NOT NULL AND status = 'active';
1113
+ CREATE UNIQUE INDEX idx_runner_leases_active_workflow_run ON runner_leases(tenant_id, workflow_run_id) WHERE workflow_run_id IS NOT NULL AND status = 'active';
1114
+
1115
+ DO $rls_policy_reset$
1116
+ DECLARE policy_record RECORD;
1117
+ BEGIN
1118
+ FOR policy_record IN
1119
+ SELECT schemaname, tablename, policyname
1120
+ FROM pg_policies
1121
+ WHERE schemaname = 'public'
1122
+ AND tablename = ANY(ARRAY[
1123
+ 'tenants', 'tenant_memberships', 'tenant_membership_roles', 'api_keys',
1124
+ 'loops', 'loop_runs', 'daemon_lease', 'workflow_specs', 'workflow_runs',
1125
+ 'workflow_invocations', 'workflow_work_items', 'workflow_step_runs', 'workflow_events',
1126
+ 'goals', 'goal_plan_nodes', 'goal_runs', 'runner_machines', 'runner_leases',
1127
+ 'audit_events', 'run_receipts'
1128
+ ])
1129
+ LOOP
1130
+ EXECUTE format('DROP POLICY %I ON %I.%I', policy_record.policyname, policy_record.schemaname, policy_record.tablename);
1131
+ END LOOP;
1132
+ END
1133
+ $rls_policy_reset$;
1134
+
1135
+ DO $rls$
1136
+ DECLARE table_name TEXT;
1137
+ BEGIN
1138
+ FOREACH table_name IN ARRAY ARRAY[
1139
+ 'tenant_memberships', 'tenant_membership_roles', 'api_keys',
1140
+ 'loops', 'loop_runs', 'daemon_lease', 'workflow_specs', 'workflow_runs',
1141
+ 'workflow_invocations', 'workflow_work_items', 'workflow_step_runs', 'workflow_events',
1142
+ 'goals', 'goal_plan_nodes', 'goal_runs', 'runner_machines', 'runner_leases',
1143
+ 'audit_events', 'run_receipts'
1144
+ ] LOOP
1145
+ EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name);
1146
+ EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name);
1147
+ EXECUTE format('CREATE POLICY tenant_isolation ON %I USING (tenant_id = public.open_loops_current_tenant_id()) WITH CHECK (tenant_id = public.open_loops_current_tenant_id())', table_name);
1148
+ END LOOP;
1149
+ END
1150
+ $rls$;
1151
+ ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
1152
+ ALTER TABLE tenants FORCE ROW LEVEL SECURITY;
1153
+ CREATE POLICY tenant_isolation ON tenants
1154
+ USING (id = public.open_loops_current_tenant_id())
1155
+ WITH CHECK (id = public.open_loops_current_tenant_id());
1156
+ CREATE POLICY auth_definer_tenant_lookup ON tenants TO open_loops_owner USING (true);
1157
+ CREATE POLICY auth_definer_membership_lookup ON tenant_memberships TO open_loops_owner USING (true);
1158
+ CREATE POLICY auth_definer_membership_roles_lookup ON tenant_membership_roles TO open_loops_owner USING (true);
1159
+ CREATE POLICY auth_definer_key_lookup ON api_keys TO open_loops_owner USING (true);
1160
+ CREATE POLICY auth_definer_audit_insert ON audit_events
1161
+ FOR INSERT TO open_loops_owner WITH CHECK (true);
1162
+
1163
+ DO $owners$
1164
+ DECLARE table_name TEXT;
1165
+ BEGIN
1166
+ FOREACH table_name IN ARRAY ARRAY[
1167
+ 'tenants', 'principals', 'tenant_memberships', 'tenant_roles', 'tenant_membership_roles',
1168
+ 'api_keys', 'loops', 'loop_runs', 'daemon_lease', 'workflow_specs', 'workflow_runs',
1169
+ 'workflow_invocations', 'workflow_work_items', 'workflow_step_runs', 'workflow_events',
1170
+ 'goals', 'goal_plan_nodes', 'goal_runs', 'runner_machines', 'runner_leases',
1171
+ 'audit_events', 'run_receipts'
1172
+ ] LOOP
1173
+ EXECUTE format('ALTER TABLE %I OWNER TO open_loops_owner', table_name);
1174
+ END LOOP;
1175
+ END
1176
+ $owners$;
1177
+ ALTER TABLE preauth_audit_events OWNER TO open_loops_owner;
1178
+ ALTER TABLE open_loops_schema_migrations OWNER TO open_loops_migrator;
1179
+
1180
+ GRANT SELECT, UPDATE, REFERENCES ON tenants TO open_loops_owner;
1181
+ SET ROLE open_loops_owner;
1182
+ DO $tenant_foreign_keys_owner$
1183
+ DECLARE table_name TEXT;
1184
+ BEGIN
1185
+ FOREACH table_name IN ARRAY ARRAY[
1186
+ 'loops', 'loop_runs', 'daemon_lease', 'workflow_specs', 'workflow_runs',
1187
+ 'workflow_invocations', 'workflow_work_items', 'workflow_step_runs', 'workflow_events',
1188
+ 'goals', 'goal_plan_nodes', 'goal_runs', 'runner_machines', 'runner_leases',
1189
+ 'audit_events', 'run_receipts'
1190
+ ] LOOP
1191
+ EXECUTE format('ALTER TABLE %I DROP CONSTRAINT IF EXISTS %I', table_name, table_name || '_tenant_id_fkey');
1192
+ EXECUTE format(
1193
+ 'ALTER TABLE %I ADD CONSTRAINT %I FOREIGN KEY (tenant_id) REFERENCES tenants(id)',
1194
+ table_name,
1195
+ table_name || '_tenant_id_fkey'
1196
+ );
1197
+ END LOOP;
1198
+ END
1199
+ $tenant_foreign_keys_owner$;
1200
+ RESET ROLE;
1201
+
1202
+ GRANT SELECT, INSERT, UPDATE, DELETE ON loops, loop_runs, daemon_lease, workflow_specs, workflow_runs, workflow_invocations,
1203
+ workflow_work_items, workflow_step_runs, workflow_events, goals, goal_plan_nodes, goal_runs,
1204
+ runner_machines, runner_leases, run_receipts TO open_loops_runtime;
1205
+ GRANT SELECT, UPDATE, REFERENCES ON tenants TO open_loops_runtime;
1206
+ GRANT SELECT ON open_loops_schema_migrations TO open_loops_runtime;
1207
+
1208
+ CREATE OR REPLACE FUNCTION public.open_loops_reject_runtime_tenant_update()
1209
+ RETURNS TRIGGER
1210
+ LANGUAGE plpgsql SECURITY INVOKER SET search_path = pg_catalog
1211
+ AS $$
1212
+ BEGIN
1213
+ IF pg_has_role(current_user, 'open_loops_runtime', 'USAGE') THEN
1214
+ RAISE EXCEPTION 'runtime role cannot update tenants' USING ERRCODE = '42501';
1215
+ END IF;
1216
+ RETURN NEW;
1217
+ END;
1218
+ $$;
1219
+ ALTER FUNCTION public.open_loops_reject_runtime_tenant_update() OWNER TO open_loops_owner;
1220
+ REVOKE ALL ON FUNCTION public.open_loops_reject_runtime_tenant_update() FROM PUBLIC, open_loops_runtime, open_loops_authenticator;
1221
+ DROP TRIGGER IF EXISTS open_loops_reject_runtime_tenant_update ON tenants;
1222
+ CREATE TRIGGER open_loops_reject_runtime_tenant_update
1223
+ BEFORE UPDATE ON tenants
1224
+ FOR EACH ROW
1225
+ EXECUTE FUNCTION public.open_loops_reject_runtime_tenant_update();
1226
+
1227
+ ALTER TABLE audit_events
1228
+ ADD CHECK (decision IS NULL OR decision IN ('allow', 'deny')),
1229
+ ADD CHECK (operation_id IS NULL OR operation_id ~ '^[A-Za-z][A-Za-z0-9.]{1,127}$'),
1230
+ ADD CHECK (request_id IS NULL OR (length(request_id) BETWEEN 1 AND 128));
1231
+
1232
+ CREATE OR REPLACE FUNCTION public.open_loops_authenticate_key(p_kid TEXT, p_token_hash TEXT)
1233
+ RETURNS TABLE (
1234
+ kid TEXT, app TEXT, agent TEXT, scopes JSONB, token_hash TEXT, issued_at TIMESTAMPTZ,
1235
+ expires_at TIMESTAMPTZ, revoked_at TIMESTAMPTZ, disabled_at TIMESTAMPTZ,
1236
+ tenant_id TEXT, tenant_status TEXT, principal_id TEXT, principal_status TEXT,
1237
+ membership_status TEXT, token_kind TEXT, roles TEXT[]
1238
+ )
1239
+ LANGUAGE sql SECURITY DEFINER SET search_path = pg_catalog
1240
+ AS $$
1241
+ SELECT key.kid, key.app, key.agent, key.scopes, key.token_hash, key.issued_at,
1242
+ key.expires_at, key.revoked_at, key.disabled_at,
1243
+ key.tenant_id, tenant.status, key.principal_id, principal.status,
1244
+ membership.status, key.token_kind,
1245
+ COALESCE(array_agg(membership_role.role ORDER BY membership_role.role)
1246
+ FILTER (WHERE membership_role.role IS NOT NULL), ARRAY[]::TEXT[])
1247
+ FROM public.api_keys key
1248
+ JOIN public.tenants tenant ON tenant.id = key.tenant_id
1249
+ JOIN public.principals principal ON principal.id = key.principal_id
1250
+ JOIN public.tenant_memberships membership
1251
+ ON membership.tenant_id = key.tenant_id AND membership.principal_id = key.principal_id
1252
+ LEFT JOIN public.tenant_membership_roles membership_role
1253
+ ON membership_role.tenant_id = membership.tenant_id AND membership_role.principal_id = membership.principal_id
1254
+ WHERE key.kid = p_kid AND key.token_hash = p_token_hash
1255
+ GROUP BY key.kid, key.app, key.agent, key.scopes, key.token_hash, key.issued_at,
1256
+ key.expires_at, key.revoked_at, key.disabled_at, key.tenant_id, tenant.status,
1257
+ key.principal_id, principal.status, membership.status, key.token_kind;
1258
+ $$;
1259
+ ALTER FUNCTION public.open_loops_authenticate_key(TEXT, TEXT) OWNER TO open_loops_owner;
1260
+ REVOKE ALL ON FUNCTION public.open_loops_authenticate_key(TEXT, TEXT)
1261
+ FROM PUBLIC, open_loops_runtime, open_loops_authenticator;
1262
+ GRANT EXECUTE ON FUNCTION public.open_loops_authenticate_key(TEXT, TEXT) TO open_loops_authenticator;
1263
+
1264
+ CREATE OR REPLACE FUNCTION public.open_loops_append_auth_audit(
1265
+ p_id TEXT, p_kid TEXT, p_token_hash TEXT, p_request_id TEXT,
1266
+ p_operation_id TEXT, p_decision TEXT, p_deny_reason TEXT, p_metadata JSONB
1267
+ ) RETURNS VOID
1268
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog
1269
+ AS $$
1270
+ DECLARE
1271
+ bound_tenant_id TEXT;
1272
+ bound_principal_id TEXT;
1273
+ BEGIN
1274
+ IF p_decision NOT IN ('allow', 'deny') THEN
1275
+ RAISE EXCEPTION 'invalid authentication audit decision';
1276
+ END IF;
1277
+ IF p_operation_id !~ '^[A-Za-z][A-Za-z0-9.]{1,127}$' THEN
1278
+ RAISE EXCEPTION 'invalid authentication audit operation';
1279
+ END IF;
1280
+ IF p_request_id IS NULL OR length(p_request_id) NOT BETWEEN 1 AND 128 THEN
1281
+ RAISE EXCEPTION 'invalid authentication audit request id';
1282
+ END IF;
1283
+ IF p_metadata IS NULL OR jsonb_typeof(p_metadata) <> 'object' THEN
1284
+ RAISE EXCEPTION 'invalid authentication audit metadata';
1285
+ END IF;
1286
+ IF p_decision = 'allow' AND p_deny_reason IS NOT NULL THEN
1287
+ RAISE EXCEPTION 'allow audit cannot include a deny reason';
1288
+ END IF;
1289
+
1290
+ SELECT key.tenant_id, key.principal_id
1291
+ INTO bound_tenant_id, bound_principal_id
1292
+ FROM public.api_keys key
1293
+ WHERE key.kid = p_kid AND key.token_hash = p_token_hash;
1294
+
1295
+ IF bound_tenant_id IS NULL THEN
1296
+ INSERT INTO public.preauth_audit_events(id, request_id, operation_id, deny_reason, metadata_json)
1297
+ VALUES (p_id, p_request_id, p_operation_id, COALESCE(p_deny_reason, 'unauthorized'), p_metadata);
1298
+ ELSE
1299
+ INSERT INTO public.audit_events(
1300
+ tenant_id, id, actor, action, subject_type, subject_id, metadata_json, created_at,
1301
+ principal_id, request_id, operation_id, decision, deny_reason
1302
+ ) VALUES (
1303
+ bound_tenant_id, p_id, bound_principal_id, 'auth.' || p_decision,
1304
+ 'api_request', p_request_id, p_metadata, now(), bound_principal_id, p_request_id,
1305
+ p_operation_id, p_decision, p_deny_reason
1306
+ );
1307
+ END IF;
1308
+ END
1309
+ $$;
1310
+ ALTER FUNCTION public.open_loops_append_auth_audit(TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, JSONB) OWNER TO open_loops_owner;
1311
+ REVOKE ALL ON FUNCTION public.open_loops_append_auth_audit(TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, JSONB)
1312
+ FROM PUBLIC, open_loops_runtime, open_loops_authenticator;
1313
+ GRANT EXECUTE ON FUNCTION public.open_loops_append_auth_audit(TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, TEXT, JSONB) TO open_loops_authenticator;
1314
+
1315
+ DO $auth_function_acl$
1316
+ DECLARE function_acl RECORD;
1317
+ BEGIN
1318
+ FOR function_acl IN
1319
+ SELECT proc.oid::regprocedure AS function_signature,
1320
+ CASE WHEN proc.oid = 'public.open_loops_current_tenant_id()'::regprocedure
1321
+ THEN (SELECT oid FROM pg_roles WHERE rolname = 'open_loops_runtime')
1322
+ ELSE (SELECT oid FROM pg_roles WHERE rolname = 'open_loops_authenticator')
1323
+ END AS allowed_grantee,
1324
+ acl.grantee,
1325
+ CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE format('%I', grantee.rolname) END AS grantee_sql
1326
+ FROM pg_proc proc
1327
+ CROSS JOIN LATERAL aclexplode(COALESCE(proc.proacl, acldefault('f', proc.proowner))) acl
1328
+ LEFT JOIN pg_roles grantee ON grantee.oid = acl.grantee
1329
+ WHERE proc.oid = ANY(ARRAY[
1330
+ 'public.open_loops_current_tenant_id()'::regprocedure,
1331
+ 'public.open_loops_authenticate_key(text,text)'::regprocedure,
1332
+ 'public.open_loops_append_auth_audit(text,text,text,text,text,text,text,jsonb)'::regprocedure
1333
+ ])
1334
+ AND (acl.grantee = 0 OR grantee.oid IS NOT NULL)
1335
+ AND acl.grantee <> CASE WHEN proc.oid = 'public.open_loops_current_tenant_id()'::regprocedure
1336
+ THEN (SELECT oid FROM pg_roles WHERE rolname = 'open_loops_runtime')
1337
+ ELSE (SELECT oid FROM pg_roles WHERE rolname = 'open_loops_authenticator')
1338
+ END
1339
+ LOOP
1340
+ EXECUTE format('REVOKE ALL PRIVILEGES ON FUNCTION %s FROM %s', function_acl.function_signature, function_acl.grantee_sql);
1341
+ END LOOP;
1342
+ END
1343
+ $auth_function_acl$;
1344
+
1345
+ REVOKE CREATE ON SCHEMA public FROM open_loops_owner, open_loops_migrator;
1346
+ GRANT USAGE ON SCHEMA public TO open_loops_owner, open_loops_migrator;
1347
+
1348
+ ${POSTGRES_TENANT_PRIVILEGED_MEMBERSHIPS_SQL}
1349
+
1350
+ DO $tenant_enforcement_postconditions$
1351
+ DECLARE tenant_function REGPROCEDURE := 'public.open_loops_current_tenant_id()'::regprocedure;
1352
+ BEGIN
1353
+ IF EXISTS (
1354
+ SELECT 1 FROM pg_roles
1355
+ WHERE rolname IN ('open_loops_owner', 'open_loops_migrator', 'open_loops_runtime', 'open_loops_authenticator')
1356
+ AND (rolcanlogin OR NOT rolinherit OR rolsuper OR rolcreatedb OR rolcreaterole OR rolreplication OR rolbypassrls)
1357
+ ) THEN
1358
+ RAISE EXCEPTION 'tenant enforcement did not normalize OpenLoops database roles';
1359
+ END IF;
1360
+ IF EXISTS (
1361
+ SELECT 1
1362
+ FROM pg_auth_members auth_membership
1363
+ JOIN pg_roles granted ON granted.oid = auth_membership.roleid
1364
+ JOIN pg_roles member ON member.oid = auth_membership.member
1365
+ WHERE (granted.rolname IN ('open_loops_owner', 'open_loops_migrator')
1366
+ OR member.rolname IN ('open_loops_owner', 'open_loops_migrator', 'open_loops_runtime', 'open_loops_authenticator'))
1367
+ AND NOT (
1368
+ granted.rolname IN ('open_loops_owner', 'open_loops_migrator')
1369
+ AND member.rolname = session_user
1370
+ AND NOT auth_membership.admin_option
1371
+ AND auth_membership.inherit_option
1372
+ AND auth_membership.set_option
1373
+ )
1374
+ ) THEN
1375
+ RAISE EXCEPTION 'tenant enforcement left privileged role memberships unsafe';
1376
+ END IF;
1377
+ IF (SELECT pg_get_userbyid(proowner) FROM pg_proc WHERE oid = tenant_function) <> 'open_loops_owner' THEN
1378
+ RAISE EXCEPTION 'tenant enforcement did not secure the tenant context function owner';
1379
+ END IF;
1380
+ IF EXISTS (
1381
+ SELECT 1
1382
+ FROM pg_proc proc,
1383
+ LATERAL aclexplode(COALESCE(proc.proacl, acldefault('f', proc.proowner))) acl
1384
+ WHERE proc.oid = tenant_function
1385
+ AND acl.privilege_type = 'EXECUTE'
1386
+ AND acl.grantee NOT IN (
1387
+ (SELECT oid FROM pg_roles WHERE rolname='open_loops_owner'),
1388
+ (SELECT oid FROM pg_roles WHERE rolname='open_loops_runtime')
1389
+ )
1390
+ ) THEN
1391
+ RAISE EXCEPTION 'tenant enforcement left an unexpected tenant context function grant';
1392
+ END IF;
1393
+ IF NOT has_function_privilege('open_loops_runtime', tenant_function, 'EXECUTE') OR
1394
+ has_function_privilege('open_loops_authenticator', tenant_function, 'EXECUTE') THEN
1395
+ RAISE EXCEPTION 'tenant enforcement tenant context function grants are incomplete';
1396
+ END IF;
1397
+ IF has_schema_privilege('open_loops_owner', 'public', 'CREATE') OR
1398
+ has_schema_privilege('open_loops_migrator', 'public', 'CREATE') THEN
1399
+ RAISE EXCEPTION 'tenant enforcement left bootstrap schema creation privileges enabled';
1400
+ END IF;
1401
+ IF EXISTS (
1402
+ SELECT 1
1403
+ FROM unnest(ARRAY[
1404
+ 'tenants', 'tenant_memberships', 'tenant_membership_roles', 'api_keys',
1405
+ 'loops', 'loop_runs', 'daemon_lease', 'workflow_specs', 'workflow_runs',
1406
+ 'workflow_invocations', 'workflow_work_items', 'workflow_step_runs', 'workflow_events',
1407
+ 'goals', 'goal_plan_nodes', 'goal_runs', 'runner_machines', 'runner_leases',
1408
+ 'audit_events', 'run_receipts'
1409
+ ]) AS protected_table(name)
1410
+ JOIN pg_class class ON class.oid = format('public.%I', protected_table.name)::regclass
1411
+ WHERE pg_get_userbyid(class.relowner) <> 'open_loops_owner'
1412
+ OR NOT class.relrowsecurity
1413
+ OR NOT class.relforcerowsecurity
1414
+ ) THEN
1415
+ RAISE EXCEPTION 'tenant enforcement did not secure protected table ownership and RLS flags';
1416
+ END IF;
1417
+ IF EXISTS (
1418
+ WITH protected(table_name, discriminator) AS (
1419
+ VALUES
1420
+ ('tenants', 'id'),
1421
+ ('tenant_memberships', 'tenant_id'),
1422
+ ('tenant_membership_roles', 'tenant_id'),
1423
+ ('api_keys', 'tenant_id'),
1424
+ ('loops', 'tenant_id'),
1425
+ ('loop_runs', 'tenant_id'),
1426
+ ('daemon_lease', 'tenant_id'),
1427
+ ('workflow_specs', 'tenant_id'),
1428
+ ('workflow_runs', 'tenant_id'),
1429
+ ('workflow_invocations', 'tenant_id'),
1430
+ ('workflow_work_items', 'tenant_id'),
1431
+ ('workflow_step_runs', 'tenant_id'),
1432
+ ('workflow_events', 'tenant_id'),
1433
+ ('goals', 'tenant_id'),
1434
+ ('goal_plan_nodes', 'tenant_id'),
1435
+ ('goal_runs', 'tenant_id'),
1436
+ ('runner_machines', 'tenant_id'),
1437
+ ('runner_leases', 'tenant_id'),
1438
+ ('audit_events', 'tenant_id'),
1439
+ ('run_receipts', 'tenant_id')
1440
+ ),
1441
+ expected(table_name, policy_name, command, roles, qualifier, check_expr) AS (
1442
+ SELECT table_name, 'tenant_isolation', '*', ARRAY['public'], discriminator, discriminator FROM protected
1443
+ UNION ALL VALUES
1444
+ ('tenants', 'auth_definer_tenant_lookup', '*', ARRAY['open_loops_owner'], 'true', NULL),
1445
+ ('tenant_memberships', 'auth_definer_membership_lookup', '*', ARRAY['open_loops_owner'], 'true', NULL),
1446
+ ('tenant_membership_roles', 'auth_definer_membership_roles_lookup', '*', ARRAY['open_loops_owner'], 'true', NULL),
1447
+ ('api_keys', 'auth_definer_key_lookup', '*', ARRAY['open_loops_owner'], 'true', NULL),
1448
+ ('audit_events', 'auth_definer_audit_insert', 'a', ARRAY['open_loops_owner'], NULL, 'true')
1449
+ ),
1450
+ actual AS (
1451
+ SELECT class.relname AS table_name,
1452
+ policy.polname AS policy_name,
1453
+ policy.polcmd::text AS command,
1454
+ policy.polpermissive AS permissive,
1455
+ ARRAY(
1456
+ SELECT CASE WHEN role_oid = 0 THEN 'public' ELSE role.rolname::text END
1457
+ FROM unnest(policy.polroles) role_oid
1458
+ LEFT JOIN pg_roles role ON role.oid = role_oid
1459
+ ORDER BY 1
1460
+ ) AS roles,
1461
+ COALESCE(regexp_replace(pg_get_expr(policy.polqual, policy.polrelid), '\\s+', ' ', 'g'), '') AS qualifier,
1462
+ COALESCE(regexp_replace(pg_get_expr(policy.polwithcheck, policy.polrelid), '\\s+', ' ', 'g'), '') AS check_expr
1463
+ FROM pg_policy policy
1464
+ JOIN pg_class class ON class.oid = policy.polrelid
1465
+ JOIN pg_namespace namespace ON namespace.oid = class.relnamespace
1466
+ WHERE namespace.nspname = 'public'
1467
+ AND class.relname IN (SELECT table_name FROM protected)
1468
+ ),
1469
+ missing_or_bad AS (
1470
+ SELECT expected.*
1471
+ FROM expected
1472
+ LEFT JOIN actual
1473
+ ON actual.table_name = expected.table_name
1474
+ AND actual.policy_name = expected.policy_name
1475
+ WHERE actual.policy_name IS NULL
1476
+ OR NOT actual.permissive
1477
+ OR actual.command <> expected.command
1478
+ OR actual.roles <> expected.roles
1479
+ OR NOT CASE expected.qualifier
1480
+ WHEN 'tenant_id' THEN actual.qualifier = ANY(ARRAY[
1481
+ '(tenant_id = open_loops_current_tenant_id())',
1482
+ '(tenant_id = public.open_loops_current_tenant_id())'
1483
+ ])
1484
+ WHEN 'id' THEN actual.qualifier = ANY(ARRAY[
1485
+ '(id = open_loops_current_tenant_id())',
1486
+ '(id = public.open_loops_current_tenant_id())'
1487
+ ])
1488
+ WHEN 'true' THEN actual.qualifier = 'true'
1489
+ ELSE actual.qualifier = ''
1490
+ END
1491
+ OR NOT CASE expected.check_expr
1492
+ WHEN 'tenant_id' THEN actual.check_expr = ANY(ARRAY[
1493
+ '(tenant_id = open_loops_current_tenant_id())',
1494
+ '(tenant_id = public.open_loops_current_tenant_id())'
1495
+ ])
1496
+ WHEN 'id' THEN actual.check_expr = ANY(ARRAY[
1497
+ '(id = open_loops_current_tenant_id())',
1498
+ '(id = public.open_loops_current_tenant_id())'
1499
+ ])
1500
+ WHEN 'true' THEN actual.check_expr = 'true'
1501
+ ELSE actual.check_expr = ''
1502
+ END
1503
+ ),
1504
+ unexpected AS (
1505
+ SELECT actual.*
1506
+ FROM actual
1507
+ LEFT JOIN expected
1508
+ ON expected.table_name = actual.table_name
1509
+ AND expected.policy_name = actual.policy_name
1510
+ WHERE expected.policy_name IS NULL
1511
+ )
1512
+ SELECT 1 FROM missing_or_bad
1513
+ UNION ALL
1514
+ SELECT 1 FROM unexpected
1515
+ ) THEN
1516
+ RAISE EXCEPTION 'tenant enforcement did not install the exact protected-table RLS policy inventory';
1517
+ END IF;
1518
+ IF EXISTS (
1519
+ WITH runtime_tables(table_name) AS (
1520
+ VALUES
1521
+ ('loops'), ('loop_runs'), ('daemon_lease'), ('workflow_specs'), ('workflow_runs'),
1522
+ ('workflow_invocations'), ('workflow_work_items'), ('workflow_step_runs'), ('workflow_events'),
1523
+ ('goals'), ('goal_plan_nodes'), ('goal_runs'), ('runner_machines'), ('runner_leases'), ('run_receipts')
1524
+ ),
1525
+ protected_tables(table_name) AS (
1526
+ VALUES
1527
+ ('tenants'), ('tenant_memberships'), ('tenant_membership_roles'), ('api_keys'), ('audit_events'),
1528
+ ('loops'), ('loop_runs'), ('daemon_lease'), ('workflow_specs'), ('workflow_runs'),
1529
+ ('workflow_invocations'), ('workflow_work_items'), ('workflow_step_runs'), ('workflow_events'),
1530
+ ('goals'), ('goal_plan_nodes'), ('goal_runs'), ('runner_machines'), ('runner_leases'), ('run_receipts')
1531
+ )
1532
+ SELECT 1
1533
+ FROM protected_tables protected
1534
+ JOIN pg_class class ON class.oid = format('public.%I', protected.table_name)::regclass
1535
+ CROSS JOIN LATERAL aclexplode(class.relacl) acl
1536
+ LEFT JOIN pg_roles grantee ON grantee.oid = acl.grantee
1537
+ WHERE acl.privilege_type IS NOT NULL
1538
+ AND NOT (
1539
+ protected.table_name IN (SELECT table_name FROM runtime_tables)
1540
+ AND COALESCE(grantee.rolname = 'open_loops_runtime', false)
1541
+ AND acl.privilege_type IN ('SELECT', 'INSERT', 'UPDATE', 'DELETE')
1542
+ AND NOT acl.is_grantable
1543
+ )
1544
+ AND NOT (
1545
+ protected.table_name = 'tenants'
1546
+ AND COALESCE(grantee.rolname = 'open_loops_runtime', false)
1547
+ AND acl.privilege_type IN ('SELECT', 'UPDATE', 'REFERENCES')
1548
+ AND NOT acl.is_grantable
1549
+ )
1550
+ AND NOT COALESCE(grantee.rolname = 'open_loops_owner', false)
1551
+ ) THEN
1552
+ RAISE EXCEPTION 'tenant enforcement left unexpected protected table grants';
1553
+ END IF;
1554
+ IF NOT has_column_privilege('open_loops_runtime', 'public.tenants', 'id', 'UPDATE') THEN
1555
+ RAISE EXCEPTION 'tenant enforcement did not grant runtime tenant key lock privilege';
1556
+ END IF;
1557
+ IF NOT EXISTS (
1558
+ SELECT 1
1559
+ FROM pg_trigger trigger
1560
+ JOIN pg_proc proc ON proc.oid = trigger.tgfoid
1561
+ WHERE trigger.tgrelid = 'public.tenants'::regclass
1562
+ AND trigger.tgname = 'open_loops_reject_runtime_tenant_update'
1563
+ AND NOT trigger.tgisinternal
1564
+ AND trigger.tgenabled = 'O'
1565
+ AND proc.oid = 'public.open_loops_reject_runtime_tenant_update()'::regprocedure
1566
+ AND pg_get_userbyid(proc.proowner) = 'open_loops_owner'
1567
+ AND NOT proc.prosecdef
1568
+ AND COALESCE(proc.proconfig, ARRAY[]::text[]) @> ARRAY['search_path=pg_catalog']
1569
+ AND proc.prosrc ILIKE '%pg_has_role%open_loops_runtime%USAGE%'
1570
+ ) THEN
1571
+ RAISE EXCEPTION 'tenant enforcement did not install runtime tenant update guard';
1572
+ END IF;
1573
+ IF EXISTS (
1574
+ SELECT 1
1575
+ FROM pg_class class
1576
+ JOIN pg_namespace namespace ON namespace.oid = class.relnamespace
1577
+ JOIN pg_attribute attribute ON attribute.attrelid = class.oid
1578
+ CROSS JOIN LATERAL aclexplode(attribute.attacl) acl
1579
+ LEFT JOIN pg_roles grantee ON grantee.oid = acl.grantee
1580
+ WHERE namespace.nspname = 'public'
1581
+ AND class.relname = ANY(ARRAY[
1582
+ 'tenants', 'tenant_memberships', 'tenant_membership_roles', 'api_keys',
1583
+ 'loops', 'loop_runs', 'daemon_lease', 'workflow_specs', 'workflow_runs',
1584
+ 'workflow_invocations', 'workflow_work_items', 'workflow_step_runs', 'workflow_events',
1585
+ 'goals', 'goal_plan_nodes', 'goal_runs', 'runner_machines', 'runner_leases',
1586
+ 'audit_events', 'run_receipts'
1587
+ ])
1588
+ AND attribute.attnum > 0
1589
+ AND NOT attribute.attisdropped
1590
+ AND acl.privilege_type IS NOT NULL
1591
+ ) THEN
1592
+ RAISE EXCEPTION 'tenant enforcement left unexpected protected column grants';
1593
+ END IF;
1594
+ IF EXISTS (
1595
+ SELECT 1
1596
+ FROM pg_proc proc
1597
+ CROSS JOIN LATERAL aclexplode(COALESCE(proc.proacl, acldefault('f', proc.proowner))) acl
1598
+ LEFT JOIN pg_roles grantee ON grantee.oid = acl.grantee
1599
+ WHERE proc.oid = ANY(ARRAY[
1600
+ 'public.open_loops_current_tenant_id()'::regprocedure,
1601
+ 'public.open_loops_authenticate_key(text,text)'::regprocedure,
1602
+ 'public.open_loops_append_auth_audit(text,text,text,text,text,text,text,jsonb)'::regprocedure
1603
+ ])
1604
+ AND acl.privilege_type = 'EXECUTE'
1605
+ AND NOT (
1606
+ proc.oid = 'public.open_loops_current_tenant_id()'::regprocedure
1607
+ AND grantee.rolname = 'open_loops_runtime'
1608
+ AND NOT acl.is_grantable
1609
+ )
1610
+ AND NOT (
1611
+ proc.oid = ANY(ARRAY[
1612
+ 'public.open_loops_authenticate_key(text,text)'::regprocedure,
1613
+ 'public.open_loops_append_auth_audit(text,text,text,text,text,text,text,jsonb)'::regprocedure
1614
+ ])
1615
+ AND grantee.rolname = 'open_loops_authenticator'
1616
+ AND NOT acl.is_grantable
1617
+ )
1618
+ ) THEN
1619
+ RAISE EXCEPTION 'tenant enforcement left unexpected authentication function grants';
1620
+ END IF;
1621
+ IF EXISTS (
1622
+ SELECT 1
1623
+ FROM pg_proc function
1624
+ JOIN pg_namespace namespace ON namespace.oid=function.pronamespace
1625
+ CROSS JOIN LATERAL aclexplode(COALESCE(function.proacl, acldefault('f', function.proowner))) acl
1626
+ LEFT JOIN pg_roles grantee ON grantee.oid = acl.grantee
1627
+ WHERE namespace.nspname NOT IN ('pg_catalog', 'information_schema')
1628
+ AND namespace.nspname NOT LIKE 'pg_toast%'
1629
+ AND namespace.nspname NOT LIKE 'pg_temp_%'
1630
+ AND acl.privilege_type = 'EXECUTE'
1631
+ AND NOT acl.is_grantable
1632
+ AND acl.grantee <> function.proowner
1633
+ AND NOT (
1634
+ namespace.nspname='public'
1635
+ AND function.oid = 'public.open_loops_current_tenant_id()'::regprocedure
1636
+ AND COALESCE(grantee.rolname IN ('open_loops_owner', 'open_loops_runtime'), false)
1637
+ )
1638
+ AND NOT (
1639
+ namespace.nspname='public'
1640
+ AND function.oid = ANY(ARRAY[
1641
+ 'public.open_loops_authenticate_key(text,text)'::regprocedure,
1642
+ 'public.open_loops_append_auth_audit(text,text,text,text,text,text,text,jsonb)'::regprocedure
1643
+ ])
1644
+ AND COALESCE(grantee.rolname IN ('open_loops_owner', 'open_loops_authenticator'), false)
1645
+ )
1646
+ ) THEN
1647
+ RAISE EXCEPTION 'tenant enforcement left unexpected function privileges outside the OpenLoops auth surface';
1648
+ END IF;
1649
+ IF EXISTS (
1650
+ SELECT 1
1651
+ FROM pg_auth_members membership
1652
+ JOIN pg_roles granted ON granted.oid = membership.roleid
1653
+ JOIN pg_roles member ON member.oid = membership.member
1654
+ WHERE granted.rolname IN ('open_loops_runtime', 'open_loops_authenticator')
1655
+ AND member.rolcanlogin
1656
+ AND NOT EXISTS (
1657
+ SELECT 1
1658
+ FROM pg_database database
1659
+ CROSS JOIN LATERAL aclexplode(COALESCE(database.datacl, acldefault('d', database.datdba))) acl
1660
+ WHERE database.datname = current_database()
1661
+ AND acl.grantee = member.oid
1662
+ AND acl.privilege_type = 'CONNECT'
1663
+ )
1664
+ ) THEN
1665
+ RAISE EXCEPTION 'tenant enforcement did not grant direct database CONNECT to every service login';
1666
+ END IF;
1667
+ END
1668
+ $tenant_enforcement_postconditions$;
1669
+
1670
+ DROP TABLE api_key_tenant_bindings;
1671
+ DROP TABLE tenant_row_assignments;
358
1672
  `)
359
1673
  ]);
360
1674
 
@@ -373,34 +1687,27 @@ class PostgresStorage {
373
1687
  }
374
1688
  async migrate(opts = {}) {
375
1689
  const dryRun = opts.dryRun === true;
376
- if (!dryRun)
377
- await this.ensureLedger();
378
- const applied = dryRun ? await this.tryReadAppliedMigrations() : await this.readAppliedMigrations();
379
- const knownMigrationIds = new Set(this.migrations.map((migration2) => migration2.id));
380
- for (const row of applied) {
381
- if (!knownMigrationIds.has(row.id)) {
382
- throw new Error(`Postgres migration ${row.id} is not recognized by this binary`);
383
- }
1690
+ const throughIndex = opts.through === undefined ? this.migrations.length - 1 : this.migrations.findIndex((migration2) => migration2.id === opts.through);
1691
+ if (throughIndex < 0)
1692
+ throw new Error(`Unknown Postgres migration target ${opts.through}`);
1693
+ if (dryRun) {
1694
+ const applied = await this.tryReadAppliedMigrations();
1695
+ const plan2 = this.buildPlan(applied);
1696
+ return { backend: this.backend, dryRun, applied, plan: plan2 };
384
1697
  }
385
- const appliedById = new Map(applied.map((row) => [row.id, row]));
386
- const plan = this.migrations.map((migration2) => ({
387
- migration: migration2,
388
- state: appliedById.has(migration2.id) ? "already_applied" : "pending"
389
- }));
390
- for (const migration2 of this.migrations) {
391
- const existing = appliedById.get(migration2.id);
392
- if (existing && existing.checksum !== migration2.checksum) {
393
- throw new Error(`Postgres migration checksum mismatch for ${migration2.id}`);
394
- }
395
- }
396
- if (dryRun)
397
- return { backend: this.backend, dryRun, applied, plan };
398
- const run = async () => {
399
- for (const item of plan) {
1698
+ await this.ensureLedger();
1699
+ let plan = [];
1700
+ const run = async (executor = this.executor) => {
1701
+ await executor.query(POSTGRES_MIGRATION_ADVISORY_LOCK_SQL);
1702
+ const lockedApplied = await this.readAppliedMigrations(executor);
1703
+ plan = this.buildPlan(lockedApplied);
1704
+ for (const [index, item] of plan.entries()) {
1705
+ if (index > throughIndex)
1706
+ break;
400
1707
  if (item.state === "already_applied")
401
1708
  continue;
402
- await this.executor.execute(item.migration.sql);
403
- await this.executor.execute(`INSERT INTO ${POSTGRES_MIGRATION_LEDGER_TABLE} (id, checksum, applied_at) VALUES ($1, $2, NOW())`, [item.migration.id, item.migration.checksum]);
1709
+ await executor.execute(item.migration.sql);
1710
+ await executor.execute(`INSERT INTO ${POSTGRES_MIGRATION_LEDGER_TABLE} (id, checksum, applied_at) VALUES ($1, $2, NOW())`, [item.migration.id, item.migration.checksum]);
404
1711
  }
405
1712
  };
406
1713
  if (this.executor.transaction)
@@ -426,8 +1733,8 @@ CREATE TABLE IF NOT EXISTS ${POSTGRES_MIGRATION_LEDGER_TABLE} (
426
1733
  );
427
1734
  `);
428
1735
  }
429
- async readAppliedMigrations() {
430
- const rows = await this.executor.query(`SELECT id, checksum, applied_at FROM ${POSTGRES_MIGRATION_LEDGER_TABLE} ORDER BY id ASC`);
1736
+ async readAppliedMigrations(executor = this.executor) {
1737
+ const rows = await executor.query(`SELECT id, checksum, applied_at FROM ${POSTGRES_MIGRATION_LEDGER_TABLE} ORDER BY id ASC`);
431
1738
  return rows.map((row) => ({
432
1739
  id: row.id,
433
1740
  checksum: row.checksum,
@@ -443,6 +1750,25 @@ CREATE TABLE IF NOT EXISTS ${POSTGRES_MIGRATION_LEDGER_TABLE} (
443
1750
  throw error;
444
1751
  }
445
1752
  }
1753
+ buildPlan(applied) {
1754
+ const knownMigrationIds = new Set(this.migrations.map((migration2) => migration2.id));
1755
+ for (const row of applied) {
1756
+ if (!knownMigrationIds.has(row.id)) {
1757
+ throw new Error(`Postgres migration ${row.id} is not recognized by this binary`);
1758
+ }
1759
+ }
1760
+ const appliedById = new Map(applied.map((row) => [row.id, row]));
1761
+ for (const migration2 of this.migrations) {
1762
+ const existing = appliedById.get(migration2.id);
1763
+ if (existing && existing.checksum !== migration2.checksum) {
1764
+ throw new Error(`Postgres migration checksum mismatch for ${migration2.id}`);
1765
+ }
1766
+ }
1767
+ return this.migrations.map((migration2) => ({
1768
+ migration: migration2,
1769
+ state: appliedById.has(migration2.id) ? "already_applied" : "pending"
1770
+ }));
1771
+ }
446
1772
  }
447
1773
  function createPostgresStorage(executor) {
448
1774
  return new PostgresStorage(executor);