@stratum-hq/lib 1.7.0 → 1.8.0

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 (59) hide show
  1. package/README.md +29 -2
  2. package/dist/crypto.d.ts.map +1 -1
  3. package/dist/crypto.js +50 -10
  4. package/dist/crypto.js.map +1 -1
  5. package/dist/index.d.ts +7 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +17 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/migrate-schemas.d.ts +4 -0
  10. package/dist/migrate-schemas.d.ts.map +1 -1
  11. package/dist/migrate-schemas.js +16 -6
  12. package/dist/migrate-schemas.js.map +1 -1
  13. package/dist/migrate.d.ts +24 -0
  14. package/dist/migrate.d.ts.map +1 -1
  15. package/dist/migrate.js +44 -6
  16. package/dist/migrate.js.map +1 -1
  17. package/dist/migration-sql.d.ts +25 -0
  18. package/dist/migration-sql.d.ts.map +1 -0
  19. package/dist/migration-sql.js +64 -0
  20. package/dist/migration-sql.js.map +1 -0
  21. package/dist/migrations/029_tenant_parent_cycle_guard.sql +2 -2
  22. package/dist/migrations/031_subtree_read_scope.sql +6 -6
  23. package/dist/migrations/032_control_role.sql +809 -0
  24. package/dist/migrations/033_api_keys_tenant_index.sql +14 -0
  25. package/dist/pinned-query.d.ts +29 -0
  26. package/dist/pinned-query.d.ts.map +1 -0
  27. package/dist/pinned-query.js +60 -0
  28. package/dist/pinned-query.js.map +1 -0
  29. package/dist/pool-helpers.d.ts +2 -0
  30. package/dist/pool-helpers.d.ts.map +1 -1
  31. package/dist/pool-helpers.js +17 -3
  32. package/dist/pool-helpers.js.map +1 -1
  33. package/dist/role-model.d.ts +178 -0
  34. package/dist/role-model.d.ts.map +1 -0
  35. package/dist/role-model.js +670 -0
  36. package/dist/role-model.js.map +1 -0
  37. package/dist/services/api-key-service.d.ts +12 -2
  38. package/dist/services/api-key-service.d.ts.map +1 -1
  39. package/dist/services/api-key-service.js +33 -9
  40. package/dist/services/api-key-service.js.map +1 -1
  41. package/dist/services/config-service.d.ts +15 -6
  42. package/dist/services/config-service.d.ts.map +1 -1
  43. package/dist/services/config-service.js +96 -50
  44. package/dist/services/config-service.js.map +1 -1
  45. package/dist/services/tenant-service.d.ts.map +1 -1
  46. package/dist/services/tenant-service.js +4 -4
  47. package/dist/services/tenant-service.js.map +1 -1
  48. package/dist/stratum-policies.d.ts +51 -0
  49. package/dist/stratum-policies.d.ts.map +1 -0
  50. package/dist/stratum-policies.js +217 -0
  51. package/dist/stratum-policies.js.map +1 -0
  52. package/dist/stratum-tables.d.ts.map +1 -1
  53. package/dist/stratum-tables.js +1 -0
  54. package/dist/stratum-tables.js.map +1 -1
  55. package/dist/stratum.d.ts +52 -7
  56. package/dist/stratum.d.ts.map +1 -1
  57. package/dist/stratum.js +75 -14
  58. package/dist/stratum.js.map +1 -1
  59. package/package.json +5 -5
@@ -0,0 +1,809 @@
1
+ -- Migration 032: The control role (opt-in hardening, 1.8).
2
+ --
3
+ -- Until now the library reached across tenants by setting app.bypass_rls in
4
+ -- its session, and the tenant_isolation policies of 019 and 020 admitted a
5
+ -- session that set it. This migration adds a role-based model, in which
6
+ -- membership in a PostgreSQL role decides which sessions reach rows across
7
+ -- tenants. It comes in two parts.
8
+ --
9
+ -- Part 1 needs no new role and always applies:
10
+ -- * stratum_security holds one switch, legacy_guc_bypass, true by default
11
+ -- in 1.8. While it is true, a session that sets app.bypass_rls still
12
+ -- passes tenant_isolation, as before. Setting it to false closes that
13
+ -- path. 2.0 removes the switch and the legacy branch.
14
+ -- * tenant_isolation on the 13 tables of 019 and 020 is re-created as
15
+ -- (SELECT stratum_legacy_bypass()) OR <the same tenant scope>.
16
+ -- * regions (T-11) gets row-level security. It has no tenant, so only the
17
+ -- legacy bypass and, once applied, the control role reach it.
18
+ -- * PUBLIC loses every privilege on the Stratum tables and the trigger
19
+ -- functions.
20
+ --
21
+ -- Part 2, stratum_apply_control_role(role_name, target_schema), applies the
22
+ -- control role. It is defined here and called at the end of this migration,
23
+ -- and it is idempotent, so `stratum db roles` and the bootstrap SQL can call
24
+ -- it again later:
25
+ -- * A NOLOGIN control role (default stratum_control), created when missing
26
+ -- and granted to the caller. Each Stratum table gets a
27
+ -- stratum_control_plane policy FOR ALL TO that role. The login behind the
28
+ -- library's adminPool is a member of it; the application's login is not.
29
+ -- Membership is checked by PostgreSQL, so a session cannot claim it by
30
+ -- setting anything.
31
+ -- * The SECURITY DEFINER helpers (the subtree read of 031 and the cycle
32
+ -- guard of 029) are re-created without their SET app.* clauses and owned
33
+ -- by the control role.
34
+ -- * The tree-column guard of 031 accepts a member of the control role, or
35
+ -- the legacy bypass.
36
+ -- * Row-level security is reset on every Stratum table: all its policies
37
+ -- are dropped, RLS is enabled and forced (stratum_security included), and
38
+ -- the canonical policies are created again. Running it restores policies
39
+ -- that an owner of the tables changed, dropped or added.
40
+ --
41
+ -- Part 2 grants the control role to the migrating role, which must be the
42
+ -- library's admin login and not the application's. So the migration applies
43
+ -- it only with an explicit opt-in (the setting stratum.apply_control_role,
44
+ -- see the end of this file), or when that grants nothing new. When it does
45
+ -- not apply it, or the migrating role can neither create nor join the
46
+ -- control role (no CREATEROLE, no ADMIN on an existing role), part 2 is
47
+ -- skipped with a WARNING that prints the bootstrap SQL, and the migration
48
+ -- still succeeds.
49
+ -- The install then behaves as before 1.8 until an administrator runs that
50
+ -- SQL; Stratum.initialize() reports the hardening as not active.
51
+ --
52
+ -- Choosing the control role name. Roles are cluster-wide, so the name can be
53
+ -- set per database. The migration takes, in order:
54
+ -- 1. the setting stratum.control_role, when the session sets it. The
55
+ -- library's migrate() and migrateAllSchemas() set it from their
56
+ -- controlRole option, and `ALTER DATABASE ... SET stratum.control_role`
57
+ -- sets it for every session of one database;
58
+ -- 2. the role of the stratum_control_plane policies this database already
59
+ -- has, so a later schema (migrateAllSchemas) uses the same role;
60
+ -- 3. stratum_control.
61
+ -- The name must be a plain lowercase identifier.
62
+ --
63
+ -- Safe to re-run, and safe in every tenant schema of migrateAllSchemas: every
64
+ -- object is created idempotently in the current schema, and the role section
65
+ -- takes an advisory lock so that concurrent schemas do not race to create it.
66
+
67
+ -- ---------------------------------------------------------------------------
68
+ -- The legacy switch.
69
+ -- ---------------------------------------------------------------------------
70
+ CREATE TABLE IF NOT EXISTS stratum_security (
71
+ id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id),
72
+ legacy_guc_bypass BOOLEAN NOT NULL DEFAULT true,
73
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
74
+ );
75
+ INSERT INTO stratum_security (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
76
+ -- Row-level security without FORCE: no other role reads or writes the table,
77
+ -- but its owner still can, so stratum_legacy_bypass() (SECURITY DEFINER,
78
+ -- owned by the migrating role until the control role takes it over) reads it
79
+ -- before the control role is applied. stratum_apply_control_role() turns on
80
+ -- FORCE.
81
+ ALTER TABLE stratum_security ENABLE ROW LEVEL SECURITY;
82
+ REVOKE ALL ON stratum_security FROM PUBLIC;
83
+
84
+ -- stratum_legacy_bypass(): true when the session set app.bypass_rls = 'on'
85
+ -- and the legacy switch is on. SECURITY DEFINER, so it reads stratum_security
86
+ -- as its owner: the migrating role, then the control role once applied. It
87
+ -- returns false when the row is missing, so deleting the row also closes the
88
+ -- legacy path. Policies call it in a scalar subquery, so it runs once per
89
+ -- policy reference in a statement. Every role that queries a Stratum table
90
+ -- evaluates the policies, so EXECUTE stays granted to PUBLIC; the function
91
+ -- returns one boolean and gives no other access.
92
+ --
93
+ -- Each DO block of this migration reads the current schema, then sets the
94
+ -- search path to pg_catalog, pg_temp until its end, so that no function or
95
+ -- operator another role created in a schema on the migrating role's search
96
+ -- path is chosen in it. It names Stratum objects with that schema.
97
+ DO $create$
98
+ DECLARE
99
+ v_schema text := pg_catalog.current_schema();
100
+ v_path text := pg_catalog.current_setting('search_path');
101
+ BEGIN
102
+ PERFORM pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true);
103
+ EXECUTE pg_catalog.format($fn$
104
+ CREATE OR REPLACE FUNCTION %1$I.stratum_legacy_bypass()
105
+ RETURNS boolean
106
+ LANGUAGE plpgsql
107
+ STABLE
108
+ SECURITY DEFINER
109
+ SET search_path = pg_catalog, pg_temp
110
+ AS $body$
111
+ BEGIN
112
+ IF pg_catalog.current_setting('app.bypass_rls', true) IS DISTINCT FROM 'on' THEN
113
+ RETURN false;
114
+ END IF;
115
+ RETURN coalesce((SELECT s.legacy_guc_bypass FROM %1$I.stratum_security s WHERE s.id), false);
116
+ END;
117
+ $body$
118
+ $fn$, v_schema);
119
+ PERFORM pg_catalog.set_config('search_path', v_path, true);
120
+ END $create$;
121
+
122
+ -- stratum_subtree_tenant_ids() as migration 031 left it for a migrating
123
+ -- role that is not a superuser: the runner dropped its SET app.* clauses
124
+ -- (see migration-sql.ts in @stratum-hq/lib), so it ran with the caller's
125
+ -- app.tenant_scope, and its read of tenants, under tenant_subtree_read,
126
+ -- could call it again. Until stratum_apply_control_role() re-creates it,
127
+ -- this version clears app.tenant_scope around that read and restores it.
128
+ -- A version that still has its SET app.* clauses, or that another role
129
+ -- owns, is left as it is.
130
+ DO $subtree$
131
+ DECLARE
132
+ v_schema text := pg_catalog.current_schema();
133
+ v_path text := pg_catalog.current_setting('search_path');
134
+ BEGIN
135
+ PERFORM pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true);
136
+ IF EXISTS (
137
+ SELECT 1 FROM pg_catalog.pg_proc p
138
+ WHERE p.pronamespace OPERATOR(pg_catalog.=) pg_catalog.to_regnamespace(pg_catalog.quote_ident(v_schema))::pg_catalog.oid
139
+ AND p.proname OPERATOR(pg_catalog.=) 'stratum_subtree_tenant_ids' AND p.pronargs OPERATOR(pg_catalog.=) 0
140
+ AND NOT p.prosecdef
141
+ AND NOT EXISTS (SELECT 1 FROM pg_catalog.unnest(p.proconfig) c WHERE c OPERATOR(pg_catalog.~~) 'app.%')
142
+ AND pg_catalog.pg_has_role(current_user, p.proowner, 'USAGE')
143
+ ) THEN
144
+ EXECUTE pg_catalog.format($fn$
145
+ CREATE OR REPLACE FUNCTION %1$I.stratum_subtree_tenant_ids()
146
+ RETURNS uuid[]
147
+ LANGUAGE plpgsql
148
+ STABLE
149
+ SET search_path = pg_catalog, pg_temp
150
+ AS $body$
151
+ DECLARE
152
+ v_scope text := pg_catalog.current_setting('app.tenant_scope', true);
153
+ v_ids uuid[];
154
+ BEGIN
155
+ PERFORM pg_catalog.set_config('app.tenant_scope', '', true);
156
+ WITH cur AS (
157
+ SELECT t.id, pg_catalog.rtrim(t.ancestry_path, '/') || '/' || t.id::text AS sub
158
+ FROM %1$I.tenants t
159
+ WHERE t.id = NULLIF(pg_catalog.current_setting('app.current_tenant_id', true), '')::uuid
160
+ )
161
+ SELECT coalesce(pg_catalog.array_agg(s.id), '{}'::uuid[]) INTO v_ids
162
+ FROM (
163
+ SELECT cur.id FROM cur
164
+ UNION ALL
165
+ SELECT d.id
166
+ FROM cur
167
+ JOIN %1$I.tenants d
168
+ ON d.ancestry_path OPERATOR(pg_catalog.~>=~) cur.sub
169
+ AND d.ancestry_path OPERATOR(pg_catalog.~<~) (cur.sub || '0')
170
+ AND (d.ancestry_path = cur.sub OR d.ancestry_path LIKE cur.sub || '/%%')
171
+ ) s;
172
+ PERFORM pg_catalog.set_config('app.tenant_scope', coalesce(v_scope, ''), true);
173
+ RETURN v_ids;
174
+ END;
175
+ $body$
176
+ $fn$, v_schema);
177
+ END IF;
178
+ PERFORM pg_catalog.set_config('search_path', v_path, true);
179
+ END $subtree$;
180
+
181
+ -- ---------------------------------------------------------------------------
182
+ -- tenant_isolation, re-created with the legacy bypass behind the switch.
183
+ -- The tenant scope of each policy is the one 019 and 020 created.
184
+ -- (SELECT stratum_legacy_bypass()) OR <scope>
185
+ -- ---------------------------------------------------------------------------
186
+
187
+ DROP POLICY IF EXISTS tenant_isolation ON config_entries;
188
+ CREATE POLICY tenant_isolation ON config_entries FOR ALL
189
+ USING ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
190
+ WITH CHECK ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
191
+
192
+ DROP POLICY IF EXISTS tenant_isolation ON permission_policies;
193
+ CREATE POLICY tenant_isolation ON permission_policies FOR ALL
194
+ USING ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
195
+ WITH CHECK ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
196
+
197
+ DROP POLICY IF EXISTS tenant_isolation ON audit_logs;
198
+ CREATE POLICY tenant_isolation ON audit_logs FOR ALL
199
+ USING ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
200
+ WITH CHECK ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
201
+
202
+ DROP POLICY IF EXISTS tenant_isolation ON webhook_events;
203
+ CREATE POLICY tenant_isolation ON webhook_events FOR ALL
204
+ USING ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
205
+ WITH CHECK ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
206
+
207
+ DROP POLICY IF EXISTS tenant_isolation ON webhooks;
208
+ CREATE POLICY tenant_isolation ON webhooks FOR ALL
209
+ USING ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
210
+ WITH CHECK ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
211
+
212
+ DROP POLICY IF EXISTS tenant_isolation ON consent_records;
213
+ CREATE POLICY tenant_isolation ON consent_records FOR ALL
214
+ USING ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
215
+ WITH CHECK ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
216
+
217
+ DROP POLICY IF EXISTS tenant_isolation ON abac_policies;
218
+ CREATE POLICY tenant_isolation ON abac_policies FOR ALL
219
+ USING ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
220
+ WITH CHECK ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
221
+
222
+ DROP POLICY IF EXISTS tenant_isolation ON api_keys;
223
+ CREATE POLICY tenant_isolation ON api_keys FOR ALL
224
+ USING ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
225
+ WITH CHECK ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
226
+
227
+ DROP POLICY IF EXISTS tenant_isolation ON roles;
228
+ CREATE POLICY tenant_isolation ON roles FOR ALL
229
+ USING ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
230
+ WITH CHECK ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
231
+
232
+ DROP POLICY IF EXISTS tenant_isolation ON usage_events;
233
+ CREATE POLICY tenant_isolation ON usage_events FOR ALL
234
+ USING ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
235
+ WITH CHECK ((SELECT stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
236
+
237
+ DROP POLICY IF EXISTS tenant_isolation ON tenants;
238
+ CREATE POLICY tenant_isolation ON tenants FOR ALL
239
+ USING ((SELECT stratum_legacy_bypass()) OR id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)
240
+ WITH CHECK ((SELECT stratum_legacy_bypass()) OR id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
241
+
242
+ DROP POLICY IF EXISTS tenant_isolation ON webhook_deliveries;
243
+ CREATE POLICY tenant_isolation ON webhook_deliveries FOR ALL
244
+ USING (
245
+ (SELECT stratum_legacy_bypass())
246
+ OR EXISTS (
247
+ SELECT 1 FROM webhook_events we
248
+ WHERE we.id = webhook_deliveries.event_id
249
+ AND we.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
250
+ )
251
+ )
252
+ WITH CHECK (
253
+ (SELECT stratum_legacy_bypass())
254
+ OR EXISTS (
255
+ SELECT 1 FROM webhook_events we
256
+ WHERE we.id = webhook_deliveries.event_id
257
+ AND we.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
258
+ )
259
+ );
260
+
261
+ DROP POLICY IF EXISTS tenant_isolation ON principal_roles;
262
+ CREATE POLICY tenant_isolation ON principal_roles FOR ALL
263
+ USING (
264
+ (SELECT stratum_legacy_bypass())
265
+ OR EXISTS (
266
+ SELECT 1 FROM roles r
267
+ WHERE r.id = principal_roles.role_id
268
+ AND r.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
269
+ )
270
+ )
271
+ WITH CHECK (
272
+ (SELECT stratum_legacy_bypass())
273
+ OR EXISTS (
274
+ SELECT 1 FROM roles r
275
+ WHERE r.id = principal_roles.role_id
276
+ AND r.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
277
+ )
278
+ );
279
+
280
+ -- ---------------------------------------------------------------------------
281
+ -- regions (T-11): global infrastructure, no tenant. Only the control role and,
282
+ -- while the switch is on, the legacy bypass reach it.
283
+ -- ---------------------------------------------------------------------------
284
+ ALTER TABLE regions ENABLE ROW LEVEL SECURITY;
285
+ ALTER TABLE regions FORCE ROW LEVEL SECURITY;
286
+ DROP POLICY IF EXISTS stratum_legacy_bypass ON regions;
287
+ CREATE POLICY stratum_legacy_bypass ON regions FOR ALL
288
+ USING ((SELECT stratum_legacy_bypass()))
289
+ WITH CHECK ((SELECT stratum_legacy_bypass()));
290
+
291
+ -- Trigger functions are not called directly; nobody needs EXECUTE on them.
292
+ REVOKE ALL ON FUNCTION refuse_tenant_parent_cycle() FROM PUBLIC;
293
+ REVOKE ALL ON FUNCTION refuse_tenant_tree_column_change() FROM PUBLIC;
294
+
295
+ -- No Stratum table is for PUBLIC.
296
+ REVOKE ALL ON tenants, config_entries, permission_policies, audit_logs, webhook_events,
297
+ webhook_deliveries, webhooks, consent_records, abac_policies, api_keys, roles,
298
+ principal_roles, usage_events, regions FROM PUBLIC;
299
+
300
+ -- ---------------------------------------------------------------------------
301
+ -- Part 2: stratum_apply_control_role(role_name, target_schema).
302
+ --
303
+ -- SECURITY INVOKER: the caller must own the Stratum tables of target_schema
304
+ -- (or be a superuser) and be able to create or join the role. EXECUTE is
305
+ -- revoked from PUBLIC. It raises insufficient_privilege, with the bootstrap
306
+ -- SQL in the message, when the caller can neither create nor join the role.
307
+ --
308
+ -- It and the functions it creates run with search_path = pg_catalog,
309
+ -- pg_temp, and name every Stratum object with its schema, so that objects
310
+ -- other roles create in the Stratum schema are never resolved in them. It
311
+ -- applies the control role only to the schema it lives in, and it revokes
312
+ -- CREATE on that schema from PUBLIC.
313
+ -- ---------------------------------------------------------------------------
314
+ CREATE OR REPLACE FUNCTION stratum_apply_control_role(role_name text, target_schema text)
315
+ RETURNS void
316
+ LANGUAGE plpgsql
317
+ SET search_path = pg_catalog, pg_temp
318
+ AS $apply$
319
+ DECLARE
320
+ v_role text := role_name;
321
+ v_schema text := target_schema;
322
+ v_context text;
323
+ v_existing text[];
324
+ v_pg16 boolean := pg_catalog.current_setting('server_version_num')::int >= 160000;
325
+ v_bootstrap text;
326
+ v_table text;
327
+ v_policy name;
328
+ v_function regprocedure;
329
+ v_tables text[] := ARRAY[
330
+ 'tenants', 'config_entries', 'permission_policies', 'audit_logs', 'webhook_events',
331
+ 'webhook_deliveries', 'webhooks', 'consent_records', 'abac_policies', 'api_keys',
332
+ 'roles', 'principal_roles', 'usage_events', 'regions', 'stratum_security'
333
+ ];
334
+ BEGIN
335
+ IF v_role IS NULL OR v_role !~ '^[a-z_][a-z0-9_]{0,62}$' THEN
336
+ RAISE EXCEPTION 'the control role must be a plain lowercase identifier, got "%"', v_role
337
+ USING ERRCODE = 'invalid_parameter_value';
338
+ END IF;
339
+ IF pg_catalog.to_regnamespace(pg_catalog.quote_ident(v_schema)) IS NULL THEN
340
+ RAISE EXCEPTION 'schema "%" does not exist', v_schema USING ERRCODE = 'invalid_schema_name';
341
+ END IF;
342
+ -- The first context line names this function. Its schema is not on the
343
+ -- search path, so PostgreSQL prints the name qualified with it.
344
+ GET DIAGNOSTICS v_context = PG_CONTEXT;
345
+ IF pg_catalog.strpos(pg_catalog.split_part(v_context, E'\n', 1),
346
+ 'function ' || pg_catalog.quote_ident(v_schema) || '.stratum_apply_control_role(text,text) ') = 0 THEN
347
+ RAISE EXCEPTION 'stratum_apply_control_role applies the control role only to the schema it is in, not to "%"', v_schema
348
+ USING ERRCODE = 'invalid_parameter_value';
349
+ END IF;
350
+ SELECT pg_catalog.array_agg(DISTINCT r::text) INTO v_existing
351
+ FROM pg_catalog.pg_policies p, pg_catalog.unnest(p.roles) r
352
+ WHERE p.policyname = 'stratum_control_plane';
353
+ IF pg_catalog.cardinality(v_existing) > 0 AND v_existing OPERATOR(pg_catalog.<>) ARRAY[v_role] THEN
354
+ RAISE EXCEPTION 'this database already uses the control role %, not "%"', v_existing, v_role
355
+ USING ERRCODE = 'invalid_parameter_value';
356
+ END IF;
357
+
358
+ v_bootstrap := pg_catalog.format('CREATE ROLE %I NOLOGIN;', v_role) || E'\n' ||
359
+ CASE WHEN v_pg16
360
+ THEN pg_catalog.format('GRANT %I TO %I WITH INHERIT TRUE, SET TRUE;', v_role, current_user)
361
+ ELSE pg_catalog.format('GRANT %I TO %I;', v_role, current_user)
362
+ END || E'\n' ||
363
+ pg_catalog.format('SELECT %I.stratum_apply_control_role(%L, %L);', v_schema, v_role, v_schema);
364
+
365
+ -- Roles are cluster-wide; serialize their creation within this database.
366
+ PERFORM pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtext('stratum.control_role'));
367
+
368
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_role) THEN
369
+ BEGIN
370
+ EXECUTE pg_catalog.format('CREATE ROLE %I NOLOGIN', v_role);
371
+ EXCEPTION
372
+ WHEN duplicate_object OR unique_violation THEN
373
+ NULL; -- created concurrently, by another database's migration
374
+ WHEN insufficient_privilege THEN
375
+ RAISE EXCEPTION E'role "%" cannot create the Stratum control role "%". Run this once as a superuser:\n%',
376
+ current_user, v_role, v_bootstrap
377
+ USING ERRCODE = 'insufficient_privilege';
378
+ END;
379
+ END IF;
380
+
381
+ -- The control role passes every Stratum policy, so it must not be usable
382
+ -- as a login, and must not carry attributes that bypass the policies.
383
+ IF EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_role AND (rolcanlogin OR rolsuper OR rolbypassrls)) THEN
384
+ RAISE EXCEPTION 'the control role "%" must be NOLOGIN NOSUPERUSER NOBYPASSRLS; run ALTER ROLE % NOLOGIN NOSUPERUSER NOBYPASSRLS and apply it again',
385
+ v_role, pg_catalog.quote_ident(v_role)
386
+ USING ERRCODE = 'invalid_parameter_value';
387
+ END IF;
388
+
389
+ IF NOT pg_catalog.pg_has_role(current_user, v_role, 'USAGE') OR (v_pg16 AND NOT pg_catalog.pg_has_role(current_user, v_role, 'SET')) THEN
390
+ BEGIN
391
+ IF v_pg16 THEN
392
+ EXECUTE pg_catalog.format('GRANT %I TO %I WITH INHERIT TRUE, SET TRUE', v_role, current_user);
393
+ ELSE
394
+ EXECUTE pg_catalog.format('GRANT %I TO %I', v_role, current_user);
395
+ END IF;
396
+ EXCEPTION WHEN insufficient_privilege THEN
397
+ RAISE EXCEPTION E'role "%" is not a member of the Stratum control role "%" and cannot grant that itself. Run this once as a superuser:\n%',
398
+ current_user, v_role, v_bootstrap
399
+ USING ERRCODE = 'insufficient_privilege';
400
+ END;
401
+ IF NOT pg_catalog.pg_has_role(current_user, v_role, 'USAGE') THEN
402
+ RAISE EXCEPTION 'role "%" is a member of the control role "%" but does not inherit its privileges (NOINHERIT)',
403
+ current_user, v_role
404
+ USING ERRCODE = 'insufficient_privilege';
405
+ END IF;
406
+ END IF;
407
+
408
+ -- The control role owns the helpers below, so it needs the schema and the
409
+ -- tables they read. It gets the same rights on every Stratum table, so that
410
+ -- membership in it is all the library's admin login needs.
411
+ BEGIN
412
+ EXECUTE pg_catalog.format('GRANT USAGE, CREATE ON SCHEMA %I TO %I', v_schema, v_role);
413
+ EXCEPTION WHEN insufficient_privilege THEN
414
+ RAISE EXCEPTION E'role "%" cannot grant the control role "%" USAGE and CREATE on schema "%". Run this once as a superuser:\n%',
415
+ current_user, v_role, v_schema, v_bootstrap
416
+ USING ERRCODE = 'insufficient_privilege';
417
+ END;
418
+ -- Only roles granted CREATE by name may create objects in the schema.
419
+ EXECUTE pg_catalog.format('REVOKE CREATE ON SCHEMA %I FROM PUBLIC', v_schema);
420
+
421
+ FOREACH v_table IN ARRAY v_tables LOOP
422
+ EXECUTE pg_catalog.format('GRANT SELECT, INSERT, UPDATE, DELETE ON %I.%I TO %I', v_schema, v_table, v_role);
423
+ END LOOP;
424
+
425
+ -- Row-level security of the Stratum tables, reset to the canonical set:
426
+ -- every policy on them is dropped, RLS is enabled and forced, and the
427
+ -- policies of 019, 020, 031 and 032 (in its legacy form) are created again,
428
+ -- with stratum_control_plane for the control role. Whatever a former owner
429
+ -- of the tables changed or added is gone afterwards. The statements below
430
+ -- are rendered by policiesPlpgsql() in @stratum-hq/lib (stratum-policies.ts);
431
+ -- a unit test keeps the two identical.
432
+ FOREACH v_table IN ARRAY v_tables LOOP
433
+ FOR v_policy IN
434
+ SELECT p.polname FROM pg_policy p WHERE p.polrelid = format('%I.%I', v_schema, v_table)::regclass
435
+ LOOP
436
+ EXECUTE format('DROP POLICY %I ON %I.%I', v_policy, v_schema, v_table);
437
+ END LOOP;
438
+ EXECUTE format('ALTER TABLE %I.%I ENABLE ROW LEVEL SECURITY', v_schema, v_table);
439
+ EXECUTE format('ALTER TABLE %I.%I FORCE ROW LEVEL SECURITY', v_schema, v_table);
440
+ END LOOP;
441
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.config_entries FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)$pol$, v_schema);
442
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.permission_policies FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)$pol$, v_schema);
443
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.audit_logs FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)$pol$, v_schema);
444
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.webhook_events FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)$pol$, v_schema);
445
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.webhooks FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)$pol$, v_schema);
446
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.consent_records FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)$pol$, v_schema);
447
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.abac_policies FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)$pol$, v_schema);
448
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.api_keys FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)$pol$, v_schema);
449
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.roles FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)$pol$, v_schema);
450
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.usage_events FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)$pol$, v_schema);
451
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.tenants FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)$pol$, v_schema);
452
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.webhook_deliveries FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR EXISTS (SELECT 1 FROM %1$I.webhook_events we WHERE we.id = webhook_deliveries.event_id AND we.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR EXISTS (SELECT 1 FROM %1$I.webhook_events we WHERE we.id = webhook_deliveries.event_id AND we.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid))$pol$, v_schema);
453
+ EXECUTE format($pol$CREATE POLICY tenant_isolation ON %1$I.principal_roles FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass()) OR EXISTS (SELECT 1 FROM %1$I.roles r WHERE r.id = principal_roles.role_id AND r.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid)) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()) OR EXISTS (SELECT 1 FROM %1$I.roles r WHERE r.id = principal_roles.role_id AND r.tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid))$pol$, v_schema);
454
+ EXECUTE format($pol$CREATE POLICY tenant_subtree_read ON %1$I.config_entries FOR SELECT USING (current_setting('app.tenant_scope', true) = 'subtree' AND tenant_id = ANY ((SELECT %1$I.stratum_subtree_tenant_ids())::uuid[]) AND NOT sensitive)$pol$, v_schema);
455
+ EXECUTE format($pol$CREATE POLICY tenant_subtree_read ON %1$I.permission_policies FOR SELECT USING (current_setting('app.tenant_scope', true) = 'subtree' AND tenant_id = ANY ((SELECT %1$I.stratum_subtree_tenant_ids())::uuid[]))$pol$, v_schema);
456
+ EXECUTE format($pol$CREATE POLICY tenant_subtree_read ON %1$I.audit_logs FOR SELECT USING (current_setting('app.tenant_scope', true) = 'subtree' AND tenant_id = ANY ((SELECT %1$I.stratum_subtree_tenant_ids())::uuid[]))$pol$, v_schema);
457
+ EXECUTE format($pol$CREATE POLICY tenant_subtree_read ON %1$I.webhook_events FOR SELECT USING (current_setting('app.tenant_scope', true) = 'subtree' AND tenant_id = ANY ((SELECT %1$I.stratum_subtree_tenant_ids())::uuid[]))$pol$, v_schema);
458
+ EXECUTE format($pol$CREATE POLICY tenant_subtree_read ON %1$I.consent_records FOR SELECT USING (current_setting('app.tenant_scope', true) = 'subtree' AND tenant_id = ANY ((SELECT %1$I.stratum_subtree_tenant_ids())::uuid[]))$pol$, v_schema);
459
+ EXECUTE format($pol$CREATE POLICY tenant_subtree_read ON %1$I.abac_policies FOR SELECT USING (current_setting('app.tenant_scope', true) = 'subtree' AND tenant_id = ANY ((SELECT %1$I.stratum_subtree_tenant_ids())::uuid[]))$pol$, v_schema);
460
+ EXECUTE format($pol$CREATE POLICY tenant_subtree_read ON %1$I.roles FOR SELECT USING (current_setting('app.tenant_scope', true) = 'subtree' AND tenant_id = ANY ((SELECT %1$I.stratum_subtree_tenant_ids())::uuid[]))$pol$, v_schema);
461
+ EXECUTE format($pol$CREATE POLICY tenant_subtree_read ON %1$I.usage_events FOR SELECT USING (current_setting('app.tenant_scope', true) = 'subtree' AND tenant_id = ANY ((SELECT %1$I.stratum_subtree_tenant_ids())::uuid[]))$pol$, v_schema);
462
+ EXECUTE format($pol$CREATE POLICY tenant_subtree_read ON %1$I.tenants FOR SELECT USING (current_setting('app.tenant_scope', true) = 'subtree' AND id = ANY ((SELECT %1$I.stratum_subtree_tenant_ids())::uuid[]))$pol$, v_schema);
463
+ EXECUTE format($pol$CREATE POLICY tenant_subtree_read ON %1$I.webhook_deliveries FOR SELECT USING (current_setting('app.tenant_scope', true) = 'subtree' AND EXISTS (SELECT 1 FROM %1$I.webhook_events we WHERE we.id = webhook_deliveries.event_id AND we.tenant_id = ANY ((SELECT %1$I.stratum_subtree_tenant_ids())::uuid[])))$pol$, v_schema);
464
+ EXECUTE format($pol$CREATE POLICY tenant_subtree_read ON %1$I.principal_roles FOR SELECT USING (current_setting('app.tenant_scope', true) = 'subtree' AND EXISTS (SELECT 1 FROM %1$I.roles r WHERE r.id = principal_roles.role_id AND r.tenant_id = ANY ((SELECT %1$I.stratum_subtree_tenant_ids())::uuid[])))$pol$, v_schema);
465
+ EXECUTE format($pol$CREATE POLICY stratum_legacy_bypass ON %1$I.regions FOR ALL USING ((SELECT %1$I.stratum_legacy_bypass())) WITH CHECK ((SELECT %1$I.stratum_legacy_bypass()))$pol$, v_schema);
466
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.tenants AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
467
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.config_entries AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
468
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.permission_policies AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
469
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.audit_logs AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
470
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.webhook_events AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
471
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.webhook_deliveries AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
472
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.webhooks AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
473
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.consent_records AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
474
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.abac_policies AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
475
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.api_keys AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
476
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.roles AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
477
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.principal_roles AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
478
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.usage_events AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
479
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.regions AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
480
+ EXECUTE format($pol$CREATE POLICY stratum_control_plane ON %1$I.stratum_security AS PERMISSIVE FOR ALL TO %2$I USING (true) WITH CHECK (true)$pol$, v_schema, v_role);
481
+
482
+ -- stratum_legacy_bypass(): as part 1 of this migration creates it. Created
483
+ -- again here so that its body and settings are the migration's, whoever
484
+ -- owned it before.
485
+ EXECUTE pg_catalog.format($fn$
486
+ CREATE OR REPLACE FUNCTION %1$I.stratum_legacy_bypass()
487
+ RETURNS boolean
488
+ LANGUAGE plpgsql
489
+ STABLE
490
+ SECURITY DEFINER
491
+ SET search_path = pg_catalog, pg_temp
492
+ AS $body$
493
+ BEGIN
494
+ IF pg_catalog.current_setting('app.bypass_rls', true) IS DISTINCT FROM 'on' THEN
495
+ RETURN false;
496
+ END IF;
497
+ RETURN coalesce((SELECT s.legacy_guc_bypass FROM %1$I.stratum_security s WHERE s.id), false);
498
+ END;
499
+ $body$
500
+ $fn$, v_schema);
501
+
502
+ -- stratum_subtree_tenant_ids(): the body of 031, SECURITY DEFINER, without
503
+ -- the SET app.* clauses. It no longer clears app.tenant_scope, so its query
504
+ -- on tenants is evaluated with the caller's scope. That cannot recurse: the
505
+ -- control role's policy is USING (true), and PostgreSQL folds
506
+ -- `true OR <tenant_subtree_read>` to true before it plans the subquery that
507
+ -- would call this function again. An integration test pins this.
508
+ EXECUTE pg_catalog.format($fn$
509
+ CREATE OR REPLACE FUNCTION %1$I.stratum_subtree_tenant_ids()
510
+ RETURNS uuid[]
511
+ LANGUAGE sql
512
+ STABLE
513
+ SECURITY DEFINER
514
+ SET search_path = pg_catalog, pg_temp
515
+ AS $body$
516
+ WITH cur AS (
517
+ SELECT t.id, pg_catalog.rtrim(t.ancestry_path, '/') || '/' || t.id::text AS sub
518
+ FROM %1$I.tenants t
519
+ WHERE t.id = NULLIF(pg_catalog.current_setting('app.current_tenant_id', true), '')::uuid
520
+ )
521
+ SELECT coalesce(pg_catalog.array_agg(s.id), '{}'::uuid[])
522
+ FROM (
523
+ SELECT cur.id FROM cur
524
+ UNION ALL
525
+ SELECT d.id
526
+ FROM cur
527
+ JOIN %1$I.tenants d
528
+ ON d.ancestry_path OPERATOR(pg_catalog.~>=~) cur.sub
529
+ AND d.ancestry_path OPERATOR(pg_catalog.~<~) (cur.sub || '0')
530
+ AND (d.ancestry_path = cur.sub OR d.ancestry_path LIKE cur.sub || '/%%')
531
+ ) s
532
+ $body$
533
+ $fn$, v_schema);
534
+
535
+ -- refuse_tenant_parent_cycle(): the body of 029, SECURITY DEFINER, without
536
+ -- the SET clause.
537
+ EXECUTE pg_catalog.format($fn$
538
+ CREATE OR REPLACE FUNCTION %1$I.refuse_tenant_parent_cycle()
539
+ RETURNS TRIGGER
540
+ LANGUAGE plpgsql
541
+ SECURITY DEFINER
542
+ SET search_path = pg_catalog, pg_temp
543
+ AS $body$
544
+ BEGIN
545
+ IF NEW.parent_id = NEW.id OR EXISTS (
546
+ WITH RECURSIVE up(id, parent_id, seen) AS (
547
+ SELECT t.id, t.parent_id, ARRAY[t.id]
548
+ FROM %1$I.tenants t
549
+ WHERE t.id = NEW.parent_id
550
+ UNION ALL
551
+ SELECT t.id, t.parent_id, up.seen OPERATOR(pg_catalog.||) t.id
552
+ FROM %1$I.tenants t
553
+ JOIN up ON t.id = up.parent_id
554
+ WHERE t.id OPERATOR(pg_catalog.<>) ALL (up.seen)
555
+ )
556
+ SELECT 1 FROM up WHERE up.id = NEW.id
557
+ ) THEN
558
+ RAISE EXCEPTION 'tenant %% cannot be its own ancestor (parent_id cycle)', NEW.id
559
+ USING ERRCODE = 'check_violation';
560
+ END IF;
561
+ RETURN NEW;
562
+ END;
563
+ $body$
564
+ $fn$, v_schema);
565
+
566
+ -- The tree-column guard of 031, which now accepts a member of the control
567
+ -- role. It is not SECURITY DEFINER: it checks the role that runs the
568
+ -- statement. pg_has_role(..., 'USAGE') is true for a member that inherits
569
+ -- the role's privileges and for a superuser. ancestry_ltree is compared as
570
+ -- text, because the ltree operators are not on its search path.
571
+ EXECUTE pg_catalog.format($fn$
572
+ CREATE OR REPLACE FUNCTION %1$I.refuse_tenant_tree_column_change()
573
+ RETURNS TRIGGER
574
+ LANGUAGE plpgsql
575
+ SET search_path = pg_catalog, pg_temp
576
+ AS $body$
577
+ BEGIN
578
+ IF (
579
+ NEW.parent_id IS DISTINCT FROM OLD.parent_id
580
+ OR NEW.ancestry_path IS DISTINCT FROM OLD.ancestry_path
581
+ OR NEW.depth IS DISTINCT FROM OLD.depth
582
+ OR NEW.ancestry_ltree::text IS DISTINCT FROM OLD.ancestry_ltree::text
583
+ ) AND NOT pg_catalog.pg_has_role(current_user, %2$L, 'USAGE')
584
+ AND NOT (SELECT %1$I.stratum_legacy_bypass()) THEN
585
+ RAISE EXCEPTION 'tenant %% tree columns (parent_id, ancestry_path, depth, ancestry_ltree) change only through the Stratum control role', OLD.id
586
+ USING ERRCODE = 'insufficient_privilege';
587
+ END IF;
588
+ RETURN NEW;
589
+ END;
590
+ $body$
591
+ $fn$, v_schema, v_role);
592
+
593
+ EXECUTE pg_catalog.format('REVOKE ALL ON FUNCTION %I.refuse_tenant_parent_cycle() FROM PUBLIC', v_schema);
594
+ EXECUTE pg_catalog.format('REVOKE ALL ON FUNCTION %I.refuse_tenant_tree_column_change() FROM PUBLIC', v_schema);
595
+
596
+ EXECUTE pg_catalog.format('ALTER FUNCTION %I.stratum_legacy_bypass() OWNER TO %I', v_schema, v_role);
597
+ EXECUTE pg_catalog.format('ALTER FUNCTION %I.stratum_subtree_tenant_ids() OWNER TO %I', v_schema, v_role);
598
+ EXECUTE pg_catalog.format('ALTER FUNCTION %I.refuse_tenant_parent_cycle() OWNER TO %I', v_schema, v_role);
599
+ EXECUTE pg_catalog.format('ALTER FUNCTION %I.refuse_tenant_tree_column_change() OWNER TO %I', v_schema, v_role);
600
+
601
+ -- The other Stratum functions, this one included, are taken from an owner
602
+ -- that is not a member of the control role (such as a former owner of the
603
+ -- tables) by the role that applies it, so that no such owner can change
604
+ -- them. An owner that is a member, such as the admin login, keeps them.
605
+ FOR v_function IN
606
+ SELECT p.oid::regprocedure FROM pg_proc p
607
+ WHERE p.pronamespace = pg_catalog.to_regnamespace(pg_catalog.quote_ident(v_schema))
608
+ AND p.proname = ANY (ARRAY['update_updated_at_column', 'maintain_ancestry_ltree',
609
+ 'propagate_ancestry_ltree', 'stratum_apply_control_role'])
610
+ AND NOT pg_catalog.pg_has_role(p.proowner, v_role, 'USAGE')
611
+ LOOP
612
+ EXECUTE pg_catalog.format('ALTER FUNCTION %s OWNER TO %I', v_function, current_user);
613
+ END LOOP;
614
+ END;
615
+ $apply$;
616
+
617
+ REVOKE ALL ON FUNCTION stratum_apply_control_role(text, text) FROM PUBLIC;
618
+
619
+ -- ---------------------------------------------------------------------------
620
+ -- Apply the control role now, when the migration may; otherwise warn.
621
+ --
622
+ -- Applying it grants the control role to the migrating login. That login
623
+ -- must be the library's admin login, never the application's, so the grant
624
+ -- needs an explicit opt-in: the setting stratum.apply_control_role = 'on',
625
+ -- set in the migrating session itself, which migrate({ applyControlRole:
626
+ -- true }) does, and Stratum's autoMigrate does when it runs on adminPool. A
627
+ -- value that comes from a default (ALTER DATABASE or ALTER ROLE ... SET, or
628
+ -- the connection options) does not count. Without the opt-in, the control
629
+ -- role is applied only when that grants nothing new: the migrating login is
630
+ -- a superuser, or already a member of the control role. Otherwise part 2 is
631
+ -- skipped with a WARNING that prints the SQL to run, and the install keeps
632
+ -- the pre-1.8 behavior until then.
633
+ --
634
+ -- Before it applies the control role, it runs the integrity check of
635
+ -- bootstrapRolesSql() in @stratum-hq/lib on the schema. When that finds
636
+ -- objects the Stratum migrations did not create, it warns with the list and
637
+ -- does not apply the control role.
638
+ -- ---------------------------------------------------------------------------
639
+ DO $control$
640
+ DECLARE
641
+ v_schema text := pg_catalog.current_schema();
642
+ v_path text := pg_catalog.current_setting('search_path');
643
+ v_role text;
644
+ v_set text;
645
+ v_default text;
646
+ v_opt_in boolean;
647
+ v_existing text[];
648
+ v_message text;
649
+ v_ns oid;
650
+ v_tables text[];
651
+ v_functions text[];
652
+ v_problems text[];
653
+ r record;
654
+ BEGIN
655
+ PERFORM pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true);
656
+ v_role := NULLIF(pg_catalog.current_setting('stratum.control_role', true), '');
657
+ -- The opt-in counts only when this session set it: RESET shows the value
658
+ -- the session would have without it.
659
+ v_set := pg_catalog.current_setting('stratum.apply_control_role', true);
660
+ -- RESET has no LOCAL form, so it clears the session's value too. The
661
+ -- set_config() below restores it for this transaction only: once the
662
+ -- migration commits, a session-level opt-in is gone, which is intended
663
+ -- (migrate() sets it per transaction).
664
+ RESET stratum.apply_control_role;
665
+ v_default := pg_catalog.current_setting('stratum.apply_control_role', true);
666
+ PERFORM pg_catalog.set_config('stratum.apply_control_role', coalesce(v_set, ''), true);
667
+ v_opt_in := coalesce(v_set = 'on' AND v_default IS DISTINCT FROM 'on', false);
668
+ SELECT pg_catalog.array_agg(DISTINCT pr::text) INTO v_existing
669
+ FROM pg_catalog.pg_policies p, pg_catalog.unnest(p.roles) pr
670
+ WHERE p.policyname = 'stratum_control_plane';
671
+ IF v_role IS NULL THEN
672
+ v_role := CASE WHEN pg_catalog.cardinality(v_existing) = 1 THEN v_existing[1] ELSE 'stratum_control' END;
673
+ END IF;
674
+
675
+ IF NOT v_opt_in
676
+ AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = current_user AND rolsuper)
677
+ AND NOT (EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = v_role)
678
+ AND pg_catalog.pg_has_role(current_user, v_role, 'USAGE')) THEN
679
+ RAISE WARNING E'Stratum control-role hardening is NOT active in schema "%": the migration did not run with the control-role opt-in (migrate({ applyControlRole: true }), or Stratum autoMigrate with adminPool), so it does not grant the control role to "%". Run this once as a superuser, naming the admin login of the library:\n%\nor run `stratum db roles --apply`. Until then, this database keeps the pre-1.8 behavior.',
680
+ v_schema, current_user,
681
+ pg_catalog.format('CREATE ROLE %I NOLOGIN;', v_role) || E'\n' ||
682
+ pg_catalog.format('GRANT %I TO <admin login> WITH INHERIT TRUE, SET TRUE;', v_role) || E'\n' ||
683
+ pg_catalog.format('SELECT %I.stratum_apply_control_role(%L, %L);', v_schema, v_role, v_schema);
684
+ PERFORM pg_catalog.set_config('search_path', v_path, true);
685
+ RETURN;
686
+ END IF;
687
+
688
+ -- The integrity check of bootstrapRolesSql(), rendered by
689
+ -- integrityChecksPlpgsql() in @stratum-hq/lib (role-model.ts); a unit test
690
+ -- keeps the two identical.
691
+ v_ns := pg_catalog.to_regnamespace(pg_catalog.quote_ident(v_schema));
692
+ v_tables := ARRAY['_migrations', 'abac_policies', 'api_keys', 'audit_logs', 'config_entries', 'consent_records', 'permission_policies', 'principal_roles', 'regions', 'roles', 'stratum_security', 'tenants', 'usage_events', 'webhook_deliveries', 'webhook_events', 'webhooks'];
693
+ v_functions := ARRAY['update_updated_at_column', 'maintain_ancestry_ltree', 'propagate_ancestry_ltree', 'refuse_tenant_parent_cycle', 'refuse_tenant_tree_column_change', 'stratum_subtree_tenant_ids', 'stratum_legacy_bypass', 'stratum_apply_control_role'];
694
+ v_problems := '{}';
695
+ FOR r IN
696
+ SELECT c.relname, c.relkind FROM pg_class c
697
+ WHERE c.relnamespace = v_ns AND c.relname = ANY (v_tables) AND c.relkind NOT IN ('r', 'p')
698
+ LOOP
699
+ v_problems := v_problems || format('%I is not a table (relkind %s)', r.relname, r.relkind);
700
+ END LOOP;
701
+ FOR r IN
702
+ SELECT c.relname, w.rulename FROM pg_rewrite w JOIN pg_class c ON c.oid = w.ev_class
703
+ WHERE c.relnamespace = v_ns AND c.relname = ANY (v_tables)
704
+ LOOP
705
+ v_problems := v_problems || format('rule %I on %I', r.rulename, r.relname);
706
+ END LOOP;
707
+ FOR r IN
708
+ SELECT c.relname, t.tgname, t.tgfoid::regprocedure::text AS fn FROM pg_trigger t
709
+ JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_proc p ON p.oid = t.tgfoid
710
+ WHERE NOT t.tgisinternal AND c.relnamespace = v_ns AND c.relname = ANY (v_tables)
711
+ AND NOT (p.pronamespace = v_ns AND p.proname = ANY (v_functions))
712
+ LOOP
713
+ v_problems := v_problems || format('trigger %I on %I calls %s', r.tgname, r.relname, r.fn);
714
+ END LOOP;
715
+ FOR r IN
716
+ WITH t AS (
717
+ SELECT c.oid, c.relname FROM pg_class c WHERE c.relnamespace = v_ns AND c.relname = ANY (v_tables)
718
+ ), o AS (
719
+ SELECT 'pg_policy'::regclass AS classid, x.oid AS objid, t.relname, 'policy ' || quote_ident(x.polname) AS what
720
+ FROM pg_policy x JOIN t ON t.oid = x.polrelid
721
+ UNION ALL
722
+ SELECT 'pg_attrdef'::regclass, x.oid, t.relname, 'column default' FROM pg_attrdef x JOIN t ON t.oid = x.adrelid
723
+ UNION ALL
724
+ SELECT 'pg_constraint'::regclass, x.oid, t.relname, 'constraint ' || quote_ident(x.conname)
725
+ FROM pg_constraint x JOIN t ON t.oid = x.conrelid
726
+ UNION ALL
727
+ SELECT 'pg_trigger'::regclass, x.oid, t.relname, 'trigger ' || quote_ident(x.tgname)
728
+ FROM pg_trigger x JOIN t ON t.oid = x.tgrelid WHERE NOT x.tgisinternal
729
+ UNION ALL
730
+ SELECT 'pg_class'::regclass, x.indexrelid, t.relname, 'index ' || quote_ident(x.indexrelid::regclass::text)
731
+ FROM pg_index x JOIN t ON t.oid = x.indrelid
732
+ )
733
+ SELECT DISTINCT o.relname, o.what,
734
+ CASE WHEN d.refclassid = 'pg_proc'::regclass THEN 'function ' || d.refobjid::regprocedure::text
735
+ ELSE 'operator ' || d.refobjid::regoperator::text END AS ref
736
+ FROM o JOIN pg_depend d ON d.classid = o.classid AND d.objid = o.objid
737
+ LEFT JOIN pg_proc p ON d.refclassid = 'pg_proc'::regclass AND p.oid = d.refobjid
738
+ LEFT JOIN pg_operator op ON d.refclassid = 'pg_operator'::regclass AND op.oid = d.refobjid
739
+ WHERE d.refclassid IN ('pg_proc'::regclass, 'pg_operator'::regclass)
740
+ AND coalesce(p.pronamespace, op.oprnamespace) <> 'pg_catalog'::regnamespace
741
+ AND NOT EXISTS (SELECT 1 FROM pg_depend e
742
+ WHERE e.classid = d.refclassid AND e.objid = d.refobjid AND e.deptype = 'e')
743
+ AND NOT (p.pronamespace = v_ns AND p.proname = ANY (v_functions))
744
+ LOOP
745
+ v_problems := v_problems || format('%s on %I uses %s', r.what, r.relname, r.ref);
746
+ END LOOP;
747
+ FOR r IN
748
+ SELECT c.relname, a.attname, a.atttypid::regtype::text AS typ FROM pg_attribute a
749
+ JOIN pg_class c ON c.oid = a.attrelid JOIN pg_type ty ON ty.oid = a.atttypid
750
+ WHERE c.relnamespace = v_ns AND c.relname = ANY (v_tables) AND a.attnum > 0 AND NOT a.attisdropped
751
+ AND ty.typnamespace <> 'pg_catalog'::regnamespace
752
+ AND NOT EXISTS (SELECT 1 FROM pg_depend e
753
+ WHERE e.classid = 'pg_type'::regclass AND e.deptype = 'e'
754
+ AND e.objid IN (ty.oid, ty.typelem))
755
+ LOOP
756
+ v_problems := v_problems || format('column %I.%I has type %s', r.relname, r.attname, r.typ);
757
+ END LOOP;
758
+ FOR r IN
759
+ SELECT p.oid::regprocedure::text AS fn FROM pg_proc p
760
+ WHERE p.pronamespace = v_ns AND NOT (p.proname = ANY (v_functions))
761
+ AND NOT EXISTS (SELECT 1 FROM pg_depend e
762
+ WHERE e.classid = 'pg_proc'::regclass AND e.objid = p.oid AND e.deptype = 'e')
763
+ AND EXISTS (SELECT 1 FROM pg_proc q
764
+ WHERE q.proname = p.proname AND q.oid <> p.oid
765
+ AND (q.pronamespace = 'pg_catalog'::regnamespace
766
+ OR EXISTS (SELECT 1 FROM pg_depend e
767
+ WHERE e.classid = 'pg_proc'::regclass AND e.objid = q.oid AND e.deptype = 'e')))
768
+ LOOP
769
+ v_problems := v_problems || format('function %s in the schema has the name of a built-in or extension function', r.fn);
770
+ END LOOP;
771
+ FOR r IN
772
+ SELECT o.oid::regoperator::text AS op FROM pg_operator o
773
+ WHERE o.oprnamespace = v_ns
774
+ AND NOT EXISTS (SELECT 1 FROM pg_depend e
775
+ WHERE e.classid = 'pg_operator'::regclass AND e.objid = o.oid AND e.deptype = 'e')
776
+ LOOP
777
+ v_problems := v_problems || format('operator %s in the schema', r.op);
778
+ END LOOP;
779
+ FOR r IN
780
+ SELECT f.name, p.oid IS NOT NULL AS present, md5(p.prosrc) = f.md5 AS same_body, p.proconfig, p.prosecdef
781
+ FROM (VALUES ('update_updated_at_column', '301a884953d37769916294bb60562e05'), ('maintain_ancestry_ltree', 'ddce857b77ffe5dad27239825949c886'), ('propagate_ancestry_ltree', 'a79bc2cb286893cb622c336876491759'), ('stratum_apply_control_role', '78e5309852d1a441403e8d7f743b9446')) AS f(name, md5)
782
+ LEFT JOIN pg_proc p ON p.pronamespace = v_ns AND p.proname = f.name
783
+ LOOP
784
+ IF r.present AND NOT r.same_body THEN
785
+ v_problems := v_problems || format('function %I has a body the migrations did not give it', r.name);
786
+ ELSIF r.present AND r.proconfig IS NOT NULL AND r.proconfig <> ARRAY['search_path=pg_catalog, pg_temp'] THEN
787
+ v_problems := v_problems || format('function %I has settings %s', r.name, r.proconfig::text);
788
+ ELSIF r.present AND r.name = 'stratum_apply_control_role' AND (r.prosecdef OR r.proconfig IS NULL) THEN
789
+ v_problems := v_problems || 'function stratum_apply_control_role is not SECURITY INVOKER with its pinned search_path';
790
+ END IF;
791
+ END LOOP;
792
+ IF pg_catalog.cardinality(v_problems) > 0 THEN
793
+ RAISE WARNING E'Stratum control-role hardening is NOT active in schema "%": the Stratum tables carry objects the Stratum migrations did not create:\n %\nSuch objects run with the rights of whoever writes to the tables. Remove them (or restore the Stratum functions from the migrations), then run `stratum db roles --apply`. Until then, this database keeps the pre-1.8 behavior.',
794
+ v_schema, pg_catalog.array_to_string(v_problems, E'\n ');
795
+ PERFORM pg_catalog.set_config('search_path', v_path, true);
796
+ RETURN;
797
+ END IF;
798
+
799
+ BEGIN
800
+ EXECUTE pg_catalog.format('SELECT %I.stratum_apply_control_role(%L, %L)', v_schema, v_role, v_schema);
801
+ EXCEPTION WHEN insufficient_privilege THEN
802
+ -- Everything the call did is rolled back to here; part 1 stays.
803
+ GET STACKED DIAGNOSTICS v_message = MESSAGE_TEXT;
804
+ RAISE WARNING E'Stratum control-role hardening is NOT active in schema "%": %\nUntil that SQL runs, this database keeps the pre-1.8 behavior.',
805
+ v_schema, v_message;
806
+ END;
807
+ PERFORM pg_catalog.set_config('search_path', v_path, true);
808
+ END
809
+ $control$;