@stratum-hq/lib 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +29 -2
  2. package/dist/crypto.d.ts.map +1 -1
  3. package/dist/crypto.js +50 -10
  4. package/dist/crypto.js.map +1 -1
  5. package/dist/index.d.ts +7 -3
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +17 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/migrate-schemas.d.ts +4 -0
  10. package/dist/migrate-schemas.d.ts.map +1 -1
  11. package/dist/migrate-schemas.js +16 -6
  12. package/dist/migrate-schemas.js.map +1 -1
  13. package/dist/migrate.d.ts +24 -0
  14. package/dist/migrate.d.ts.map +1 -1
  15. package/dist/migrate.js +44 -6
  16. package/dist/migrate.js.map +1 -1
  17. package/dist/migration-sql.d.ts +25 -0
  18. package/dist/migration-sql.d.ts.map +1 -0
  19. package/dist/migration-sql.js +64 -0
  20. package/dist/migration-sql.js.map +1 -0
  21. package/dist/migrations/029_tenant_parent_cycle_guard.sql +2 -2
  22. package/dist/migrations/031_subtree_read_scope.sql +6 -6
  23. package/dist/migrations/032_control_role.sql +809 -0
  24. package/dist/migrations/033_api_keys_tenant_index.sql +14 -0
  25. package/dist/pinned-query.d.ts +29 -0
  26. package/dist/pinned-query.d.ts.map +1 -0
  27. package/dist/pinned-query.js +60 -0
  28. package/dist/pinned-query.js.map +1 -0
  29. package/dist/pool-helpers.d.ts +2 -0
  30. package/dist/pool-helpers.d.ts.map +1 -1
  31. package/dist/pool-helpers.js +17 -3
  32. package/dist/pool-helpers.js.map +1 -1
  33. package/dist/role-model.d.ts +178 -0
  34. package/dist/role-model.d.ts.map +1 -0
  35. package/dist/role-model.js +670 -0
  36. package/dist/role-model.js.map +1 -0
  37. package/dist/services/api-key-service.d.ts +12 -2
  38. package/dist/services/api-key-service.d.ts.map +1 -1
  39. package/dist/services/api-key-service.js +33 -9
  40. package/dist/services/api-key-service.js.map +1 -1
  41. package/dist/services/config-service.d.ts +15 -6
  42. package/dist/services/config-service.d.ts.map +1 -1
  43. package/dist/services/config-service.js +96 -50
  44. package/dist/services/config-service.js.map +1 -1
  45. package/dist/services/tenant-service.d.ts.map +1 -1
  46. package/dist/services/tenant-service.js +4 -4
  47. package/dist/services/tenant-service.js.map +1 -1
  48. package/dist/stratum-policies.d.ts +51 -0
  49. package/dist/stratum-policies.d.ts.map +1 -0
  50. package/dist/stratum-policies.js +217 -0
  51. package/dist/stratum-policies.js.map +1 -0
  52. package/dist/stratum-tables.d.ts.map +1 -1
  53. package/dist/stratum-tables.js +1 -0
  54. package/dist/stratum-tables.js.map +1 -1
  55. package/dist/stratum.d.ts +52 -7
  56. package/dist/stratum.d.ts.map +1 -1
  57. package/dist/stratum.js +75 -14
  58. package/dist/stratum.js.map +1 -1
  59. package/package.json +5 -5
@@ -0,0 +1,670 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.STRATUM_FUNCTION_BODY_MD5 = exports.APP_READ_TABLES = void 0;
4
+ exports.bootstrapRolesSql = bootstrapRolesSql;
5
+ exports.integrityChecksPlpgsql = integrityChecksPlpgsql;
6
+ exports.currentLogin = currentLogin;
7
+ exports.checkRoleModel = checkRoleModel;
8
+ exports.inspectRoleModel = inspectRoleModel;
9
+ exports.warnNoAdminPool = warnNoAdminPool;
10
+ exports.warnLegacyKeyHash = warnLegacyKeyHash;
11
+ const stratum_tables_js_1 = require("./stratum-tables.js");
12
+ const migration_sql_js_1 = require("./migration-sql.js");
13
+ const pinned_query_js_1 = require("./pinned-query.js");
14
+ /**
15
+ * The role model of migration 032.
16
+ *
17
+ * - The control role (default stratum_control) is NOLOGIN. Every Stratum
18
+ * table has a stratum_control_plane policy for it, and it owns the
19
+ * SECURITY DEFINER helpers.
20
+ * - The admin login backs the library's adminPool. It is a member of the
21
+ * control role (INHERIT), owns the Stratum objects and runs the migrations.
22
+ * It needs neither SUPERUSER nor BYPASSRLS.
23
+ * - The application login is not a member of the control role, owns nothing
24
+ * of Stratum's, and may read only the tables in APP_READ_TABLES.
25
+ */
26
+ /** The Stratum tables the application role may read; it may write none. */
27
+ exports.APP_READ_TABLES = Object.freeze([
28
+ "tenants",
29
+ "config_entries",
30
+ "permission_policies",
31
+ "audit_logs",
32
+ "webhook_events",
33
+ "webhook_deliveries",
34
+ "consent_records",
35
+ "abac_policies",
36
+ "roles",
37
+ "principal_roles",
38
+ "usage_events",
39
+ ]);
40
+ /** Tables whose rows hold credentials or infrastructure; the application role reads none of them. */
41
+ const CREDENTIAL_TABLES = ["api_keys", "webhooks", "regions", "stratum_security"];
42
+ /** Functions of the Stratum migrations, which the application role must not own. */
43
+ const STRATUM_FUNCTIONS = [
44
+ "update_updated_at_column",
45
+ "maintain_ancestry_ltree",
46
+ "propagate_ancestry_ltree",
47
+ "refuse_tenant_parent_cycle",
48
+ "refuse_tenant_tree_column_change",
49
+ "stratum_subtree_tenant_ids",
50
+ "stratum_legacy_bypass",
51
+ "stratum_apply_control_role",
52
+ ];
53
+ /**
54
+ * The md5 of the body (pg_proc.prosrc) of each Stratum function that
55
+ * stratum_apply_control_role() does not re-create, as the migrations define
56
+ * it last. A unit test derives them from the migration files.
57
+ */
58
+ exports.STRATUM_FUNCTION_BODY_MD5 = Object.freeze({
59
+ update_updated_at_column: "301a884953d37769916294bb60562e05",
60
+ maintain_ancestry_ltree: "ddce857b77ffe5dad27239825949c886",
61
+ propagate_ancestry_ltree: "a79bc2cb286893cb622c336876491759",
62
+ stratum_apply_control_role: "78e5309852d1a441403e8d7f743b9446",
63
+ });
64
+ function quote(name) {
65
+ return `"${name}"`;
66
+ }
67
+ function literal(name) {
68
+ return `'${name}'`;
69
+ }
70
+ function sqlArray(names) {
71
+ return `ARRAY[${names.map(literal).join(", ")}]`;
72
+ }
73
+ /**
74
+ * The SQL a database administrator runs to set up the role model, as a
75
+ * superuser, or a role with CREATEROLE that owns the Stratum tables. It is
76
+ * idempotent.
77
+ *
78
+ * - Creates the NOLOGIN control role when it does not exist, and lets it use
79
+ * and create objects in the schema (it owns the 032 helpers).
80
+ * - With `adminRole`: makes the admin login a member of the control role
81
+ * (WITH INHERIT TRUE, SET TRUE on PostgreSQL 16 and later), and moves the
82
+ * Stratum tables and functions that `appRole` owns to it.
83
+ * - Applies the control role to the Stratum objects through
84
+ * stratum_apply_control_role() of migration 032, when it exists. This is
85
+ * what activates the hardening when the migration could not (its migrating
86
+ * role could neither create nor join the control role). It needs a
87
+ * superuser or the owner of the Stratum tables.
88
+ * - Revokes CREATE on the schema from PUBLIC, and with `appRole` from the
89
+ * application login, so that only roles granted CREATE by name can add
90
+ * objects to the schema of the Stratum tables.
91
+ * - With `appRole`: removes the application login from the control role,
92
+ * revokes its privileges on every Stratum table, and grants it SELECT on
93
+ * the read-list tables only. This part acts on the tables that exist, so
94
+ * run it again after a migration that adds a table.
95
+ *
96
+ * The login roles themselves, with their passwords, are yours to create.
97
+ *
98
+ * @throws Error when a name is not a plain lowercase identifier.
99
+ */
100
+ function bootstrapRolesSql(options = {}) {
101
+ const control = options.controlRole ?? migration_sql_js_1.STRATUM_CONTROL_ROLE;
102
+ const schema = options.schema ?? "public";
103
+ (0, migration_sql_js_1.assertRoleName)(control, "control role");
104
+ (0, migration_sql_js_1.assertRoleName)(schema, "schema");
105
+ if (options.adminRole !== undefined)
106
+ (0, migration_sql_js_1.assertRoleName)(options.adminRole, "admin role");
107
+ if (options.appRole !== undefined)
108
+ (0, migration_sql_js_1.assertRoleName)(options.appRole, "app role");
109
+ const parts = [
110
+ `-- Stratum role model (migration 032). Run as a superuser, or a role with CREATEROLE that owns the Stratum tables.`,
111
+ `-- Only pg_catalog is on the search path while it runs; every Stratum object is named with its schema.`,
112
+ `SET search_path = pg_catalog, pg_temp;`,
113
+ integrityCheckSql(schema),
114
+ `DO $$ BEGIN
115
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = ${literal(control)}) THEN
116
+ CREATE ROLE ${quote(control)} NOLOGIN NOSUPERUSER NOBYPASSRLS;
117
+ END IF;
118
+ END $$;`,
119
+ `GRANT USAGE, CREATE ON SCHEMA ${quote(schema)} TO ${quote(control)};`,
120
+ `-- Only roles granted CREATE by name may create objects in the schema of the Stratum tables.
121
+ REVOKE CREATE ON SCHEMA ${quote(schema)} FROM PUBLIC;`,
122
+ ];
123
+ if (options.adminRole !== undefined) {
124
+ const admin = options.adminRole;
125
+ parts.push(`-- The admin login (adminPool) is a member of the control role.
126
+ DO $$ BEGIN
127
+ IF current_setting('server_version_num')::int >= 160000 THEN
128
+ EXECUTE 'GRANT ${quote(control)} TO ${quote(admin)} WITH INHERIT TRUE, SET TRUE';
129
+ ELSE
130
+ EXECUTE 'GRANT ${quote(control)} TO ${quote(admin)}';
131
+ END IF;
132
+ END $$;`);
133
+ if (options.appRole !== undefined) {
134
+ const app = options.appRole;
135
+ parts.push(`-- The admin login owns the Stratum objects the application login owned.
136
+ DO $$ DECLARE r record; BEGIN
137
+ FOR r IN
138
+ SELECT c.relname FROM pg_class c
139
+ WHERE c.relnamespace = ${literal(schema)}::regnamespace AND c.relkind IN ('r', 'p')
140
+ AND c.relname = ANY (${sqlArray(stratum_tables_js_1.STRATUM_TABLES)})
141
+ AND c.relowner = (SELECT oid FROM pg_roles WHERE rolname = ${literal(app)})
142
+ LOOP
143
+ EXECUTE format('ALTER TABLE %I.%I OWNER TO %I', ${literal(schema)}, r.relname, ${literal(admin)});
144
+ END LOOP;
145
+ FOR r IN
146
+ SELECT p.oid::regprocedure AS fn FROM pg_proc p
147
+ WHERE p.pronamespace = ${literal(schema)}::regnamespace
148
+ AND p.proname = ANY (${sqlArray(STRATUM_FUNCTIONS)})
149
+ AND p.proowner = (SELECT oid FROM pg_roles WHERE rolname = ${literal(app)})
150
+ LOOP
151
+ EXECUTE format('ALTER FUNCTION %s OWNER TO %I', r.fn, ${literal(admin)});
152
+ END LOOP;
153
+ END $$;`);
154
+ }
155
+ }
156
+ parts.push(`-- Apply the control role to the Stratum objects (migration 032), when they exist.
157
+ DO $$ BEGIN
158
+ IF to_regprocedure('${quote(schema)}.stratum_apply_control_role(text, text)') IS NOT NULL THEN
159
+ PERFORM ${quote(schema)}.stratum_apply_control_role(${literal(control)}, ${literal(schema)});
160
+ END IF;
161
+ END $$;`);
162
+ if (options.appRole !== undefined) {
163
+ const app = options.appRole;
164
+ parts.push(`-- The application login: not a member of the control role, SELECT on the read list only.
165
+ DO $$ DECLARE t text; BEGIN
166
+ IF EXISTS (SELECT 1 FROM pg_auth_members m
167
+ WHERE m.roleid = (SELECT oid FROM pg_roles WHERE rolname = ${literal(control)})
168
+ AND m.member = (SELECT oid FROM pg_roles WHERE rolname = ${literal(app)})) THEN
169
+ EXECUTE 'REVOKE ${quote(control)} FROM ${quote(app)}';
170
+ END IF;
171
+ FOREACH t IN ARRAY ${sqlArray(stratum_tables_js_1.STRATUM_TABLES)} LOOP
172
+ IF to_regclass(format('%I.%I', ${literal(schema)}, t)) IS NOT NULL THEN
173
+ EXECUTE format('REVOKE ALL ON %I.%I FROM %I', ${literal(schema)}, t, ${literal(app)});
174
+ END IF;
175
+ END LOOP;
176
+ FOREACH t IN ARRAY ${sqlArray(exports.APP_READ_TABLES)} LOOP
177
+ IF to_regclass(format('%I.%I', ${literal(schema)}, t)) IS NOT NULL THEN
178
+ EXECUTE format('GRANT SELECT ON %I.%I TO %I', ${literal(schema)}, t, ${literal(app)});
179
+ END IF;
180
+ END LOOP;
181
+ END $$;`, `GRANT USAGE ON SCHEMA ${quote(schema)} TO ${quote(app)};`, `REVOKE CREATE ON SCHEMA ${quote(schema)} FROM ${quote(app)};`);
182
+ }
183
+ parts.push("RESET search_path;");
184
+ return `${parts.join("\n")}\n`;
185
+ }
186
+ /**
187
+ * The PL/pgSQL statements of the integrity check: they collect, in
188
+ * v_problems (text[]), the objects of the schema v_ns (oid) that the Stratum
189
+ * migrations did not put there. Whoever owned the Stratum tables before
190
+ * (often the application login) could have attached them, and they would run
191
+ * with the rights of whoever writes to the tables later: the admin login, the
192
+ * migrating role, or a superuser. They check, in that schema:
193
+ *
194
+ * - every Stratum relation (STRATUM_TABLES, _migrations included) is a table;
195
+ * - no rule is attached to one of them;
196
+ * - every trigger calls a Stratum function;
197
+ * - policies, column defaults, constraints, triggers and indexes of them
198
+ * call only functions and operators of pg_catalog, of an extension, or of
199
+ * Stratum;
200
+ * - every column has a type of pg_catalog or of an extension;
201
+ * - the schema holds no operator, and no function or aggregate named like a
202
+ * function of pg_catalog or of an extension, other than those of
203
+ * extensions and of Stratum. Such an object can be chosen in place of the
204
+ * built-in one by a query that has the schema on its search path;
205
+ * - the Stratum functions that stratum_apply_control_role() does not
206
+ * re-create have the bodies the migrations give them, and
207
+ * stratum_apply_control_role() itself is SECURITY INVOKER with its pinned
208
+ * search_path.
209
+ *
210
+ * They expect the variables v_ns oid, v_tables text[], v_functions text[],
211
+ * v_problems text[] and r record, and run with search_path = pg_catalog,
212
+ * pg_temp. bootstrapRolesSql() raises when they find anything; migration 032
213
+ * renders the same statements (a unit test keeps them identical) and then
214
+ * warns and leaves the control role unapplied.
215
+ */
216
+ function integrityChecksPlpgsql() {
217
+ const bodies = Object.entries(exports.STRATUM_FUNCTION_BODY_MD5)
218
+ .map(([name, md5]) => `(${literal(name)}, ${literal(md5)})`)
219
+ .join(", ");
220
+ return ` v_tables := ${sqlArray(stratum_tables_js_1.STRATUM_TABLES)};
221
+ v_functions := ${sqlArray(STRATUM_FUNCTIONS)};
222
+ v_problems := '{}';
223
+ FOR r IN
224
+ SELECT c.relname, c.relkind FROM pg_class c
225
+ WHERE c.relnamespace = v_ns AND c.relname = ANY (v_tables) AND c.relkind NOT IN ('r', 'p')
226
+ LOOP
227
+ v_problems := v_problems || format('%I is not a table (relkind %s)', r.relname, r.relkind);
228
+ END LOOP;
229
+ FOR r IN
230
+ SELECT c.relname, w.rulename FROM pg_rewrite w JOIN pg_class c ON c.oid = w.ev_class
231
+ WHERE c.relnamespace = v_ns AND c.relname = ANY (v_tables)
232
+ LOOP
233
+ v_problems := v_problems || format('rule %I on %I', r.rulename, r.relname);
234
+ END LOOP;
235
+ FOR r IN
236
+ SELECT c.relname, t.tgname, t.tgfoid::regprocedure::text AS fn FROM pg_trigger t
237
+ JOIN pg_class c ON c.oid = t.tgrelid JOIN pg_proc p ON p.oid = t.tgfoid
238
+ WHERE NOT t.tgisinternal AND c.relnamespace = v_ns AND c.relname = ANY (v_tables)
239
+ AND NOT (p.pronamespace = v_ns AND p.proname = ANY (v_functions))
240
+ LOOP
241
+ v_problems := v_problems || format('trigger %I on %I calls %s', r.tgname, r.relname, r.fn);
242
+ END LOOP;
243
+ FOR r IN
244
+ WITH t AS (
245
+ SELECT c.oid, c.relname FROM pg_class c WHERE c.relnamespace = v_ns AND c.relname = ANY (v_tables)
246
+ ), o AS (
247
+ SELECT 'pg_policy'::regclass AS classid, x.oid AS objid, t.relname, 'policy ' || quote_ident(x.polname) AS what
248
+ FROM pg_policy x JOIN t ON t.oid = x.polrelid
249
+ UNION ALL
250
+ SELECT 'pg_attrdef'::regclass, x.oid, t.relname, 'column default' FROM pg_attrdef x JOIN t ON t.oid = x.adrelid
251
+ UNION ALL
252
+ SELECT 'pg_constraint'::regclass, x.oid, t.relname, 'constraint ' || quote_ident(x.conname)
253
+ FROM pg_constraint x JOIN t ON t.oid = x.conrelid
254
+ UNION ALL
255
+ SELECT 'pg_trigger'::regclass, x.oid, t.relname, 'trigger ' || quote_ident(x.tgname)
256
+ FROM pg_trigger x JOIN t ON t.oid = x.tgrelid WHERE NOT x.tgisinternal
257
+ UNION ALL
258
+ SELECT 'pg_class'::regclass, x.indexrelid, t.relname, 'index ' || quote_ident(x.indexrelid::regclass::text)
259
+ FROM pg_index x JOIN t ON t.oid = x.indrelid
260
+ )
261
+ SELECT DISTINCT o.relname, o.what,
262
+ CASE WHEN d.refclassid = 'pg_proc'::regclass THEN 'function ' || d.refobjid::regprocedure::text
263
+ ELSE 'operator ' || d.refobjid::regoperator::text END AS ref
264
+ FROM o JOIN pg_depend d ON d.classid = o.classid AND d.objid = o.objid
265
+ LEFT JOIN pg_proc p ON d.refclassid = 'pg_proc'::regclass AND p.oid = d.refobjid
266
+ LEFT JOIN pg_operator op ON d.refclassid = 'pg_operator'::regclass AND op.oid = d.refobjid
267
+ WHERE d.refclassid IN ('pg_proc'::regclass, 'pg_operator'::regclass)
268
+ AND coalesce(p.pronamespace, op.oprnamespace) <> 'pg_catalog'::regnamespace
269
+ AND NOT EXISTS (SELECT 1 FROM pg_depend e
270
+ WHERE e.classid = d.refclassid AND e.objid = d.refobjid AND e.deptype = 'e')
271
+ AND NOT (p.pronamespace = v_ns AND p.proname = ANY (v_functions))
272
+ LOOP
273
+ v_problems := v_problems || format('%s on %I uses %s', r.what, r.relname, r.ref);
274
+ END LOOP;
275
+ FOR r IN
276
+ SELECT c.relname, a.attname, a.atttypid::regtype::text AS typ FROM pg_attribute a
277
+ JOIN pg_class c ON c.oid = a.attrelid JOIN pg_type ty ON ty.oid = a.atttypid
278
+ WHERE c.relnamespace = v_ns AND c.relname = ANY (v_tables) AND a.attnum > 0 AND NOT a.attisdropped
279
+ AND ty.typnamespace <> 'pg_catalog'::regnamespace
280
+ AND NOT EXISTS (SELECT 1 FROM pg_depend e
281
+ WHERE e.classid = 'pg_type'::regclass AND e.deptype = 'e'
282
+ AND e.objid IN (ty.oid, ty.typelem))
283
+ LOOP
284
+ v_problems := v_problems || format('column %I.%I has type %s', r.relname, r.attname, r.typ);
285
+ END LOOP;
286
+ FOR r IN
287
+ SELECT p.oid::regprocedure::text AS fn FROM pg_proc p
288
+ WHERE p.pronamespace = v_ns AND NOT (p.proname = ANY (v_functions))
289
+ AND NOT EXISTS (SELECT 1 FROM pg_depend e
290
+ WHERE e.classid = 'pg_proc'::regclass AND e.objid = p.oid AND e.deptype = 'e')
291
+ AND EXISTS (SELECT 1 FROM pg_proc q
292
+ WHERE q.proname = p.proname AND q.oid <> p.oid
293
+ AND (q.pronamespace = 'pg_catalog'::regnamespace
294
+ OR EXISTS (SELECT 1 FROM pg_depend e
295
+ WHERE e.classid = 'pg_proc'::regclass AND e.objid = q.oid AND e.deptype = 'e')))
296
+ LOOP
297
+ v_problems := v_problems || format('function %s in the schema has the name of a built-in or extension function', r.fn);
298
+ END LOOP;
299
+ FOR r IN
300
+ SELECT o.oid::regoperator::text AS op FROM pg_operator o
301
+ WHERE o.oprnamespace = v_ns
302
+ AND NOT EXISTS (SELECT 1 FROM pg_depend e
303
+ WHERE e.classid = 'pg_operator'::regclass AND e.objid = o.oid AND e.deptype = 'e')
304
+ LOOP
305
+ v_problems := v_problems || format('operator %s in the schema', r.op);
306
+ END LOOP;
307
+ FOR r IN
308
+ SELECT f.name, p.oid IS NOT NULL AS present, md5(p.prosrc) = f.md5 AS same_body, p.proconfig, p.prosecdef
309
+ FROM (VALUES ${bodies}) AS f(name, md5)
310
+ LEFT JOIN pg_proc p ON p.pronamespace = v_ns AND p.proname = f.name
311
+ LOOP
312
+ IF r.present AND NOT r.same_body THEN
313
+ v_problems := v_problems || format('function %I has a body the migrations did not give it', r.name);
314
+ ELSIF r.present AND r.proconfig IS NOT NULL AND r.proconfig <> ARRAY['search_path=pg_catalog, pg_temp'] THEN
315
+ v_problems := v_problems || format('function %I has settings %s', r.name, r.proconfig::text);
316
+ ELSIF r.present AND r.name = 'stratum_apply_control_role' AND (r.prosecdef OR r.proconfig IS NULL) THEN
317
+ v_problems := v_problems || 'function stratum_apply_control_role is not SECURITY INVOKER with its pinned search_path';
318
+ END IF;
319
+ END LOOP;`;
320
+ }
321
+ /**
322
+ * A DO block that stops the bootstrap when the Stratum tables carry code that
323
+ * the migrations did not put there; see {@link integrityChecksPlpgsql}.
324
+ */
325
+ function integrityCheckSql(schema) {
326
+ return `-- Refuse to continue when the Stratum tables carry code the migrations did not put there.
327
+ DO $$
328
+ DECLARE
329
+ v_ns oid := to_regnamespace(${literal(quote(schema))});
330
+ v_tables text[];
331
+ v_functions text[];
332
+ v_problems text[];
333
+ r record;
334
+ BEGIN
335
+ IF v_ns IS NULL THEN
336
+ RETURN;
337
+ END IF;
338
+ ${integrityChecksPlpgsql()}
339
+ IF cardinality(v_problems) > 0 THEN
340
+ RAISE EXCEPTION E'The Stratum tables in schema % carry objects the Stratum migrations did not create:\n %\nSuch objects run with the rights of whoever writes to the tables. Remove them (or restore the Stratum functions from the migrations), then run this again.',
341
+ ${literal(schema)}, array_to_string(v_problems, E'\n ')
342
+ USING ERRCODE = 'object_not_in_prerequisite_state';
343
+ END IF;
344
+ END $$;`;
345
+ }
346
+ /*
347
+ * Every catalog query below runs with the search path pinned to pg_catalog
348
+ * (pinnedQuery), because the logins that run them (a superuser, the admin
349
+ * login) must not resolve functions or operators that other roles created in
350
+ * a schema on their search path. Stratum's own tables are named with the
351
+ * schema that schemaOfTable() finds through the caller's search path.
352
+ */
353
+ /**
354
+ * The control role the database uses: the role of its stratum_control_plane
355
+ * policies, or null before migration 032.
356
+ */
357
+ async function databaseControlRole(pool) {
358
+ const res = await (0, pinned_query_js_1.pinnedQuery)(pool, `SELECT DISTINCT r::text AS role FROM pg_policies p, unnest(p.roles) r
359
+ WHERE p.policyname = 'stratum_control_plane'`);
360
+ return res.rows.length === 1 ? res.rows[0].role : null;
361
+ }
362
+ /** Why the admin login cannot act as the control plane. Empty when it can. */
363
+ async function adminRoleIssues(subject, control) {
364
+ const res = await (0, pinned_query_js_1.pinnedQuery)(subject.pool, `SELECT r.rolname::text AS me, r.rolsuper, r.rolbypassrls,
365
+ EXISTS (SELECT 1 FROM pg_roles c WHERE c.rolname = $1) AS exists,
366
+ (SELECT pg_has_role(r.oid, c.oid, 'USAGE') FROM pg_roles c WHERE c.rolname = $1) AS usage
367
+ FROM pg_roles r WHERE r.rolname = COALESCE($2::text, current_user)`, [control, subject.role ?? null]);
368
+ const row = res.rows[0];
369
+ if (!row)
370
+ return [`the admin role "${subject.role}" does not exist`];
371
+ if (row.rolsuper || row.rolbypassrls || row.usage)
372
+ return [];
373
+ if (!row.exists)
374
+ return [`the control role "${control}" does not exist; run the Stratum migrations (032)`];
375
+ return [`the admin role "${row.me}" is not a member of the control role "${control}" with INHERIT`];
376
+ }
377
+ /** Why the application login is not limited to the application's share. Empty when it is. */
378
+ async function appRoleIssues(subject, control) {
379
+ const schema = await (0, pinned_query_js_1.schemaOfTable)(subject.pool);
380
+ const res = await (0, pinned_query_js_1.pinnedQuery)(subject.pool, `WITH s AS (
381
+ SELECT n.oid FROM pg_namespace n WHERE n.nspname = $6::text
382
+ ), t AS (
383
+ SELECT c.oid, c.relname, c.relowner FROM pg_class c
384
+ WHERE c.relkind IN ('r', 'p') AND c.relname = ANY ($2::text[])
385
+ AND c.relnamespace = (SELECT oid FROM s)
386
+ )
387
+ SELECT r.rolname::text AS me, r.rolsuper, r.rolbypassrls,
388
+ (SELECT pg_has_role(r.oid, c.oid, 'MEMBER') FROM pg_roles c WHERE c.rolname = $1) AS member,
389
+ (SELECT array_agg(t.relname::text ORDER BY t.relname) FROM t
390
+ WHERE pg_has_role(r.oid, t.relowner, 'MEMBER')) AS owned,
391
+ (SELECT array_agg(t.relname::text ORDER BY t.relname) FROM t
392
+ WHERE has_table_privilege(r.oid, t.oid, 'INSERT') OR has_table_privilege(r.oid, t.oid, 'UPDATE')
393
+ OR has_table_privilege(r.oid, t.oid, 'DELETE') OR has_table_privilege(r.oid, t.oid, 'TRUNCATE')) AS writable,
394
+ (SELECT array_agg(t.relname::text ORDER BY t.relname) FROM t
395
+ WHERE t.relname = ANY ($3::text[]) AND has_table_privilege(r.oid, t.oid, 'SELECT')) AS credential_reads,
396
+ (SELECT array_agg(DISTINCT p.proname::text) FROM pg_proc p
397
+ WHERE pg_has_role(r.oid, p.proowner, 'MEMBER') AND p.proname = ANY ($4::text[])
398
+ AND p.pronamespace = (SELECT oid FROM s)) AS owned_functions,
399
+ -- The owner of the schema can drop and re-create any table in it.
400
+ -- On PostgreSQL 15 and later, public belongs to pg_database_owner,
401
+ -- so the owner of the database owns it.
402
+ (SELECT n.nspname::text FROM pg_namespace n
403
+ WHERE n.oid = (SELECT oid FROM s)
404
+ AND pg_has_role(r.oid, n.nspowner, 'MEMBER')) AS owned_schema,
405
+ -- A role that can create objects in the schema can add functions
406
+ -- and operators that queries with the schema on their path use.
407
+ (SELECT n.nspname::text FROM pg_namespace n
408
+ WHERE n.oid = (SELECT oid FROM s)
409
+ AND has_schema_privilege(r.oid, n.oid, 'CREATE')) AS create_schema
410
+ FROM pg_roles r WHERE r.rolname = COALESCE($5::text, current_user)`, [control, [...stratum_tables_js_1.STRATUM_TABLES], CREDENTIAL_TABLES, STRATUM_FUNCTIONS, subject.role ?? null, schema]);
411
+ const row = res.rows[0];
412
+ if (!row)
413
+ return subject.role === undefined ? [] : [`the app role "${subject.role}" does not exist`];
414
+ const issues = [];
415
+ const who = `the app role "${row.me}"`;
416
+ if (row.rolsuper)
417
+ issues.push(`${who} is a superuser`);
418
+ if (row.rolbypassrls)
419
+ issues.push(`${who} has BYPASSRLS`);
420
+ if (row.member)
421
+ issues.push(`${who} is a member of the control role "${control}"`);
422
+ if (!row.rolsuper && row.owned?.length)
423
+ issues.push(`${who} owns Stratum tables: ${row.owned.join(", ")}`);
424
+ if (!row.rolsuper && row.owned_functions?.length) {
425
+ issues.push(`${who} owns Stratum functions: ${row.owned_functions.join(", ")}`);
426
+ }
427
+ if (!row.rolsuper && row.owned_schema) {
428
+ issues.push(`${who} owns the schema "${row.owned_schema}" of the Stratum tables (directly or as the database owner)`);
429
+ }
430
+ if (!row.rolsuper && !row.owned_schema && row.create_schema) {
431
+ issues.push(`${who} can create objects in the schema "${row.create_schema}" of the Stratum tables`);
432
+ }
433
+ if (!row.rolsuper && row.writable?.length)
434
+ issues.push(`${who} can write Stratum tables: ${row.writable.join(", ")}`);
435
+ if (!row.rolsuper && row.credential_reads?.length) {
436
+ issues.push(`${who} can read credential tables: ${row.credential_reads.join(", ")}`);
437
+ }
438
+ return issues;
439
+ }
440
+ /** The search_path of the admin login: the session's for a pool, else the role's default in this database. */
441
+ async function adminSearchPath(admin) {
442
+ if (admin.role === undefined) {
443
+ // Unpinned, so it reads the path the admin login's sessions use.
444
+ const res = await admin.pool.query("SELECT current_user::text AS me, pg_catalog.current_setting('search_path') AS path");
445
+ return res.rows[0] ?? null;
446
+ }
447
+ // As PostgreSQL applies them: role in database, role, database, then the default.
448
+ const res = await (0, pinned_query_js_1.pinnedQuery)(admin.pool, `WITH cfg AS (
449
+ SELECT s.setrole, s.setdatabase, substr(c, 13) AS path
450
+ FROM pg_db_role_setting s, unnest(s.setconfig) c WHERE c LIKE 'search_path=%'
451
+ )
452
+ SELECT r.rolname::text AS me, COALESCE(
453
+ (SELECT path FROM cfg WHERE setrole = r.oid AND setdatabase = d.oid),
454
+ (SELECT path FROM cfg WHERE setrole = r.oid AND setdatabase = 0),
455
+ (SELECT path FROM cfg WHERE setrole = 0 AND setdatabase = d.oid),
456
+ (SELECT boot_val FROM pg_settings WHERE name = 'search_path')) AS path
457
+ FROM pg_roles r, pg_database d WHERE r.rolname = $1::text AND d.datname = current_database()`, [admin.role]);
458
+ return res.rows[0] ?? null;
459
+ }
460
+ /**
461
+ * Why a schema the application login creates could shadow the Stratum schema
462
+ * on the admin login's search path, or null when it cannot: the application
463
+ * login can create schemas in the database and the admin login's path starts
464
+ * from "$user", so a schema named after the admin login would come first.
465
+ */
466
+ async function searchPathShadowIssue(app, admin) {
467
+ const res = await (0, pinned_query_js_1.pinnedQuery)(app.pool, `SELECT r.rolname::text AS me, r.rolsuper AS su, has_database_privilege(r.oid, d.oid, 'CREATE') AS can_create,
468
+ d.datname::text AS db
469
+ FROM pg_roles r, pg_database d
470
+ WHERE r.rolname = COALESCE($1::text, current_user) AND d.datname = current_database()`, [app.role ?? null]);
471
+ const row = res.rows[0];
472
+ if (!row || row.su || !row.can_create)
473
+ return null;
474
+ const adminPath = await adminSearchPath(admin);
475
+ if (!adminPath || !adminPath.path.includes("$user"))
476
+ return null;
477
+ return (`the app role "${row.me}" can create schemas in the database "${row.db}", and the search_path of the admin ` +
478
+ `login "${adminPath.me}" (${adminPath.path}) contains "$user", so a schema named after the admin login would ` +
479
+ `come first on it. Set the admin login's path (ALTER ROLE ${(0, pinned_query_js_1.quoteIdentifier)(adminPath.me)} IN DATABASE ` +
480
+ `${(0, pinned_query_js_1.quoteIdentifier)(row.db)} SET search_path = <Stratum schema>) or REVOKE CREATE ON DATABASE ` +
481
+ `${(0, pinned_query_js_1.quoteIdentifier)(row.db)} FROM ${(0, pinned_query_js_1.quoteIdentifier)(row.me)}`);
482
+ }
483
+ /** The login of `pool`. */
484
+ async function currentLogin(pool) {
485
+ const res = await (0, pinned_query_js_1.pinnedQuery)(pool, "SELECT current_user::text AS me");
486
+ return res.rows[0].me;
487
+ }
488
+ /** Whether the login of `pool` is a member of `control` and not a superuser. */
489
+ async function isNonSuperuserMember(pool, control) {
490
+ const res = await (0, pinned_query_js_1.pinnedQuery)(pool, `SELECT NOT r.rolsuper AND (SELECT pg_has_role(r.oid, c.oid, 'MEMBER') FROM pg_roles c WHERE c.rolname = $1) AS member
491
+ FROM pg_roles r WHERE r.rolname = current_user`, [control]);
492
+ return res.rows[0]?.member === true;
493
+ }
494
+ /**
495
+ * The roles, other than superusers, that are members of `control`, directly
496
+ * or through another role, with whether each can log in.
497
+ */
498
+ async function controlRoleMembers(pool, control) {
499
+ const res = await (0, pinned_query_js_1.pinnedQuery)(pool, `SELECT r.rolname::text AS role, r.rolcanlogin AS login FROM pg_roles r, pg_roles c
500
+ WHERE c.rolname = $1 AND r.oid <> c.oid AND NOT r.rolsuper AND pg_has_role(r.oid, c.oid, 'MEMBER')
501
+ ORDER BY r.rolname`, [control]);
502
+ return res.rows;
503
+ }
504
+ /** Whether the legacy app.bypass_rls switch of migration 032 is on, or null without 032. */
505
+ async function legacyBypassOn(adminPool) {
506
+ const schema = await (0, pinned_query_js_1.schemaOfTable)(adminPool, "stratum_security");
507
+ if (schema === null)
508
+ return null;
509
+ const res = await (0, pinned_query_js_1.pinnedQuery)(adminPool, `SELECT legacy_guc_bypass AS on FROM ${(0, pinned_query_js_1.quoteIdentifier)(schema)}.stratum_security LIMIT 1`);
510
+ return res.rows[0]?.on ?? null;
511
+ }
512
+ /**
513
+ * Why the login of `pool`, an application login, should not be able to
514
+ * create objects in the schema of the Stratum tables, or null when it cannot
515
+ * (or is a superuser, which the other checks report). The privilege may come
516
+ * from PUBLIC.
517
+ */
518
+ async function schemaCreateIssue(pool) {
519
+ const schema = await (0, pinned_query_js_1.schemaOfTable)(pool);
520
+ if (schema === null)
521
+ return null;
522
+ const res = await (0, pinned_query_js_1.pinnedQuery)(pool, `SELECT r.rolname::text AS me, r.rolsuper AS su,
523
+ has_schema_privilege(r.oid, n.oid, 'CREATE') AS login,
524
+ has_schema_privilege('public', n.oid, 'CREATE') AS everyone
525
+ FROM pg_roles r, pg_namespace n
526
+ WHERE r.rolname = current_user AND n.nspname = $1::text`, [schema]);
527
+ const row = res.rows[0];
528
+ if (!row || row.su || !row.login)
529
+ return null;
530
+ const via = row.everyone ? " (granted to PUBLIC)" : "";
531
+ return (`the app role "${row.me}" can create objects in the schema "${schema}" of the Stratum tables${via}. ` +
532
+ `Only the logins that run the Stratum migrations should: REVOKE CREATE ON SCHEMA ${(0, pinned_query_js_1.quoteIdentifier)(schema)} ` +
533
+ `FROM ${row.everyone ? "PUBLIC" : (0, pinned_query_js_1.quoteIdentifier)(row.me)}, and see the guide "Hardening: separate admin and app roles"`);
534
+ }
535
+ /** Whether a stratum_control_plane policy exists in the database. */
536
+ async function controlPlanePolicyExists(pool) {
537
+ const res = await (0, pinned_query_js_1.pinnedQuery)(pool, "SELECT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'stratum_control_plane') AS active");
538
+ return res.rows[0]?.active === true;
539
+ }
540
+ /**
541
+ * Whether migration 032 ran but the control role is not applied: the
542
+ * stratum_security table exists and no stratum_control_plane policy does.
543
+ */
544
+ async function hardeningInactive(pool) {
545
+ if ((await (0, pinned_query_js_1.schemaOfTable)(pool, "stratum_security")) === null)
546
+ return false;
547
+ return !(await controlPlanePolicyExists(pool));
548
+ }
549
+ /** Warns, or with `strict` throws, when the application login can create objects in the Stratum schema. */
550
+ async function reportSchemaCreate(appPool, strict, logger) {
551
+ const issue = await schemaCreateIssue(appPool);
552
+ if (issue === null)
553
+ return;
554
+ const message = `[stratum] pool: ${issue}.`;
555
+ if (strict)
556
+ throw new Error(message);
557
+ logger.warn(message);
558
+ }
559
+ /**
560
+ * Reports whether the control-role hardening of migration 032 is active and,
561
+ * with an adminPool, checks the admin and application logins against the
562
+ * role model, warning about each problem. With `strict`, a misconfigured
563
+ * application login throws instead. In every mode it reports an
564
+ * application login that can create objects in the schema of the Stratum
565
+ * tables; with an adminPool and `strict` that is an error too. With an
566
+ * adminPool it also warns when a schema the application login can create
567
+ * would come first on the admin login's search path.
568
+ */
569
+ async function checkRoleModel(options) {
570
+ const { adminPool, appPool, logger } = options;
571
+ if (adminPool) {
572
+ const shadow = await searchPathShadowIssue({ pool: appPool }, { pool: adminPool });
573
+ if (shadow !== null)
574
+ logger.warn(`[stratum] ${shadow}.`);
575
+ }
576
+ if (await hardeningInactive(adminPool ?? appPool)) {
577
+ logger.warn("Stratum control-role hardening is not active: migration 032 could not apply the control role, " +
578
+ "so this database keeps the pre-1.8 behavior. Run the SQL from bootstrapRolesSql() as a superuser " +
579
+ "(or `stratum db roles`) to activate it.");
580
+ // With adminPool and strict, an application login that can create
581
+ // objects in the Stratum schema is an error, as in the checks below.
582
+ await reportSchemaCreate(appPool, adminPool !== undefined && options.strict, logger);
583
+ return;
584
+ }
585
+ if (!adminPool) {
586
+ // Single-pool mode: the pool's login is the application's. A member of
587
+ // the control role passes every Stratum policy, whatever tenant context
588
+ // it sets, so the pool must not be one.
589
+ const control = options.controlRole ?? (await databaseControlRole(appPool));
590
+ if (control !== null && (await isNonSuperuserMember(appPool, control))) {
591
+ const message = `[stratum] pool: the login of pool is a member of the control role "${control}", so row-level security ` +
592
+ `does not limit it to a tenant. Give the library an adminPool (a separate login that is a member), and ` +
593
+ `remove the application login from the control role (REVOKE ${quote(control)} FROM <app login>).`;
594
+ if (options.strict)
595
+ throw new Error(message);
596
+ logger.warn(message, { control_role: control });
597
+ }
598
+ await reportSchemaCreate(appPool, false, logger);
599
+ return;
600
+ }
601
+ // appRoleIssues() below reports CREATE on the Stratum schema too.
602
+ const control = options.controlRole ?? (await databaseControlRole(adminPool)) ?? migration_sql_js_1.STRATUM_CONTROL_ROLE;
603
+ for (const issue of await adminRoleIssues({ pool: adminPool }, control)) {
604
+ logger.warn(`adminPool: ${issue}`, { control_role: control });
605
+ }
606
+ const appIssues = await appRoleIssues({ pool: appPool }, control);
607
+ if (appIssues.length > 0) {
608
+ const message = `[stratum] pool: ${appIssues.join("; ")}. The application role should be limited to SELECT on ` +
609
+ `the Stratum read-list tables; bootstrapRolesSql() prints the grants.`;
610
+ if (options.strict)
611
+ throw new Error(message);
612
+ logger.warn(message, { control_role: control });
613
+ }
614
+ if ((await legacyBypassOn(adminPool)) === true) {
615
+ logger.warn("The legacy app.bypass_rls switch is on. Once every client of this database uses adminPool, " +
616
+ "turn it off with `stratum db lock` (or UPDATE stratum_security SET legacy_guc_bypass = false " +
617
+ "as a member of the control role).", { control_role: control });
618
+ }
619
+ }
620
+ /**
621
+ * Checks the logins of a database against the role model of migration 032,
622
+ * without logging or throwing. `stratum health`, `stratum doctor` and
623
+ * `stratum db roles` use it; Stratum.initialize() applies the same checks.
624
+ */
625
+ async function inspectRoleModel(options) {
626
+ const { appPool, adminPool } = options;
627
+ const pool = options.pool ?? adminPool ?? appPool;
628
+ if (!pool)
629
+ throw new Error("[stratum] inspectRoleModel needs pool, appPool or adminPool");
630
+ if (options.controlRole !== undefined)
631
+ (0, migration_sql_js_1.assertRoleName)(options.controlRole, "control role");
632
+ const appSubject = options.appRole !== undefined ? { pool, role: options.appRole } : appPool ? { pool: appPool } : undefined;
633
+ const adminSubject = options.adminRole !== undefined ? { pool, role: options.adminRole } : adminPool ? { pool: adminPool } : undefined;
634
+ const switchReader = adminPool ?? options.pool;
635
+ const migrated = (await (0, pinned_query_js_1.schemaOfTable)(pool, "stratum_security")) !== null;
636
+ const hardeningActive = await controlPlanePolicyExists(pool);
637
+ const control = options.controlRole ?? (await databaseControlRole(pool)) ?? migration_sql_js_1.STRATUM_CONTROL_ROLE;
638
+ return {
639
+ migrated,
640
+ hardeningActive,
641
+ controlRole: control,
642
+ adminIssues: adminSubject ? await adminRoleIssues(adminSubject, control) : null,
643
+ appIssues: appSubject ? await appRoleIssues(appSubject, control) : null,
644
+ legacyBypass: switchReader && migrated ? await legacyBypassOn(switchReader) : null,
645
+ controlMembers: await controlRoleMembers(pool, control),
646
+ adminLogin: options.adminRole ?? (adminPool ? await currentLogin(adminPool) : null),
647
+ searchPathIssue: appSubject && adminSubject ? await searchPathShadowIssue(appSubject, adminSubject) : null,
648
+ };
649
+ }
650
+ let warnedNoAdminPool = false;
651
+ /** Warns once per process that Stratum runs without an adminPool. */
652
+ function warnNoAdminPool(logger) {
653
+ if (warnedNoAdminPool)
654
+ return;
655
+ warnedNoAdminPool = true;
656
+ logger.warn("Stratum was created without adminPool, so the library uses the legacy app.bypass_rls path. " +
657
+ "Pass adminPool, a login that is a member of the control role, and keep pool for the application role. " +
658
+ "adminPool becomes required in 2.0.");
659
+ }
660
+ let warnedLegacyKeyHash = false;
661
+ /** Warns once per process that an API key with a legacy SHA-256 hash authenticated. */
662
+ function warnLegacyKeyHash(logger) {
663
+ if (warnedLegacyKeyHash)
664
+ return;
665
+ warnedLegacyKeyHash = true;
666
+ logger.warn("An API key with a legacy SHA-256 hash (version 1) authenticated while STRATUM_API_KEY_HMAC_SECRET is set; " +
667
+ "it was re-hashed with HMAC. 2.0 will refuse such keys: rotate the ones that are not used before then, " +
668
+ "or set allowLegacyKeyHashes: false to refuse them now.");
669
+ }
670
+ //# sourceMappingURL=role-model.js.map