db-firewall 0.0.0-stage → 0.1.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.
@@ -0,0 +1,1402 @@
1
+ -- ============================================================================
2
+ -- DB FIREWALL — PostgreSQL Migration
3
+ -- ============================================================================
4
+ -- Uses native Row-Level Security (RLS). Signed tenant context (the default)
5
+ -- needs PostgreSQL 13+; dbfw.context = 'plain' works on 9.5+.
6
+ --
7
+ -- Signed context: the app sets app.current_tenant AND app.tenant_sig =
8
+ -- hex(HMAC-SHA256(key, _firewall_nonce() || ':' || tenant)). The key lives in
9
+ -- _firewall_keys, which no app role can read, and the nonce changes with every
10
+ -- transaction. So SQL the app (or an AI agent) runs cannot switch tenants:
11
+ -- it can set app.current_tenant, but it cannot sign it. The app gets the key
12
+ -- from DB_FIREWALL_TENANT_KEY (hex). Set the same variable when you run this
13
+ -- script to install your key, or leave it unset to generate one and read it
14
+ -- with: SELECT encode(key, 'hex') FROM db_firewall._firewall_keys;
15
+ --
16
+ -- Tenant column and schema (defaults: tenant_id, public). run_migration()
17
+ -- sets them from your config. With psql, set them in the same session:
18
+ -- psql -c "SET dbfw.tenant_column = 'org_id'" -c "SET dbfw.schema = 'app'" \
19
+ -- -c "SET dbfw.context = 'signed'" -c "SET dbfw.tenant_key = '<hex>'" \
20
+ -- -c "SET dbfw.harden_signals = 'off'" -c "SET dbfw.harden = 'off'" \
21
+ -- -c "SET dbfw.app_role = 'app'" -c "SET dbfw.ddl_guard = 'on'" \
22
+ -- -f migrations/001_postgresql.sql
23
+ -- ============================================================================
24
+
25
+ BEGIN;
26
+
27
+ -- Resolve every name and operator in pg_catalog only, for this transaction. An
28
+ -- app role that can CREATE in public could otherwise plant an operator (say
29
+ -- varchar = text) that the policy predicates below would bind to.
30
+ SET LOCAL search_path = pg_catalog, pg_temp;
31
+ -- The DDL guard (section 11) must not stop this script's own changes.
32
+ SET LOCAL dbfw.ddl_guard_skip = 'on';
33
+
34
+ -- 0. Refuse objects someone else planted. A role that could create schema
35
+ -- db_firewall first, or an object in it, could read the key this script
36
+ -- then writes, or stay the owner of a firewall function through CREATE OR
37
+ -- REPLACE. Schema db_firewall and every object in it, and every
38
+ -- public._firewall_* object (an earlier version kept them there), must
39
+ -- belong to the role running this script or to a superuser. The same block runs
40
+ -- again as the last statement: a role could plant one while this script
41
+ -- waits on a lock (say, in section 6). Two DO blocks, not a pg_temp
42
+ -- function, so the script needs no TEMP privilege.
43
+ DO $$
44
+ DECLARE
45
+ bad TEXT;
46
+ fw OID := to_regnamespace('db_firewall');
47
+ BEGIN
48
+ SELECT string_agg(o.name || ' (owner ' || pg_get_userbyid(o.owner) || ')', ', ') INTO bad
49
+ FROM (SELECT 'schema db_firewall' AS name, n.nspowner AS owner FROM pg_namespace n WHERE n.oid = fw
50
+ UNION ALL
51
+ SELECT c.oid::regclass::text, c.relowner FROM pg_class c
52
+ WHERE c.relnamespace = fw
53
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#')
54
+ UNION ALL
55
+ SELECT p.oid::regprocedure::text, p.proowner FROM pg_proc p
56
+ WHERE p.pronamespace = fw
57
+ OR (p.pronamespace = 'public'::regnamespace AND p.proname LIKE '#_firewall#_%' ESCAPE '#')
58
+ UNION ALL
59
+ SELECT t.oid::regtype::text, t.typowner FROM pg_type t
60
+ WHERE (t.typnamespace = fw
61
+ OR (t.typnamespace = 'public'::regnamespace AND t.typname LIKE '#_firewall#_%' ESCAPE '#'))
62
+ AND t.typrelid = 0
63
+ UNION ALL
64
+ SELECT o.oid::regoperator::text, o.oprowner FROM pg_operator o WHERE o.oprnamespace = fw) o
65
+ JOIN pg_roles r ON r.oid = o.owner
66
+ WHERE r.rolname <> current_user AND NOT r.rolsuper;
67
+ IF bad IS NOT NULL THEN
68
+ RAISE EXCEPTION 'DB Firewall: % not owned by % or a superuser. Another role may have planted them: drop them, or ALTER ... OWNER TO a trusted role, and re-run.', bad, current_user;
69
+ END IF;
70
+ -- And code or row rules on the firewall's own tables: a trigger there runs
71
+ -- for whoever writes the table (_firewall_set_tenant writes the audit log
72
+ -- as its owner). The firewall makes none; an earlier version let the app
73
+ -- have TRIGGER on the audit log.
74
+ SELECT string_agg(h, ', ') INTO bad FROM (
75
+ SELECT format('trigger %I on %s', t.tgname, t.tgrelid::regclass) AS h
76
+ FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid
77
+ WHERE NOT t.tgisinternal AND (c.relnamespace = fw
78
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#'))
79
+ UNION ALL
80
+ SELECT format('rule %I on %s', w.rulename, w.ev_class::regclass)
81
+ FROM pg_rewrite w JOIN pg_class c ON c.oid = w.ev_class
82
+ WHERE w.rulename <> '_RETURN' AND (c.relnamespace = fw
83
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#'))
84
+ UNION ALL
85
+ SELECT format('foreign key %I from %s', k.conname, k.conrelid::regclass) -- it can block the audit purge
86
+ FROM pg_constraint k JOIN pg_class c ON c.oid = k.confrelid
87
+ WHERE k.contype = 'f' AND (c.relnamespace = fw
88
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#'))
89
+ UNION ALL
90
+ SELECT format('policy %I on %s', y.polname, y.polrelid::regclass)
91
+ FROM pg_policy y JOIN pg_class c ON c.oid = y.polrelid
92
+ WHERE c.relnamespace = fw
93
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#')) z;
94
+ IF bad IS NOT NULL THEN
95
+ RAISE EXCEPTION 'DB Firewall: % on the firewall''s own tables: the firewall makes none, and they run for whoever writes there (or block what it deletes). Drop them, then re-run.', bad;
96
+ END IF;
97
+ END;
98
+ $$;
99
+
100
+ -- 0b. The firewall's own schema. The policies and the firewall's functions
101
+ -- call its functions by name: a role that could add one there (an
102
+ -- overload with a default argument, say) could make every call
103
+ -- ambiguous, so only the owner creates in it. Every role uses it: the
104
+ -- policies run its functions as the role that reads the table.
105
+ DO $$
106
+ DECLARE
107
+ r RECORD;
108
+ bad TEXT;
109
+ made BOOLEAN := false;
110
+ owners CONSTANT TEXT := $q$SELECT string_agg(o.name || ' (owner ' || pg_get_userbyid(o.owner) || ')', ', ')
111
+ FROM (SELECT 'schema db_firewall' AS name, n.nspowner AS owner FROM pg_namespace n WHERE n.nspname = 'db_firewall'
112
+ UNION ALL
113
+ SELECT c.oid::regclass::text, c.relowner FROM pg_class c
114
+ WHERE c.relnamespace = 'db_firewall'::regnamespace
115
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#')
116
+ UNION ALL
117
+ SELECT p.oid::regprocedure::text, p.proowner FROM pg_proc p
118
+ WHERE p.pronamespace = 'db_firewall'::regnamespace
119
+ OR (p.pronamespace = 'public'::regnamespace AND p.proname LIKE '#_firewall#_%' ESCAPE '#')
120
+ UNION ALL
121
+ SELECT t.oid::regtype::text, t.typowner FROM pg_type t
122
+ WHERE (t.typnamespace = 'db_firewall'::regnamespace
123
+ OR (t.typnamespace = 'public'::regnamespace AND t.typname LIKE '#_firewall#_%' ESCAPE '#'))
124
+ AND t.typrelid = 0
125
+ UNION ALL
126
+ SELECT o.oid::regoperator::text, o.oprowner FROM pg_operator o WHERE o.oprnamespace = 'db_firewall'::regnamespace) o
127
+ JOIN pg_roles x ON x.oid = o.owner
128
+ WHERE x.rolname <> current_user AND NOT x.rolsuper$q$;
129
+ hooks CONSTANT TEXT := $q$SELECT string_agg(h, ', ') FROM (
130
+ SELECT format('trigger %I on %s', t.tgname, t.tgrelid::regclass) AS h
131
+ FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid
132
+ WHERE NOT t.tgisinternal AND (c.relnamespace = 'db_firewall'::regnamespace
133
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#'))
134
+ UNION ALL
135
+ SELECT format('rule %I on %s', w.rulename, w.ev_class::regclass)
136
+ FROM pg_rewrite w JOIN pg_class c ON c.oid = w.ev_class
137
+ WHERE w.rulename <> '_RETURN' AND (c.relnamespace = 'db_firewall'::regnamespace
138
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#'))
139
+ UNION ALL
140
+ SELECT format('foreign key %I from %s', k.conname, k.conrelid::regclass)
141
+ FROM pg_constraint k JOIN pg_class c ON c.oid = k.confrelid
142
+ WHERE k.contype = 'f' AND (c.relnamespace = 'db_firewall'::regnamespace
143
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#'))
144
+ UNION ALL
145
+ SELECT format('policy %I on %s', y.polname, y.polrelid::regclass)
146
+ FROM pg_policy y JOIN pg_class c ON c.oid = y.polrelid
147
+ WHERE c.relnamespace = 'db_firewall'::regnamespace
148
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#')) z$q$;
149
+ BEGIN
150
+ -- Only this block sets it (a value from ALTER ROLE/DATABASE ... SET, or
151
+ -- PGOPTIONS, must not reach the restore block before section 6).
152
+ PERFORM set_config('dbfw.guard_restore', '', true);
153
+ IF current_setting('dbfw.schema', true) = 'db_firewall' THEN
154
+ RAISE EXCEPTION 'DB Firewall: dbfw.schema must not be db_firewall, the firewall''s own schema';
155
+ END IF;
156
+ -- No IF NOT EXISTS: a schema another role made after section 0 checked
157
+ -- makes this fail, instead of being used. One made here stays out of other
158
+ -- sessions' sight until COMMIT, so nobody else can put anything in it.
159
+ IF to_regnamespace('db_firewall') IS NULL THEN
160
+ CREATE SCHEMA db_firewall;
161
+ made := true;
162
+ END IF;
163
+ -- Section 0's check again, now that the schema exists, before anything is
164
+ -- written or moved (a schema or object made after section 0 ran), and
165
+ -- again after the move below (an object planted in public meanwhile).
166
+ EXECUTE owners INTO bad;
167
+ IF bad IS NOT NULL THEN
168
+ RAISE EXCEPTION 'DB Firewall: % not owned by % or a superuser. Another role may have planted them: drop them, or ALTER ... OWNER TO a trusted role, and re-run.', bad, current_user;
169
+ END IF;
170
+ EXECUTE hooks INTO bad;
171
+ IF bad IS NOT NULL THEN
172
+ RAISE EXCEPTION 'DB Firewall: % on the firewall''s own tables: the firewall makes none, and they run for whoever writes there (or block what it deletes). Drop them, then re-run.', bad;
173
+ END IF;
174
+ -- Who else can CREATE in it. In a schema made here, revoke (default
175
+ -- privileges may have granted it): nobody else sees the schema before
176
+ -- COMMIT. In one that existed, refuse: a REVOKE here would only take
177
+ -- effect at COMMIT, and until then that role could still add an object
178
+ -- this script then uses.
179
+ IF made THEN
180
+ FOR r IN
181
+ SELECT a.grantee FROM pg_namespace n, aclexplode(n.nspacl) a
182
+ WHERE n.nspname = 'db_firewall' AND a.privilege_type = 'CREATE' AND a.grantee <> n.nspowner
183
+ LOOP
184
+ EXECUTE format('REVOKE CREATE ON SCHEMA db_firewall FROM %s CASCADE',
185
+ CASE r.grantee WHEN 0 THEN 'PUBLIC' ELSE quote_ident(pg_get_userbyid(r.grantee)) END);
186
+ END LOOP;
187
+ END IF;
188
+ IF EXISTS (SELECT 1 FROM pg_roles x, pg_namespace n WHERE n.nspname = 'db_firewall' AND NOT x.rolsuper
189
+ AND NOT pg_has_role(x.oid, n.nspowner, 'MEMBER') AND has_schema_privilege(x.oid, n.oid, 'CREATE')) THEN
190
+ RAISE EXCEPTION 'DB Firewall: other roles can CREATE in schema db_firewall (%): REVOKE CREATE ON SCHEMA db_firewall FROM them (and from PUBLIC), then re-run',
191
+ (SELECT string_agg(x.rolname, ', ') FROM pg_roles x, pg_namespace n WHERE n.nspname = 'db_firewall'
192
+ AND NOT x.rolsuper AND NOT pg_has_role(x.oid, n.nspowner, 'MEMBER')
193
+ AND has_schema_privilege(x.oid, n.oid, 'CREATE'));
194
+ END IF;
195
+ GRANT USAGE ON SCHEMA db_firewall TO PUBLIC;
196
+ -- An earlier version's DDL guard reads the public tables this block moves,
197
+ -- so it would fail on the move: pause it until section 5b has replaced its
198
+ -- functions (the block before section 6 sets it back as it was).
199
+ IF EXISTS (SELECT 1 FROM pg_event_trigger WHERE evtname IN ('_firewall_ddl_guard', '_firewall_ddl_drop_guard'))
200
+ AND (EXISTS (SELECT 1 FROM pg_class c WHERE c.relnamespace = 'public'::regnamespace
201
+ AND c.relname LIKE '#_firewall#_%' ESCAPE '#' AND c.relkind IN ('r', 'p'))
202
+ OR EXISTS (SELECT 1 FROM pg_proc p WHERE p.pronamespace = 'public'::regnamespace
203
+ AND p.proname LIKE '#_firewall#_%' ESCAPE '#'
204
+ AND p.prosrc IS DISTINCT FROM format('BEGIN RAISE EXCEPTION ''DB Firewall: %s moved to schema db_firewall: upgrade the app''; END', p.proname))) THEN
205
+ IF NOT (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) THEN
206
+ RAISE EXCEPTION 'DB Firewall: the DDL guard is installed: run this upgrade (it moves the firewall to schema db_firewall) as a superuser';
207
+ END IF;
208
+ PERFORM set_config('dbfw.guard_restore',
209
+ (SELECT string_agg(evtname || '=' || evtenabled::text, ',') FROM pg_event_trigger
210
+ WHERE evtname IN ('_firewall_ddl_guard', '_firewall_ddl_drop_guard')), true);
211
+ FOR r IN SELECT evtname FROM pg_event_trigger WHERE evtname IN ('_firewall_ddl_guard', '_firewall_ddl_drop_guard') LOOP
212
+ EXECUTE format('ALTER EVENT TRIGGER %I DISABLE', r.evtname);
213
+ END LOOP;
214
+ END IF;
215
+ -- Move what an earlier version made in public. SET SCHEMA keeps each
216
+ -- object, so the policies, event triggers and rows that use it stay. The
217
+ -- placeholders section 7b leaves in public (views, and functions that
218
+ -- raise) stay where they are.
219
+ FOR r IN
220
+ SELECT c.relname::text AS name, c.relowner FROM pg_class c
221
+ WHERE c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#'
222
+ AND c.relkind IN ('r', 'p')
223
+ LOOP
224
+ IF NOT (pg_get_userbyid(r.relowner) = current_user OR (SELECT rolsuper FROM pg_roles WHERE oid = r.relowner)) THEN
225
+ RAISE EXCEPTION 'DB Firewall: public.% (owner %) not owned by % or a superuser: another role may have planted it', r.name, pg_get_userbyid(r.relowner), current_user;
226
+ END IF;
227
+ IF to_regclass(format('db_firewall.%I', r.name)) IS NOT NULL THEN
228
+ RAISE EXCEPTION 'DB Firewall: both public.% and db_firewall.% exist: keep one (db_firewall), then re-run', r.name, r.name;
229
+ END IF;
230
+ EXECUTE format('ALTER TABLE public.%I SET SCHEMA db_firewall', r.name);
231
+ END LOOP;
232
+ FOR r IN
233
+ SELECT p.oid::regprocedure::text AS sig, p.proname::text AS name, oidvectortypes(p.proargtypes) AS args, p.proowner
234
+ FROM pg_proc p
235
+ WHERE p.pronamespace = 'public'::regnamespace AND p.proname LIKE '#_firewall#_%' ESCAPE '#'
236
+ AND p.prosrc IS DISTINCT FROM format('BEGIN RAISE EXCEPTION ''DB Firewall: %s moved to schema db_firewall: upgrade the app''; END', p.proname) -- not a placeholder (7b)
237
+ LOOP
238
+ IF NOT (pg_get_userbyid(r.proowner) = current_user OR (SELECT rolsuper FROM pg_roles WHERE oid = r.proowner)) THEN
239
+ RAISE EXCEPTION 'DB Firewall: % (owner %) not owned by % or a superuser: another role may have planted it', r.sig, pg_get_userbyid(r.proowner), current_user;
240
+ END IF;
241
+ IF to_regprocedure(format('db_firewall.%I(%s)', r.name, r.args)) IS NOT NULL THEN
242
+ RAISE EXCEPTION 'DB Firewall: both % and its copy in db_firewall exist: drop the public one, then re-run', r.sig;
243
+ END IF;
244
+ EXECUTE format('ALTER ROUTINE %s SET SCHEMA db_firewall', r.sig);
245
+ END LOOP;
246
+ -- What was moved is now in db_firewall, under this transaction's locks.
247
+ EXECUTE owners INTO bad;
248
+ IF bad IS NOT NULL THEN
249
+ RAISE EXCEPTION 'DB Firewall: % not owned by % or a superuser. Another role may have planted them: drop them, or ALTER ... OWNER TO a trusted role, and re-run.', bad, current_user;
250
+ END IF;
251
+ EXECUTE hooks INTO bad;
252
+ IF bad IS NOT NULL THEN
253
+ RAISE EXCEPTION 'DB Firewall: % on the firewall''s own tables: the firewall makes none, and they run for whoever writes there (or block what it deletes). Drop them, then re-run.', bad;
254
+ END IF;
255
+ END;
256
+ $$;
257
+
258
+ -- 1. Tenant mapping table
259
+ CREATE TABLE IF NOT EXISTS db_firewall._firewall_tenant_map (
260
+ db_user TEXT PRIMARY KEY,
261
+ tenant_id TEXT NOT NULL,
262
+ is_active BOOLEAN DEFAULT TRUE,
263
+ created_at TIMESTAMPTZ DEFAULT NOW(),
264
+ updated_at TIMESTAMPTZ DEFAULT NOW()
265
+ );
266
+
267
+ -- 2. Audit log
268
+ CREATE TABLE IF NOT EXISTS db_firewall._firewall_audit_log (
269
+ id BIGSERIAL PRIMARY KEY,
270
+ session_pid INTEGER NOT NULL DEFAULT pg_backend_pid(),
271
+ db_user TEXT NOT NULL DEFAULT current_user,
272
+ client_addr INET,
273
+ tenant_id TEXT NOT NULL,
274
+ assigned_at TIMESTAMPTZ DEFAULT NOW(),
275
+ auth_method TEXT DEFAULT 'auto',
276
+ application TEXT DEFAULT current_setting('application_name', true)
277
+ );
278
+
279
+ CREATE INDEX IF NOT EXISTS idx_firewall_audit_tenant
280
+ ON db_firewall._firewall_audit_log (tenant_id, assigned_at);
281
+
282
+ -- 2b. Bypass roles — EMPTY by default. No role is hardcoded: on Supabase,
283
+ -- Neon, RDS etc. the app itself connects as "postgres", so a built-in
284
+ -- bypass would disable every policy. Opt a role in deliberately:
285
+ -- INSERT INTO _firewall_bypass_roles VALUES ('_migrator');
286
+ -- Superusers and BYPASSRLS roles skip RLS regardless of this table.
287
+ CREATE TABLE IF NOT EXISTS db_firewall._firewall_bypass_roles (
288
+ role_name TEXT PRIMARY KEY,
289
+ created_at TIMESTAMPTZ DEFAULT NOW()
290
+ );
291
+
292
+ -- 2c. Signing key for the tenant context. One row. No app role may read it:
293
+ -- see the REVOKE block at the end. ipad/opad are the HMAC-SHA256 inner and
294
+ -- outer key blocks, so policies need only the built-in sha256().
295
+ CREATE TABLE IF NOT EXISTS db_firewall._firewall_keys (
296
+ id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id),
297
+ key BYTEA NOT NULL,
298
+ ipad BYTEA NOT NULL,
299
+ opad BYTEA NOT NULL,
300
+ created_at TIMESTAMPTZ DEFAULT NOW()
301
+ );
302
+
303
+ -- Install dbfw.tenant_key (hex, 32 to 64 bytes) when given, which also
304
+ -- rotates it; otherwise keep the key, or generate one on the first run.
305
+ DO $$
306
+ DECLARE
307
+ hex TEXT := NULLIF(current_setting('dbfw.tenant_key', true), '');
308
+ k BYTEA;
309
+ ip BYTEA := decode(repeat('36', 64), 'hex');
310
+ op BYTEA := decode(repeat('5c', 64), 'hex');
311
+ BEGIN
312
+ IF hex IS NOT NULL THEN
313
+ IF hex !~ '^([0-9a-fA-F]{2}){32,64}$' THEN
314
+ RAISE EXCEPTION 'DB Firewall: dbfw.tenant_key must be 32 to 64 bytes, hex-encoded';
315
+ END IF;
316
+ k := decode(hex, 'hex');
317
+ ELSIF EXISTS (SELECT 1 FROM db_firewall._firewall_keys) THEN
318
+ RETURN;
319
+ ELSE
320
+ -- 48 bytes, 366 random bits (gen_random_uuid uses the strong RNG).
321
+ k := decode(replace(gen_random_uuid()::text || gen_random_uuid()::text
322
+ || gen_random_uuid()::text, '-', ''), 'hex');
323
+ END IF;
324
+ FOR i IN 0 .. length(k) - 1 LOOP
325
+ ip := set_byte(ip, i, get_byte(k, i) # 54); -- 0x36
326
+ op := set_byte(op, i, get_byte(k, i) # 92); -- 0x5c
327
+ END LOOP;
328
+ INSERT INTO db_firewall._firewall_keys (id, key, ipad, opad) VALUES (TRUE, k, ip, op)
329
+ ON CONFLICT (id) DO UPDATE SET key = EXCLUDED.key, ipad = EXCLUDED.ipad,
330
+ opad = EXCLUDED.opad, created_at = NOW();
331
+ -- The key must not stay readable in this session.
332
+ PERFORM set_config('dbfw.tenant_key', '', false);
333
+ END;
334
+ $$;
335
+
336
+ -- SECURITY DEFINER so policies can consult the table without granting
337
+ -- SELECT on it to application roles.
338
+ -- The caller passes current_user: inside SECURITY DEFINER it would resolve
339
+ -- to the function owner.
340
+ -- Every SECURITY DEFINER function below pins search_path (pg_temp LAST) and
341
+ -- schema-qualifies its tables: otherwise any caller could CREATE TEMP TABLE
342
+ -- _firewall_bypass_roles listing itself, put pg_temp first in search_path,
343
+ -- and switch every RLS policy off. It must stay executable by PUBLIC because
344
+ -- policies evaluate it as the calling role.
345
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_is_bypass_role(p_role TEXT)
346
+ RETURNS BOOLEAN AS $$
347
+ SELECT EXISTS (SELECT 1 FROM db_firewall._firewall_bypass_roles WHERE role_name = p_role);
348
+ $$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, pg_temp;
349
+
350
+ -- 2d. Signed tenant context.
351
+ -- The nonce: this backend and this transaction's start. A signature is valid in
352
+ -- one transaction, or in several started within one query string (they share
353
+ -- the start time); the app signs in its own statement after BEGIN, so SQL it
354
+ -- runs later cannot reuse a signature in a new transaction.
355
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_nonce()
356
+ RETURNS TEXT AS $$
357
+ SELECT pg_backend_pid()::text || ':'
358
+ || (extract(epoch FROM transaction_timestamp()) * 1000000)::bigint::text;
359
+ $$ LANGUAGE sql STABLE SET search_path = pg_catalog, pg_temp;
360
+
361
+ -- HMAC-SHA256(key, nonce || ':' || tenant), hex. No app role may execute it.
362
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_sign(p_tenant TEXT)
363
+ RETURNS TEXT AS $$
364
+ SELECT encode(sha256(k.opad || sha256(k.ipad
365
+ || convert_to(db_firewall._firewall_nonce() || ':' || p_tenant, 'UTF8'))), 'hex')
366
+ FROM db_firewall._firewall_keys k;
367
+ $$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, pg_temp;
368
+
369
+ -- The tenant the policies use: app.current_tenant when app.tenant_sig signs
370
+ -- it for this transaction, else NULL (no rows). Hashing both sides before the
371
+ -- comparison keeps its timing independent of the signature.
372
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_tenant()
373
+ RETURNS TEXT AS $$
374
+ SELECT g.t FROM (SELECT NULLIF(current_setting('app.current_tenant', true), '') AS t,
375
+ current_setting('app.tenant_sig', true) AS s) g
376
+ WHERE sha256(convert_to(g.s, 'UTF8')) = sha256(convert_to(db_firewall._firewall_sign(g.t), 'UTF8'));
377
+ $$ LANGUAGE sql STABLE SECURITY DEFINER SET search_path = pg_catalog, pg_temp;
378
+
379
+ -- 2e. Session reset: withTenant (TypeScript) runs it after each transaction,
380
+ -- pool_checkin / release() (Python) on return. SQL run in a tenant's
381
+ -- transaction can leave session state that the connection's next user
382
+ -- (another tenant) runs into: a temp view or function that shadows a
383
+ -- table, a SQL-level prepared statement under a driver's statement name,
384
+ -- a custom setting used as a drop box, a holdable cursor, a LISTEN, an
385
+ -- advisory lock (its keys can carry row data), currval/lastval. This
386
+ -- clears them all. Prepared statements the driver made (protocol level,
387
+ -- from_sql false) stay, so driver caches stay valid. RESET ALL also clears
388
+ -- custom settings, which pg_settings cannot list. p_keep is the app's own
389
+ -- session settings and role from before (_firewall_session_settings()),
390
+ -- restored afterwards. No SECURITY DEFINER and no SET clause (a SET clause
391
+ -- would put back the search_path it resets), so every name is qualified.
392
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_reset_session(p_keep JSONB)
393
+ RETURNS VOID AS $$
394
+ DECLARE
395
+ r RECORD;
396
+ BEGIN
397
+ EXECUTE 'DISCARD TEMP';
398
+ EXECUTE 'DISCARD SEQUENCES'; -- currval/lastval: the last tenant's ids
399
+ EXECUTE 'CLOSE ALL';
400
+ EXECUTE 'UNLISTEN *';
401
+ PERFORM pg_catalog.pg_advisory_unlock_all();
402
+ FOR r IN SELECT s.name FROM pg_catalog.pg_prepared_statements s WHERE s.from_sql LOOP
403
+ EXECUTE pg_catalog.format('DEALLOCATE %I', r.name);
404
+ END LOOP;
405
+ EXECUTE 'RESET ALL';
406
+ FOR r IN SELECT k.key, k.value FROM pg_catalog.jsonb_each_text(p_keep) k LOOP
407
+ PERFORM pg_catalog.set_config(r.key, r.value, false);
408
+ END LOOP;
409
+ END;
410
+ $$ LANGUAGE plpgsql;
411
+
412
+ -- The settings set at session level (standard ones; pg_settings lists no
413
+ -- custom ones; not transaction_*, which only a transaction's start may set)
414
+ -- and the role: what _firewall_reset_session restores. No SET
415
+ -- clause: inside the function it would show as a session-level search_path.
416
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_session_settings()
417
+ RETURNS JSONB AS $$
418
+ SELECT COALESCE(pg_catalog.jsonb_object_agg(s.name, s.setting)
419
+ FILTER (WHERE s.source OPERATOR(pg_catalog.=) 'session'
420
+ AND NOT pg_catalog.starts_with(s.name, 'transaction_')), '{}'::pg_catalog.jsonb)
421
+ OPERATOR(pg_catalog.||) pg_catalog.jsonb_build_object('role', pg_catalog.current_setting('role'))
422
+ FROM pg_catalog.pg_settings s;
423
+ $$ LANGUAGE sql STABLE;
424
+
425
+ -- 3. Session variable helper functions
426
+ -- NOTE: _firewall_set_tenant is SESSION-scoped (manual/DBA use and the
427
+ -- per-DB-user auto-resolve). The Python middleware sets the GUC
428
+ -- transaction-locally instead; pool_checkin()/release() clear this one.
429
+ -- It signs the tenant, so it is DBA-only: no app role may execute it
430
+ -- (REVOKE block at the end). The signature holds for this transaction.
431
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_set_tenant(p_tenant TEXT)
432
+ RETURNS VOID AS $$
433
+ BEGIN
434
+ PERFORM set_config('app.current_tenant', p_tenant, false);
435
+ PERFORM set_config('app.tenant_sig', COALESCE(db_firewall._firewall_sign(p_tenant), ''), false);
436
+ INSERT INTO db_firewall._firewall_audit_log (client_addr, tenant_id, auth_method)
437
+ VALUES (inet_client_addr(), p_tenant, 'set_tenant');
438
+ END;
439
+ $$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp;
440
+
441
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_get_tenant()
442
+ RETURNS TEXT AS $$
443
+ BEGIN
444
+ RETURN NULLIF(current_setting('app.current_tenant', true), '');
445
+ END;
446
+ $$ LANGUAGE plpgsql STABLE;
447
+
448
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_clear_tenant()
449
+ RETURNS VOID AS $$
450
+ BEGIN
451
+ PERFORM set_config('app.current_tenant', '', false);
452
+ PERFORM set_config('app.tenant_sig', '', false);
453
+ END;
454
+ $$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp;
455
+
456
+ -- 4. Auto-resolve tenant from user mapping
457
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_auto_resolve()
458
+ RETURNS TEXT AS $$
459
+ DECLARE
460
+ v_tenant TEXT;
461
+ BEGIN
462
+ -- Check if already set
463
+ v_tenant := db_firewall._firewall_get_tenant();
464
+ IF v_tenant IS NOT NULL AND v_tenant != '' THEN
465
+ RETURN v_tenant;
466
+ END IF;
467
+
468
+ -- Resolve from mapping table
469
+ SELECT tenant_id INTO v_tenant
470
+ FROM db_firewall._firewall_tenant_map
471
+ WHERE db_user = session_user AND is_active = TRUE; -- current_user is the owner here
472
+
473
+ IF v_tenant IS NOT NULL THEN
474
+ PERFORM db_firewall._firewall_set_tenant(v_tenant);
475
+ END IF;
476
+
477
+ RETURN v_tenant;
478
+ END;
479
+ $$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp;
480
+
481
+ -- 5. Universal RLS policy function
482
+ -- This is applied to every protected table.
483
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_tenant_check(table_name TEXT DEFAULT '')
484
+ RETURNS BOOLEAN AS $$
485
+ DECLARE
486
+ v_tenant TEXT;
487
+ v_user TEXT := current_user;
488
+ BEGIN
489
+ -- Bypass only for roles deliberately listed in _firewall_bypass_roles
490
+ IF db_firewall._firewall_is_bypass_role(v_user) THEN
491
+ RETURN TRUE;
492
+ END IF;
493
+
494
+ -- Auto-resolve tenant
495
+ v_tenant := db_firewall._firewall_auto_resolve();
496
+
497
+ -- FAIL CLOSED: no tenant = deny all
498
+ IF v_tenant IS NULL OR v_tenant = '' THEN
499
+ RETURN FALSE;
500
+ END IF;
501
+
502
+ RETURN TRUE;
503
+ END;
504
+ $$ LANGUAGE plpgsql STABLE;
505
+
506
+
507
+ -- 5b. Protecting one table, the record of what this script protects, and the
508
+ -- DDL guard (installed by section 11 when dbfw.ddl_guard = on).
509
+ --
510
+ -- _firewall_protected_schemas: the schemas this script protects, with the
511
+ -- tenant column, policy mode and context it used, so the guard protects a
512
+ -- later table the same way. Only its owner reads or writes it (section 8).
513
+ CREATE TABLE IF NOT EXISTS db_firewall._firewall_protected_schemas (
514
+ schema_name TEXT PRIMARY KEY,
515
+ tenant_column TEXT NOT NULL,
516
+ policy_mode TEXT NOT NULL,
517
+ context TEXT NOT NULL,
518
+ updated_at TIMESTAMPTZ DEFAULT now()
519
+ );
520
+
521
+ -- _firewall_guard_calls: the guard's own calls in progress (see the guard).
522
+ -- Only its owner reads or writes it (section 8): a role that could add a row
523
+ -- could skip the guard.
524
+ CREATE TABLE IF NOT EXISTS db_firewall._firewall_guard_calls (pid INT NOT NULL, call_id TEXT NOT NULL);
525
+
526
+ -- RLS on and forced, TRIGGER and TRUNCATE revoked from all but the owner, and
527
+ -- the four firewall policies (dropped and created again). Section 6 calls it
528
+ -- for every table; the guard for a new one. The caller must own the table.
529
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_protect(p_schema TEXT, p_table TEXT, p_column TEXT, p_mode TEXT, p_context TEXT)
530
+ RETURNS VOID AS $$
531
+ DECLARE
532
+ g RECORD;
533
+ rel REGCLASS := format('%I.%I', p_schema, p_table)::regclass;
534
+ -- Policy names are capped at 63 bytes. 'firewall_rls_' || table || '_sel'
535
+ -- overflows for table names > 46 chars and the truncated names collide
536
+ -- (CREATE POLICY ... already exists). A hash stem is short and unique.
537
+ stem TEXT := 'fw_' || left(md5(p_schema || '.' || p_table), 12);
538
+ legacy TEXT := 'firewall_rls_' || p_table;
539
+ legacies TEXT[] := ARRAY[]::TEXT[];
540
+ n TEXT;
541
+ kind TEXT := CASE p_mode WHEN 'restrictive' THEN 'AS RESTRICTIVE' ELSE 'AS PERMISSIVE' END;
542
+ pred TEXT;
543
+ typ OID;
544
+ coll OID;
545
+ BEGIN
546
+ IF p_mode NOT IN ('permissive', 'restrictive') THEN
547
+ RAISE EXCEPTION 'DB Firewall: dbfw.policy_mode must be permissive or restrictive, got %', p_mode;
548
+ END IF;
549
+ IF p_context NOT IN ('signed', 'plain') THEN
550
+ RAISE EXCEPTION 'DB Firewall: dbfw.context must be signed or plain, got %', p_context;
551
+ END IF;
552
+ -- The tenant column's type (through any domain) must belong to a
553
+ -- superuser, and its collation must be deterministic: the policies call
554
+ -- the type's cast and equality, and a type another role created can
555
+ -- carry a cast of theirs (it runs in every tenant's session and can
556
+ -- return any tenant); a collation that is not deterministic makes two
557
+ -- tenant ids equal.
558
+ SELECT a.atttypid, a.attcollation INTO typ, coll FROM pg_attribute a
559
+ WHERE a.attrelid = rel AND a.attname = p_column AND a.attnum > 0 AND NOT a.attisdropped;
560
+ WHILE (SELECT typtype FROM pg_type WHERE oid = typ) = 'd' LOOP
561
+ typ := (SELECT typbasetype FROM pg_type WHERE oid = typ);
562
+ END LOOP;
563
+ IF NOT coalesce((SELECT r.rolsuper FROM pg_type y JOIN pg_roles r ON r.oid = y.typowner WHERE y.oid = typ), true)
564
+ OR NOT coalesce((SELECT collisdeterministic FROM pg_collation WHERE oid = coll), true) THEN
565
+ RAISE EXCEPTION 'DB Firewall: the tenant column %.%.% has the type % and the collation %: it needs a built-in or extension type and a deterministic collation',
566
+ p_schema, p_table, p_column, typ::regtype, coalesce(coll::regcollation::text, '-')
567
+ USING HINT = 'Use text or varchar for the tenant column.';
568
+ END IF;
569
+
570
+ -- (SELECT f()) runs once per query (an InitPlan), not once per row.
571
+ pred := CASE p_context
572
+ WHEN 'signed' THEN format('%I = (SELECT db_firewall._firewall_tenant())
573
+ OR (SELECT db_firewall._firewall_is_bypass_role(current_user::text))', p_column)
574
+ ELSE format('%I = NULLIF(current_setting(''app.current_tenant'', true), '''')
575
+ OR db_firewall._firewall_is_bypass_role(current_user::text)', p_column) END;
576
+
577
+ -- Enable RLS on the table, and force it even for table owners
578
+ EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', rel);
579
+ EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', rel);
580
+
581
+ -- RLS does not cover TRUNCATE (it empties every tenant's rows) or what
582
+ -- a trigger does: with TRIGGER, a role attaches its own function (even
583
+ -- one in pg_temp) that runs in other tenants' sessions as them. Only
584
+ -- the owner keeps either.
585
+ FOR g IN
586
+ SELECT a.grantee, a.privilege_type FROM pg_class c, aclexplode(c.relacl) a
587
+ WHERE c.oid = rel AND a.grantee <> c.relowner AND a.privilege_type IN ('TRIGGER', 'TRUNCATE')
588
+ LOOP
589
+ EXECUTE format('REVOKE %s ON %s FROM %s CASCADE', g.privilege_type, rel,
590
+ CASE g.grantee WHEN 0 THEN 'PUBLIC' ELSE quote_ident(pg_get_userbyid(g.grantee)) END);
591
+ END LOOP;
592
+
593
+ -- Drop existing policies if re-running (idempotent). Permissive
594
+ -- policies OR together, so every earlier generation must go: the
595
+ -- legacy single policy and the legacy per-command policies hardcoded
596
+ -- a postgres/admin/_migrator bypass that would otherwise survive an
597
+ -- upgrade, and the firewall policies of an earlier name of the table (a
598
+ -- rename changes the stem). CREATE POLICY cut over-long legacy names to
599
+ -- 63 bytes: compare them cut the same way.
600
+ FOREACH n IN ARRAY ARRAY[legacy, legacy || '_sel', legacy || '_ins', legacy || '_upd', legacy || '_del'] LOOP
601
+ WHILE octet_length(n) > 63 LOOP
602
+ n := left(n, -1);
603
+ END LOOP;
604
+ legacies := legacies || n;
605
+ END LOOP;
606
+ FOR g IN
607
+ SELECT polname FROM pg_policy
608
+ WHERE polrelid = rel AND (polname = ANY (legacies) OR polname ~ '^fw_[0-9a-f]{12}_(sel|ins|upd|del|base)$')
609
+ LOOP
610
+ EXECUTE format('DROP POLICY %I ON %s', g.polname, rel);
611
+ END LOOP;
612
+
613
+ EXECUTE format('CREATE POLICY %I ON %s %s FOR SELECT USING (%s)', stem || '_sel', rel, kind, pred);
614
+ -- INSERT: new rows must carry the current tenant
615
+ EXECUTE format('CREATE POLICY %I ON %s %s FOR INSERT WITH CHECK (%s)', stem || '_ins', rel, kind, pred);
616
+ EXECUTE format('CREATE POLICY %I ON %s %s FOR UPDATE USING (%s) WITH CHECK (%s)', stem || '_upd', rel, kind, pred, pred);
617
+ EXECUTE format('CREATE POLICY %I ON %s %s FOR DELETE USING (%s)', stem || '_del', rel, kind, pred);
618
+
619
+ -- Restrictive policies only narrow: PostgreSQL also needs a permissive
620
+ -- policy to allow any row. A table with no permissive policy of its own
621
+ -- gets "<stem>_base" (USING true); the tenant policies still apply. A
622
+ -- table that has its own gets none, so those keep deciding which rows
623
+ -- of the tenant a user sees. Re-run after adding the first one.
624
+ IF p_mode = 'restrictive' AND NOT EXISTS (
625
+ SELECT 1 FROM pg_policies p
626
+ WHERE p.schemaname = p_schema AND p.tablename = p_table AND p.permissive = 'PERMISSIVE') THEN
627
+ EXECUTE format('CREATE POLICY %I ON %s AS PERMISSIVE FOR ALL USING (true) WITH CHECK (true)',
628
+ stem || '_base', rel);
629
+ END IF;
630
+ END;
631
+ $$ LANGUAGE plpgsql SET search_path = pg_catalog, pg_temp;
632
+
633
+ -- The DDL guard, at the end of every DDL command (section 11). A table counts
634
+ -- as protected when it has firewall policies. After each command it checks
635
+ -- the tables the command names and their descendants (a column added to or
636
+ -- renamed on a parent changes its children too) and, after a command that
637
+ -- can change tables it does not name (ALTER TYPE ... CASCADE, CREATE SCHEMA,
638
+ -- GRANT ... ON ALL TABLES, any command not listed below), every table in
639
+ -- the schemas this script protects. A schema migrated by a version without
640
+ -- _firewall_protected_schemas is guarded once it is migrated again.
641
+ -- * A table with the tenant column and no firewall policies is protected at
642
+ -- once, the way this script would, so there is no window until the next
643
+ -- run. One with a permissive policy of its own, in permissive mode, is
644
+ -- refused instead (that policy would OR with the tenant policies).
645
+ -- * A command fails when it leaves a protected table weaker: RLS off or not
646
+ -- forced, a firewall policy missing or altered, another permissive policy
647
+ -- in permissive mode, a rule, or the table taken out of what this script
648
+ -- protects (its schema, a name it skips, its tenant column). A renamed
649
+ -- table gets the policies of its new name. TRIGGER and TRUNCATE granted
650
+ -- to another role are revoked again, as this script does.
651
+ -- * Policy names fw_<12 hex digits>_* are the firewall's: no command may
652
+ -- create one, rename a policy to one, or alter one.
653
+ -- * Inheritance and partitions: a protected table with a parent or a child
654
+ -- that is not protected fails, either way round. Reading the parent shows
655
+ -- the child's rows; a partition gets the rows written to the parent.
656
+ -- Not covered: code the table's owner attaches to it (a trigger, a CHECK
657
+ -- constraint, a default, a generated column, an index expression, a domain
658
+ -- constraint) runs on every tenant's writes, and can copy them out.
659
+ -- It binds every role but a superuser and the role that owns this script's
660
+ -- objects: only they can skip it (SET dbfw.ddl_guard_skip = 'on'), as this
661
+ -- script does for itself. SECURITY DEFINER (its owner is a superuser, who
662
+ -- installs it): it protects a table whoever created it. Every call it makes passes text arguments,
663
+ -- so a function planted with other argument types (name, say) cannot match
664
+ -- better than the firewall's own.
665
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_ddl_guard()
666
+ RETURNS EVENT_TRIGGER AS $$
667
+ DECLARE
668
+ fwpat CONSTANT TEXT := '^fw_[0-9a-f]{12}_(sel|ins|upd|del)$';
669
+ t RECORD;
670
+ s RECORD;
671
+ g RECORD;
672
+ stem TEXT;
673
+ names TEXT[];
674
+ eligible BOOLEAN;
675
+ have INT;
676
+ fw INT;
677
+ rls BOOLEAN;
678
+ forced BOOLEAN;
679
+ reported OID[];
680
+ scope OID[];
681
+ whole BOOLEAN;
682
+ todo BOOLEAN := false;
683
+ mark TEXT;
684
+ planted TEXT;
685
+ BEGIN
686
+ IF TG_TAG = 'REVOKE' -- takes away only
687
+ OR EXISTS (SELECT 1 FROM db_firewall._firewall_guard_calls gc -- a call from inside the guard
688
+ WHERE gc.pid = pg_backend_pid() AND gc.call_id = current_setting('dbfw.guard_call', true)) THEN
689
+ RETURN;
690
+ END IF;
691
+ IF current_setting('dbfw.ddl_guard_skip', true) = 'on'
692
+ AND ((SELECT rolsuper FROM pg_roles WHERE rolname = session_user)
693
+ OR pg_has_role(session_user, (SELECT proowner FROM pg_proc
694
+ WHERE oid = 'db_firewall._firewall_protect(text, text, text, text, text)'::regprocedure),
695
+ 'MEMBER')) THEN
696
+ RETURN;
697
+ END IF;
698
+ -- A db_firewall._firewall_* object of another role: the firewall calls its
699
+ -- functions by name, so an overload (one with a default argument, say)
700
+ -- makes them ambiguous, and every read of a protected table, and every
701
+ -- protection, fails. Section 0 refuses the same at the next migration.
702
+ SELECT string_agg(o.name, ', ') INTO planted FROM (
703
+ SELECT c.oid::regclass::text AS name, c.relowner AS owner FROM pg_class c
704
+ WHERE c.relnamespace = 'db_firewall'::regnamespace
705
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#')
706
+ UNION ALL
707
+ SELECT p.oid::regprocedure::text, p.proowner FROM pg_proc p
708
+ WHERE p.pronamespace = 'db_firewall'::regnamespace
709
+ OR (p.pronamespace = 'public'::regnamespace AND p.proname LIKE '#_firewall#_%' ESCAPE '#')
710
+ UNION ALL
711
+ SELECT y.oid::regtype::text, y.typowner FROM pg_type y
712
+ WHERE (y.typnamespace = 'db_firewall'::regnamespace
713
+ OR (y.typnamespace = 'public'::regnamespace AND y.typname LIKE '#_firewall#_%' ESCAPE '#'))
714
+ AND y.typrelid = 0
715
+ UNION ALL
716
+ SELECT x.oid::regoperator::text, x.oprowner FROM pg_operator x WHERE x.oprnamespace = 'db_firewall'::regnamespace) o
717
+ JOIN pg_roles r ON r.oid = o.owner
718
+ WHERE NOT r.rolsuper
719
+ AND o.owner <> (SELECT proowner FROM pg_proc WHERE oid = 'db_firewall._firewall_protect(text, text, text, text, text)'::regprocedure);
720
+ IF planted IS NOT NULL THEN
721
+ RAISE EXCEPTION 'DB Firewall: % leaves %, which another role owns, where the firewall keeps its own objects', TG_TAG, planted
722
+ USING HINT = 'Schema db_firewall, and the names public._firewall_*, belong to the migration''s role.';
723
+ END IF;
724
+ -- The tables the command names (the table of a policy or a rule).
725
+ reported := ARRAY(SELECT CASE d.classid
726
+ WHEN 'pg_catalog.pg_policy'::regclass THEN (SELECT polrelid FROM pg_policy WHERE oid = d.objid)
727
+ WHEN 'pg_catalog.pg_rewrite'::regclass THEN (SELECT ev_class FROM pg_rewrite WHERE oid = d.objid)
728
+ ELSE d.objid END
729
+ FROM pg_event_trigger_ddl_commands() d
730
+ WHERE d.classid IN ('pg_catalog.pg_class'::regclass, 'pg_catalog.pg_policy'::regclass,
731
+ 'pg_catalog.pg_rewrite'::regclass));
732
+ -- What to check. These commands change only the tables they name and,
733
+ -- through inheritance, their descendants: check those. Any other
734
+ -- command (ALTER TYPE ... CASCADE, CREATE SCHEMA, GRANT ... ON ALL
735
+ -- TABLES, an extension) checks every table in the protected schemas.
736
+ whole := TG_TAG NOT IN ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO', 'ALTER TABLE', 'CREATE FOREIGN TABLE',
737
+ 'ALTER FOREIGN TABLE', 'ALTER INDEX', 'ALTER VIEW', 'ALTER MATERIALIZED VIEW',
738
+ 'ALTER SEQUENCE', 'CREATE POLICY', 'ALTER POLICY', 'CREATE RULE', 'CREATE INDEX',
739
+ 'REINDEX', 'CREATE VIEW', 'CREATE SEQUENCE', 'CREATE FUNCTION', 'ALTER FUNCTION',
740
+ 'CREATE PROCEDURE', 'ALTER PROCEDURE', 'CREATE TRIGGER', 'ALTER TRIGGER', 'COMMENT')
741
+ AND TG_TAG NOT LIKE 'DROP %'; -- a drop only takes away: the drop guard checks the policies
742
+ scope := ARRAY(WITH RECURSIVE d(oid) AS (SELECT unnest(reported)
743
+ UNION SELECT i.inhrelid FROM pg_inherits i JOIN d ON i.inhparent = d.oid)
744
+ SELECT oid FROM d)
745
+ -- and every table in a protected schema with RLS off or not forced:
746
+ -- EXPLAIN ANALYZE CREATE TABLE AS (or SELECT INTO) fires no event
747
+ -- trigger, so the next command, whatever it is, protects it
748
+ || ARRAY(SELECT c.oid FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
749
+ JOIN db_firewall._firewall_protected_schemas ps ON ps.schema_name = n.nspname
750
+ WHERE c.relkind IN ('r', 'p') AND c.relpersistence <> 't'
751
+ AND NOT (c.relrowsecurity AND c.relforcerowsecurity));
752
+ -- A renamed schema takes its protected tables out of what this script protects.
753
+ FOR t IN
754
+ SELECT n.oid, n.nspname::text AS nspname FROM pg_event_trigger_ddl_commands() d
755
+ JOIN pg_namespace n ON n.oid = d.objid
756
+ WHERE d.classid = 'pg_catalog.pg_namespace'::regclass
757
+ LOOP
758
+ IF NOT EXISTS (SELECT 1 FROM db_firewall._firewall_protected_schemas WHERE schema_name = t.nspname)
759
+ AND EXISTS (SELECT 1 FROM pg_class c JOIN pg_policy p ON p.polrelid = c.oid
760
+ WHERE c.relnamespace = t.oid AND p.polname ~ fwpat) THEN
761
+ RAISE EXCEPTION 'DB Firewall: schema % holds protected tables and is not a schema the migration protects', t.nspname
762
+ USING HINT = 'Rename it back, or migrate the new name first.';
763
+ END IF;
764
+ END LOOP;
765
+ -- The firewall's policy names: created, renamed to, or altered.
766
+ FOR t IN
767
+ SELECT p.polname::text AS polname, n.nspname::text AS nspname, c.relname::text AS relname
768
+ FROM pg_event_trigger_ddl_commands() d JOIN pg_policy p ON p.oid = d.objid
769
+ JOIN pg_class c ON c.oid = p.polrelid JOIN pg_namespace n ON n.oid = c.relnamespace
770
+ WHERE d.classid = 'pg_catalog.pg_policy'::regclass AND p.polname ~ '^fw_[0-9a-f]{12}_'
771
+ AND TG_TAG IN ('CREATE POLICY', 'ALTER POLICY')
772
+ LOOP
773
+ RAISE EXCEPTION 'DB Firewall: % on the policy % of %.%: policy names fw_<hash>_* are the firewall''s', TG_TAG, t.polname, t.nspname, t.relname
774
+ USING HINT = 'Pick another name. Re-run the migration to change the firewall policies.';
775
+ END LOOP;
776
+ -- One set query picks the tables that need work (normally none); the
777
+ -- loop then acts on each, so a command costs little with many tables.
778
+ FOR t IN
779
+ WITH c AS (
780
+ SELECT c.oid, n.nspname::text AS nspname, c.relname::text AS relname, c.relrowsecurity AS rls,
781
+ c.relforcerowsecurity AS forced, c.relacl, c.relowner, ps.policy_mode,
782
+ 'fw_' || left(md5(n.nspname || '.' || c.relname), 12) AS st,
783
+ ps.schema_name IS NOT NULL
784
+ AND c.relname NOT LIKE '#_firewall#_%' ESCAPE '#'
785
+ AND c.relname NOT IN ('schema_migrations', 'alembic_version', 'knex_migrations', 'prisma_migrations',
786
+ '__diesel_schema_migrations', 'flyway_schema_history')
787
+ AND EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = c.oid AND a.attname = ps.tenant_column
788
+ AND a.attnum > 0 AND NOT a.attisdropped) AS eligible
789
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
790
+ LEFT JOIN db_firewall._firewall_protected_schemas ps ON ps.schema_name = n.nspname
791
+ WHERE c.relkind IN ('r', 'p') AND c.relpersistence <> 't'
792
+ AND ((whole AND ps.schema_name IS NOT NULL) OR c.oid = ANY (scope)))
793
+ SELECT c.oid, c.nspname, c.relname FROM c
794
+ CROSS JOIN LATERAL (
795
+ SELECT count(*) FILTER (WHERE polname IN (c.st || '_sel', c.st || '_ins', c.st || '_upd', c.st || '_del')) AS n_have,
796
+ count(*) FILTER (WHERE polname ~ fwpat) AS n_fw,
797
+ count(*) FILTER (WHERE polpermissive AND polname NOT IN (c.st || '_sel', c.st || '_ins',
798
+ c.st || '_upd', c.st || '_del')) AS n_other
799
+ FROM pg_policy WHERE polrelid = c.oid) p
800
+ WHERE (c.eligible AND p.n_fw = 0)
801
+ OR (p.n_fw > 0 AND (NOT c.eligible OR p.n_have < 4 OR NOT c.rls OR NOT c.forced
802
+ OR (c.policy_mode = 'permissive' AND p.n_other > 0)
803
+ OR EXISTS (SELECT 1 FROM pg_rewrite r WHERE r.ev_class = c.oid AND r.rulename <> '_RETURN')
804
+ OR EXISTS (SELECT 1 FROM aclexplode(c.relacl) a WHERE a.grantee <> c.relowner
805
+ AND a.privilege_type IN ('TRIGGER', 'TRUNCATE'))))
806
+ ORDER BY c.oid
807
+ LOOP
808
+ SELECT * INTO s FROM db_firewall._firewall_protected_schemas WHERE schema_name = t.nspname;
809
+ eligible := s.schema_name IS NOT NULL
810
+ AND t.relname NOT LIKE '#_firewall#_%' ESCAPE '#'
811
+ AND t.relname NOT IN ('schema_migrations', 'alembic_version', 'knex_migrations', 'prisma_migrations',
812
+ '__diesel_schema_migrations', 'flyway_schema_history')
813
+ AND EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = t.oid AND a.attname = s.tenant_column
814
+ AND a.attnum > 0 AND NOT a.attisdropped);
815
+ stem := 'fw_' || left(md5(t.nspname || '.' || t.relname), 12);
816
+ names := ARRAY[stem || '_sel', stem || '_ins', stem || '_upd', stem || '_del'];
817
+ SELECT relrowsecurity, relforcerowsecurity INTO rls, forced FROM pg_class WHERE oid = t.oid;
818
+ SELECT count(*) FILTER (WHERE polname = ANY (names)), count(*) FILTER (WHERE polname ~ fwpat)
819
+ INTO have, fw FROM pg_policy WHERE polrelid = t.oid;
820
+ IF fw = 0 THEN
821
+ CONTINUE WHEN NOT eligible;
822
+ IF s.policy_mode = 'permissive' AND EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = t.oid AND polpermissive) THEN
823
+ RAISE EXCEPTION 'DB Firewall: %.% has a permissive policy of its own, which would OR with the tenant policies', t.nspname, t.relname
824
+ USING HINT = 'Make it AS RESTRICTIVE, or migrate with policy_mode restrictive.';
825
+ END IF;
826
+ todo := true;
827
+ RAISE NOTICE 'DB Firewall: RLS applied to %.% (DDL guard)', t.nspname, t.relname;
828
+ -- On to the checks below: a table protected just now can bring a rule along.
829
+ ELSIF NOT eligible THEN
830
+ RAISE EXCEPTION 'DB Firewall: % takes the protected table %.% out of what the migration protects (its schema, a name it skips, or its tenant column changed)', TG_TAG, t.nspname, t.relname
831
+ USING HINT = 'Undo the change, or drop the firewall policies with the migration''s role first.';
832
+ ELSIF have = 0 AND rls AND forced THEN
833
+ -- Renamed: the policies of the old name, still in place, give way to the new name's.
834
+ todo := true;
835
+ ELSIF have < 4 OR NOT rls OR NOT forced THEN
836
+ RAISE EXCEPTION 'DB Firewall: % would leave %.% without its row-level security (RLS enabled and forced, the four firewall policies)', TG_TAG, t.nspname, t.relname
837
+ USING HINT = 'Re-run the migration to change how tables are protected. The DDL guard checks every change: see migrations/001_postgresql.sql, section 5b.';
838
+ END IF;
839
+ IF todo THEN
840
+ -- _firewall_protect runs DDL, which calls both guards again. They
841
+ -- return at once for a call from here: a one-time value, in a
842
+ -- table only the owner reads, marks it (no catalog write, so no
843
+ -- lock that another session's open transaction could hold).
844
+ mark := gen_random_uuid()::text;
845
+ INSERT INTO db_firewall._firewall_guard_calls (pid, call_id) VALUES (pg_backend_pid(), mark);
846
+ PERFORM set_config('dbfw.guard_call', mark, true);
847
+ PERFORM db_firewall._firewall_protect(t.nspname::text, t.relname::text, s.tenant_column::text,
848
+ s.policy_mode::text, s.context::text);
849
+ PERFORM set_config('dbfw.guard_call', '', true);
850
+ DELETE FROM db_firewall._firewall_guard_calls gc WHERE gc.pid = pg_backend_pid();
851
+ todo := false;
852
+ END IF;
853
+ IF s.policy_mode = 'permissive' AND EXISTS (SELECT 1 FROM pg_policy WHERE polrelid = t.oid AND polpermissive
854
+ AND NOT polname = ANY (names)) THEN
855
+ RAISE EXCEPTION 'DB Firewall: % leaves a permissive policy on %.% next to the tenant policies: it ORs with them and opens other tenants'' rows', TG_TAG, t.nspname, t.relname
856
+ USING HINT = 'Make it AS RESTRICTIVE, or migrate with policy_mode restrictive.';
857
+ END IF;
858
+ IF EXISTS (SELECT 1 FROM pg_rewrite WHERE ev_class = t.oid AND rulename <> '_RETURN') THEN
859
+ RAISE EXCEPTION 'DB Firewall: % leaves a rule on %.%: a rule runs on every tenant''s rows', TG_TAG, t.nspname, t.relname
860
+ USING HINT = 'Drop the rule. Use a view or a restrictive policy instead.';
861
+ END IF;
862
+ -- REVOKE only takes away: the guard returns at once for it, so this
863
+ -- does not nest a guard per table (GRANT ... ON ALL TABLES).
864
+ FOR g IN
865
+ SELECT DISTINCT a.grantee, a.privilege_type FROM pg_class c, aclexplode(c.relacl) a
866
+ WHERE c.oid = t.oid AND a.grantee <> c.relowner AND a.privilege_type IN ('TRIGGER', 'TRUNCATE')
867
+ LOOP
868
+ EXECUTE format('REVOKE %s ON %s FROM %s CASCADE', g.privilege_type, t.oid::regclass,
869
+ CASE g.grantee WHEN 0 THEN 'PUBLIC' ELSE quote_ident(pg_get_userbyid(g.grantee)) END);
870
+ RAISE NOTICE 'DB Firewall: % on %.% revoked again: only the owner keeps it (DDL guard)', g.privilege_type, t.nspname, t.relname;
871
+ END LOOP;
872
+ END LOOP;
873
+ -- A protected table and a parent or child that is not protected.
874
+ FOR t IN
875
+ SELECT i.inhparent::regclass::text AS parent, i.inhrelid::regclass::text AS child,
876
+ EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = i.inhparent AND p.polname ~ fwpat) AS parent_protected
877
+ FROM pg_inherits i JOIN pg_class ch ON ch.oid = i.inhrelid JOIN pg_class pa ON pa.oid = i.inhparent
878
+ WHERE ch.relpersistence <> 't'
879
+ AND (i.inhparent = ANY (scope) OR i.inhrelid = ANY (scope)
880
+ OR (whole AND EXISTS (SELECT 1 FROM pg_namespace n JOIN db_firewall._firewall_protected_schemas ps ON ps.schema_name = n.nspname
881
+ WHERE n.oid IN (ch.relnamespace, pa.relnamespace))))
882
+ AND EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = i.inhparent AND p.polname ~ fwpat)
883
+ <> EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = i.inhrelid AND p.polname ~ fwpat)
884
+ LIMIT 1
885
+ LOOP
886
+ IF t.parent_protected THEN
887
+ RAISE EXCEPTION 'DB Firewall: % puts %, which is not protected, under the protected table %: it gets the rows written to it', TG_TAG, t.child, t.parent
888
+ USING HINT = 'Create the partition or child in a schema the migration protects, with the tenant column.';
889
+ END IF;
890
+ RAISE EXCEPTION 'DB Firewall: % puts the protected table % under %, which is not protected: reading it shows the protected rows', TG_TAG, t.child, t.parent
891
+ USING HINT = 'Protect the parent first (give it the tenant column and re-run the migration).';
892
+ END LOOP;
893
+ END;
894
+ $$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp;
895
+
896
+ -- The same guard for drops: a firewall policy dropped from a table that still
897
+ -- exists (DROP POLICY, a column dropped with CASCADE, DROP OWNED). Dropping
898
+ -- the table itself is fine. The same roles can skip it.
899
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_ddl_drop_guard()
900
+ RETURNS EVENT_TRIGGER AS $$
901
+ DECLARE
902
+ o RECORD;
903
+ BEGIN
904
+ IF EXISTS (SELECT 1 FROM db_firewall._firewall_guard_calls gc -- a call from inside the guard
905
+ WHERE gc.pid = pg_backend_pid() AND gc.call_id = current_setting('dbfw.guard_call', true)) THEN
906
+ RETURN;
907
+ END IF;
908
+ IF current_setting('dbfw.ddl_guard_skip', true) = 'on'
909
+ AND ((SELECT rolsuper FROM pg_roles WHERE rolname = session_user)
910
+ OR pg_has_role(session_user, (SELECT proowner FROM pg_proc
911
+ WHERE oid = 'db_firewall._firewall_protect(text, text, text, text, text)'::regprocedure),
912
+ 'MEMBER')) THEN
913
+ RETURN;
914
+ END IF;
915
+ FOR o IN
916
+ SELECT d.address_names[1] AS sch, d.address_names[2] AS tbl, d.address_names[3] AS pol
917
+ FROM pg_event_trigger_dropped_objects() d
918
+ WHERE d.object_type = 'policy' AND d.address_names[3] ~ '^fw_[0-9a-f]{12}_(sel|ins|upd|del)$'
919
+ AND to_regclass(quote_ident(d.address_names[1]) || '.' || quote_ident(d.address_names[2])) IS NOT NULL
920
+ LOOP
921
+ RAISE EXCEPTION 'DB Firewall: % drops the firewall policy % and would leave %.% without its row-level security', TG_TAG, o.pol, o.sch, o.tbl
922
+ USING HINT = 'A DROP ... CASCADE of the tenant column, its type, domain or collation also drops the policies. Re-run the migration to change the firewall policies.';
923
+ END LOOP;
924
+ END;
925
+ $$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, pg_temp;
926
+
927
+ -- The event triggers section 0b paused, as they were (their functions are
928
+ -- this script's now).
929
+ DO $$
930
+ DECLARE
931
+ e TEXT;
932
+ BEGIN
933
+ FOREACH e IN ARRAY COALESCE(string_to_array(NULLIF(current_setting('dbfw.guard_restore', true), ''), ','), '{}') LOOP
934
+ CONTINUE WHEN split_part(e, '=', 1) NOT IN ('_firewall_ddl_guard', '_firewall_ddl_drop_guard');
935
+ EXECUTE format('ALTER EVENT TRIGGER %I %s', split_part(e, '=', 1),
936
+ CASE split_part(e, '=', 2) WHEN 'A' THEN 'ENABLE ALWAYS' WHEN 'R' THEN 'ENABLE REPLICA'
937
+ WHEN 'O' THEN 'ENABLE' ELSE 'DISABLE' END);
938
+ END LOOP;
939
+ PERFORM set_config('dbfw.guard_restore', '', true);
940
+ END;
941
+ $$;
942
+
943
+ -- ============================================================================
944
+ -- 6. AUTO-APPLY RLS TO ALL TABLES WITH THE TENANT COLUMN (dbfw.tenant_column)
945
+ -- ============================================================================
946
+ -- This DO block scans your schema and applies RLS to every table
947
+ -- that has a tenant_id column. Run after every migration.
948
+ -- ============================================================================
949
+
950
+ DO $$
951
+ DECLARE
952
+ r RECORD;
953
+ col TEXT := COALESCE(NULLIF(current_setting('dbfw.tenant_column', true), ''), 'tenant_id');
954
+ sch TEXT := COALESCE(NULLIF(current_setting('dbfw.schema', true), ''), 'public');
955
+ -- dbfw.policy_mode: 'permissive' (default) or 'restrictive'. Restrictive
956
+ -- tenant policies AND with the app's own policies (Supabase, Drizzle
957
+ -- pgPolicy), so an app policy can never widen access past the tenant.
958
+ mode TEXT := lower(COALESCE(NULLIF(current_setting('dbfw.policy_mode', true), ''), 'permissive'));
959
+ -- dbfw.context: 'signed' (default, see the header) or 'plain' (any SQL the
960
+ -- app runs can set app.current_tenant and so switch tenants).
961
+ ctx TEXT := lower(COALESCE(NULLIF(current_setting('dbfw.context', true), ''), 'signed'));
962
+ BEGIN
963
+ IF mode NOT IN ('permissive', 'restrictive') THEN
964
+ RAISE EXCEPTION 'DB Firewall: dbfw.policy_mode must be permissive or restrictive, got %', mode;
965
+ END IF;
966
+ IF ctx NOT IN ('signed', 'plain') THEN
967
+ RAISE EXCEPTION 'DB Firewall: dbfw.context must be signed or plain, got %', ctx;
968
+ END IF;
969
+ -- What the DDL guard protects later tables with (and no schema that is gone).
970
+ DELETE FROM db_firewall._firewall_protected_schemas p
971
+ WHERE NOT EXISTS (SELECT 1 FROM pg_namespace n WHERE n.nspname = p.schema_name);
972
+ INSERT INTO db_firewall._firewall_protected_schemas (schema_name, tenant_column, policy_mode, context)
973
+ VALUES (sch, col, mode, ctx)
974
+ ON CONFLICT (schema_name) DO UPDATE SET tenant_column = EXCLUDED.tenant_column,
975
+ policy_mode = EXCLUDED.policy_mode, context = EXCLUDED.context, updated_at = now();
976
+ FOR r IN
977
+ SELECT c.table_schema, c.table_name
978
+ FROM information_schema.columns c
979
+ JOIN information_schema.tables t
980
+ ON c.table_schema = t.table_schema
981
+ AND c.table_name = t.table_name
982
+ WHERE c.column_name = col
983
+ AND t.table_type = 'BASE TABLE'
984
+ AND c.table_schema = sch
985
+ AND c.table_name NOT LIKE '#_firewall#_%' ESCAPE '#'
986
+ AND c.table_name NOT IN (
987
+ 'schema_migrations', 'alembic_version',
988
+ 'knex_migrations', 'prisma_migrations',
989
+ '__diesel_schema_migrations', 'flyway_schema_history'
990
+ )
991
+ LOOP
992
+ -- text arguments: a function planted with other types cannot match better.
993
+ PERFORM db_firewall._firewall_protect(r.table_schema::text, r.table_name::text, col::text, mode::text, ctx::text);
994
+
995
+ RAISE NOTICE 'DB Firewall: RLS applied to %.%', r.table_schema, r.table_name;
996
+ END LOOP;
997
+ END;
998
+ $$;
999
+
1000
+
1001
+ -- 7. Audit log auto-purge (run via pg_cron or application scheduler)
1002
+ CREATE OR REPLACE FUNCTION db_firewall._firewall_purge_audit(retention_days INTEGER DEFAULT 90)
1003
+ RETURNS INTEGER AS $$
1004
+ DECLARE
1005
+ deleted INTEGER;
1006
+ BEGIN
1007
+ DELETE FROM db_firewall._firewall_audit_log
1008
+ WHERE assigned_at < NOW() - (retention_days || ' days')::INTERVAL;
1009
+ GET DIAGNOSTICS deleted = ROW_COUNT;
1010
+ RETURN deleted;
1011
+ END;
1012
+ $$ LANGUAGE plpgsql;
1013
+
1014
+ -- 7b. Placeholders in public for one release, under the names an earlier
1015
+ -- version used there, so app code from before the move (which calls them
1016
+ -- without a schema, or as public._firewall_*) fails instead of reaching
1017
+ -- an object another role created there: a tenant map that names another
1018
+ -- tenant, say. The views have no rows and take no writes; each function
1019
+ -- raises.
1020
+ DO $$
1021
+ DECLARE
1022
+ f RECORD;
1023
+ BEGIN
1024
+ IF to_regclass('public._firewall_tenant_map') IS NULL THEN
1025
+ CREATE VIEW public._firewall_tenant_map AS
1026
+ SELECT NULL::text AS db_user, NULL::text AS tenant_id, NULL::boolean AS is_active WHERE false;
1027
+ END IF;
1028
+ IF to_regclass('public._firewall_audit_log') IS NULL THEN
1029
+ CREATE VIEW public._firewall_audit_log AS
1030
+ SELECT NULL::inet AS client_addr, NULL::text AS tenant_id, NULL::text AS auth_method WHERE false;
1031
+ END IF;
1032
+ FOR f IN
1033
+ SELECT p.proname::text AS name, pg_get_function_arguments(p.oid) AS args,
1034
+ pg_get_function_identity_arguments(p.oid) AS ident, pg_get_function_result(p.oid) AS ret
1035
+ FROM pg_proc p
1036
+ WHERE p.pronamespace = 'db_firewall'::regnamespace
1037
+ AND p.prorettype <> 'pg_catalog.event_trigger'::regtype AND p.proname <> '_firewall_protect'
1038
+ LOOP
1039
+ EXECUTE format('CREATE OR REPLACE FUNCTION public.%I(%s) RETURNS %s LANGUAGE plpgsql AS %L',
1040
+ f.name, f.args, f.ret,
1041
+ format('BEGIN RAISE EXCEPTION ''DB Firewall: %s moved to schema db_firewall: upgrade the app''; END', f.name));
1042
+ EXECUTE format('COMMENT ON FUNCTION public.%I(%s) IS %L', f.name, f.ident, 'DB Firewall: moved to db_firewall');
1043
+ END LOOP;
1044
+ END;
1045
+ $$;
1046
+
1047
+ -- 8. Only the owner may read the key or sign, or read or change what the DDL
1048
+ -- guard protects; only the owner may change the bypass list and the tenant
1049
+ -- map (reading them stays allowed: the db_user_map resolution reads the
1050
+ -- map); the app may add audit rows but not read, change or trigger on them
1051
+ -- (a trigger there runs inside _firewall_set_tenant, as its owner). Every
1052
+ -- relation in db_firewall is covered, with that allow list. REVOKE ... FROM PUBLIC is not enough:
1053
+ -- platforms such as Supabase grant new objects in public to their roles
1054
+ -- directly (default privileges), so every grantee but the owner is
1055
+ -- revoked, column grants included.
1056
+ DO $$
1057
+ DECLARE
1058
+ r RECORD;
1059
+ BEGIN
1060
+ -- The audit log: INSERT on the columns the middleware writes only. With
1061
+ -- INSERT on id, the app could take future ids (and later rows, and
1062
+ -- _firewall_set_tenant, would fail); with db_user or session_pid, it
1063
+ -- could forge them. A table-level INSERT becomes these column grants.
1064
+ FOR r IN
1065
+ SELECT DISTINCT a.grantee FROM pg_class c, aclexplode(c.relacl) a
1066
+ WHERE c.oid = 'db_firewall._firewall_audit_log'::regclass AND a.privilege_type = 'INSERT' AND a.grantee <> c.relowner
1067
+ LOOP
1068
+ EXECUTE format('REVOKE INSERT ON db_firewall._firewall_audit_log FROM %1$s; '
1069
+ 'GRANT INSERT (client_addr, tenant_id, auth_method, application) ON db_firewall._firewall_audit_log TO %1$s',
1070
+ CASE r.grantee WHEN 0 THEN 'PUBLIC' ELSE quote_ident(pg_get_userbyid(r.grantee)) END);
1071
+ END LOOP;
1072
+ FOR r IN
1073
+ -- Every relation in db_firewall and every public._firewall_* placeholder:
1074
+ -- each grantee but the owner keeps only what this list allows.
1075
+ WITH rel AS (
1076
+ SELECT c.oid, c.relacl, c.relowner, c.relkind,
1077
+ CASE WHEN c.relnamespace = 'db_firewall'::regnamespace THEN c.relname::text END AS name,
1078
+ CASE c.relname WHEN '_firewall_bypass_roles' THEN ARRAY['SELECT']
1079
+ WHEN '_firewall_tenant_map' THEN ARRAY['SELECT']
1080
+ WHEN '_firewall_audit_log' THEN ARRAY[]::text[] -- column grants only, below
1081
+ WHEN '_firewall_audit_log_id_seq' THEN ARRAY['USAGE']
1082
+ ELSE ARRAY[]::text[] END AS allowed
1083
+ FROM pg_class c
1084
+ WHERE c.relkind IN ('r', 'p', 'v', 'm', 'S', 'f')
1085
+ AND (c.relnamespace = 'db_firewall'::regnamespace
1086
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#')))
1087
+ SELECT CASE WHEN rel.relkind = 'S' THEN 'SEQUENCE ' ELSE 'TABLE ' END || rel.oid::regclass::text AS obj,
1088
+ a.privilege_type AS priv, '' AS cols, a.grantee
1089
+ FROM rel, aclexplode(rel.relacl) a
1090
+ WHERE a.grantee <> rel.relowner AND NOT (rel.name IS NOT NULL AND a.privilege_type = ANY (rel.allowed))
1091
+ UNION
1092
+ SELECT 'TABLE ' || rel.oid::regclass::text, a.privilege_type, quote_ident(att.attname), a.grantee
1093
+ FROM rel JOIN pg_attribute att ON att.attrelid = rel.oid, aclexplode(att.attacl) a
1094
+ WHERE a.grantee <> rel.relowner AND NOT (rel.name IS NOT NULL AND a.privilege_type = ANY (rel.allowed))
1095
+ AND NOT (rel.name = '_firewall_audit_log' AND a.privilege_type = 'INSERT'
1096
+ AND att.attname IN ('client_addr', 'tenant_id', 'auth_method', 'application'))
1097
+ UNION
1098
+ SELECT 'FUNCTION ' || p.oid::regprocedure::text, 'ALL', '', a.grantee
1099
+ FROM pg_proc p, aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) a
1100
+ WHERE p.oid IN ('db_firewall._firewall_sign(text)'::regprocedure, 'db_firewall._firewall_set_tenant(text)'::regprocedure)
1101
+ AND a.grantee <> p.proowner
1102
+ LOOP
1103
+ EXECUTE format('REVOKE %s%s ON %s FROM %s', r.priv,
1104
+ CASE WHEN r.cols <> '' THEN ' (' || r.cols || ')' ELSE '' END, r.obj,
1105
+ CASE r.grantee WHEN 0 THEN 'PUBLIC' ELSE quote_ident(pg_get_userbyid(r.grantee)) END);
1106
+ END LOOP;
1107
+ END;
1108
+ $$;
1109
+
1110
+ -- 9. Opt-in (dbfw.harden_signals = on): all tenants share one role, and a role
1111
+ -- may end or cancel its own other sessions, so SQL one tenant runs could
1112
+ -- stop another tenant's requests. This revokes pg_terminate_backend and
1113
+ -- pg_cancel_backend from PUBLIC in this database. That covers every app
1114
+ -- and role in the database, so it is off by default; it needs a superuser.
1115
+ -- Superusers keep both; grant them back to admin roles that need them.
1116
+ DO $$
1117
+ DECLARE
1118
+ f REGPROCEDURE;
1119
+ BEGIN
1120
+ IF lower(COALESCE(NULLIF(current_setting('dbfw.harden_signals', true), ''), 'off')) NOT IN ('on', 'true', '1')
1121
+ AND lower(COALESCE(NULLIF(current_setting('dbfw.harden', true), ''), 'off')) NOT IN ('on', 'true', '1') THEN
1122
+ RETURN;
1123
+ END IF;
1124
+ IF NOT (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) THEN
1125
+ RAISE EXCEPTION 'DB Firewall: dbfw.harden_signals needs a superuser (it changes pg_catalog functions)';
1126
+ END IF;
1127
+ FOR f IN SELECT p.oid FROM pg_proc p
1128
+ WHERE p.pronamespace = 'pg_catalog'::regnamespace
1129
+ AND p.proname IN ('pg_terminate_backend', 'pg_cancel_backend')
1130
+ LOOP
1131
+ EXECUTE format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC', f);
1132
+ END LOOP;
1133
+ END;
1134
+ $$;
1135
+
1136
+ -- 10. Opt-in (dbfw.harden = on, which also does section 9): remove what
1137
+ -- `db-firewall verify` fails on that this script can remove, from PUBLIC
1138
+ -- and from dbfw.app_role when set, whoever granted it:
1139
+ -- - CREATE on the database (new schemas, such as one named like the role,
1140
+ -- come first in the default search_path) and on every schema (planted
1141
+ -- objects and operators);
1142
+ -- - TRIGGER and TRUNCATE on every table (a trigger function runs in other
1143
+ -- tenants' sessions; TRUNCATE ignores RLS), also in default privileges,
1144
+ -- so tables created later do not get them;
1145
+ -- - pg_stat_activity, pg_stat_get_activity and pg_stat_get_backend_activity,
1146
+ -- and pg_stat_statements when installed: one shared role sees the SQL
1147
+ -- text of its other sessions, so one tenant could read the literal
1148
+ -- values in another tenant's queries. Roles that only have pg_monitor
1149
+ -- lose pg_stat_activity too: grant it to them again;
1150
+ -- - pg_notify(text, text): any session can send any payload to every
1151
+ -- listener in the database (a job queue, a cache), so SQL one tenant runs
1152
+ -- could send messages in another's name. The NOTIFY statement needs no
1153
+ -- grant: give pg_notify back to roles that call it (GRANT EXECUTE).
1154
+ -- Database-wide, so off by default; needs a superuser. With dbfw.app_role
1155
+ -- it refuses (and the whole script changes nothing) when that role can act
1156
+ -- as the owner of the database or of any object in it (an owner can grant
1157
+ -- itself everything back), or still has one of these privileges afterwards
1158
+ -- through a role it is a member of. CASCADE also removes what the app role
1159
+ -- passed on with a grant option.
1160
+ DO $$
1161
+ DECLARE
1162
+ app TEXT := NULLIF(current_setting('dbfw.app_role', true), '');
1163
+ app_oid OID;
1164
+ me TEXT := current_user;
1165
+ r RECORD;
1166
+ found TEXT;
1167
+ BEGIN
1168
+ IF lower(COALESCE(NULLIF(current_setting('dbfw.harden', true), ''), 'off')) NOT IN ('on', 'true', '1') THEN
1169
+ RETURN;
1170
+ END IF;
1171
+ IF NOT (SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = current_user) THEN
1172
+ RAISE EXCEPTION 'DB Firewall: dbfw.harden needs a superuser';
1173
+ END IF;
1174
+ IF app IS NOT NULL THEN
1175
+ SELECT oid INTO app_oid FROM pg_catalog.pg_roles WHERE rolname = app;
1176
+ IF app_oid IS NULL THEN
1177
+ RAISE EXCEPTION 'DB Firewall: dbfw.app_role %: no such role (give the bare name, without quotes)', app;
1178
+ END IF;
1179
+ -- Any object (pg_shdepend), as `verify` counts them: an owner can grant
1180
+ -- itself everything back, or change what runs in other tenants'
1181
+ -- sessions (a domain constraint, a function body).
1182
+ SELECT string_agg(o, ', ') INTO found FROM (
1183
+ SELECT 'database ' || quote_ident(datname) AS o FROM pg_catalog.pg_database
1184
+ WHERE datname = current_database() AND pg_catalog.pg_has_role(app_oid, datdba, 'MEMBER')
1185
+ UNION ALL
1186
+ (SELECT o.type || ' ' || o.identity FROM pg_catalog.pg_shdepend d
1187
+ CROSS JOIN LATERAL pg_catalog.pg_identify_object(d.classid, d.objid, 0) o
1188
+ WHERE d.dbid = (SELECT oid FROM pg_catalog.pg_database WHERE datname = current_database())
1189
+ AND d.deptype = 'o'
1190
+ AND d.classid NOT IN ('pg_catalog.pg_largeobject'::pg_catalog.regclass,
1191
+ 'pg_catalog.pg_default_acl'::pg_catalog.regclass,
1192
+ 'pg_catalog.pg_user_mapping'::pg_catalog.regclass)
1193
+ AND pg_catalog.pg_has_role(app_oid, d.refobjid, 'MEMBER')
1194
+ AND COALESCE(o.schema, '') NOT LIKE 'pg#_temp#_%' ESCAPE '#'
1195
+ AND COALESCE(o.schema, '') NOT LIKE 'pg#_toast#_temp#_%' ESCAPE '#'
1196
+ ORDER BY 1 LIMIT 20)) s;
1197
+ IF found IS NOT NULL THEN
1198
+ RAISE EXCEPTION 'DB Firewall: dbfw.harden: % can act as the owner of %: give them to another role first (ALTER ... OWNER TO)', app, found;
1199
+ END IF;
1200
+ END IF;
1201
+ -- Every grant to PUBLIC or the app role, by any grantor: a REVOKE removes
1202
+ -- only the grants of the role that runs it (a superuser's: the owner's), so
1203
+ -- each runs as its grantor. CASCADE: also what the app role passed on with
1204
+ -- a grant option.
1205
+ FOR r IN
1206
+ WITH g AS (SELECT 0::pg_catalog.oid AS oid UNION ALL SELECT app_oid WHERE app_oid IS NOT NULL),
1207
+ acl AS (
1208
+ SELECT format('DATABASE %I', d.datname) AS obj, a.* FROM pg_catalog.pg_database d,
1209
+ LATERAL pg_catalog.aclexplode(d.datacl) a
1210
+ WHERE d.datname = current_database() AND a.privilege_type = 'CREATE'
1211
+ UNION ALL
1212
+ SELECT format('SCHEMA %I', n.nspname), a.* FROM pg_catalog.pg_namespace n,
1213
+ LATERAL pg_catalog.aclexplode(n.nspacl) a
1214
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg#_%' ESCAPE '#'
1215
+ AND a.privilege_type = 'CREATE'
1216
+ UNION ALL
1217
+ SELECT format('TABLE %s', c.oid::pg_catalog.regclass), a.* FROM pg_catalog.pg_class c
1218
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace, LATERAL pg_catalog.aclexplode(c.relacl) a
1219
+ WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f')
1220
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg#_%' ESCAPE '#'
1221
+ AND a.privilege_type IN ('TRIGGER', 'TRUNCATE'))
1222
+ SELECT pg_catalog.pg_get_userbyid(acl.grantor) AS grantor,
1223
+ format('REVOKE %s ON %s FROM %s CASCADE', acl.privilege_type, acl.obj,
1224
+ CASE WHEN acl.grantee = 0 THEN 'PUBLIC' ELSE quote_ident(pg_catalog.pg_get_userbyid(acl.grantee)) END) AS cmd
1225
+ FROM acl WHERE acl.grantee IN (SELECT oid FROM g)
1226
+ UNION ALL
1227
+ -- Default privileges: tables (and schemas) created later.
1228
+ SELECT me, format('ALTER DEFAULT PRIVILEGES FOR ROLE %I%s REVOKE %s ON %s FROM %s',
1229
+ pg_catalog.pg_get_userbyid(da.defaclrole),
1230
+ CASE WHEN da.defaclnamespace <> 0 THEN format(' IN SCHEMA %I', n.nspname) ELSE '' END,
1231
+ a.privilege_type, CASE da.defaclobjtype WHEN 'r' THEN 'TABLES' ELSE 'SCHEMAS' END,
1232
+ CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE quote_ident(pg_catalog.pg_get_userbyid(a.grantee)) END)
1233
+ FROM pg_catalog.pg_default_acl da LEFT JOIN pg_catalog.pg_namespace n ON n.oid = da.defaclnamespace,
1234
+ LATERAL pg_catalog.aclexplode(da.defaclacl) a
1235
+ WHERE a.grantee IN (SELECT oid FROM g)
1236
+ AND ((da.defaclobjtype = 'r' AND a.privilege_type IN ('TRIGGER', 'TRUNCATE'))
1237
+ OR (da.defaclobjtype = 'n' AND a.privilege_type = 'CREATE'))
1238
+ LOOP
1239
+ BEGIN
1240
+ EXECUTE format('SET LOCAL ROLE %I', r.grantor);
1241
+ EXECUTE r.cmd;
1242
+ EXCEPTION WHEN insufficient_privilege THEN
1243
+ RAISE EXCEPTION 'DB Firewall: dbfw.harden: % as its grantor % failed: % (grant % USAGE on that schema, or revoke it by hand)',
1244
+ r.cmd, r.grantor, SQLERRM, r.grantor;
1245
+ END;
1246
+ EXECUTE format('SET LOCAL ROLE %I', me);
1247
+ END LOOP;
1248
+ REVOKE SELECT ON pg_catalog.pg_stat_activity FROM PUBLIC;
1249
+ REVOKE EXECUTE ON FUNCTION pg_catalog.pg_stat_get_activity(integer),
1250
+ pg_catalog.pg_stat_get_backend_activity(integer),
1251
+ pg_catalog.pg_notify(text, text) FROM PUBLIC;
1252
+ -- pg_stat_statements, when installed: its text keeps some literal values.
1253
+ FOR r IN SELECT format('REVOKE EXECUTE ON FUNCTION %s FROM PUBLIC', p.oid::pg_catalog.regprocedure) AS cmd
1254
+ FROM pg_catalog.pg_proc p WHERE p.proname = 'pg_stat_statements'
1255
+ UNION ALL
1256
+ SELECT format('REVOKE SELECT ON %s FROM PUBLIC', c.oid::pg_catalog.regclass)
1257
+ FROM pg_catalog.pg_class c WHERE c.relname IN ('pg_stat_statements', 'pg_stat_statements_info')
1258
+ AND c.relkind = 'v'
1259
+ LOOP
1260
+ EXECUTE r.cmd;
1261
+ END LOOP;
1262
+ IF app_oid IS NOT NULL THEN
1263
+ -- What is left comes through roles the app role is a member of.
1264
+ SELECT string_agg(o, ', ') INTO found FROM (
1265
+ SELECT 'CREATE on database ' || quote_ident(current_database()) AS o
1266
+ WHERE EXISTS (SELECT 1 FROM pg_catalog.pg_roles m WHERE pg_catalog.pg_has_role(app_oid, m.oid, 'MEMBER')
1267
+ AND pg_catalog.has_database_privilege(m.oid, current_database(), 'CREATE'))
1268
+ UNION ALL
1269
+ SELECT 'CREATE on schema ' || quote_ident(n.nspname) FROM pg_catalog.pg_namespace n
1270
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg#_%' ESCAPE '#'
1271
+ AND EXISTS (SELECT 1 FROM pg_catalog.pg_roles m WHERE pg_catalog.pg_has_role(app_oid, m.oid, 'MEMBER')
1272
+ AND pg_catalog.has_schema_privilege(m.oid, n.oid, 'CREATE'))
1273
+ UNION ALL
1274
+ SELECT 'TRIGGER or TRUNCATE on ' || c.oid::pg_catalog.regclass::text FROM pg_catalog.pg_class c
1275
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
1276
+ WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f')
1277
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg#_%' ESCAPE '#'
1278
+ AND EXISTS (SELECT 1 FROM pg_catalog.pg_roles m WHERE pg_catalog.pg_has_role(app_oid, m.oid, 'MEMBER')
1279
+ AND pg_catalog.has_table_privilege(m.oid, c.oid, 'TRIGGER, TRUNCATE'))
1280
+ UNION ALL
1281
+ SELECT 'EXECUTE on pg_notify'
1282
+ WHERE EXISTS (SELECT 1 FROM pg_catalog.pg_roles m WHERE pg_catalog.pg_has_role(app_oid, m.oid, 'MEMBER')
1283
+ AND pg_catalog.has_function_privilege(m.oid, 'pg_catalog.pg_notify(text, text)', 'EXECUTE'))
1284
+ UNION ALL
1285
+ SELECT 'pg_stat_activity or pg_stat_statements'
1286
+ WHERE EXISTS (SELECT 1 FROM pg_catalog.pg_roles m WHERE pg_catalog.pg_has_role(app_oid, m.oid, 'MEMBER')
1287
+ AND (pg_catalog.has_table_privilege(m.oid, 'pg_catalog.pg_stat_activity', 'SELECT')
1288
+ OR pg_catalog.has_function_privilege(m.oid, 'pg_catalog.pg_stat_get_activity(integer)', 'EXECUTE')
1289
+ OR pg_catalog.has_function_privilege(m.oid, 'pg_catalog.pg_stat_get_backend_activity(integer)', 'EXECUTE')
1290
+ OR EXISTS (SELECT 1 FROM pg_catalog.pg_proc f WHERE f.proname = 'pg_stat_statements'
1291
+ AND pg_catalog.has_function_privilege(m.oid, f.oid, 'EXECUTE'))
1292
+ OR EXISTS (SELECT 1 FROM pg_catalog.pg_class v WHERE v.relname = 'pg_stat_statements'
1293
+ AND v.relkind = 'v' AND pg_catalog.has_table_privilege(m.oid, v.oid, 'SELECT'))))
1294
+ LIMIT 20) s;
1295
+ IF found IS NOT NULL THEN
1296
+ RAISE EXCEPTION 'DB Firewall: dbfw.harden: % still has %, through a role it is a member of: revoke it there, or end that membership', app, found;
1297
+ END IF;
1298
+ END IF;
1299
+ END;
1300
+ $$;
1301
+
1302
+ -- 11. Opt-in (dbfw.ddl_guard = on): install the DDL guard (section 5b) as two
1303
+ -- event triggers in this database. Event triggers need a superuser. A
1304
+ -- later run without dbfw.ddl_guard leaves them as they are; remove them
1305
+ -- with DROP EVENT TRIGGER _firewall_ddl_guard, _firewall_ddl_drop_guard.
1306
+ DO $$
1307
+ BEGIN
1308
+ IF lower(COALESCE(NULLIF(current_setting('dbfw.ddl_guard', true), ''), 'off')) NOT IN ('on', 'true', '1') THEN
1309
+ RETURN;
1310
+ END IF;
1311
+ IF NOT (SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = current_user) THEN
1312
+ RAISE EXCEPTION 'DB Firewall: dbfw.ddl_guard needs a superuser (event triggers)';
1313
+ END IF;
1314
+ DROP EVENT TRIGGER IF EXISTS _firewall_ddl_guard; -- created again: its tags may have changed
1315
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_event_trigger WHERE evtname = '_firewall_ddl_guard') THEN
1316
+ -- Every command, no tag list: ALTER INDEX and ALTER VIEW also rename
1317
+ -- a table or its columns, and a list would miss the next such path.
1318
+ CREATE EVENT TRIGGER _firewall_ddl_guard ON ddl_command_end
1319
+ EXECUTE FUNCTION db_firewall._firewall_ddl_guard();
1320
+ END IF;
1321
+ IF NOT EXISTS (SELECT 1 FROM pg_catalog.pg_event_trigger WHERE evtname = '_firewall_ddl_drop_guard') THEN
1322
+ CREATE EVENT TRIGGER _firewall_ddl_drop_guard ON sql_drop EXECUTE FUNCTION db_firewall._firewall_ddl_drop_guard();
1323
+ END IF;
1324
+ -- The guard must fire for every session, also one with
1325
+ -- session_replication_role = replica.
1326
+ ALTER EVENT TRIGGER _firewall_ddl_guard ENABLE ALWAYS;
1327
+ ALTER EVENT TRIGGER _firewall_ddl_drop_guard ENABLE ALWAYS;
1328
+ END;
1329
+ $$;
1330
+
1331
+ -- The same check as section 0, again (see there). Kept identical to it.
1332
+ DO $$
1333
+ DECLARE
1334
+ bad TEXT;
1335
+ fw OID := to_regnamespace('db_firewall');
1336
+ BEGIN
1337
+ SELECT string_agg(o.name || ' (owner ' || pg_get_userbyid(o.owner) || ')', ', ') INTO bad
1338
+ FROM (SELECT 'schema db_firewall' AS name, n.nspowner AS owner FROM pg_namespace n WHERE n.oid = fw
1339
+ UNION ALL
1340
+ SELECT c.oid::regclass::text, c.relowner FROM pg_class c
1341
+ WHERE c.relnamespace = fw
1342
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#')
1343
+ UNION ALL
1344
+ SELECT p.oid::regprocedure::text, p.proowner FROM pg_proc p
1345
+ WHERE p.pronamespace = fw
1346
+ OR (p.pronamespace = 'public'::regnamespace AND p.proname LIKE '#_firewall#_%' ESCAPE '#')
1347
+ UNION ALL
1348
+ SELECT t.oid::regtype::text, t.typowner FROM pg_type t
1349
+ WHERE (t.typnamespace = fw
1350
+ OR (t.typnamespace = 'public'::regnamespace AND t.typname LIKE '#_firewall#_%' ESCAPE '#'))
1351
+ AND t.typrelid = 0
1352
+ UNION ALL
1353
+ SELECT o.oid::regoperator::text, o.oprowner FROM pg_operator o WHERE o.oprnamespace = fw) o
1354
+ JOIN pg_roles r ON r.oid = o.owner
1355
+ WHERE r.rolname <> current_user AND NOT r.rolsuper;
1356
+ IF bad IS NOT NULL THEN
1357
+ RAISE EXCEPTION 'DB Firewall: % not owned by % or a superuser. Another role may have planted them: drop them, or ALTER ... OWNER TO a trusted role, and re-run.', bad, current_user;
1358
+ END IF;
1359
+ -- And code or row rules on the firewall's own tables: a trigger there runs
1360
+ -- for whoever writes the table (_firewall_set_tenant writes the audit log
1361
+ -- as its owner). The firewall makes none; an earlier version let the app
1362
+ -- have TRIGGER on the audit log.
1363
+ SELECT string_agg(h, ', ') INTO bad FROM (
1364
+ SELECT format('trigger %I on %s', t.tgname, t.tgrelid::regclass) AS h
1365
+ FROM pg_trigger t JOIN pg_class c ON c.oid = t.tgrelid
1366
+ WHERE NOT t.tgisinternal AND (c.relnamespace = fw
1367
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#'))
1368
+ UNION ALL
1369
+ SELECT format('rule %I on %s', w.rulename, w.ev_class::regclass)
1370
+ FROM pg_rewrite w JOIN pg_class c ON c.oid = w.ev_class
1371
+ WHERE w.rulename <> '_RETURN' AND (c.relnamespace = fw
1372
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#'))
1373
+ UNION ALL
1374
+ SELECT format('foreign key %I from %s', k.conname, k.conrelid::regclass) -- it can block the audit purge
1375
+ FROM pg_constraint k JOIN pg_class c ON c.oid = k.confrelid
1376
+ WHERE k.contype = 'f' AND (c.relnamespace = fw
1377
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#'))
1378
+ UNION ALL
1379
+ SELECT format('policy %I on %s', y.polname, y.polrelid::regclass)
1380
+ FROM pg_policy y JOIN pg_class c ON c.oid = y.polrelid
1381
+ WHERE c.relnamespace = fw
1382
+ OR (c.relnamespace = 'public'::regnamespace AND c.relname LIKE '#_firewall#_%' ESCAPE '#')) z;
1383
+ IF bad IS NOT NULL THEN
1384
+ RAISE EXCEPTION 'DB Firewall: % on the firewall''s own tables: the firewall makes none, and they run for whoever writes there (or block what it deletes). Drop them, then re-run.', bad;
1385
+ END IF;
1386
+ END;
1387
+ $$;
1388
+
1389
+ -- And schema db_firewall: no role but its owner's (and superusers) can CREATE
1390
+ -- in it, and every role may use it (the same test as in section 0b).
1391
+ DO $$
1392
+ BEGIN
1393
+ IF EXISTS (SELECT 1 FROM pg_roles x, pg_namespace n WHERE n.nspname = 'db_firewall' AND NOT x.rolsuper
1394
+ AND NOT pg_has_role(x.oid, n.nspowner, 'MEMBER') AND has_schema_privilege(x.oid, n.oid, 'CREATE'))
1395
+ OR NOT EXISTS (SELECT 1 FROM pg_namespace n, aclexplode(n.nspacl) a
1396
+ WHERE n.nspname = 'db_firewall' AND a.grantee = 0 AND a.privilege_type = 'USAGE') THEN
1397
+ RAISE EXCEPTION 'DB Firewall: schema db_firewall: another role can CREATE in it, or PUBLIC lacks USAGE on it (granted while this script ran?): re-run';
1398
+ END IF;
1399
+ END;
1400
+ $$;
1401
+
1402
+ COMMIT;