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,532 @@
1
+ -- The PostgreSQL checks of `db-firewall scan` and `db-firewall verify`, in
2
+ -- one place: packages/cli (TypeScript) and hooks/ (Python) both run these
3
+ -- queries, so the two tools find the same things. Change a check here, not in
4
+ -- either tool.
5
+ --
6
+ -- Format, read by both tools:
7
+ -- -- @excluded <glob> ... the tables the migration skips (and so the scan)
8
+ -- -- @check <name> a global verify check: one row (ok boolean, detail
9
+ -- text). A query error fails it.
10
+ -- -- @info <name> the same, but never fails: an information line
11
+ -- -- @query <name> a query a tool runs itself, with its own logic
12
+ -- Each block runs to the next "-- @" line. Parameters are $1, $2, ... in the
13
+ -- order given. The tools run everything with search_path = pg_catalog,
14
+ -- pg_temp, so a planted operator in another schema cannot change a result.
15
+ --
16
+ -- "A role the app can use": the role itself, a role it inherits, or one it can
17
+ -- SET ROLE to (pg_has_role ... 'MEMBER'). The verify checks run as the app role.
18
+
19
+ -- @excluded _firewall_* schema_migrations alembic_version knex_migrations prisma_migrations __diesel_schema_migrations flyway_schema_history
20
+
21
+ -- ============================================================== scan
22
+ -- @query scan_tables
23
+ -- Every table in schema $1 with the tenant column $2, and its firewall
24
+ -- policies. Protected = RLS enabled AND forced AND all four firewall policies
25
+ -- AND their exact shape AND, when they are permissive, no other permissive
26
+ -- policy (PostgreSQL ORs permissive policies). ref_rel: the relation the
27
+ -- reference predicate is built on (a partition's root).
28
+ WITH t AS (
29
+ SELECT c.oid, n.nspname, c.relname, c.relrowsecurity, c.relforcerowsecurity,
30
+ 'fw_' || left(md5(n.nspname || '.' || c.relname), 12) AS stem,
31
+ 'firewall_rls_' || c.relname AS legacy
32
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
33
+ WHERE c.relkind IN ('r', 'p') AND n.nspname = $1
34
+ AND EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = c.oid AND a.attname = $2
35
+ AND a.attnum > 0 AND NOT a.attisdropped))
36
+ SELECT t.relname AS table, t.relrowsecurity AS enabled, t.relforcerowsecurity AS forced,
37
+ pg_get_userbyid((SELECT relowner FROM pg_class WHERE oid = t.oid)) AS owner,
38
+ (CASE WHEN (SELECT relispartition FROM pg_class WHERE oid = t.oid) THEN pg_partition_root(t.oid)
39
+ ELSE t.oid END)::regclass::text AS ref_rel,
40
+ ARRAY(SELECT op FROM unnest(ARRAY['sel', 'ins', 'upd', 'del']) AS op
41
+ WHERE NOT EXISTS (SELECT 1 FROM pg_policies p
42
+ WHERE p.schemaname = t.nspname AND p.tablename = t.relname
43
+ AND p.policyname IN (t.stem || '_' || op, t.legacy || '_' || op))) AS missing,
44
+ ARRAY(SELECT p.policyname::text FROM pg_policies p
45
+ WHERE p.schemaname = t.nspname AND p.tablename = t.relname AND p.permissive = 'PERMISSIVE'
46
+ AND p.policyname NOT IN (SELECT pre || '_' || op FROM unnest(ARRAY[t.stem, t.legacy]) AS pre,
47
+ unnest(ARRAY['sel', 'ins', 'upd', 'del']) AS op)) AS extra,
48
+ COALESCE((SELECT count(*) = 4 AND bool_and(p.permissive = 'RESTRICTIVE') FROM pg_policies p
49
+ WHERE p.schemaname = t.nspname AND p.tablename = t.relname
50
+ AND p.policyname IN (t.stem || '_sel', t.stem || '_ins',
51
+ t.stem || '_upd', t.stem || '_del')), false) AS restrictive,
52
+ COALESCE((SELECT json_agg(json_build_array(p.policyname, p.cmd, p.roles, p.qual, p.with_check))
53
+ FROM pg_policies p
54
+ WHERE p.schemaname = t.nspname AND p.tablename = t.relname
55
+ AND p.policyname IN (SELECT pre || '_' || op FROM unnest(ARRAY[t.stem, t.legacy]) AS pre,
56
+ unnest(ARRAY['sel', 'ins', 'upd', 'del']) AS op)), '[]') AS policies,
57
+ EXISTS (SELECT 1 FROM pg_policies p WHERE p.schemaname = t.nspname AND p.tablename = t.relname
58
+ AND p.policyname = t.stem || '_base') AS base
59
+ FROM t ORDER BY t.relname
60
+
61
+ -- @query reference_qual
62
+ -- The deparsed predicate of the policy on the temporary reference table.
63
+ SELECT qual FROM pg_policies WHERE tablename = '_fw_ref' AND policyname = '_fw_ref_p'
64
+ AND schemaname = pg_my_temp_schema()::regnamespace::text
65
+
66
+ -- @query fw_function
67
+ -- One function the migration creates ($1: its regprocedure text), as it is now.
68
+ SELECT prosrc, prosecdef, proconfig, provolatile, l.lanname, format_type(prorettype, NULL) AS ret
69
+ FROM pg_proc p JOIN pg_language l ON l.oid = p.prolang WHERE p.oid = to_regprocedure($1)
70
+
71
+ -- @query fw_extra
72
+ -- Functions in schema db_firewall the migration does not create ($1: its
73
+ -- signatures), and any public._firewall_* function (an earlier version kept
74
+ -- them there; the migration moves them).
75
+ SELECT p.oid::regprocedure::text AS fn FROM pg_proc p
76
+ WHERE (p.pronamespace = to_regnamespace('db_firewall')
77
+ AND NOT (p.proname || '(' || oidvectortypes(p.proargtypes) || ')') = ANY($1))
78
+ OR (p.pronamespace = 'public'::regnamespace AND p.proname LIKE '#_firewall#_%' ESCAPE '#'
79
+ -- not the migration's placeholders, which only raise (section 7b): the
80
+ -- exact body, plpgsql, not SECURITY DEFINER, and the firewall's owner
81
+ AND NOT (p.proname ~ '^_firewall_[a-z_]+$' -- no quote in the name to break out of the body
82
+ AND p.prosrc = format('BEGIN RAISE EXCEPTION ''DB Firewall: %s moved to schema db_firewall: upgrade the app''; END', p.proname)
83
+ AND NOT p.prosecdef AND p.proconfig IS NULL
84
+ AND p.prolang = (SELECT oid FROM pg_language WHERE lanname = 'plpgsql')
85
+ AND p.proowner = (SELECT proowner FROM pg_proc
86
+ WHERE oid = to_regprocedure('db_firewall._firewall_protect(text, text, text, text, text)'))))
87
+ ORDER BY 1
88
+
89
+ -- @query fw_owners
90
+ -- The owners of schema db_firewall, of the objects in it, and of any
91
+ -- public._firewall_* object: one, or only superusers.
92
+ SELECT pg_get_userbyid(o.owner) AS owner, r.rolsuper AS super, string_agg(o.name, ', ' ORDER BY o.name) AS objects
93
+ FROM (SELECT 'schema db_firewall' AS name, nspowner AS owner FROM pg_namespace WHERE nspname = 'db_firewall'
94
+ UNION ALL SELECT relname::text, relowner FROM pg_class
95
+ WHERE relnamespace = to_regnamespace('db_firewall')
96
+ OR (relnamespace = 'public'::regnamespace AND relname LIKE '#_firewall#_%' ESCAPE '#')
97
+ UNION ALL SELECT proname::text, proowner FROM pg_proc
98
+ WHERE pronamespace = to_regnamespace('db_firewall')
99
+ OR (pronamespace = 'public'::regnamespace AND proname LIKE '#_firewall#_%' ESCAPE '#')) o
100
+ JOIN pg_roles r ON r.oid = o.owner
101
+ GROUP BY o.owner, r.rolsuper ORDER BY count(*) DESC
102
+
103
+ -- ============================================================== verify: the role
104
+ -- @check role
105
+ -- RLS must apply to every role the app can use: none is a superuser, has
106
+ -- BYPASSRLS or is in _firewall_bypass_roles, or is a member of a predefined
107
+ -- role that reads every row (pg_read_all_data, pg_write_all_data) or runs
108
+ -- programs or reads and writes files as the server.
109
+ WITH bad AS (
110
+ SELECT CASE WHEN r.rolname = current_user THEN 'role ' || r.rolname ELSE 'is a member of ' || r.rolname || ', which' END
111
+ || CASE WHEN r.rolsuper THEN ' is a superuser' WHEN r.rolbypassrls THEN ' has BYPASSRLS'
112
+ ELSE ' is in _firewall_bypass_roles' END AS item, 0 AS o, r.rolname
113
+ FROM pg_roles r WHERE pg_has_role(current_user, r.oid, 'MEMBER')
114
+ AND (r.rolsuper OR r.rolbypassrls OR db_firewall._firewall_is_bypass_role(r.rolname::text))
115
+ UNION ALL
116
+ SELECT 'is a member of ' || rolname || ', which reads or runs things as the server', 1, rolname FROM pg_roles
117
+ WHERE rolname IN ('pg_read_all_data', 'pg_write_all_data', 'pg_execute_server_program',
118
+ 'pg_read_server_files', 'pg_write_server_files')
119
+ AND pg_has_role(current_user, oid, 'MEMBER'))
120
+ SELECT count(*) = 0 AS ok,
121
+ COALESCE(string_agg(item, '; ' ORDER BY o, rolname) || ': RLS does not apply', 'no role it can use skips RLS') AS detail
122
+ FROM bad
123
+
124
+ -- @check cannot write firewall tables
125
+ -- The app could add itself as a bypass role, map itself to a tenant, or take
126
+ -- a schema out of the DDL guard.
127
+ WITH bad AS (
128
+ SELECT t AS name FROM unnest(ARRAY['db_firewall._firewall_bypass_roles', 'db_firewall._firewall_tenant_map',
129
+ 'db_firewall._firewall_protected_schemas', 'db_firewall._firewall_guard_calls',
130
+ 'public._firewall_bypass_roles', 'public._firewall_tenant_map']) t
131
+ WHERE to_regclass(t) IS NOT NULL
132
+ AND EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
133
+ AND (has_any_column_privilege(m.oid, t, 'INSERT') OR has_any_column_privilege(m.oid, t, 'UPDATE')
134
+ OR has_table_privilege(m.oid, t, 'DELETE, TRUNCATE'))))
135
+ SELECT count(*) = 0 AS ok,
136
+ COALESCE('can write ' || string_agg(name, ', ' ORDER BY name) || ' (could add itself as a bypass role)',
137
+ 'no write access') AS detail
138
+ FROM bad
139
+
140
+ -- @check cannot read the key or sign
141
+ -- With the key, or a way to sign, the app could sign any tenant.
142
+ WITH bad AS (
143
+ SELECT 'table ' || k AS name FROM unnest(ARRAY['db_firewall._firewall_keys', 'public._firewall_keys']) k
144
+ WHERE to_regclass(k) IS NOT NULL
145
+ AND EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
146
+ AND has_any_column_privilege(m.oid, to_regclass(k), 'SELECT'))
147
+ UNION ALL SELECT 'function ' || p.proname FROM pg_proc p
148
+ WHERE p.pronamespace = to_regnamespace('db_firewall') AND p.proname IN ('_firewall_sign', '_firewall_set_tenant')
149
+ AND EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
150
+ AND has_function_privilege(m.oid, p.oid, 'EXECUTE')))
151
+ SELECT count(*) = 0 AS ok,
152
+ COALESCE('can use ' || string_agg(name, ', ' ORDER BY name) || ' (could sign another tenant): re-run migrate',
153
+ 'neither') AS detail
154
+ FROM bad
155
+
156
+ -- @check does not own firewall objects
157
+ -- The owner of schema db_firewall can drop the bypass list and create its
158
+ -- own, or add an overload that makes the firewall's calls ambiguous; an
159
+ -- owner of a firewall object can replace it.
160
+ WITH bad AS (
161
+ SELECT 'schema db_firewall' AS name FROM pg_namespace WHERE nspname = 'db_firewall' AND pg_has_role(current_user, nspowner, 'MEMBER')
162
+ UNION ALL SELECT 'table ' || relname FROM pg_class
163
+ WHERE (relnamespace = to_regnamespace('db_firewall')
164
+ OR (relnamespace = 'public'::regnamespace AND relname LIKE '#_firewall#_%' ESCAPE '#'))
165
+ AND pg_has_role(current_user, relowner, 'MEMBER')
166
+ UNION ALL SELECT 'function ' || proname FROM pg_proc
167
+ WHERE (pronamespace = to_regnamespace('db_firewall')
168
+ OR (pronamespace = 'public'::regnamespace AND proname LIKE '#_firewall#_%' ESCAPE '#'))
169
+ AND pg_has_role(current_user, proowner, 'MEMBER'))
170
+ SELECT count(*) = 0 AS ok,
171
+ COALESCE('can act as the owner of ' || string_agg(name, ', ' ORDER BY name)
172
+ || ' (could drop or replace the bypass list)', 'owns none') AS detail
173
+ FROM bad
174
+
175
+ -- @check owns no database objects
176
+ -- An owner can change what runs in other tenants' sessions: a domain
177
+ -- constraint or a function body that sends values out (pg_notify), a table's
178
+ -- RLS, its own grants back. Any object in the database counts (pg_shdepend),
179
+ -- except temporary ones, large objects, default ACLs and user mappings (the
180
+ -- mapped role counts as their owner).
181
+ WITH bad AS (
182
+ SELECT 'database ' || datname AS name FROM pg_database
183
+ WHERE datname = current_database() AND pg_has_role(current_user, datdba, 'MEMBER')
184
+ UNION ALL (SELECT o.type || ' ' || o.identity FROM pg_shdepend s
185
+ CROSS JOIN LATERAL pg_identify_object(s.classid, s.objid, 0) o
186
+ WHERE s.dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) AND s.deptype = 'o'
187
+ AND s.classid NOT IN ('pg_largeobject'::regclass, 'pg_default_acl'::regclass, 'pg_user_mapping'::regclass)
188
+ AND pg_has_role(current_user, s.refobjid, 'MEMBER')
189
+ AND COALESCE(o.schema, '') NOT LIKE 'pg#_temp#_%' ESCAPE '#'
190
+ AND COALESCE(o.schema, '') NOT LIKE 'pg#_toast#_temp#_%' ESCAPE '#'
191
+ ORDER BY 1 LIMIT 20))
192
+ SELECT count(*) = 0 AS ok,
193
+ COALESCE('a role it can use owns ' || string_agg(name, ', ' ORDER BY name)
194
+ || ': give them to another role (run migrations as it)', 'owns none') AS detail
195
+ FROM bad
196
+
197
+ -- @info signals other sessions (info)
198
+ -- What one shared role allows that no tenant check can see.
199
+ SELECT true AS ok,
200
+ CASE WHEN EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
201
+ AND EXISTS (SELECT 1 FROM pg_proc p WHERE p.pronamespace = 'pg_catalog'::regnamespace
202
+ AND p.proname IN ('pg_terminate_backend', 'pg_cancel_backend')
203
+ AND has_function_privilege(m.oid, p.oid, 'EXECUTE')))
204
+ THEN 'yes: SQL one tenant runs can end or cancel another tenant''s requests (availability only); '
205
+ || 'migrate --harden-signals revokes it'
206
+ ELSE 'no (pg_terminate_backend / pg_cancel_backend revoked)' END AS detail
207
+
208
+ -- @info role defaults (info)
209
+ SELECT true AS ok,
210
+ COALESCE('set: ' || string_agg(ro.rolname || COALESCE(' in ' || d.datname, '') || ': '
211
+ || array_to_string(s.setconfig, ', '), '; ' ORDER BY ro.rolname), 'none')
212
+ || ' (the role can change its own with ALTER ROLE CURRENT_USER SET; no grant prevents it, '
213
+ || 'and a bad value can break its later connections)' AS detail
214
+ FROM pg_db_role_setting s JOIN pg_roles ro ON ro.oid = s.setrole
215
+ LEFT JOIN pg_database d ON d.oid = s.setdatabase
216
+ WHERE pg_has_role(current_user, s.setrole, 'MEMBER') AND (s.setdatabase = 0 OR d.datname = current_database())
217
+
218
+ -- @info DDL guard (info)
219
+ -- migrate --ddl-guard: two event triggers protect a new table at once and
220
+ -- refuse a change that weakens a protected one.
221
+ SELECT true AS ok,
222
+ CASE WHEN (SELECT count(*) FROM pg_event_trigger
223
+ WHERE evtname IN ('_firewall_ddl_guard', '_firewall_ddl_drop_guard') AND evtenabled <> 'D') = 2
224
+ THEN 'on: a new table with the tenant column is protected at once, and a change that weakens a '
225
+ || 'protected table fails, for every role but a superuser and the migration''s'
226
+ ELSE 'off (migrate --ddl-guard installs it: a new table is then protected at once, not at the next '
227
+ || 'migration run)' END AS detail
228
+
229
+ -- @check cannot create triggers
230
+ -- TRIGGER on a table lets the app attach a deferred trigger: it fires at
231
+ -- COMMIT and can leave state for the connection's next user.
232
+ WITH bad AS (
233
+ SELECT c.oid::regclass::text AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
234
+ WHERE c.relkind IN ('r', 'p', 'v', 'f') AND n.nspname NOT IN ('pg_catalog', 'information_schema')
235
+ AND n.nspname NOT LIKE 'pg#_toast%' ESCAPE '#' AND n.nspname NOT LIKE 'pg#_temp#_%' ESCAPE '#'
236
+ AND (EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
237
+ AND has_table_privilege(m.oid, c.oid, 'TRIGGER'))
238
+ OR pg_has_role(current_user, c.relowner, 'MEMBER'))
239
+ ORDER BY 1 LIMIT 20)
240
+ SELECT count(*) = 0 AS ok,
241
+ COALESCE('TRIGGER is granted on, or a role it can use owns, ' || string_agg(name, ', ' ORDER BY name)
242
+ || ': REVOKE TRIGGER ... FROM the app role, and give owned tables to another role',
243
+ 'no TRIGGER on any table') AS detail
244
+ FROM bad
245
+
246
+ -- @check cannot create in any schema
247
+ -- CREATE in a schema lets the app plant objects or operators that a later
248
+ -- query binds to, reached through a search_path it sets for itself (ALTER
249
+ -- ROLE ... SET persists, and no reset can undo it). CREATE on the database
250
+ -- makes new schemas, such as one named like the role: the default search_path
251
+ -- ("$user", public) looks there first. An owner can grant itself CREATE.
252
+ WITH db AS (
253
+ SELECT EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
254
+ AND has_database_privilege(m.oid, current_database(), 'CREATE')) AS create_db,
255
+ pg_has_role(current_user, datdba, 'MEMBER') AS owns
256
+ FROM pg_database WHERE datname = current_database()),
257
+ ns AS (
258
+ SELECT n.nspname FROM pg_namespace n
259
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg#_toast%' ESCAPE '#'
260
+ AND n.nspname NOT LIKE 'pg#_temp#_%' ESCAPE '#'
261
+ AND (EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
262
+ AND has_schema_privilege(m.oid, n.oid, 'CREATE'))
263
+ OR pg_has_role(current_user, n.nspowner, 'MEMBER')))
264
+ SELECT NOT db.create_db AND NOT db.owns AND NOT EXISTS (SELECT 1 FROM ns) AS ok,
265
+ CASE WHEN db.owns THEN 'a role it can use owns this database (an owner can grant itself CREATE)'
266
+ WHEN db.create_db THEN 'a role it can use can CREATE schemas in this database '
267
+ || '(REVOKE CREATE ON DATABASE ... FROM PUBLIC and the app role)'
268
+ WHEN EXISTS (SELECT 1 FROM ns) THEN 'a role it can use can CREATE in, or owns, '
269
+ || (SELECT string_agg(nspname, ', ' ORDER BY nspname) FROM ns)
270
+ || ': REVOKE CREATE ON SCHEMA ... FROM PUBLIC and the app role, and give owned schemas to another role '
271
+ || '(run migrations as another role)'
272
+ ELSE 'no CREATE in any schema' END AS detail
273
+ FROM db
274
+
275
+ -- ============================================================== verify: per table
276
+ -- @query table_privileges
277
+ -- For table $1 (regclass text) and the tenant column $2: privileges RLS does
278
+ -- not cover (the owner can turn RLS off; TRUNCATE ignores it), and who can
279
+ -- read the tenant column.
280
+ SELECT pg_has_role(current_user, c.relowner, 'MEMBER') AS owns,
281
+ EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
282
+ AND has_table_privilege(m.oid, c.oid, 'TRUNCATE')) AS truncate,
283
+ has_column_privilege(current_user, c.oid, $2, 'SELECT') AS can_select,
284
+ EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
285
+ AND has_column_privilege(m.oid, c.oid, $2, 'SELECT')) AS some_role_selects
286
+ FROM pg_class c WHERE c.oid = $1::regclass
287
+
288
+ -- @query nonce
289
+ SELECT db_firewall._firewall_nonce() AS n
290
+
291
+ -- ============================================================== verify: read paths RLS does not guard
292
+ -- @query bypass_views
293
+ -- A view or materialized view the app can read whose chain down to a table
294
+ -- with the tenant column ($1) holds a materialized view (no RLS at all) or a
295
+ -- view that runs as an owner who skips RLS (not security_invoker); and a
296
+ -- materialized view with the tenant column or built over a function.
297
+ -- ponytail: any such view in the chain counts, even one shadowed by a lower
298
+ -- definer view of a non-bypassing owner (a false fail, never a false pass).
299
+ WITH RECURSIVE rel AS (
300
+ SELECT v.oid, v.relkind = 'm' OR (NOT COALESCE((SELECT option_value::boolean FROM pg_options_to_table(v.reloptions)
301
+ WHERE option_name = 'security_invoker'), false)
302
+ AND (r.rolsuper OR r.rolbypassrls OR db_firewall._firewall_is_bypass_role(r.rolname::text))) AS bad
303
+ FROM pg_class v JOIN pg_roles r ON r.oid = v.relowner WHERE v.relkind IN ('v', 'm')),
304
+ dep AS (
305
+ SELECT DISTINCT rw.ev_class AS v, d.refobjid AS t FROM pg_rewrite rw
306
+ JOIN pg_depend d ON d.objid = rw.oid AND d.classid = 'pg_rewrite'::regclass
307
+ AND d.refclassid = 'pg_class'::regclass AND d.refobjid <> rw.ev_class),
308
+ chain(top, cur, bad) AS (
309
+ SELECT d.v, d.t, rel.bad FROM dep d JOIN rel ON rel.oid = d.v
310
+ UNION
311
+ SELECT c.top, d.t, c.bad OR rel.bad FROM chain c JOIN dep d ON d.v = c.cur JOIN rel ON rel.oid = d.v)
312
+ SELECT DISTINCT n.nspname || '.' || v.relname AS name, v.relkind = 'm' AS matview,
313
+ pg_get_userbyid(v.relowner) AS owner
314
+ FROM chain c JOIN pg_class v ON v.oid = c.top JOIN pg_namespace n ON n.oid = v.relnamespace
315
+ JOIN pg_class t ON t.oid = c.cur AND t.relkind IN ('r', 'p')
316
+ WHERE c.bad AND n.nspname NOT IN ('pg_catalog', 'information_schema')
317
+ AND EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
318
+ AND has_any_column_privilege(m.oid, v.oid, 'SELECT'))
319
+ AND EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = t.oid AND a.attname = $1
320
+ AND a.attnum > 0 AND NOT a.attisdropped)
321
+ UNION
322
+ SELECT n.nspname || '.' || v.relname, true, pg_get_userbyid(v.relowner)
323
+ FROM pg_class v JOIN pg_namespace n ON n.oid = v.relnamespace
324
+ WHERE v.relkind = 'm' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
325
+ AND EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
326
+ AND has_any_column_privilege(m.oid, v.oid, 'SELECT'))
327
+ AND (EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = v.oid AND a.attname = $1
328
+ AND a.attnum > 0 AND NOT a.attisdropped)
329
+ OR EXISTS (SELECT 1 FROM pg_rewrite rw JOIN pg_depend d ON d.objid = rw.oid
330
+ AND d.classid = 'pg_rewrite'::regclass AND d.refclassid = 'pg_proc'::regclass
331
+ JOIN pg_proc f ON f.oid = d.refobjid AND f.pronamespace <> 'pg_catalog'::regnamespace
332
+ WHERE rw.ev_class = v.oid))
333
+ ORDER BY 1
334
+
335
+ -- @query bypass_foreign
336
+ -- Foreign tables the app can read: RLS does not reach the remote data. tenant:
337
+ -- it has the tenant column ($1), so no allowlist entry clears it.
338
+ SELECT n.nspname || '.' || c.relname AS name,
339
+ EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = c.oid AND a.attname = $1
340
+ AND a.attnum > 0 AND NOT a.attisdropped) AS tenant
341
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
342
+ WHERE c.relkind = 'f' AND EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
343
+ AND has_any_column_privilege(m.oid, c.oid, 'SELECT'))
344
+ ORDER BY 1
345
+
346
+ -- @query bypass_rules
347
+ -- A rule's actions run with the table owner's privileges and can read (or
348
+ -- copy) what the app cannot. Any rule counts: a write reaches a table through
349
+ -- a view, a column grant or a cascade. Use a trigger instead.
350
+ SELECT rw.rulename || ' on ' || c.oid::regclass::text AS name FROM pg_rewrite rw JOIN pg_class c ON c.oid = rw.ev_class
351
+ JOIN pg_namespace n ON n.oid = c.relnamespace
352
+ WHERE rw.rulename <> '_RETURN' AND n.nspname NOT IN ('pg_catalog', 'information_schema')
353
+ ORDER BY 1
354
+
355
+ -- @query bypass_functions
356
+ -- A SECURITY DEFINER function owned by a role that skips RLS, which the app
357
+ -- can execute or which PostgreSQL runs without checking EXECUTE: trigger and
358
+ -- event trigger functions, aggregate and range support, index support (amproc,
359
+ -- and the operators of operator families), selectivity estimators, planner
360
+ -- support, type I/O, encoding conversions, transforms, text search parsers and
361
+ -- templates, and FDW and language handlers and validators. And a LEAKPROOF
362
+ -- function outside pg_catalog: it runs before the RLS filter and can leak the
363
+ -- rows it sees. ponytail: a function body is opaque (plpgsql records no
364
+ -- dependencies), so every such function counts, the firewall's own aside.
365
+ WITH implicit AS (
366
+ SELECT tgfoid AS f FROM pg_trigger UNION SELECT evtfoid FROM pg_event_trigger
367
+ UNION SELECT unnest(ARRAY[aggtransfn, aggfinalfn, aggcombinefn, aggserialfn, aggdeserialfn,
368
+ aggmtransfn, aggminvtransfn, aggmfinalfn])::oid FROM pg_aggregate
369
+ UNION SELECT unnest(ARRAY[rngcanonical, rngsubdiff])::oid FROM pg_range
370
+ UNION SELECT amproc FROM pg_amproc
371
+ UNION SELECT unnest(ARRAY[oprrest, oprjoin])::oid FROM pg_operator
372
+ UNION SELECT unnest(ARRAY[typinput, typoutput, typreceive, typsend, typmodin, typmodout,
373
+ typanalyze, typsubscript])::oid FROM pg_type
374
+ UNION SELECT conproc FROM pg_conversion
375
+ UNION SELECT o.oprcode::oid FROM pg_amop a JOIN pg_operator o ON o.oid = a.amopopr
376
+ UNION SELECT prosupport::oid FROM pg_proc WHERE prosupport <> 0
377
+ UNION SELECT unnest(ARRAY[trffromsql, trftosql])::oid FROM pg_transform
378
+ UNION SELECT unnest(ARRAY[prsstart, prstoken, prsend, prsheadline, prslextype])::oid FROM pg_ts_parser
379
+ UNION SELECT unnest(ARRAY[tmplinit, tmpllexize])::oid FROM pg_ts_template
380
+ UNION SELECT unnest(ARRAY[fdwhandler, fdwvalidator]) FROM pg_foreign_data_wrapper
381
+ UNION SELECT unnest(ARRAY[lanplcallfoid, laninline, lanvalidator]) FROM pg_language)
382
+ SELECT p.oid::regprocedure::text AS name, pg_get_userbyid(p.proowner) AS owner, p.proleakproof AS leakproof
383
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace JOIN pg_roles r ON r.oid = p.proowner
384
+ WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
385
+ AND n.nspname <> 'db_firewall'
386
+ AND (EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
387
+ AND has_function_privilege(m.oid, p.oid, 'EXECUTE'))
388
+ OR p.oid IN (SELECT f FROM implicit))
389
+ AND (p.proleakproof OR (p.prosecdef AND (r.rolsuper OR r.rolbypassrls OR db_firewall._firewall_is_bypass_role(r.rolname::text))))
390
+ ORDER BY 1
391
+
392
+ -- @query other_schemas
393
+ -- Tables with the tenant column ($2) outside the scanned schemas ($1) that a
394
+ -- role the app can use can read (a column grant is enough).
395
+ SELECT n.nspname || '.' || c.relname AS name FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
396
+ WHERE c.relkind IN ('r', 'p') AND NOT (n.nspname = ANY($1))
397
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg_toast%'
398
+ AND EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
399
+ AND has_any_column_privilege(m.oid, c.oid, 'SELECT'))
400
+ AND EXISTS (SELECT 1 FROM pg_attribute a WHERE a.attrelid = c.oid AND a.attname = $2
401
+ AND a.attnum > 0 AND NOT a.attisdropped)
402
+ ORDER BY 1
403
+
404
+ -- @query keys_without_tenant
405
+ -- Keys that are not scoped to the tenant: RLS hides other tenants' rows, but
406
+ -- a unique key or an exclusion constraint still refuses a duplicate of their
407
+ -- value, and a foreign key still finds (or misses) their parent row, so the
408
+ -- error tells the app that another tenant has the value. $1: the protected
409
+ -- tables, as jsonb [[schema, table], ...]; $2: the tenant column.
410
+ -- * A unique index (primary keys included) or exclusion constraint on the
411
+ -- table whose key columns leave out the tenant column (a tenant column only
412
+ -- in INCLUDE, or in an exclusion constraint with an operator other than =,
413
+ -- does not count). A single-column key on a generated value (an
414
+ -- identity column, or a default from a sequence or a uuid function) is not
415
+ -- counted: a duplicate then shows only that an id is taken (an app that
416
+ -- sends its own ids can still learn which ids exist).
417
+ -- * A foreign key to a protected table, unless the referencing table is
418
+ -- protected too and its tenant column fills the referenced one (else the
419
+ -- app can probe another tenant's rows with that tenant's value there).
420
+ WITH prot AS (
421
+ SELECT c.oid, n.nspname || '.' || c.relname AS name, a.attnum AS att
422
+ FROM jsonb_array_elements($1::jsonb) e
423
+ JOIN pg_namespace n ON n.nspname = e->>0 JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = e->>1
424
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = $2 AND a.attnum > 0 AND NOT a.attisdropped)
425
+ SELECT CASE WHEN x.indisexclusion THEN 'exclusion constraint ' ELSE 'unique key ' END
426
+ || i.relname || ' on ' || p.name AS item
427
+ FROM prot p JOIN pg_index x ON x.indrelid = p.oid AND (x.indisunique OR x.indisexclusion)
428
+ JOIN pg_class i ON i.oid = x.indexrelid
429
+ WHERE NOT EXISTS (SELECT 1 FROM generate_series(0, x.indnkeyatts - 1) k WHERE x.indkey[k] = p.att
430
+ AND (NOT x.indisexclusion -- an exclusion constraint counts it only with =
431
+ OR EXISTS (SELECT 1 FROM pg_constraint xc JOIN pg_operator o ON o.oid = xc.conexclop[k + 1]
432
+ WHERE xc.conindid = x.indexrelid AND o.oprname = '=')))
433
+ AND NOT (x.indnkeyatts = 1 AND x.indkey[0] <> 0 AND NOT x.indisexclusion
434
+ AND EXISTS (SELECT 1 FROM pg_attribute a LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
435
+ WHERE a.attrelid = p.oid AND a.attnum = x.indkey[0]
436
+ AND (a.attidentity <> ''
437
+ OR pg_get_expr(d.adbin, d.adrelid) ~ '^((public|extensions)\.)?(nextval|gen_random_uuid|uuid_generate_v[14])\(')))
438
+ UNION ALL
439
+ SELECT 'foreign key ' || con.conname || ' on ' || con.conrelid::regclass::text || ' to ' || p.name
440
+ FROM prot p JOIN pg_constraint con ON con.confrelid = p.oid AND con.contype = 'f'
441
+ WHERE con.conrelid NOT IN (SELECT oid FROM prot) -- else its policies do not pin that column
442
+ OR NOT EXISTS (SELECT 1 FROM generate_subscripts(con.confkey, 1) k
443
+ JOIN pg_attribute ca ON ca.attrelid = con.conrelid AND ca.attnum = con.conkey[k]
444
+ WHERE con.confkey[k] = p.att AND ca.attname = $2)
445
+ ORDER BY 1
446
+
447
+ -- @query query_text
448
+ -- All tenants share one role, so its sessions see each other's SQL text in
449
+ -- pg_stat_activity and pg_stat_statements, literal values included.
450
+ SELECT EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
451
+ AND (has_table_privilege(m.oid, 'pg_catalog.pg_stat_activity', 'SELECT')
452
+ OR has_function_privilege(m.oid, 'pg_catalog.pg_stat_get_activity(integer)', 'EXECUTE')
453
+ OR has_function_privilege(m.oid, 'pg_catalog.pg_stat_get_backend_activity(integer)', 'EXECUTE')
454
+ OR EXISTS (SELECT 1 FROM pg_proc f WHERE f.proname = 'pg_stat_statements'
455
+ AND has_function_privilege(m.oid, f.oid, 'EXECUTE'))
456
+ OR EXISTS (SELECT 1 FROM pg_class v WHERE v.relname = 'pg_stat_statements' AND v.relkind = 'v'
457
+ AND has_table_privilege(m.oid, v.oid, 'SELECT')))) AS can
458
+
459
+ -- ============================================================== verify --strict
460
+ -- @query strict_readable
461
+ -- Every relation a role the app can use can read, outside the system schemas,
462
+ -- with what decides whether it is tenant data. $1: the protected tables, as
463
+ -- jsonb [[schema, table], ...]. safe: a protected table, or a sequence one owns.
464
+ -- extension: the extension the relation belongs to, if any.
465
+ -- For a security_invoker view, the walk down its chain through invoker views:
466
+ -- broken when a view on it reads no relation, calls a function outside
467
+ -- pg_catalog, or it reaches a system relation; blockers: the relations it
468
+ -- reaches that are neither invoker views nor protected (each must be
469
+ -- allowlisted). ponytail: constants in a view body (VALUES, literals in a
470
+ -- UNION) cannot be seen.
471
+ WITH RECURSIVE prot AS (
472
+ SELECT c.oid FROM jsonb_array_elements($1::jsonb) e
473
+ JOIN pg_namespace n ON n.nspname = e->>0 JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = e->>1),
474
+ rel AS (
475
+ SELECT c.oid, n.nspname || '.' || c.relname AS name, c.relkind AS kind,
476
+ c.relkind = 'v' AND COALESCE((SELECT option_value::boolean FROM pg_options_to_table(c.reloptions)
477
+ WHERE option_name = 'security_invoker'), false) AS invoker,
478
+ EXISTS (SELECT 1 FROM pg_rewrite rw JOIN pg_depend d ON d.objid = rw.oid
479
+ AND d.classid = 'pg_rewrite'::regclass AND d.refclassid = 'pg_proc'::regclass
480
+ JOIN pg_proc f ON f.oid = d.refobjid AND f.pronamespace <> 'pg_catalog'::regnamespace
481
+ WHERE rw.ev_class = c.oid) AS calls_fn,
482
+ c.oid IN (SELECT oid FROM prot) AS prot,
483
+ (SELECT e.extname FROM pg_depend d JOIN pg_extension e ON e.oid = d.refobjid
484
+ WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.refclassid = 'pg_extension'::regclass
485
+ AND d.deptype = 'e' LIMIT 1) AS extension,
486
+ c.relkind = 'S' AND EXISTS (SELECT 1 FROM pg_depend d WHERE d.classid = 'pg_class'::regclass
487
+ AND d.objid = c.oid AND d.refclassid = 'pg_class'::regclass
488
+ AND d.deptype IN ('a', 'i') AND d.refobjid IN (SELECT oid FROM prot)) AS prot_seq,
489
+ EXISTS (SELECT 1 FROM pg_roles m WHERE pg_has_role(current_user, m.oid, 'MEMBER')
490
+ AND CASE WHEN c.relkind = 'S' THEN has_sequence_privilege(m.oid, c.oid, 'SELECT, USAGE')
491
+ ELSE has_any_column_privilege(m.oid, c.oid, 'SELECT') END) AS readable
492
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
493
+ WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f', 'S')
494
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema') AND n.nspname NOT LIKE 'pg#_%' ESCAPE '#'),
495
+ edge AS (
496
+ SELECT DISTINCT rw.ev_class AS v, d.refobjid AS t FROM pg_rewrite rw
497
+ JOIN pg_depend d ON d.objid = rw.oid AND d.classid = 'pg_rewrite'::regclass
498
+ AND d.refclassid = 'pg_class'::regclass AND d.refobjid <> rw.ev_class),
499
+ walk(top, cur) AS (
500
+ SELECT r.oid, r.oid FROM rel r WHERE r.readable AND r.invoker
501
+ UNION
502
+ SELECT w.top, e.t FROM walk w JOIN rel x ON x.oid = w.cur AND x.invoker JOIN edge e ON e.v = w.cur)
503
+ SELECT r.name, r.kind, r.invoker, r.extension, r.prot OR r.prot_seq AS safe,
504
+ r.invoker AND EXISTS (SELECT 1 FROM walk w LEFT JOIN rel x ON x.oid = w.cur WHERE w.top = r.oid
505
+ AND (x.oid IS NULL
506
+ OR (x.invoker AND (x.calls_fn OR NOT EXISTS (SELECT 1 FROM edge e WHERE e.v = x.oid)))))
507
+ AS broken,
508
+ ARRAY(SELECT DISTINCT x.name FROM walk w JOIN rel x ON x.oid = w.cur
509
+ WHERE w.top = r.oid AND NOT x.invoker AND NOT x.prot ORDER BY 1) AS blockers
510
+ FROM rel r WHERE r.readable ORDER BY r.name
511
+
512
+ -- @check strict: no readable large objects
513
+ -- Large objects are one store for the whole database: no RLS, no tenant.
514
+ SELECT count(*) = 0 AS ok,
515
+ CASE WHEN count(*) = 0 THEN 'none'
516
+ ELSE count(*) || ' large object(s) readable'
517
+ || CASE WHEN current_setting('lo_compat_privileges')::boolean THEN ' (lo_compat_privileges is on)' ELSE '' END
518
+ || ': shared by all tenants (lo_unlink them, or revoke)' END AS detail
519
+ FROM pg_largeobject_metadata l
520
+ WHERE current_setting('lo_compat_privileges')::boolean OR pg_has_role(current_user, l.lomowner, 'MEMBER')
521
+ OR EXISTS (SELECT 1 FROM aclexplode(l.lomacl) a WHERE a.privilege_type = 'SELECT'
522
+ AND (a.grantee = 0 OR pg_has_role(current_user, a.grantee, 'MEMBER')))
523
+
524
+ -- @query strict_definer
525
+ -- Every SECURITY DEFINER function outside the firewall's own, executable or
526
+ -- not: PostgreSQL runs some (triggers, aggregate, range and index support,
527
+ -- type I/O) without checking EXECUTE.
528
+ SELECT p.oid::regprocedure::text AS sig
529
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
530
+ WHERE p.prosecdef AND n.nspname NOT IN ('pg_catalog', 'information_schema')
531
+ AND n.nspname <> 'db_firewall'
532
+ ORDER BY 1