@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.
- package/README.md +29 -2
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +50 -10
- package/dist/crypto.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/migrate-schemas.d.ts +4 -0
- package/dist/migrate-schemas.d.ts.map +1 -1
- package/dist/migrate-schemas.js +16 -6
- package/dist/migrate-schemas.js.map +1 -1
- package/dist/migrate.d.ts +24 -0
- package/dist/migrate.d.ts.map +1 -1
- package/dist/migrate.js +44 -6
- package/dist/migrate.js.map +1 -1
- package/dist/migration-sql.d.ts +25 -0
- package/dist/migration-sql.d.ts.map +1 -0
- package/dist/migration-sql.js +64 -0
- package/dist/migration-sql.js.map +1 -0
- package/dist/migrations/029_tenant_parent_cycle_guard.sql +2 -2
- package/dist/migrations/031_subtree_read_scope.sql +6 -6
- package/dist/migrations/032_control_role.sql +809 -0
- package/dist/migrations/033_api_keys_tenant_index.sql +14 -0
- package/dist/pinned-query.d.ts +29 -0
- package/dist/pinned-query.d.ts.map +1 -0
- package/dist/pinned-query.js +60 -0
- package/dist/pinned-query.js.map +1 -0
- package/dist/pool-helpers.d.ts +2 -0
- package/dist/pool-helpers.d.ts.map +1 -1
- package/dist/pool-helpers.js +17 -3
- package/dist/pool-helpers.js.map +1 -1
- package/dist/role-model.d.ts +178 -0
- package/dist/role-model.d.ts.map +1 -0
- package/dist/role-model.js +670 -0
- package/dist/role-model.js.map +1 -0
- package/dist/services/api-key-service.d.ts +12 -2
- package/dist/services/api-key-service.d.ts.map +1 -1
- package/dist/services/api-key-service.js +33 -9
- package/dist/services/api-key-service.js.map +1 -1
- package/dist/services/config-service.d.ts +15 -6
- package/dist/services/config-service.d.ts.map +1 -1
- package/dist/services/config-service.js +96 -50
- package/dist/services/config-service.js.map +1 -1
- package/dist/services/tenant-service.d.ts.map +1 -1
- package/dist/services/tenant-service.js +4 -4
- package/dist/services/tenant-service.js.map +1 -1
- package/dist/stratum-policies.d.ts +51 -0
- package/dist/stratum-policies.d.ts.map +1 -0
- package/dist/stratum-policies.js +217 -0
- package/dist/stratum-policies.js.map +1 -0
- package/dist/stratum-tables.d.ts.map +1 -1
- package/dist/stratum-tables.js +1 -0
- package/dist/stratum-tables.js.map +1 -1
- package/dist/stratum.d.ts +52 -7
- package/dist/stratum.d.ts.map +1 -1
- package/dist/stratum.js +75 -14
- package/dist/stratum.js.map +1 -1
- 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$;
|