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.
- package/LICENSE +21 -0
- package/README.md +106 -3
- package/dist/checks.d.ts +19 -0
- package/dist/checks.js +55 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +204 -0
- package/dist/key.d.ts +5 -0
- package/dist/key.js +15 -0
- package/dist/migrate.d.ts +12 -0
- package/dist/migrate.js +43 -0
- package/dist/scan.d.ts +55 -0
- package/dist/scan.js +199 -0
- package/dist/verify.d.ts +40 -0
- package/dist/verify.js +271 -0
- package/package.json +33 -6
- package/sql/001_postgresql.sql +1402 -0
- package/sql/checks_postgresql.sql +532 -0
|
@@ -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;
|