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,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
|