@stratum-hq/lib 1.6.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 (83) hide show
  1. package/README.md +49 -20
  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 +17 -7
  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/001_init.sql +3 -3
  22. package/dist/migrations/029_tenant_parent_cycle_guard.sql +2 -2
  23. package/dist/migrations/031_subtree_read_scope.sql +307 -0
  24. package/dist/migrations/032_control_role.sql +809 -0
  25. package/dist/migrations/033_api_keys_tenant_index.sql +14 -0
  26. package/dist/pinned-query.d.ts +29 -0
  27. package/dist/pinned-query.d.ts.map +1 -0
  28. package/dist/pinned-query.js +60 -0
  29. package/dist/pinned-query.js.map +1 -0
  30. package/dist/pool-helpers.d.ts +2 -0
  31. package/dist/pool-helpers.d.ts.map +1 -1
  32. package/dist/pool-helpers.js +17 -3
  33. package/dist/pool-helpers.js.map +1 -1
  34. package/dist/role-model.d.ts +178 -0
  35. package/dist/role-model.d.ts.map +1 -0
  36. package/dist/role-model.js +670 -0
  37. package/dist/role-model.js.map +1 -0
  38. package/dist/scoped-job.d.ts +9 -1
  39. package/dist/scoped-job.d.ts.map +1 -1
  40. package/dist/scoped-job.js +3 -2
  41. package/dist/scoped-job.js.map +1 -1
  42. package/dist/services/abac-service.js +1 -1
  43. package/dist/services/abac-service.js.map +1 -1
  44. package/dist/services/api-key-service.d.ts +13 -3
  45. package/dist/services/api-key-service.d.ts.map +1 -1
  46. package/dist/services/api-key-service.js +33 -9
  47. package/dist/services/api-key-service.js.map +1 -1
  48. package/dist/services/config-service.d.ts +15 -6
  49. package/dist/services/config-service.d.ts.map +1 -1
  50. package/dist/services/config-service.js +101 -55
  51. package/dist/services/config-service.js.map +1 -1
  52. package/dist/services/event-service.js +2 -2
  53. package/dist/services/event-service.js.map +1 -1
  54. package/dist/services/permission-service.js +1 -1
  55. package/dist/services/permission-service.js.map +1 -1
  56. package/dist/services/rate-limit-service.d.ts +3 -3
  57. package/dist/services/region-service.js +1 -1
  58. package/dist/services/region-service.js.map +1 -1
  59. package/dist/services/retention-service.d.ts +2 -2
  60. package/dist/services/retention-service.js +2 -2
  61. package/dist/services/role-service.d.ts +1 -1
  62. package/dist/services/role-service.js +1 -1
  63. package/dist/services/tenant-service.d.ts +2 -2
  64. package/dist/services/tenant-service.d.ts.map +1 -1
  65. package/dist/services/tenant-service.js +8 -8
  66. package/dist/services/tenant-service.js.map +1 -1
  67. package/dist/stratum-policies.d.ts +51 -0
  68. package/dist/stratum-policies.d.ts.map +1 -0
  69. package/dist/stratum-policies.js +217 -0
  70. package/dist/stratum-policies.js.map +1 -0
  71. package/dist/stratum-tables.d.ts.map +1 -1
  72. package/dist/stratum-tables.js +1 -0
  73. package/dist/stratum-tables.js.map +1 -1
  74. package/dist/stratum.d.ts +56 -11
  75. package/dist/stratum.d.ts.map +1 -1
  76. package/dist/stratum.js +82 -21
  77. package/dist/stratum.js.map +1 -1
  78. package/dist/telemetry.d.ts +2 -2
  79. package/dist/telemetry.js +4 -4
  80. package/dist/telemetry.js.map +1 -1
  81. package/dist/url-redaction.js +1 -1
  82. package/dist/url-redaction.js.map +1 -1
  83. package/package.json +21 -13
package/dist/migrate.js CHANGED
@@ -3,14 +3,42 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.assertRoleSubjectToRls = assertRoleSubjectToRls;
6
7
  exports.migrate = migrate;
7
8
  const node_fs_1 = __importDefault(require("node:fs"));
8
9
  const node_path_1 = __importDefault(require("node:path"));
10
+ const migration_sql_js_1 = require("./migration-sql.js");
11
+ const pinned_query_js_1 = require("./pinned-query.js");
12
+ /**
13
+ * Throws when the connecting role has BYPASSRLS, so row-level security would
14
+ * not apply to it. Used by every enforceRls entry point, so the check does not
15
+ * depend on migration 001 running.
16
+ */
17
+ async function assertRoleSubjectToRls(pool) {
18
+ const { rows } = await pool.query("SELECT current_user AS role, rolbypassrls AS bypass FROM pg_catalog.pg_roles WHERE rolname = current_user");
19
+ if (rows[0]?.bypass) {
20
+ throw new Error(`SECURITY: Application role "${rows[0].role}" has BYPASSRLS privilege. ` +
21
+ "Connect as a dedicated role without BYPASSRLS, or turn enforceRls off for development.");
22
+ }
23
+ }
9
24
  async function migrate(options) {
10
- const { pool, enforceRls } = options;
25
+ const { pool, enforceRls, controlRole, applyControlRole } = options;
26
+ if (controlRole !== undefined)
27
+ (0, migration_sql_js_1.assertRoleName)(controlRole, "control role");
28
+ if (enforceRls) {
29
+ await assertRoleSubjectToRls(pool);
30
+ }
31
+ // The migrations create their objects in the first schema of the search
32
+ // path; the tracking table lives there too, named with that schema.
33
+ const current = await pool.query("SELECT pg_catalog.current_schema() AS schema");
34
+ const schema = current.rows[0]?.schema;
35
+ if (!schema) {
36
+ throw new Error("[stratum] migrate: no schema on the search path of the migrating login to create the Stratum tables in");
37
+ }
38
+ const migrationsTable = `${(0, pinned_query_js_1.quoteIdentifier)(schema)}._migrations`;
11
39
  // Create migrations tracking table
12
40
  await pool.query(`
13
- CREATE TABLE IF NOT EXISTS _migrations (
41
+ CREATE TABLE IF NOT EXISTS ${migrationsTable} (
14
42
  id SERIAL PRIMARY KEY,
15
43
  name TEXT NOT NULL UNIQUE,
16
44
  applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -25,14 +53,22 @@ async function migrate(options) {
25
53
  .readdirSync(migrationsDir)
26
54
  .filter((f) => f.endsWith(".sql"))
27
55
  .sort();
56
+ // Migrations 029 and 031 created helper functions with `SET app.*` clauses,
57
+ // which PostgreSQL accepts only from a superuser (or a role granted SET on
58
+ // the parameter). For any other migrating role, migrationSql() drops those
59
+ // clause lines from these two files only; migration 032 then re-creates
60
+ // both functions without them when it applies the control role. Until it
61
+ // does, they see only what their caller sees, which can only narrow what
62
+ // they return. See migration-sql.ts.
63
+ const superuser = await (0, migration_sql_js_1.isSuperuser)(pool);
28
64
  for (const file of files) {
29
65
  const client = await pool.connect();
30
66
  try {
31
67
  await client.query("BEGIN");
32
68
  // Acquire advisory lock to prevent concurrent migrations
33
- await client.query("SELECT pg_advisory_xact_lock(8675309)");
69
+ await client.query("SELECT pg_catalog.pg_advisory_xact_lock(8675309::pg_catalog.int8)");
34
70
  // Re-check if already applied (after lock, to prevent TOCTOU race)
35
- const { rows } = await client.query("SELECT 1 FROM _migrations WHERE name = $1", [file]);
71
+ const { rows } = await client.query(`SELECT 1 FROM ${migrationsTable} WHERE name = $1`, [file]);
36
72
  if (rows.length > 0) {
37
73
  await client.query("COMMIT");
38
74
  continue;
@@ -41,9 +77,11 @@ async function migrate(options) {
41
77
  if (enforceRls) {
42
78
  await client.query("SET LOCAL stratum.enforce_rls = 'on'");
43
79
  }
44
- const sql = node_fs_1.default.readFileSync(node_path_1.default.join(migrationsDir, file), "utf-8");
80
+ await (0, migration_sql_js_1.setControlRole)(client, controlRole);
81
+ await (0, migration_sql_js_1.setApplyControlRole)(client, applyControlRole);
82
+ const sql = (0, migration_sql_js_1.migrationSql)(file, node_fs_1.default.readFileSync(node_path_1.default.join(migrationsDir, file), "utf-8"), superuser);
45
83
  await client.query(sql);
46
- await client.query("INSERT INTO _migrations (name) VALUES ($1)", [file]);
84
+ await client.query(`INSERT INTO ${migrationsTable} (name) VALUES ($1)`, [file]);
47
85
  await client.query("COMMIT");
48
86
  }
49
87
  catch (err) {
@@ -1 +1 @@
1
- {"version":3,"file":"migrate.js","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":";;;;;AAUA,0BA2DC;AArED,sDAAyB;AACzB,0DAA6B;AAStB,KAAK,UAAU,OAAO,CAAC,OAAuB;IACnD,MAAM,EAAE,IAAI,EAAE,UAAU,EAAE,GAAG,OAAO,CAAC;IAErC,mCAAmC;IACnC,MAAM,IAAI,CAAC,KAAK,CAAC;;;;;;GAMhB,CAAC,CAAC;IAEH,sBAAsB;IACtB,MAAM,aAAa,GAAG,mBAAI,CAAC,OAAO,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;IAC5D,IAAI,CAAC,iBAAE,CAAC,UAAU,CAAC,aAAa,CAAC,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CACb,gCAAgC,aAAa,sDAAsD,CACpG,CAAC;IACJ,CAAC;IAED,MAAM,KAAK,GAAG,iBAAE;SACb,WAAW,CAAC,aAAa,CAAC;SAC1B,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;SACjC,IAAI,EAAE,CAAC;IAEV,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;QACpC,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YAE5B,yDAAyD;YACzD,MAAM,MAAM,CAAC,KAAK,CAAC,uCAAuC,CAAC,CAAC;YAE5D,mEAAmE;YACnE,MAAM,EAAE,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,KAAK,CACjC,2CAA2C,EAC3C,CAAC,IAAI,CAAC,CACP,CAAC;YACF,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACpB,MAAM,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;gBAC7B,SAAS;YACX,CAAC;YAED,wCAAwC;YACxC,IAAI,UAAU,EAAE,CAAC;gBACf,MAAM,MAAM,CAAC,KAAK,CAAC,sCAAsC,CAAC,CAAC;YAC7D,CAAC;YAED,MAAM,GAAG,GAAG,iBAAE,CAAC,YAAY,CAAC,mBAAI,CAAC,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,EAAE,OAAO,CAAC,CAAC;YACrE,MAAM,MAAM,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YACxB,MAAM,MAAM,CAAC,KAAK,CAAC,4CAA4C,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC;YACzE,MAAM,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;QAC/B,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,CAAC;gBAAC,MAAM,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;YAAC,CAAC;YAAC,MAAM,CAAC,CAAC,6BAA6B,CAAC,CAAC;YAC/E,MAAM,GAAG,CAAC;QACZ,CAAC;gBAAS,CAAC;YACT,MAAM,CAAC,OAAO,EAAE,CAAC;QACnB,CAAC;IACH,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"migrate.js","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,25 @@
1
+ import type pg from "pg";
2
+ /** The default name of the NOLOGIN control role (migration 032). */
3
+ export declare const STRATUM_CONTROL_ROLE = "stratum_control";
4
+ /** A role name Stratum accepts: a plain lowercase identifier. */
5
+ export declare const ROLE_NAME: RegExp;
6
+ /** Throws when `role` is not a plain lowercase identifier. */
7
+ export declare function assertRoleName(role: string, what: string): void;
8
+ /** The SQL to run for one migration file. */
9
+ export declare function migrationSql(name: string, sql: string, superuser: boolean): string;
10
+ /** Whether the role of `client` is a superuser. */
11
+ export declare function isSuperuser(client: pg.PoolClient | pg.Pool): Promise<boolean>;
12
+ /**
13
+ * Sets stratum.control_role for the current transaction, which migration 032
14
+ * reads. Without a name the setting stays as the session has it.
15
+ */
16
+ export declare function setControlRole(client: pg.PoolClient, controlRole: string | undefined): Promise<void>;
17
+ /**
18
+ * Sets stratum.apply_control_role = 'on' for the current transaction when
19
+ * `apply` is true. Migration 032 grants the control role to the migrating
20
+ * login only with this opt-in (or when the login is a superuser or already a
21
+ * member), because that login must be the library's admin login and never
22
+ * the application's.
23
+ */
24
+ export declare function setApplyControlRole(client: pg.PoolClient, apply: boolean | undefined): Promise<void>;
25
+ //# sourceMappingURL=migration-sql.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migration-sql.d.ts","sourceRoot":"","sources":["../src/migration-sql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AAEzB,oEAAoE;AACpE,eAAO,MAAM,oBAAoB,oBAAoB,CAAC;AAEtD,iEAAiE;AACjE,eAAO,MAAM,SAAS,QAA4B,CAAC;AAEnD,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAI/D;AAgBD,6CAA6C;AAC7C,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,MAAM,CAGlF;AAED,mDAAmD;AACnD,wBAAsB,WAAW,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,GAAG,EAAE,CAAC,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAKnF;AAED;;;GAGG;AACH,wBAAsB,cAAc,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,EAAE,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAG1G;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAG1G"}
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ROLE_NAME = exports.STRATUM_CONTROL_ROLE = void 0;
4
+ exports.assertRoleName = assertRoleName;
5
+ exports.migrationSql = migrationSql;
6
+ exports.isSuperuser = isSuperuser;
7
+ exports.setControlRole = setControlRole;
8
+ exports.setApplyControlRole = setApplyControlRole;
9
+ /** The default name of the NOLOGIN control role (migration 032). */
10
+ exports.STRATUM_CONTROL_ROLE = "stratum_control";
11
+ /** A role name Stratum accepts: a plain lowercase identifier. */
12
+ exports.ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
13
+ /** Throws when `role` is not a plain lowercase identifier. */
14
+ function assertRoleName(role, what) {
15
+ if (!exports.ROLE_NAME.test(role)) {
16
+ throw new Error(`[stratum] Invalid ${what} '${role}': use lowercase letters, digits and underscores`);
17
+ }
18
+ }
19
+ // Migrations 029 and 031 created their helper functions with
20
+ // `SET app.bypass_rls = 'on'` (and `SET app.tenant_scope = ''`) clauses.
21
+ // PostgreSQL lets only a superuser, or a role granted SET on the parameter,
22
+ // put a custom setting in a function, so a fresh install by a role that is
23
+ // not a superuser stopped at 029. For such a role the runner drops the
24
+ // clauses from these two files. Migration 032 re-creates both functions
25
+ // without them (SECURITY DEFINER, owned by the control role) when it applies
26
+ // the control role, in the same run or later through
27
+ // stratum_apply_control_role(). Until then the functions see what their
28
+ // caller sees, which can only narrow what they return. A superuser runs the
29
+ // files as they are.
30
+ const SUPERSEDED_SET_CLAUSES = new Set(["029_tenant_parent_cycle_guard.sql", "031_subtree_read_scope.sql"]);
31
+ const APP_SET_CLAUSE = /^SET app\.[a-z_]+ = '[^']*'\n/gm;
32
+ /** The SQL to run for one migration file. */
33
+ function migrationSql(name, sql, superuser) {
34
+ if (superuser || !SUPERSEDED_SET_CLAUSES.has(name))
35
+ return sql;
36
+ return sql.replace(APP_SET_CLAUSE, "");
37
+ }
38
+ /** Whether the role of `client` is a superuser. */
39
+ async function isSuperuser(client) {
40
+ const res = await client.query("SELECT rolsuper FROM pg_catalog.pg_roles WHERE rolname = current_user");
41
+ return res.rows[0]?.rolsuper === true;
42
+ }
43
+ /**
44
+ * Sets stratum.control_role for the current transaction, which migration 032
45
+ * reads. Without a name the setting stays as the session has it.
46
+ */
47
+ async function setControlRole(client, controlRole) {
48
+ if (controlRole === undefined)
49
+ return;
50
+ await client.query("SELECT pg_catalog.set_config('stratum.control_role', $1, true)", [controlRole]);
51
+ }
52
+ /**
53
+ * Sets stratum.apply_control_role = 'on' for the current transaction when
54
+ * `apply` is true. Migration 032 grants the control role to the migrating
55
+ * login only with this opt-in (or when the login is a superuser or already a
56
+ * member), because that login must be the library's admin login and never
57
+ * the application's.
58
+ */
59
+ async function setApplyControlRole(client, apply) {
60
+ if (!apply)
61
+ return;
62
+ await client.query("SELECT pg_catalog.set_config('stratum.apply_control_role', 'on', true)");
63
+ }
64
+ //# sourceMappingURL=migration-sql.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migration-sql.js","sourceRoot":"","sources":["../src/migration-sql.ts"],"names":[],"mappings":";;;AASA,wCAIC;AAiBD,oCAGC;AAGD,kCAKC;AAMD,wCAGC;AASD,kDAGC;AA5DD,oEAAoE;AACvD,QAAA,oBAAoB,GAAG,iBAAiB,CAAC;AAEtD,iEAAiE;AACpD,QAAA,SAAS,GAAG,yBAAyB,CAAC;AAEnD,8DAA8D;AAC9D,SAAgB,cAAc,CAAC,IAAY,EAAE,IAAY;IACvD,IAAI,CAAC,iBAAS,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,qBAAqB,IAAI,KAAK,IAAI,kDAAkD,CAAC,CAAC;IACxG,CAAC;AACH,CAAC;AAED,6DAA6D;AAC7D,yEAAyE;AACzE,4EAA4E;AAC5E,2EAA2E;AAC3E,uEAAuE;AACvE,wEAAwE;AACxE,6EAA6E;AAC7E,qDAAqD;AACrD,wEAAwE;AACxE,4EAA4E;AAC5E,qBAAqB;AACrB,MAAM,sBAAsB,GAAG,IAAI,GAAG,CAAC,CAAC,mCAAmC,EAAE,4BAA4B,CAAC,CAAC,CAAC;AAC5G,MAAM,cAAc,GAAG,iCAAiC,CAAC;AAEzD,6CAA6C;AAC7C,SAAgB,YAAY,CAAC,IAAY,EAAE,GAAW,EAAE,SAAkB;IACxE,IAAI,SAAS,IAAI,CAAC,sBAAsB,CAAC,GAAG,CAAC,IAAI,CAAC;QAAE,OAAO,GAAG,CAAC;IAC/D,OAAO,GAAG,CAAC,OAAO,CAAC,cAAc,EAAE,EAAE,CAAC,CAAC;AACzC,CAAC;AAED,mDAAmD;AAC5C,KAAK,UAAU,WAAW,CAAC,MAA+B;IAC/D,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,KAAK,CAC5B,uEAAuE,CACxE,CAAC;IACF,OAAO,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,QAAQ,KAAK,IAAI,CAAC;AACxC,CAAC;AAED;;;GAGG;AACI,KAAK,UAAU,cAAc,CAAC,MAAqB,EAAE,WAA+B;IACzF,IAAI,WAAW,KAAK,SAAS;QAAE,OAAO;IACtC,MAAM,MAAM,CAAC,KAAK,CAAC,gEAAgE,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC;AACtG,CAAC;AAED;;;;;;GAMG;AACI,KAAK,UAAU,mBAAmB,CAAC,MAAqB,EAAE,KAA0B;IACzF,IAAI,CAAC,KAAK;QAAE,OAAO;IACnB,MAAM,MAAM,CAAC,KAAK,CAAC,wEAAwE,CAAC,CAAC;AAC/F,CAAC"}
@@ -2,13 +2,13 @@
2
2
  DO $$ BEGIN
3
3
  CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
4
4
  EXCEPTION WHEN insufficient_privilege THEN
5
- RAISE WARNING 'Cannot create uuid-ossp extension — insufficient privileges. Ask a database administrator to run: CREATE EXTENSION IF NOT EXISTS "uuid-ossp";';
5
+ RAISE WARNING 'Cannot create uuid-ossp extension: insufficient privileges. Ask a database administrator to run: CREATE EXTENSION IF NOT EXISTS "uuid-ossp";';
6
6
  END $$;
7
7
 
8
8
  DO $$ BEGIN
9
9
  CREATE EXTENSION IF NOT EXISTS "ltree";
10
10
  EXCEPTION WHEN insufficient_privilege THEN
11
- RAISE WARNING 'Cannot create ltree extension — insufficient privileges. Ask a database administrator to run: CREATE EXTENSION IF NOT EXISTS "ltree";';
11
+ RAISE WARNING 'Cannot create ltree extension: insufficient privileges. Ask a database administrator to run: CREATE EXTENSION IF NOT EXISTS "ltree";';
12
12
  END $$;
13
13
 
14
14
  -- Warn if application role has BYPASSRLS privilege.
@@ -127,7 +127,7 @@ CREATE TRIGGER update_config_entries_updated_at
127
127
  FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
128
128
 
129
129
  -- Config entries uses ON DELETE CASCADE (unlike permission_policies which uses RESTRICT)
130
- -- because config values are safe to delete with a tenant — they carry no semantic guarantees
130
+ -- because config values are safe to delete with a tenant; they carry no semantic guarantees
131
131
  -- like PERMANENT revocation mode. If soft-delete is used (v1 default), CASCADE never fires.
132
132
 
133
133
  -- API keys table (for control plane auth)
@@ -48,9 +48,9 @@ END;
48
48
  $$ language 'plpgsql';
49
49
 
50
50
  DO $pin$ BEGIN
51
- EXECUTE format(
51
+ EXECUTE pg_catalog.format(
52
52
  'ALTER FUNCTION refuse_tenant_parent_cycle() SET search_path = pg_catalog, %I',
53
- current_schema()
53
+ pg_catalog.current_schema()
54
54
  );
55
55
  END $pin$;
56
56
 
@@ -0,0 +1,307 @@
1
+ -- Migration 031: Opt-in subtree read scope for row-level security.
2
+ --
3
+ -- A session scoped to tenant T can opt in to read the rows of T and of every
4
+ -- descendant of T. The default stays exact-tenant isolation (019, 020).
5
+ --
6
+ -- Context model, in addition to 019:
7
+ -- * Subtree scope: SET LOCAL app.tenant_scope = 'subtree' (transaction
8
+ -- scoped). Only the exact value 'subtree' widens reads. The library sets it
9
+ -- only through its context helpers (db-adapters setTenantContext and
10
+ -- withTenantContext with { scope: 'subtree' }).
11
+ --
12
+ -- Reads only. Each table below gets a second permissive policy, tenant_subtree_read,
13
+ -- FOR SELECT. PostgreSQL ORs it with tenant_isolation for reads. INSERT,
14
+ -- UPDATE and DELETE must also pass the policies for their own command, and
15
+ -- tenant_isolation is the only one. Writes therefore stay limited to the exact
16
+ -- tenant: an insert for a descendant fails WITH CHECK, and UPDATE and DELETE
17
+ -- do not reach a descendant's rows. SELECT ... FOR UPDATE and FOR SHARE also
18
+ -- need the UPDATE policy, so in subtree scope they return the exact tenant's
19
+ -- rows only. The 019 and 020 policies do not change.
20
+ --
21
+ -- Tables with a subtree read policy:
22
+ -- tenants (on id), config_entries (rows with sensitive = false only),
23
+ -- permission_policies, audit_logs, webhook_events, consent_records,
24
+ -- abac_policies, roles, usage_events, and, through their parent row,
25
+ -- webhook_deliveries (via webhook_events) and principal_roles (via roles).
26
+ --
27
+ -- Credential-bearing rows stay exact-tenant. api_keys and webhooks get no
28
+ -- subtree policy, and the config_entries policy admits no row with
29
+ -- sensitive = true. A key row holds a key hash, a webhook row holds a signing
30
+ -- secret hash and a URL that can carry credentials, and a sensitive config
31
+ -- row holds a secret value. Their 019 policies stay the only ones that admit
32
+ -- a tenant session, so only the exact tenant reads them. webhook_deliveries
33
+ -- is scoped through webhook_events, not webhooks, so it gives no subtree path
34
+ -- to a webhook row.
35
+ --
36
+ -- Tenant status does not affect the scope. Suspended, archived and pending
37
+ -- descendants are in the subtree, as exact-tenant RLS ignores status too.
38
+ -- Applications filter by status when they need to.
39
+ --
40
+ -- The bypass (app.bypass_rls = 'on') does not change: it sees every row.
41
+ --
42
+ -- The scope follows the tree as it is when the statement runs: a move through
43
+ -- moveTenant changes the subtree at once. The tree columns of tenants change
44
+ -- only under the bypass (see guard_tenant_tree_columns below), so a tenant
45
+ -- session cannot change its own position in the tree.
46
+ --
47
+ -- Cost: the function runs once per policy reference in a statement, and its
48
+ -- result grows with the size of the subtree. Each table then matches tenant_id
49
+ -- against that array, so it needs an index on tenant_id.
50
+
51
+ -- ---------------------------------------------------------------------------
52
+ -- stratum_subtree_tenant_ids(): the current tenant and all its descendants.
53
+ --
54
+ -- The subtree is selected by the ID-based ancestry_path, not by the
55
+ -- slug-derived ancestry_ltree, for the reason getDescendants gives in
56
+ -- tenant-service.ts: an isolation boundary must not follow slugs. A
57
+ -- descendant's ancestry_path is the subtree path itself or starts with it
58
+ -- plus '/'. The ~>=~ / ~<~ range lets idx_tenant_ancestry_path_prefix (028)
59
+ -- serve the lookup with a value known only at run time ('0' is the character
60
+ -- after '/'). The LIKE then drops what the range admits but is not a path
61
+ -- segment boundary.
62
+ --
63
+ -- The policies call the function in a scalar subquery, so PostgreSQL runs it
64
+ -- once per policy reference in a statement as an InitPlan and then matches
65
+ -- tenant_id against the array with the table's own index on tenant_id.
66
+ --
67
+ -- The function must read the whole subtree while the caller sees only its own
68
+ -- tenants row (019). It does what 029 does: SECURITY DEFINER would not help,
69
+ -- because FORCE ROW LEVEL SECURITY applies to the owner too, so a SET clause
70
+ -- turns on the 019 bypass for the function's own duration. A SET clause is
71
+ -- restored when the function exits, on error too. The second SET clause clears
72
+ -- app.tenant_scope inside the function, so the tenant_subtree_read policy on
73
+ -- tenants cannot call the function again while it runs.
74
+ --
75
+ -- The function returns only ids in the subtree of app.current_tenant_id. A
76
+ -- session that can call it can already set both settings, so it gives no new
77
+ -- access. With no current tenant it returns an empty array.
78
+ --
79
+ -- search_path is pinned below to pg_catalog, the schema this migration runs
80
+ -- in (where 001 created tenants), and pg_temp last, so that a table a session
81
+ -- creates cannot stand in for tenants.
82
+ -- ---------------------------------------------------------------------------
83
+ CREATE OR REPLACE FUNCTION stratum_subtree_tenant_ids()
84
+ RETURNS uuid[]
85
+ LANGUAGE sql
86
+ STABLE
87
+ SET app.bypass_rls = 'on'
88
+ SET app.tenant_scope = ''
89
+ AS $$
90
+ WITH cur AS (
91
+ SELECT t.id, rtrim(t.ancestry_path, '/') || '/' || t.id::text AS sub
92
+ FROM tenants t
93
+ WHERE t.id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid
94
+ )
95
+ SELECT coalesce(array_agg(s.id), '{}'::uuid[])
96
+ FROM (
97
+ SELECT cur.id FROM cur
98
+ UNION ALL
99
+ SELECT d.id
100
+ FROM cur
101
+ JOIN tenants d
102
+ ON d.ancestry_path ~>=~ cur.sub
103
+ AND d.ancestry_path ~<~ (cur.sub || '0')
104
+ AND (d.ancestry_path = cur.sub OR d.ancestry_path LIKE cur.sub || '/%')
105
+ ) s
106
+ $$;
107
+
108
+ DO $pin$ BEGIN
109
+ EXECUTE pg_catalog.format(
110
+ 'ALTER FUNCTION stratum_subtree_tenant_ids() SET search_path = pg_catalog, %I, pg_temp',
111
+ pg_catalog.current_schema()
112
+ );
113
+ END $pin$;
114
+
115
+ -- Reusable predicate (inlined per policy below, as in 019):
116
+ -- current_setting('app.tenant_scope', true) = 'subtree'
117
+ -- AND tenant_id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
118
+ -- The scope check comes first. With any other scope the AND stops there, so
119
+ -- the default path never runs the function.
120
+
121
+ -- ---------------------------------------------------------------------------
122
+ -- Direct tenant_id tables.
123
+ -- ---------------------------------------------------------------------------
124
+
125
+ -- config_entries: non-sensitive rows only. A sensitive row holds a secret
126
+ -- value, so only the exact tenant reads it (019).
127
+ DROP POLICY IF EXISTS tenant_subtree_read ON config_entries;
128
+ CREATE POLICY tenant_subtree_read ON config_entries FOR SELECT
129
+ USING (
130
+ current_setting('app.tenant_scope', true) = 'subtree'
131
+ AND tenant_id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
132
+ AND NOT sensitive
133
+ );
134
+
135
+ DROP POLICY IF EXISTS tenant_subtree_read ON permission_policies;
136
+ CREATE POLICY tenant_subtree_read ON permission_policies FOR SELECT
137
+ USING (
138
+ current_setting('app.tenant_scope', true) = 'subtree'
139
+ AND tenant_id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
140
+ );
141
+
142
+ DROP POLICY IF EXISTS tenant_subtree_read ON audit_logs;
143
+ CREATE POLICY tenant_subtree_read ON audit_logs FOR SELECT
144
+ USING (
145
+ current_setting('app.tenant_scope', true) = 'subtree'
146
+ AND tenant_id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
147
+ );
148
+
149
+ DROP POLICY IF EXISTS tenant_subtree_read ON webhook_events;
150
+ CREATE POLICY tenant_subtree_read ON webhook_events FOR SELECT
151
+ USING (
152
+ current_setting('app.tenant_scope', true) = 'subtree'
153
+ AND tenant_id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
154
+ );
155
+
156
+ -- webhooks: no subtree policy. A webhook row holds credential material, so
157
+ -- only the exact tenant reads it (019).
158
+ DROP POLICY IF EXISTS tenant_subtree_read ON webhooks;
159
+
160
+ DROP POLICY IF EXISTS tenant_subtree_read ON consent_records;
161
+ CREATE POLICY tenant_subtree_read ON consent_records FOR SELECT
162
+ USING (
163
+ current_setting('app.tenant_scope', true) = 'subtree'
164
+ AND tenant_id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
165
+ );
166
+
167
+ DROP POLICY IF EXISTS tenant_subtree_read ON abac_policies;
168
+ CREATE POLICY tenant_subtree_read ON abac_policies FOR SELECT
169
+ USING (
170
+ current_setting('app.tenant_scope', true) = 'subtree'
171
+ AND tenant_id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
172
+ );
173
+
174
+ DROP POLICY IF EXISTS tenant_subtree_read ON roles;
175
+ CREATE POLICY tenant_subtree_read ON roles FOR SELECT
176
+ USING (
177
+ current_setting('app.tenant_scope', true) = 'subtree'
178
+ AND tenant_id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
179
+ );
180
+
181
+ DROP POLICY IF EXISTS tenant_subtree_read ON usage_events;
182
+ CREATE POLICY tenant_subtree_read ON usage_events FOR SELECT
183
+ USING (
184
+ current_setting('app.tenant_scope', true) = 'subtree'
185
+ AND tenant_id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
186
+ );
187
+
188
+ -- ---------------------------------------------------------------------------
189
+ -- tenants registry: the subtree's own registry rows, on id.
190
+ -- ---------------------------------------------------------------------------
191
+ DROP POLICY IF EXISTS tenant_subtree_read ON tenants;
192
+ CREATE POLICY tenant_subtree_read ON tenants FOR SELECT
193
+ USING (
194
+ current_setting('app.tenant_scope', true) = 'subtree'
195
+ AND id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
196
+ );
197
+
198
+ -- ---------------------------------------------------------------------------
199
+ -- Indirect tables: scoped through their tenant-bearing parent, as in 019.
200
+ -- ---------------------------------------------------------------------------
201
+
202
+ DROP POLICY IF EXISTS tenant_subtree_read ON webhook_deliveries;
203
+ CREATE POLICY tenant_subtree_read ON webhook_deliveries FOR SELECT
204
+ USING (
205
+ current_setting('app.tenant_scope', true) = 'subtree'
206
+ AND EXISTS (
207
+ SELECT 1 FROM webhook_events we
208
+ WHERE we.id = webhook_deliveries.event_id
209
+ AND we.tenant_id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
210
+ )
211
+ );
212
+
213
+ DROP POLICY IF EXISTS tenant_subtree_read ON principal_roles;
214
+ CREATE POLICY tenant_subtree_read ON principal_roles FOR SELECT
215
+ USING (
216
+ current_setting('app.tenant_scope', true) = 'subtree'
217
+ AND EXISTS (
218
+ SELECT 1 FROM roles r
219
+ WHERE r.id = principal_roles.role_id
220
+ AND r.tenant_id = ANY ((SELECT stratum_subtree_tenant_ids())::uuid[])
221
+ )
222
+ );
223
+
224
+ -- ---------------------------------------------------------------------------
225
+ -- Tree columns change only under the bypass.
226
+ --
227
+ -- A tenant session may update its own tenants row (019), and the subtree read
228
+ -- above follows parent_id and ancestry_path. This BEFORE trigger refuses any
229
+ -- change to parent_id, ancestry_path, depth or ancestry_ltree unless
230
+ -- app.bypass_rls = 'on'. The library changes these columns only in
231
+ -- createTenant, moveTenant and the other tree operations, which all run under
232
+ -- the bypass (pool-helpers.ts), and so do the migrations. A role that skips
233
+ -- RLS must also set the bypass to change them.
234
+ --
235
+ -- A slug rename stays allowed: the trigger is named to run before
236
+ -- maintain_tenant_ancestry_ltree (BEFORE triggers run in name order), so it
237
+ -- compares the ancestry_ltree the statement wrote, not the one that trigger
238
+ -- recomputes from the new slug.
239
+ -- ---------------------------------------------------------------------------
240
+ CREATE OR REPLACE FUNCTION refuse_tenant_tree_column_change()
241
+ RETURNS TRIGGER
242
+ AS $$
243
+ BEGIN
244
+ IF current_setting('app.bypass_rls', true) IS DISTINCT FROM 'on' AND (
245
+ NEW.parent_id IS DISTINCT FROM OLD.parent_id
246
+ OR NEW.ancestry_path IS DISTINCT FROM OLD.ancestry_path
247
+ OR NEW.depth IS DISTINCT FROM OLD.depth
248
+ OR NEW.ancestry_ltree IS DISTINCT FROM OLD.ancestry_ltree
249
+ ) THEN
250
+ RAISE EXCEPTION 'tenant % tree columns (parent_id, ancestry_path, depth, ancestry_ltree) change only under app.bypass_rls', OLD.id
251
+ USING ERRCODE = 'insufficient_privilege';
252
+ END IF;
253
+ RETURN NEW;
254
+ END;
255
+ $$ language 'plpgsql';
256
+
257
+ DO $pin$ BEGIN
258
+ EXECUTE pg_catalog.format(
259
+ 'ALTER FUNCTION refuse_tenant_tree_column_change() SET search_path = pg_catalog, %I, pg_temp',
260
+ pg_catalog.current_schema()
261
+ );
262
+ END $pin$;
263
+
264
+ DROP TRIGGER IF EXISTS guard_tenant_tree_columns ON tenants;
265
+ CREATE TRIGGER guard_tenant_tree_columns
266
+ BEFORE UPDATE OF parent_id, ancestry_path, depth, ancestry_ltree ON tenants
267
+ FOR EACH ROW
268
+ EXECUTE FUNCTION refuse_tenant_tree_column_change();
269
+
270
+ -- ---------------------------------------------------------------------------
271
+ -- refuse_tenant_parent_cycle() (029), with pg_temp last on its search_path.
272
+ --
273
+ -- The body is the one 029 created, unchanged. 029 pinned search_path to
274
+ -- pg_catalog and the migration schema; this adds pg_temp at the end, so the
275
+ -- cycle walk always reads the tenants table of that schema.
276
+ -- ---------------------------------------------------------------------------
277
+ CREATE OR REPLACE FUNCTION refuse_tenant_parent_cycle()
278
+ RETURNS TRIGGER
279
+ SET app.bypass_rls = 'on'
280
+ AS $$
281
+ BEGIN
282
+ IF NEW.parent_id = NEW.id OR EXISTS (
283
+ WITH RECURSIVE up(id, parent_id, seen) AS (
284
+ SELECT t.id, t.parent_id, ARRAY[t.id]
285
+ FROM tenants t
286
+ WHERE t.id = NEW.parent_id
287
+ UNION ALL
288
+ SELECT t.id, t.parent_id, up.seen || t.id
289
+ FROM tenants t
290
+ JOIN up ON t.id = up.parent_id
291
+ WHERE t.id <> ALL (up.seen)
292
+ )
293
+ SELECT 1 FROM up WHERE up.id = NEW.id
294
+ ) THEN
295
+ RAISE EXCEPTION 'tenant % cannot be its own ancestor (parent_id cycle)', NEW.id
296
+ USING ERRCODE = 'check_violation';
297
+ END IF;
298
+ RETURN NEW;
299
+ END;
300
+ $$ language 'plpgsql';
301
+
302
+ DO $pin$ BEGIN
303
+ EXECUTE pg_catalog.format(
304
+ 'ALTER FUNCTION refuse_tenant_parent_cycle() SET search_path = pg_catalog, %I, pg_temp',
305
+ pg_catalog.current_schema()
306
+ );
307
+ END $pin$;