@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.
- package/README.md +49 -20
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +50 -10
- package/dist/crypto.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/migrate-schemas.d.ts +4 -0
- package/dist/migrate-schemas.d.ts.map +1 -1
- package/dist/migrate-schemas.js +17 -7
- package/dist/migrate-schemas.js.map +1 -1
- package/dist/migrate.d.ts +24 -0
- package/dist/migrate.d.ts.map +1 -1
- package/dist/migrate.js +44 -6
- package/dist/migrate.js.map +1 -1
- package/dist/migration-sql.d.ts +25 -0
- package/dist/migration-sql.d.ts.map +1 -0
- package/dist/migration-sql.js +64 -0
- package/dist/migration-sql.js.map +1 -0
- package/dist/migrations/001_init.sql +3 -3
- package/dist/migrations/029_tenant_parent_cycle_guard.sql +2 -2
- package/dist/migrations/031_subtree_read_scope.sql +307 -0
- package/dist/migrations/032_control_role.sql +809 -0
- package/dist/migrations/033_api_keys_tenant_index.sql +14 -0
- package/dist/pinned-query.d.ts +29 -0
- package/dist/pinned-query.d.ts.map +1 -0
- package/dist/pinned-query.js +60 -0
- package/dist/pinned-query.js.map +1 -0
- package/dist/pool-helpers.d.ts +2 -0
- package/dist/pool-helpers.d.ts.map +1 -1
- package/dist/pool-helpers.js +17 -3
- package/dist/pool-helpers.js.map +1 -1
- package/dist/role-model.d.ts +178 -0
- package/dist/role-model.d.ts.map +1 -0
- package/dist/role-model.js +670 -0
- package/dist/role-model.js.map +1 -0
- package/dist/scoped-job.d.ts +9 -1
- package/dist/scoped-job.d.ts.map +1 -1
- package/dist/scoped-job.js +3 -2
- package/dist/scoped-job.js.map +1 -1
- package/dist/services/abac-service.js +1 -1
- package/dist/services/abac-service.js.map +1 -1
- package/dist/services/api-key-service.d.ts +13 -3
- package/dist/services/api-key-service.d.ts.map +1 -1
- package/dist/services/api-key-service.js +33 -9
- package/dist/services/api-key-service.js.map +1 -1
- package/dist/services/config-service.d.ts +15 -6
- package/dist/services/config-service.d.ts.map +1 -1
- package/dist/services/config-service.js +101 -55
- package/dist/services/config-service.js.map +1 -1
- package/dist/services/event-service.js +2 -2
- package/dist/services/event-service.js.map +1 -1
- package/dist/services/permission-service.js +1 -1
- package/dist/services/permission-service.js.map +1 -1
- package/dist/services/rate-limit-service.d.ts +3 -3
- package/dist/services/region-service.js +1 -1
- package/dist/services/region-service.js.map +1 -1
- package/dist/services/retention-service.d.ts +2 -2
- package/dist/services/retention-service.js +2 -2
- package/dist/services/role-service.d.ts +1 -1
- package/dist/services/role-service.js +1 -1
- package/dist/services/tenant-service.d.ts +2 -2
- package/dist/services/tenant-service.d.ts.map +1 -1
- package/dist/services/tenant-service.js +8 -8
- package/dist/services/tenant-service.js.map +1 -1
- package/dist/stratum-policies.d.ts +51 -0
- package/dist/stratum-policies.d.ts.map +1 -0
- package/dist/stratum-policies.js +217 -0
- package/dist/stratum-policies.js.map +1 -0
- package/dist/stratum-tables.d.ts.map +1 -1
- package/dist/stratum-tables.js +1 -0
- package/dist/stratum-tables.js.map +1 -1
- package/dist/stratum.d.ts +56 -11
- package/dist/stratum.d.ts.map +1 -1
- package/dist/stratum.js +82 -21
- package/dist/stratum.js.map +1 -1
- package/dist/telemetry.d.ts +2 -2
- package/dist/telemetry.js +4 -4
- package/dist/telemetry.js.map +1 -1
- package/dist/url-redaction.js +1 -1
- package/dist/url-redaction.js.map +1 -1
- 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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
84
|
+
await client.query(`INSERT INTO ${migrationsTable} (name) VALUES ($1)`, [file]);
|
|
47
85
|
await client.query("COMMIT");
|
|
48
86
|
}
|
|
49
87
|
catch (err) {
|
package/dist/migrate.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"migrate.js","sourceRoot":"","sources":["../src/migrate.ts"],"names":[],"mappings":";;;;;
|
|
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
|
|
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
|
|
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
|
|
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$;
|