@blamejs/core 0.6.5 → 0.6.7
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/CHANGELOG.md +2 -0
- package/README.md +3 -3
- package/lib/audit.js +2 -0
- package/lib/db-declare-row-policy.js +272 -0
- package/lib/db-role-context.js +50 -0
- package/lib/db.js +5 -0
- package/lib/external-db.js +254 -14
- package/lib/middleware/db-role-for.js +292 -0
- package/lib/middleware/index.js +2 -0
- package/lib/permissions.js +76 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.6.x
|
|
10
10
|
|
|
11
|
+
- **0.6.6** (2026-05-01) — request-time DB role binding + Postgres RLS migrations
|
|
12
|
+
- **0.6.5** (2026-05-01) — b.db.declareView + b.externalDb.migrate
|
|
11
13
|
- **0.6.4** (2026-05-01) — wiki schema docs realigned with the actual lib API
|
|
12
14
|
- **0.6.3** (2026-05-01) — externalDb pool tuning + role-aware connect + read-replica routing
|
|
13
15
|
- **0.6.2** (2026-05-01) — input validation + identifier-quoting consistency
|
package/README.md
CHANGED
|
@@ -41,10 +41,10 @@ var b = require("@blamejs/core");
|
|
|
41
41
|
|
|
42
42
|
The framework bundles the surface a typical Node app reaches for. Every primitive listed is callable today; nothing is a stub.
|
|
43
43
|
|
|
44
|
-
- **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket ops (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
|
|
45
|
-
- **Identity & access** — passwords (Argon2id), passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth, sessions, brute-force lockout (`b.auth.*`, `b.session`); RBAC (`b.permissions`); API keys with rotation (`b.apiKey`); break-glass column gates with second-factor + audit (`b.breakGlass`).
|
|
44
|
+
- **Data layer** — SQLite with sealed-by-default columns (`b.db`), migrations, seeders, atomic-file writes; bring-your-own external Postgres / MySQL / etc. with pool tuning + role-aware connect + read-replica routing (`b.externalDb`); declarative role-narrowed views and Postgres row-level-security migrations (`b.db.declareView`, `b.db.declareRowPolicy`); S3 / R2 / B2 / GCS / Azure object store with multipart upload + SSE + bucket ops (`b.storage`, `b.objectStore`); durable queue with priority + cron + flows (`b.queue`, `b.jobs`); cluster-shared cache (`b.cache`).
|
|
45
|
+
- **Identity & access** — passwords (Argon2id), passkeys (WebAuthn), TOTP, JWT (PQ-default), OAuth, sessions, brute-force lockout (`b.auth.*`, `b.session`); RBAC with optional per-role DB binding (`b.permissions`, role-spec `dbRole` field); API keys with rotation (`b.apiKey`); break-glass column gates with second-factor + audit (`b.breakGlass`).
|
|
46
46
|
- **Crypto** — envelope-versioned PQC at rest (ML-KEM-1024 + P-384 hybrid, XChaCha20-Poly1305, SHAKE256), vault sealing, field-level crypto, signed webhooks (SLH-DSA-SHAKE-256f), ECIES API encryption (`b.crypto`, `b.vault`, `b.webhook`); pure-JS mTLS CA, PQC TLS gates inbound + outbound (`b.mtlsCa`, `b.pqcGate`, `b.pqcAgent`).
|
|
47
|
-
- **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`).
|
|
47
|
+
- **HTTP** — router with schema-validated routes + OpenAPI publication; full middleware stack (CSRF, CORS, rate-limit, security headers, CSP nonce, body parser, compression, SSE, request log, request-time DB role binding via `b.middleware.dbRoleFor`) wired by `createApp`; HTTP/1.1 + HTTP/2 outbound client with SSRF gate, redirects, multipart, interceptors, progress, encrypted cookie jar (`b.httpClient`, `b.ssrfGuard`, `b.safeUrl`).
|
|
48
48
|
- **Defensive parsers** — `b.safeJson`, `b.safeBuffer`, `b.safeSql`, `b.safeSchema`, `b.parsers` (XML / TOML / YAML / .env), `b.config` (schema-validated env).
|
|
49
49
|
- **Communication** — WebSockets with channel/room fan-out across cluster replicas (`b.websocket`, `b.websocketChannels`); mail with multipart + attachments + DKIM + calendar invites + bounce intake (`b.mail`, `b.mailBounce`); generic notification dispatcher with operator-supplied transports (`b.notify`).
|
|
50
50
|
- **Observability** — tamper-evident audit chain with SLH-DSA-signed checkpoints, metrics, tracing (OTel pass-through when wired), PII redaction, log-stream sinks, OTLP/HTTP-JSON exporter for any OTel-compatible backend (`b.audit`, `b.metrics`, `b.tracing`, `b.redact`, `b.logStream`, `b.otelExport`).
|
package/lib/audit.js
CHANGED
|
@@ -203,6 +203,8 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
203
203
|
"backup", // b.backup
|
|
204
204
|
"breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
|
|
205
205
|
"cache", // b.cache
|
|
206
|
+
"db", // b.db / b.middleware.dbRoleFor / b.externalDb.runAs
|
|
207
|
+
// (role-switching, RLS-shaped events)
|
|
206
208
|
"dkim", // b.mail.dkim (DKIM-Signature generation events)
|
|
207
209
|
"mail", // b.mail (b.mail-bounce uses "system.mail.*")
|
|
208
210
|
"notify", // b.notify
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.db.declareRowPolicy — declarative Postgres ROW LEVEL SECURITY policy
|
|
4
|
+
* migration spec.
|
|
5
|
+
*
|
|
6
|
+
* Returns a migration-shape object that b.externalDb.migrate(...) applies
|
|
7
|
+
* against a Postgres backend. Generates:
|
|
8
|
+
*
|
|
9
|
+
* ALTER TABLE <schema>.<table> ENABLE ROW LEVEL SECURITY; -- idempotent
|
|
10
|
+
* CREATE POLICY <name> ON <schema>.<table>
|
|
11
|
+
* [AS PERMISSIVE | RESTRICTIVE]
|
|
12
|
+
* FOR <command>
|
|
13
|
+
* [TO <role>]
|
|
14
|
+
* USING (<expr>)
|
|
15
|
+
* [WITH CHECK (<expr>)];
|
|
16
|
+
*
|
|
17
|
+
* Pairs with b.externalDb.transaction({ sessionGucs: { 'app.tenant_id': uuid } })
|
|
18
|
+
* for the per-request `SET LOCAL` plumbing. The recommended tenant-per-row
|
|
19
|
+
* shape:
|
|
20
|
+
*
|
|
21
|
+
* b.db.declareRowPolicy({
|
|
22
|
+
* schema: "public",
|
|
23
|
+
* table: "sessions",
|
|
24
|
+
* name: "tenant_isolation",
|
|
25
|
+
* role: "app_user",
|
|
26
|
+
* using: "tenant_id = current_setting('app.tenant_id')::uuid",
|
|
27
|
+
* withCheck: "tenant_id = current_setting('app.tenant_id')::uuid",
|
|
28
|
+
* command: "ALL",
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* await b.externalDb.transaction(async function (tx) {
|
|
32
|
+
* return await tx.query("SELECT * FROM sessions WHERE _id = $1", [sid]);
|
|
33
|
+
* }, { sessionGucs: { "app.tenant_id": req.user.tenantId } });
|
|
34
|
+
*
|
|
35
|
+
* Postgres-only: SQLite + MySQL have no equivalent grammar. Apply throws
|
|
36
|
+
* NOT_SUPPORTED at migration-apply time when the targeted backend's
|
|
37
|
+
* dialect isn't "postgres".
|
|
38
|
+
*
|
|
39
|
+
* Validation at declareRowPolicy() call time — bad shape throws here, not
|
|
40
|
+
* at apply time:
|
|
41
|
+
* - schema, table, name, role → safeSql.validateIdentifier
|
|
42
|
+
* - command ∈ {ALL, SELECT, INSERT, UPDATE, DELETE}
|
|
43
|
+
* - permissive boolean
|
|
44
|
+
* - using / withCheck operator-supplied SQL strings; semicolons rejected
|
|
45
|
+
*
|
|
46
|
+
* Audit metadata emitted on apply:
|
|
47
|
+
* {
|
|
48
|
+
* policy: "schema.table.name",
|
|
49
|
+
* table: "schema.table",
|
|
50
|
+
* role: "...",
|
|
51
|
+
* command: "ALL"|...,
|
|
52
|
+
* permissive: true|false,
|
|
53
|
+
* hasWithCheck: bool,
|
|
54
|
+
* }
|
|
55
|
+
*/
|
|
56
|
+
var safeSql = require("./safe-sql");
|
|
57
|
+
var { defineClass } = require("./framework-error");
|
|
58
|
+
|
|
59
|
+
var DeclareRowPolicyError = defineClass("DeclareRowPolicyError", { alwaysPermanent: true });
|
|
60
|
+
|
|
61
|
+
var ALLOWED_OPTS = [
|
|
62
|
+
"schema", "table", "name", "role",
|
|
63
|
+
"using", "withCheck", "command", "permissive", "backend",
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
var ALLOWED_COMMANDS = ["ALL", "SELECT", "INSERT", "UPDATE", "DELETE"];
|
|
67
|
+
|
|
68
|
+
function _err(code, message) {
|
|
69
|
+
return new DeclareRowPolicyError(code, message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function _validateIdent(where, value) {
|
|
73
|
+
try {
|
|
74
|
+
safeSql.validateIdentifier(value, { allowReserved: true });
|
|
75
|
+
} catch (e) {
|
|
76
|
+
throw _err("declare-row-policy/bad-identifier",
|
|
77
|
+
where + ": invalid identifier '" + value + "': " + ((e && e.message) || String(e)));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _validateExpression(where, value) {
|
|
82
|
+
if (typeof value !== "string") {
|
|
83
|
+
throw _err("declare-row-policy/bad-type", where + " must be a string");
|
|
84
|
+
}
|
|
85
|
+
if (value.length === 0) {
|
|
86
|
+
throw _err("declare-row-policy/empty-expression", where + " must be a non-empty boolean expression");
|
|
87
|
+
}
|
|
88
|
+
if (value.indexOf(";") !== -1) {
|
|
89
|
+
throw _err("declare-row-policy/bad-expression",
|
|
90
|
+
where + " must not contain ';' — use a single boolean expression");
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function _validateOpts(opts) {
|
|
96
|
+
if (!opts || typeof opts !== "object") {
|
|
97
|
+
throw _err("declare-row-policy/bad-opts", "declareRowPolicy requires an opts object");
|
|
98
|
+
}
|
|
99
|
+
for (var k in opts) {
|
|
100
|
+
if (Object.prototype.hasOwnProperty.call(opts, k) && ALLOWED_OPTS.indexOf(k) === -1) {
|
|
101
|
+
throw _err("declare-row-policy/unknown-opt",
|
|
102
|
+
"unknown opt '" + k + "'. Allowed: " + ALLOWED_OPTS.join(", "));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof opts.schema !== "string" || opts.schema.length === 0) {
|
|
107
|
+
throw _err("declare-row-policy/missing-opt", "schema is required");
|
|
108
|
+
}
|
|
109
|
+
_validateIdent("schema", opts.schema);
|
|
110
|
+
|
|
111
|
+
if (typeof opts.table !== "string" || opts.table.length === 0) {
|
|
112
|
+
throw _err("declare-row-policy/missing-opt", "table is required");
|
|
113
|
+
}
|
|
114
|
+
_validateIdent("table", opts.table);
|
|
115
|
+
|
|
116
|
+
if (typeof opts.name !== "string" || opts.name.length === 0) {
|
|
117
|
+
throw _err("declare-row-policy/missing-opt", "name is required");
|
|
118
|
+
}
|
|
119
|
+
_validateIdent("name", opts.name);
|
|
120
|
+
|
|
121
|
+
var role = null;
|
|
122
|
+
if (opts.role !== undefined && opts.role !== null) {
|
|
123
|
+
if (typeof opts.role !== "string" || opts.role.length === 0) {
|
|
124
|
+
throw _err("declare-row-policy/bad-type", "role must be a non-empty string");
|
|
125
|
+
}
|
|
126
|
+
_validateIdent("role", opts.role);
|
|
127
|
+
role = opts.role;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (opts.using === undefined || opts.using === null) {
|
|
131
|
+
throw _err("declare-row-policy/missing-opt", "using is required (USING expression)");
|
|
132
|
+
}
|
|
133
|
+
var using = _validateExpression("using", opts.using);
|
|
134
|
+
|
|
135
|
+
var withCheck = null;
|
|
136
|
+
if (opts.withCheck !== undefined && opts.withCheck !== null) {
|
|
137
|
+
withCheck = _validateExpression("withCheck", opts.withCheck);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
var command = "ALL";
|
|
141
|
+
if (opts.command !== undefined && opts.command !== null) {
|
|
142
|
+
if (typeof opts.command !== "string") {
|
|
143
|
+
throw _err("declare-row-policy/bad-type", "command must be a string");
|
|
144
|
+
}
|
|
145
|
+
var upper = opts.command.toUpperCase();
|
|
146
|
+
if (ALLOWED_COMMANDS.indexOf(upper) === -1) {
|
|
147
|
+
throw _err("declare-row-policy/bad-command",
|
|
148
|
+
"command must be one of " + ALLOWED_COMMANDS.join(", ") + ", got '" + opts.command + "'");
|
|
149
|
+
}
|
|
150
|
+
command = upper;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
var permissive = true;
|
|
154
|
+
if (opts.permissive !== undefined && opts.permissive !== null) {
|
|
155
|
+
if (typeof opts.permissive !== "boolean") {
|
|
156
|
+
throw _err("declare-row-policy/bad-type", "permissive must be a boolean");
|
|
157
|
+
}
|
|
158
|
+
permissive = opts.permissive;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (opts.backend !== undefined && opts.backend !== null) {
|
|
162
|
+
if (typeof opts.backend !== "string" || opts.backend.length === 0) {
|
|
163
|
+
throw _err("declare-row-policy/bad-type", "backend must be a non-empty string");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
schema: opts.schema,
|
|
169
|
+
table: opts.table,
|
|
170
|
+
name: opts.name,
|
|
171
|
+
role: role,
|
|
172
|
+
using: using,
|
|
173
|
+
withCheck: withCheck,
|
|
174
|
+
command: command,
|
|
175
|
+
permissive: permissive,
|
|
176
|
+
backend: opts.backend || null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _ensureBackendIsPostgres(externalDb, backendName) {
|
|
181
|
+
var list = externalDb.listBackends();
|
|
182
|
+
var found = null;
|
|
183
|
+
for (var i = 0; i < list.length; i++) {
|
|
184
|
+
if (list[i].name === backendName) { found = list[i]; break; }
|
|
185
|
+
}
|
|
186
|
+
if (!found) {
|
|
187
|
+
throw _err("declare-row-policy/unknown-backend",
|
|
188
|
+
"no externalDb backend named '" + backendName + "' — declared backends: " +
|
|
189
|
+
list.map(function (b) { return b.name; }).join(", "));
|
|
190
|
+
}
|
|
191
|
+
if (found.dialect !== "postgres") {
|
|
192
|
+
throw _err("declare-row-policy/not-supported",
|
|
193
|
+
"declareRowPolicy is Postgres-only; backend '" + backendName + "' has dialect='" +
|
|
194
|
+
found.dialect + "'. Write the policy as a hand-rolled migration for this dialect.");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function declareRowPolicy(opts) {
|
|
199
|
+
var spec = _validateOpts(opts);
|
|
200
|
+
var qTable = safeSql.quoteQualified([spec.schema, spec.table], "postgres");
|
|
201
|
+
var qPolicy = safeSql.quoteIdentifier(spec.name, "postgres");
|
|
202
|
+
var qRole = spec.role ? safeSql.quoteIdentifier(spec.role, "postgres") : null;
|
|
203
|
+
|
|
204
|
+
var description = "declareRowPolicy " + spec.schema + "." + spec.table + "." + spec.name;
|
|
205
|
+
|
|
206
|
+
async function up(xdb, ctx) {
|
|
207
|
+
if (ctx && ctx.externalDb && ctx.backendName) {
|
|
208
|
+
_ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Idempotent ENABLE — Postgres has no IF NOT EXISTS for this. Read
|
|
212
|
+
// the current setting from pg_class and skip the ALTER when already
|
|
213
|
+
// on, so re-running a migration set in a partially-applied state
|
|
214
|
+
// doesn't fail with a no-op error from the lock acquisition.
|
|
215
|
+
var rlsCheck = await xdb.query(
|
|
216
|
+
"SELECT relrowsecurity FROM pg_class c " +
|
|
217
|
+
"JOIN pg_namespace n ON n.oid = c.relnamespace " +
|
|
218
|
+
"WHERE n.nspname = $1 AND c.relname = $2",
|
|
219
|
+
[spec.schema, spec.table]
|
|
220
|
+
);
|
|
221
|
+
var rows = (rlsCheck && rlsCheck.rows) || [];
|
|
222
|
+
if (rows.length === 0) {
|
|
223
|
+
throw _err("declare-row-policy/table-not-found",
|
|
224
|
+
"source table '" + spec.schema + "." + spec.table +
|
|
225
|
+
"' not found (does it exist? does the migration role have visibility?)");
|
|
226
|
+
}
|
|
227
|
+
if (!rows[0].relrowsecurity) {
|
|
228
|
+
await xdb.query("ALTER TABLE " + qTable + " ENABLE ROW LEVEL SECURITY", []);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// CREATE POLICY assembled in canonical order: name → table → AS
|
|
232
|
+
// PERMISSIVE/RESTRICTIVE → FOR command → TO role → USING → WITH CHECK.
|
|
233
|
+
var sql = "CREATE POLICY " + qPolicy + " ON " + qTable;
|
|
234
|
+
sql += " AS " + (spec.permissive ? "PERMISSIVE" : "RESTRICTIVE");
|
|
235
|
+
sql += " FOR " + spec.command;
|
|
236
|
+
if (qRole) sql += " TO " + qRole;
|
|
237
|
+
sql += " USING (" + spec.using + ")";
|
|
238
|
+
if (spec.withCheck) sql += " WITH CHECK (" + spec.withCheck + ")";
|
|
239
|
+
|
|
240
|
+
await xdb.query(sql, []);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
policy: spec.schema + "." + spec.table + "." + spec.name,
|
|
244
|
+
table: spec.schema + "." + spec.table,
|
|
245
|
+
role: spec.role,
|
|
246
|
+
command: spec.command,
|
|
247
|
+
permissive: spec.permissive,
|
|
248
|
+
hasWithCheck: !!spec.withCheck,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function down(xdb, ctx) {
|
|
253
|
+
if (ctx && ctx.externalDb && ctx.backendName) {
|
|
254
|
+
_ensureBackendIsPostgres(ctx.externalDb, ctx.backendName);
|
|
255
|
+
}
|
|
256
|
+
await xdb.query("DROP POLICY IF EXISTS " + qPolicy + " ON " + qTable, []);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
description: description,
|
|
261
|
+
target: "externalDb",
|
|
262
|
+
backend: spec.backend,
|
|
263
|
+
up: up,
|
|
264
|
+
down: down,
|
|
265
|
+
_spec: spec,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
module.exports = {
|
|
270
|
+
declareRowPolicy: declareRowPolicy,
|
|
271
|
+
DeclareRowPolicyError: DeclareRowPolicyError,
|
|
272
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* db-role-context — shared AsyncLocalStorage registry for the request-time
|
|
4
|
+
* DB role binding.
|
|
5
|
+
*
|
|
6
|
+
* The b.middleware.dbRoleFor middleware enters a scope with { role }; the
|
|
7
|
+
* externalDb backend picker reads the same store at query time. Anything
|
|
8
|
+
* deep in the async stack — handler, db query, transaction body, audit
|
|
9
|
+
* write — sees the same role without explicit threading.
|
|
10
|
+
*
|
|
11
|
+
* Out-of-request callers (jobs, schedulers, CLIs) use externalDb.runAs to
|
|
12
|
+
* push a role into the same store for the body of their work.
|
|
13
|
+
*
|
|
14
|
+
* Public API (consumed by externalDb / middleware / permissions):
|
|
15
|
+
* getRole() → string | null
|
|
16
|
+
* runWithRole(role, fn) → fn() inside the role-bound ALS scope
|
|
17
|
+
*
|
|
18
|
+
* The role string must be a SQL-identifier-shaped value; callers are
|
|
19
|
+
* responsible for validating before pushing into the store. The store
|
|
20
|
+
* holds a frozen { role } shape so consumers can't mutate it sideways.
|
|
21
|
+
*/
|
|
22
|
+
var { AsyncLocalStorage } = require("node:async_hooks");
|
|
23
|
+
|
|
24
|
+
var _als = new AsyncLocalStorage();
|
|
25
|
+
|
|
26
|
+
function getStore() {
|
|
27
|
+
return _als.getStore() || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getRole() {
|
|
31
|
+
var s = getStore();
|
|
32
|
+
return s && s.role ? s.role : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runWithRole(role, fn) {
|
|
36
|
+
if (typeof fn !== "function") {
|
|
37
|
+
throw new TypeError("db-role-context.runWithRole: fn must be a function");
|
|
38
|
+
}
|
|
39
|
+
// Null / undefined role passes through as "no binding" — useful for
|
|
40
|
+
// explicitly entering a scope that resets any inherited role.
|
|
41
|
+
var store = role ? Object.freeze({ role: String(role) }) : Object.freeze({ role: null });
|
|
42
|
+
return _als.run(store, fn);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
getRole: getRole,
|
|
47
|
+
runWithRole: runWithRole,
|
|
48
|
+
// For diagnostic use; consumers should prefer getRole.
|
|
49
|
+
_als: _als,
|
|
50
|
+
};
|
package/lib/db.js
CHANGED
|
@@ -1173,6 +1173,11 @@ module.exports = {
|
|
|
1173
1173
|
// b.externalDb.migrate. Postgres-only; fail-fast at apply time on other
|
|
1174
1174
|
// dialects. See lib/db-declare-view.js.
|
|
1175
1175
|
declareView: require("./db-declare-view").declareView,
|
|
1176
|
+
// declareRowPolicy — declarative Postgres ROW LEVEL SECURITY migration
|
|
1177
|
+
// spec. Pairs with externalDb.transaction({ sessionGucs }) for the
|
|
1178
|
+
// per-request `SET LOCAL` plumbing. Postgres-only; fail-fast on other
|
|
1179
|
+
// dialects. See lib/db-declare-row-policy.js.
|
|
1180
|
+
declareRowPolicy: require("./db-declare-row-policy").declareRowPolicy,
|
|
1176
1181
|
// Internal accessors used by audit / subject / consent modules.
|
|
1177
1182
|
// Not part of the public contract — apps should not depend on them.
|
|
1178
1183
|
_getSubjectTables: function () { return subjectTables.slice(); },
|
package/lib/external-db.js
CHANGED
|
@@ -52,19 +52,30 @@
|
|
|
52
52
|
*/
|
|
53
53
|
var retryHelper = require("./retry");
|
|
54
54
|
var C = require("./constants");
|
|
55
|
+
var dbRoleContext = require("./db-role-context");
|
|
55
56
|
var lazyRequire = require("./lazy-require");
|
|
56
57
|
var safeAsync = require("./safe-async");
|
|
57
58
|
var safeSql = require("./safe-sql");
|
|
58
59
|
var { ExternalDbError } = require("./framework-error");
|
|
59
60
|
|
|
60
|
-
var audit
|
|
61
|
-
var db
|
|
61
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
62
|
+
var db = lazyRequire(function () { return require("./db"); });
|
|
63
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
64
|
+
|
|
65
|
+
function _emitMetric(name, value, labels) {
|
|
66
|
+
try { observability().event(name, value, labels || {}); }
|
|
67
|
+
catch (_e) { /* hot-path observability sink — drop silent by design */ }
|
|
68
|
+
}
|
|
62
69
|
|
|
63
70
|
var _err = ExternalDbError.factory;
|
|
64
71
|
|
|
65
72
|
var initialized = false;
|
|
66
73
|
var backends = {};
|
|
67
74
|
var defaultBackend = null;
|
|
75
|
+
// Operator-declared { role: backendName } map for request-time pool pick.
|
|
76
|
+
// Populated at init() from opts.dbRoleBackends. Read by _pickBackend
|
|
77
|
+
// when no explicit opts.backend is supplied AND the ALS scope has a role.
|
|
78
|
+
var dbRoleBackends = {};
|
|
68
79
|
|
|
69
80
|
// ---- Pool ----
|
|
70
81
|
//
|
|
@@ -99,13 +110,21 @@ class Pool {
|
|
|
99
110
|
throw e;
|
|
100
111
|
}
|
|
101
112
|
}
|
|
102
|
-
// At max — wait for a release
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
113
|
+
// At max — wait for a release. The waiter's clock starts now;
|
|
114
|
+
// when release() resolves the waiter we emit the wait duration so
|
|
115
|
+
// operators can see backpressure on the pool.
|
|
116
|
+
var self = this;
|
|
117
|
+
var waitStartedAt = Date.now();
|
|
118
|
+
return new Promise(function (resolve, reject) {
|
|
119
|
+
self.waiters.push({
|
|
120
|
+
resolve: function (client) {
|
|
121
|
+
_emitMetric("externaldb.pool.acquire_wait", Date.now() - waitStartedAt,
|
|
122
|
+
{ backend: self.name });
|
|
123
|
+
resolve(client);
|
|
124
|
+
},
|
|
125
|
+
reject: reject,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
109
128
|
}
|
|
110
129
|
|
|
111
130
|
release(client) {
|
|
@@ -166,6 +185,7 @@ function init(opts) {
|
|
|
166
185
|
if (!opts || !opts.backends) throw new Error("externalDb.init({ backends }) is required");
|
|
167
186
|
|
|
168
187
|
backends = {};
|
|
188
|
+
dbRoleBackends = {};
|
|
169
189
|
for (var name in opts.backends) {
|
|
170
190
|
var cfg = opts.backends[name];
|
|
171
191
|
if (typeof cfg.connect !== "function") {
|
|
@@ -208,6 +228,39 @@ function init(opts) {
|
|
|
208
228
|
}
|
|
209
229
|
|
|
210
230
|
defaultBackend = opts.defaultBackend || Object.keys(backends)[0];
|
|
231
|
+
|
|
232
|
+
// dbRoleBackends — request-time role → backend mapping. Each role name
|
|
233
|
+
// validates as a SQL identifier at init (matches the dbRoleFor
|
|
234
|
+
// middleware's runtime check) so a typo surfaces at boot rather than
|
|
235
|
+
// as a silent default-backend fallback at the first request.
|
|
236
|
+
if (opts.dbRoleBackends !== undefined && opts.dbRoleBackends !== null) {
|
|
237
|
+
if (typeof opts.dbRoleBackends !== "object" || Array.isArray(opts.dbRoleBackends)) {
|
|
238
|
+
throw _err("INVALID_CONFIG",
|
|
239
|
+
"dbRoleBackends must be an object map of role → backendName", true);
|
|
240
|
+
}
|
|
241
|
+
for (var role in opts.dbRoleBackends) {
|
|
242
|
+
if (!Object.prototype.hasOwnProperty.call(opts.dbRoleBackends, role)) continue;
|
|
243
|
+
try {
|
|
244
|
+
safeSql.validateIdentifier(role, { allowReserved: false });
|
|
245
|
+
} catch (e) {
|
|
246
|
+
throw _err("INVALID_CONFIG",
|
|
247
|
+
"dbRoleBackends: role '" + role + "' is not a valid SQL identifier: " +
|
|
248
|
+
((e && e.message) || String(e)), true);
|
|
249
|
+
}
|
|
250
|
+
var bn = opts.dbRoleBackends[role];
|
|
251
|
+
if (typeof bn !== "string" || bn.length === 0) {
|
|
252
|
+
throw _err("INVALID_CONFIG",
|
|
253
|
+
"dbRoleBackends['" + role + "']: backend name must be a non-empty string", true);
|
|
254
|
+
}
|
|
255
|
+
if (!Object.prototype.hasOwnProperty.call(backends, bn)) {
|
|
256
|
+
throw _err("INVALID_CONFIG",
|
|
257
|
+
"dbRoleBackends['" + role + "']: no backend named '" + bn + "' " +
|
|
258
|
+
"(declared backends: " + Object.keys(backends).join(", ") + ")", true);
|
|
259
|
+
}
|
|
260
|
+
dbRoleBackends[role] = bn;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
211
264
|
_validateResidency();
|
|
212
265
|
initialized = true;
|
|
213
266
|
}
|
|
@@ -232,6 +285,16 @@ function _validateResidency() {
|
|
|
232
285
|
}
|
|
233
286
|
|
|
234
287
|
// ---- Backend selection ----
|
|
288
|
+
//
|
|
289
|
+
// Pick precedence:
|
|
290
|
+
// 1. opts.backend — explicit override always wins
|
|
291
|
+
// 2. opts.classification — first backend serving that class
|
|
292
|
+
// 3. ALS-bound dbRole + dbRoleBackends — request-time auto-pick
|
|
293
|
+
// 4. defaultBackend — final fallback
|
|
294
|
+
//
|
|
295
|
+
// The ALS path matches the dbRoleFor middleware shape: middleware sets
|
|
296
|
+
// the role; deep async reads pick up the matching backend without having
|
|
297
|
+
// to thread `req` through every call site.
|
|
235
298
|
|
|
236
299
|
function _pickBackend(opts) {
|
|
237
300
|
opts = opts || {};
|
|
@@ -252,6 +315,10 @@ function _pickBackend(opts) {
|
|
|
252
315
|
throw _err("NO_BACKEND_FOR_CLASSIFICATION",
|
|
253
316
|
"no backend serves classification '" + classification + "'", true);
|
|
254
317
|
}
|
|
318
|
+
var role = dbRoleContext.getRole();
|
|
319
|
+
if (role && Object.prototype.hasOwnProperty.call(dbRoleBackends, role)) {
|
|
320
|
+
return backends[dbRoleBackends[role]];
|
|
321
|
+
}
|
|
255
322
|
return backends[defaultBackend] || null;
|
|
256
323
|
}
|
|
257
324
|
|
|
@@ -265,6 +332,7 @@ async function query(sql, params, opts) {
|
|
|
265
332
|
_requireInit();
|
|
266
333
|
opts = opts || {};
|
|
267
334
|
var b = _pickBackend(opts);
|
|
335
|
+
var role = dbRoleContext.getRole();
|
|
268
336
|
|
|
269
337
|
var t0 = Date.now();
|
|
270
338
|
try {
|
|
@@ -291,9 +359,11 @@ async function query(sql, params, opts) {
|
|
|
291
359
|
});
|
|
292
360
|
}, b.retryConfig);
|
|
293
361
|
|
|
362
|
+
var durationMs = Date.now() - t0;
|
|
294
363
|
_emit("system.externaldb.query", "success", {
|
|
295
364
|
backend: b.name,
|
|
296
|
-
|
|
365
|
+
role: role,
|
|
366
|
+
durationMs: durationMs,
|
|
297
367
|
classification: opts.classification || null,
|
|
298
368
|
rowCount: result && result.rowCount,
|
|
299
369
|
// SQL is NOT logged by default — may contain sensitive literal values
|
|
@@ -302,14 +372,33 @@ async function query(sql, params, opts) {
|
|
|
302
372
|
// field-crypto on the audit row).
|
|
303
373
|
sql: opts.includeSqlInAudit ? sql : null,
|
|
304
374
|
});
|
|
375
|
+
_emitMetric("externaldb.query.success", 1,
|
|
376
|
+
{ backend: b.name, role: role || "(none)" });
|
|
377
|
+
_emitMetric("externaldb.query.duration_ms", durationMs,
|
|
378
|
+
{ backend: b.name, role: role || "(none)" });
|
|
305
379
|
return result;
|
|
306
380
|
} catch (e) {
|
|
381
|
+
var failureMs = Date.now() - t0;
|
|
307
382
|
_emit("system.externaldb.query", "failure", {
|
|
308
383
|
backend: b.name,
|
|
309
|
-
|
|
384
|
+
role: role,
|
|
385
|
+
durationMs: failureMs,
|
|
310
386
|
classification: opts.classification || null,
|
|
311
387
|
errorCode: e.code || null,
|
|
312
388
|
}, (e && e.message) || String(e));
|
|
389
|
+
_emitMetric("externaldb.query.failure", 1,
|
|
390
|
+
{ backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
|
|
391
|
+
// Postgres signals authorization-denied as SQLSTATE 42501
|
|
392
|
+
// (insufficient_privilege). RLS-shaped writes that violate a
|
|
393
|
+
// policy and GRANT-denied SELECTs both surface this code. The
|
|
394
|
+
// operator's role-views recipe relies on this signal: a row of
|
|
395
|
+
// db.role.denied means a request-time role attempted something its
|
|
396
|
+
// grant or RLS policy forbids — the highest-signal compliance event
|
|
397
|
+
// the externalDb layer can emit.
|
|
398
|
+
if (e && e.code === "42501") {
|
|
399
|
+
_emitMetric("db.role.denied", 1,
|
|
400
|
+
{ backend: b.name, role: role || "(none)" });
|
|
401
|
+
}
|
|
313
402
|
throw e;
|
|
314
403
|
}
|
|
315
404
|
}
|
|
@@ -319,6 +408,17 @@ async function transaction(fn, opts) {
|
|
|
319
408
|
if (typeof fn !== "function") throw _err("INVALID_FN", "transaction requires a function", true);
|
|
320
409
|
opts = opts || {};
|
|
321
410
|
var b = _pickBackend(opts);
|
|
411
|
+
var role = dbRoleContext.getRole();
|
|
412
|
+
|
|
413
|
+
// sessionGucs — per-transaction `SET LOCAL "name" = value` plumbing.
|
|
414
|
+
// Each name validates as a SQL identifier (Postgres GUC names follow
|
|
415
|
+
// the same NAMEDATALEN-shaped rules; dotted GUCs like 'app.tenant_id'
|
|
416
|
+
// validate per-segment via quoteQualified). Values are emitted as SQL
|
|
417
|
+
// string literals (single-quote escaped) for strings, raw for finite
|
|
418
|
+
// numbers. SET LOCAL ties the binding to the surrounding transaction
|
|
419
|
+
// so the tenant_id used by RLS policies resets cleanly at
|
|
420
|
+
// COMMIT/ROLLBACK without caller cleanup.
|
|
421
|
+
var prebuiltGucs = _buildSessionGucsStatements(opts.sessionGucs);
|
|
322
422
|
|
|
323
423
|
var t0 = Date.now();
|
|
324
424
|
return await b.breaker.wrap(async function () {
|
|
@@ -329,19 +429,36 @@ async function transaction(fn, opts) {
|
|
|
329
429
|
var committed = false;
|
|
330
430
|
try {
|
|
331
431
|
await b.beginTx(client);
|
|
432
|
+
for (var gi = 0; gi < prebuiltGucs.length; gi++) {
|
|
433
|
+
await b.query(client, prebuiltGucs[gi], []);
|
|
434
|
+
}
|
|
332
435
|
var result = await fn(txClient);
|
|
333
436
|
await b.commit(client);
|
|
334
437
|
committed = true;
|
|
438
|
+
var durationMs = Date.now() - t0;
|
|
335
439
|
_emit("system.externaldb.transaction", "success", {
|
|
336
|
-
backend: b.name,
|
|
440
|
+
backend: b.name, role: role, durationMs: durationMs,
|
|
441
|
+
classification: opts.classification || null,
|
|
337
442
|
});
|
|
443
|
+
_emitMetric("externaldb.transaction.success", 1,
|
|
444
|
+
{ backend: b.name, role: role || "(none)" });
|
|
445
|
+
_emitMetric("externaldb.transaction.duration_ms", durationMs,
|
|
446
|
+
{ backend: b.name, role: role || "(none)" });
|
|
338
447
|
return result;
|
|
339
448
|
} catch (e) {
|
|
340
449
|
try { if (!committed) await b.rollback(client); } catch (_e) { /* best effort */ }
|
|
450
|
+
var failureMs = Date.now() - t0;
|
|
341
451
|
_emit("system.externaldb.transaction", "failure", {
|
|
342
|
-
backend: b.name,
|
|
452
|
+
backend: b.name, role: role, durationMs: failureMs,
|
|
453
|
+
classification: opts.classification || null,
|
|
343
454
|
errorCode: e.code || null,
|
|
344
455
|
}, (e && e.message) || String(e));
|
|
456
|
+
_emitMetric("externaldb.transaction.failure", 1,
|
|
457
|
+
{ backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
|
|
458
|
+
if (e && e.code === "42501") {
|
|
459
|
+
_emitMetric("db.role.denied", 1,
|
|
460
|
+
{ backend: b.name, role: role || "(none)" });
|
|
461
|
+
}
|
|
345
462
|
throw e;
|
|
346
463
|
} finally {
|
|
347
464
|
b.pool.release(client);
|
|
@@ -410,6 +527,60 @@ async function shutdown() {
|
|
|
410
527
|
initialized = false;
|
|
411
528
|
}
|
|
412
529
|
|
|
530
|
+
// Build the SET LOCAL statements for a transaction's sessionGucs map.
|
|
531
|
+
// Identifier-validates each GUC name (per dot-segment so dotted names
|
|
532
|
+
// like 'app.tenant_id' work), quotes them with the Postgres dialect,
|
|
533
|
+
// and renders the value as either a SQL string literal (single-quoted,
|
|
534
|
+
// embedded quotes doubled) or a numeric literal for finite numbers.
|
|
535
|
+
// Bad shapes throw at the call site rather than as a confused Postgres
|
|
536
|
+
// error mid-transaction.
|
|
537
|
+
function _buildSessionGucsStatements(sessionGucs) {
|
|
538
|
+
if (sessionGucs === undefined || sessionGucs === null) return [];
|
|
539
|
+
if (typeof sessionGucs !== "object" || Array.isArray(sessionGucs)) {
|
|
540
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
541
|
+
"sessionGucs must be an object map of name → value", true);
|
|
542
|
+
}
|
|
543
|
+
var out = [];
|
|
544
|
+
for (var name in sessionGucs) {
|
|
545
|
+
if (!Object.prototype.hasOwnProperty.call(sessionGucs, name)) continue;
|
|
546
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
547
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
548
|
+
"sessionGucs: GUC name must be a non-empty string", true);
|
|
549
|
+
}
|
|
550
|
+
// Validate per-segment so dotted GUCs (Postgres custom GUC class.
|
|
551
|
+
// setting form) pass. quoteQualified handles both the validation
|
|
552
|
+
// and the dot-quoted rendering.
|
|
553
|
+
var qName;
|
|
554
|
+
try {
|
|
555
|
+
qName = safeSql.quoteQualified(name, "postgres");
|
|
556
|
+
} catch (e) {
|
|
557
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
558
|
+
"sessionGucs: name '" + name + "' is not a valid identifier: " +
|
|
559
|
+
((e && e.message) || String(e)), true);
|
|
560
|
+
}
|
|
561
|
+
var value = sessionGucs[name];
|
|
562
|
+
var literal;
|
|
563
|
+
if (typeof value === "number" && isFinite(value)) {
|
|
564
|
+
literal = String(value);
|
|
565
|
+
} else if (typeof value === "boolean") {
|
|
566
|
+
// Postgres SET accepts on/off/true/false — render true/false.
|
|
567
|
+
literal = value ? "true" : "false";
|
|
568
|
+
} else if (typeof value === "string") {
|
|
569
|
+
literal = "'" + value.replace(/'/g, "''") + "'";
|
|
570
|
+
} else if (value === null || value === undefined) {
|
|
571
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
572
|
+
"sessionGucs['" + name + "']: value must be a string, finite number, or boolean (got " +
|
|
573
|
+
(value === null ? "null" : "undefined") + ")", true);
|
|
574
|
+
} else {
|
|
575
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
576
|
+
"sessionGucs['" + name + "']: value must be a string, finite number, or boolean (got " +
|
|
577
|
+
typeof value + ")", true);
|
|
578
|
+
}
|
|
579
|
+
out.push("SET LOCAL " + qName + " = " + literal);
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
|
|
413
584
|
// Fire-and-forget audit emission. We CANNOT await this in cluster mode:
|
|
414
585
|
// audit storage routes back through external-db when cluster mode is
|
|
415
586
|
// active, so awaiting would create a recursive dependency (every audit
|
|
@@ -531,6 +702,7 @@ async function _readQuery(sql, params, opts) {
|
|
|
531
702
|
throw _err("ALL_REPLICAS_UNHEALTHY",
|
|
532
703
|
"backend '" + b.name + "': all replicas unhealthy and fallback disabled", true);
|
|
533
704
|
}
|
|
705
|
+
var role = dbRoleContext.getRole();
|
|
534
706
|
var t0 = Date.now();
|
|
535
707
|
try {
|
|
536
708
|
var client = await replica.pool.acquire();
|
|
@@ -538,12 +710,18 @@ async function _readQuery(sql, params, opts) {
|
|
|
538
710
|
var res = await replica.query(client, sql, params || []);
|
|
539
711
|
replica.pool.release(client);
|
|
540
712
|
replica.consecutiveFailures = 0;
|
|
713
|
+
var durationMs = Date.now() - t0;
|
|
541
714
|
_emit("system.externaldb.read", "success", {
|
|
542
715
|
backend: b.name,
|
|
716
|
+
role: role,
|
|
543
717
|
replicaIdx: replica.index,
|
|
544
|
-
durationMs:
|
|
718
|
+
durationMs: durationMs,
|
|
545
719
|
rowCount: res && res.rowCount,
|
|
546
720
|
});
|
|
721
|
+
_emitMetric("externaldb.read.success", 1,
|
|
722
|
+
{ backend: b.name, role: role || "(none)", replicaIdx: replica.index });
|
|
723
|
+
_emitMetric("externaldb.read.duration_ms", durationMs,
|
|
724
|
+
{ backend: b.name, role: role || "(none)", replicaIdx: replica.index });
|
|
547
725
|
return res;
|
|
548
726
|
} catch (e) {
|
|
549
727
|
// Connection-shape errors mark unhealthy + destroy.
|
|
@@ -561,10 +739,17 @@ async function _readQuery(sql, params, opts) {
|
|
|
561
739
|
} catch (e) {
|
|
562
740
|
_emit("system.externaldb.read", "failure", {
|
|
563
741
|
backend: b.name,
|
|
742
|
+
role: role,
|
|
564
743
|
replicaIdx: replica.index,
|
|
565
744
|
durationMs: Date.now() - t0,
|
|
566
745
|
errorCode: e.code || null,
|
|
567
746
|
}, (e && e.message) || String(e));
|
|
747
|
+
_emitMetric("externaldb.read.failure", 1,
|
|
748
|
+
{ backend: b.name, role: role || "(none)", errorCode: e.code || "(none)" });
|
|
749
|
+
if (e && e.code === "42501") {
|
|
750
|
+
_emitMetric("db.role.denied", 1,
|
|
751
|
+
{ backend: b.name, role: role || "(none)" });
|
|
752
|
+
}
|
|
568
753
|
// Fallback to primary on a failed replica read when allowed.
|
|
569
754
|
if (b.replicaFallbackToPrimary) {
|
|
570
755
|
return query(sql, params, opts);
|
|
@@ -594,6 +779,7 @@ function _resetForTest() {
|
|
|
594
779
|
});
|
|
595
780
|
backends = {};
|
|
596
781
|
defaultBackend = null;
|
|
782
|
+
dbRoleBackends = {};
|
|
597
783
|
initialized = false;
|
|
598
784
|
audit.reset();
|
|
599
785
|
db.reset();
|
|
@@ -791,6 +977,58 @@ function _adaptersConnectAs(connect, opts) {
|
|
|
791
977
|
return _connectAs(connect, query, roleOpts);
|
|
792
978
|
}
|
|
793
979
|
|
|
980
|
+
// ---- runAs / currentRole — out-of-request role binding ----
|
|
981
|
+
//
|
|
982
|
+
// Inside an HTTP request the dbRoleFor middleware already pushes the
|
|
983
|
+
// role into the shared db-role-context ALS. Background workers (jobs,
|
|
984
|
+
// schedulers, CLI commands) don't run under that middleware — they wrap
|
|
985
|
+
// their work in runAs(role, fn) so the same backend-pick logic applies.
|
|
986
|
+
//
|
|
987
|
+
// await b.externalDb.runAs("analytics_user", async function () {
|
|
988
|
+
// return await b.externalDb.read.query("SELECT ..."); // → analytics backend
|
|
989
|
+
// });
|
|
990
|
+
//
|
|
991
|
+
// currentRole() returns the active role (or null) — useful for diagnostic
|
|
992
|
+
// logs and observability labels.
|
|
993
|
+
function runAs(role, fn) {
|
|
994
|
+
if (typeof fn !== "function") {
|
|
995
|
+
throw _err("INVALID_FN", "externalDb.runAs: fn must be a function", true);
|
|
996
|
+
}
|
|
997
|
+
if (role !== null && role !== undefined) {
|
|
998
|
+
if (typeof role !== "string" || role.length === 0) {
|
|
999
|
+
throw _err("INVALID_ROLE",
|
|
1000
|
+
"externalDb.runAs: role must be a non-empty string or null", true);
|
|
1001
|
+
}
|
|
1002
|
+
safeSql.validateIdentifier(role, { allowReserved: false });
|
|
1003
|
+
}
|
|
1004
|
+
// Audit the role transition. runAs has no req, so the actor 5 W's
|
|
1005
|
+
// come from whatever the caller has bound on the audit-context ALS
|
|
1006
|
+
// (log.js requestId, plus any request-bound actor that was set in
|
|
1007
|
+
// an outer scope). Same audit shape as the dbRoleFor middleware
|
|
1008
|
+
// path — forensic walkers can reconstruct the role timeline whether
|
|
1009
|
+
// the binding came from request middleware or a job runner.
|
|
1010
|
+
var previousRole = dbRoleContext.getRole();
|
|
1011
|
+
var newRole = role || null;
|
|
1012
|
+
if (previousRole !== newRole) {
|
|
1013
|
+
audit().safeEmit({
|
|
1014
|
+
action: "db.role.switched",
|
|
1015
|
+
actor: {},
|
|
1016
|
+
resource: { kind: "db.role", id: newRole || "(none)" },
|
|
1017
|
+
outcome: "success",
|
|
1018
|
+
metadata: {
|
|
1019
|
+
previousRole: previousRole,
|
|
1020
|
+
newRole: newRole,
|
|
1021
|
+
source: "runAs",
|
|
1022
|
+
},
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
return dbRoleContext.runWithRole(role || null, fn);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function currentRole() {
|
|
1029
|
+
return dbRoleContext.getRole();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
794
1032
|
module.exports = {
|
|
795
1033
|
init: init,
|
|
796
1034
|
query: query,
|
|
@@ -801,6 +1039,8 @@ module.exports = {
|
|
|
801
1039
|
configurePool: configurePool,
|
|
802
1040
|
read: read,
|
|
803
1041
|
write: write,
|
|
1042
|
+
runAs: runAs,
|
|
1043
|
+
currentRole: currentRole,
|
|
804
1044
|
adapters: {
|
|
805
1045
|
connectAs: _adaptersConnectAs,
|
|
806
1046
|
},
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* dbRoleFor middleware — binds a request-time DB role.
|
|
4
|
+
*
|
|
5
|
+
* Operators using the search_path-views compliance recipe (see
|
|
6
|
+
* b.db.declareView and the Compliance Patterns wiki page) declare two
|
|
7
|
+
* Postgres roles: app_user (full source) and analytics_user (redacted
|
|
8
|
+
* view). Each role gets its own externalDb backend — same SQL,
|
|
9
|
+
* different connection pool. dbRoleFor picks the role for the current
|
|
10
|
+
* request and pushes it into the shared db-role-context AsyncLocalStorage
|
|
11
|
+
* scope so b.externalDb.query / read / write / transaction auto-route
|
|
12
|
+
* to the matching backend without any operator threading of the role
|
|
13
|
+
* through their handler signature.
|
|
14
|
+
*
|
|
15
|
+
* var perms = b.permissions.create({
|
|
16
|
+
* roles: {
|
|
17
|
+
* admin: { extends: ["app"], permissions: ["*:*"] },
|
|
18
|
+
* app: { permissions: ["sessions:*"], dbRole: "app_user" },
|
|
19
|
+
* analyst: { permissions: ["sessions:read"], dbRole: "analytics_user" },
|
|
20
|
+
* },
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* router.use(b.middleware.attachUser(...));
|
|
24
|
+
* router.use(b.middleware.dbRoleFor({
|
|
25
|
+
* permissions: perms, // resolves dbRole from req.user.roles
|
|
26
|
+
* defaultRole: "app_user",
|
|
27
|
+
* }));
|
|
28
|
+
*
|
|
29
|
+
* router.get("/sessions", function (req, res) {
|
|
30
|
+
* // No `{ backend: ... }` opt — the framework picked it from req.dbRole.
|
|
31
|
+
* b.externalDb.read.query("SELECT * FROM sessions WHERE _id = $1", [sid])
|
|
32
|
+
* .then(...);
|
|
33
|
+
* });
|
|
34
|
+
*
|
|
35
|
+
* Resolution order:
|
|
36
|
+
* 1. opts.resolve(req) — operator-supplied custom resolver
|
|
37
|
+
* 2. opts.permissions.dbRoleFor — RBAC mapping (when permissions provided)
|
|
38
|
+
* 3. opts.defaultRole — fallback string
|
|
39
|
+
* 4. null — no binding (externalDb falls back to default backend)
|
|
40
|
+
*
|
|
41
|
+
* Validation at create() time — bad shape throws here, not at the first
|
|
42
|
+
* request:
|
|
43
|
+
* - opts shape (validateOpts allow-list)
|
|
44
|
+
* - resolve / responder must be functions if provided
|
|
45
|
+
* - permissions must expose dbRoleFor (the b.permissions shape)
|
|
46
|
+
* - defaultRole, when provided, must be a SQL-identifier-shaped string
|
|
47
|
+
* - missingRoleStatus must be a 100-599 integer
|
|
48
|
+
*
|
|
49
|
+
* Runtime validation on resolver output:
|
|
50
|
+
* - resolver returns must be string | null | undefined
|
|
51
|
+
* - non-empty string return MUST match safeSql.validateIdentifier; a
|
|
52
|
+
* malformed identifier from a resolver is a wiring bug (the operator
|
|
53
|
+
* plugged in a resolver that returns garbage). Routed through
|
|
54
|
+
* next(err) so the request surfaces a clear error instead of silently
|
|
55
|
+
* routing to the default backend.
|
|
56
|
+
*
|
|
57
|
+
* Failure modes:
|
|
58
|
+
* - resolver throws → 500 propagated via next(err)
|
|
59
|
+
* - role required but absent → respond with missingRoleStatus (default 401)
|
|
60
|
+
* - role identifier malformed → respond with 500 (resolver bug — not a runtime user error)
|
|
61
|
+
*
|
|
62
|
+
* Observability event: db.role.bound { value: 1, labels: { role, source } }
|
|
63
|
+
* source ∈ "resolver" | "permissions" | "default"
|
|
64
|
+
*
|
|
65
|
+
* Audit emission: db.role.switched is recorded once per request when a
|
|
66
|
+
* role binds. The audit row carries the actor 5 W's via
|
|
67
|
+
* requestHelpers.extractActorContext and metadata { previousRole,
|
|
68
|
+
* newRole, source }. Defaults align with the framework's "the
|
|
69
|
+
* authorization decision IS the audit-worthy event" stance — both
|
|
70
|
+
* auditFailures and auditSuccess default true. The audit sink can be
|
|
71
|
+
* pinned via opts.audit (any object exposing safeEmit), defaults to
|
|
72
|
+
* the framework's b.audit.
|
|
73
|
+
*/
|
|
74
|
+
var dbRoleContext = require("../db-role-context");
|
|
75
|
+
var lazyRequire = require("../lazy-require");
|
|
76
|
+
var requestHelpers = require("../request-helpers");
|
|
77
|
+
var safeSql = require("../safe-sql");
|
|
78
|
+
var validateOpts = require("../validate-opts");
|
|
79
|
+
var { defineClass } = require("../framework-error");
|
|
80
|
+
|
|
81
|
+
var audit = lazyRequire(function () { return require("../audit"); });
|
|
82
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
83
|
+
|
|
84
|
+
var DbRoleForError = defineClass("DbRoleForError", { alwaysPermanent: true });
|
|
85
|
+
var _err = function (code, message) { return new DbRoleForError(code, message); };
|
|
86
|
+
|
|
87
|
+
var ALLOWED_OPTS = [
|
|
88
|
+
"resolve", "permissions", "defaultRole",
|
|
89
|
+
"requireRole", "missingRoleStatus", "responder",
|
|
90
|
+
"audit", "auditFailures", "auditSuccess",
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
function _emitEvent(name, value, labels) {
|
|
94
|
+
try { observability().event(name, value, labels || {}); }
|
|
95
|
+
catch (_e) { /* hot-path observability sink — drop silent by design */ }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function _validateRoleIdentifier(role, where) {
|
|
99
|
+
try {
|
|
100
|
+
safeSql.validateIdentifier(role, { allowReserved: false });
|
|
101
|
+
} catch (e) {
|
|
102
|
+
throw _err("db-role-for/bad-role",
|
|
103
|
+
where + ": role '" + role + "' is not a valid SQL identifier: " +
|
|
104
|
+
((e && e.message) || String(e)));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function _defaultResponder(req, res, status, info) {
|
|
109
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
110
|
+
res.end(JSON.stringify(info));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function create(opts) {
|
|
114
|
+
opts = opts || {};
|
|
115
|
+
validateOpts(opts, ALLOWED_OPTS, "middleware.dbRoleFor");
|
|
116
|
+
|
|
117
|
+
if (opts.resolve !== undefined && typeof opts.resolve !== "function") {
|
|
118
|
+
throw _err("db-role-for/bad-opt",
|
|
119
|
+
"middleware.dbRoleFor: resolve must be a function");
|
|
120
|
+
}
|
|
121
|
+
if (opts.responder !== undefined && typeof opts.responder !== "function") {
|
|
122
|
+
throw _err("db-role-for/bad-opt",
|
|
123
|
+
"middleware.dbRoleFor: responder must be a function");
|
|
124
|
+
}
|
|
125
|
+
if (opts.permissions !== undefined && opts.permissions !== null) {
|
|
126
|
+
if (typeof opts.permissions !== "object" ||
|
|
127
|
+
typeof opts.permissions.dbRoleFor !== "function") {
|
|
128
|
+
throw _err("db-role-for/bad-opt",
|
|
129
|
+
"middleware.dbRoleFor: permissions must be a b.permissions instance " +
|
|
130
|
+
"(missing dbRoleFor method)");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (opts.defaultRole !== undefined && opts.defaultRole !== null) {
|
|
134
|
+
if (typeof opts.defaultRole !== "string" || opts.defaultRole.length === 0) {
|
|
135
|
+
throw _err("db-role-for/bad-opt",
|
|
136
|
+
"middleware.dbRoleFor: defaultRole must be a non-empty string");
|
|
137
|
+
}
|
|
138
|
+
_validateRoleIdentifier(opts.defaultRole, "middleware.dbRoleFor: defaultRole");
|
|
139
|
+
}
|
|
140
|
+
if (opts.requireRole !== undefined && typeof opts.requireRole !== "boolean") {
|
|
141
|
+
throw _err("db-role-for/bad-opt",
|
|
142
|
+
"middleware.dbRoleFor: requireRole must be a boolean");
|
|
143
|
+
}
|
|
144
|
+
if (opts.missingRoleStatus !== undefined) {
|
|
145
|
+
if (typeof opts.missingRoleStatus !== "number" ||
|
|
146
|
+
!isFinite(opts.missingRoleStatus) ||
|
|
147
|
+
opts.missingRoleStatus < 100 || opts.missingRoleStatus > 599) {
|
|
148
|
+
throw _err("db-role-for/bad-opt",
|
|
149
|
+
"middleware.dbRoleFor: missingRoleStatus must be an HTTP status code (100-599)");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (opts.audit !== undefined && opts.audit !== null) {
|
|
153
|
+
if (typeof opts.audit !== "object" || typeof opts.audit.safeEmit !== "function") {
|
|
154
|
+
throw _err("db-role-for/bad-opt",
|
|
155
|
+
"middleware.dbRoleFor: audit must be a b.audit-shaped object (safeEmit fn)");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (opts.auditFailures !== undefined && typeof opts.auditFailures !== "boolean") {
|
|
159
|
+
throw _err("db-role-for/bad-opt",
|
|
160
|
+
"middleware.dbRoleFor: auditFailures must be a boolean");
|
|
161
|
+
}
|
|
162
|
+
if (opts.auditSuccess !== undefined && typeof opts.auditSuccess !== "boolean") {
|
|
163
|
+
throw _err("db-role-for/bad-opt",
|
|
164
|
+
"middleware.dbRoleFor: auditSuccess must be a boolean");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
var resolveFn = opts.resolve || null;
|
|
168
|
+
var perms = opts.permissions || null;
|
|
169
|
+
var defaultRole = opts.defaultRole || null;
|
|
170
|
+
var requireRole = !!opts.requireRole;
|
|
171
|
+
var missingRoleStatus = opts.missingRoleStatus || 401;
|
|
172
|
+
var responder = opts.responder || _defaultResponder;
|
|
173
|
+
// Audit defaults match permissions: the role-binding decision IS the
|
|
174
|
+
// audit-worthy act. Operators with extreme volume opt out via
|
|
175
|
+
// auditSuccess: false; failures stay on regardless. The audit sink
|
|
176
|
+
// defaults to the framework's b.audit; operators with multiple audit
|
|
177
|
+
// chains pass their own (matches captureAudit's shape).
|
|
178
|
+
var auditSink = opts.audit || null;
|
|
179
|
+
var auditFailures = (opts.auditFailures === undefined) ? true : opts.auditFailures;
|
|
180
|
+
var auditSuccess = (opts.auditSuccess === undefined) ? true : opts.auditSuccess;
|
|
181
|
+
|
|
182
|
+
return function dbRoleForMiddleware(req, res, next) {
|
|
183
|
+
var role = null;
|
|
184
|
+
var source = null;
|
|
185
|
+
|
|
186
|
+
if (resolveFn) {
|
|
187
|
+
var resolved;
|
|
188
|
+
try { resolved = resolveFn(req); }
|
|
189
|
+
catch (e) { return next(e); }
|
|
190
|
+
if (resolved !== undefined && resolved !== null && resolved !== "") {
|
|
191
|
+
role = resolved;
|
|
192
|
+
source = "resolver";
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!role && perms) {
|
|
197
|
+
// permissions.dbRoleFor walks req.user.roles / req.apiKey.scopes via
|
|
198
|
+
// the configured resolver and returns the first declared dbRole.
|
|
199
|
+
var fromPerms;
|
|
200
|
+
try { fromPerms = perms.dbRoleFor(req); }
|
|
201
|
+
catch (e) { return next(e); }
|
|
202
|
+
if (fromPerms) {
|
|
203
|
+
role = fromPerms;
|
|
204
|
+
source = "permissions";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!role && defaultRole) {
|
|
209
|
+
role = defaultRole;
|
|
210
|
+
source = "default";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!role) {
|
|
214
|
+
if (requireRole) {
|
|
215
|
+
_emitEvent("db.role.missing", 1, {});
|
|
216
|
+
if (auditFailures) {
|
|
217
|
+
_auditSwitch(auditSink, req, {
|
|
218
|
+
previousRole: dbRoleContext.getRole(),
|
|
219
|
+
newRole: null,
|
|
220
|
+
source: "middleware",
|
|
221
|
+
outcome: "failure",
|
|
222
|
+
reason: "no-role",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return responder(req, res, missingRoleStatus, {
|
|
226
|
+
error: "missing_db_role",
|
|
227
|
+
status: missingRoleStatus,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
// No binding — let externalDb fall back to its default backend.
|
|
231
|
+
req.dbRole = null;
|
|
232
|
+
return next();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (typeof role !== "string") {
|
|
236
|
+
return next(_err("db-role-for/bad-resolver-return",
|
|
237
|
+
"middleware.dbRoleFor: resolver returned non-string role: " + typeof role));
|
|
238
|
+
}
|
|
239
|
+
// Validate the resolver-supplied identifier at request time — a
|
|
240
|
+
// malformed identifier is a wiring bug, not a request-shape concern.
|
|
241
|
+
// Route the throw through next(err) so an operator's errorHandler
|
|
242
|
+
// reaches it instead of the request hanging.
|
|
243
|
+
try {
|
|
244
|
+
_validateRoleIdentifier(role, "middleware.dbRoleFor: resolver/" + source);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
return next(e);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
var previousRole = dbRoleContext.getRole();
|
|
250
|
+
req.dbRole = role;
|
|
251
|
+
_emitEvent("db.role.bound", 1, { role: role, source: source });
|
|
252
|
+
if (auditSuccess) {
|
|
253
|
+
_auditSwitch(auditSink, req, {
|
|
254
|
+
previousRole: previousRole,
|
|
255
|
+
newRole: role,
|
|
256
|
+
source: "middleware",
|
|
257
|
+
outcome: "success",
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
dbRoleContext.runWithRole(role, function () { next(); });
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Emit the db.role.switched audit row. Fire-and-forget — the audit
|
|
265
|
+
// handler's own try/catch keeps a momentary outage from breaking the
|
|
266
|
+
// request. The actor 5 W's come from extractActorContext (req-driven);
|
|
267
|
+
// metadata carries the previous + new role + binding source so a
|
|
268
|
+
// forensic walker can reconstruct "which role read which row when."
|
|
269
|
+
// The sink defaults to the framework's b.audit when the operator
|
|
270
|
+
// didn't pass an explicit instance.
|
|
271
|
+
function _auditSwitch(sink, req, info) {
|
|
272
|
+
try {
|
|
273
|
+
var emitter = sink || audit();
|
|
274
|
+
emitter.safeEmit({
|
|
275
|
+
action: "db.role.switched",
|
|
276
|
+
actor: requestHelpers.extractActorContext(req),
|
|
277
|
+
resource: { kind: "db.role", id: info.newRole || "(none)" },
|
|
278
|
+
outcome: info.outcome || "success",
|
|
279
|
+
reason: info.reason || null,
|
|
280
|
+
metadata: {
|
|
281
|
+
previousRole: info.previousRole || null,
|
|
282
|
+
newRole: info.newRole || null,
|
|
283
|
+
source: info.source,
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
} catch (_e) { /* audit best-effort */ }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = {
|
|
290
|
+
create: create,
|
|
291
|
+
DbRoleForError: DbRoleForError,
|
|
292
|
+
};
|
package/lib/middleware/index.js
CHANGED
|
@@ -33,6 +33,7 @@ module.exports = {
|
|
|
33
33
|
sse: require("./sse").create,
|
|
34
34
|
requestLog: require("./request-log").create,
|
|
35
35
|
apiEncrypt: require("./api-encrypt"),
|
|
36
|
+
dbRoleFor: require("./db-role-for").create,
|
|
36
37
|
|
|
37
38
|
// Module exports for advanced use (constants, raw factory access)
|
|
38
39
|
_modules: {
|
|
@@ -52,5 +53,6 @@ module.exports = {
|
|
|
52
53
|
sse: require("./sse"),
|
|
53
54
|
requestLog: require("./request-log"),
|
|
54
55
|
apiEncrypt: require("./api-encrypt"),
|
|
56
|
+
dbRoleFor: require("./db-role-for"),
|
|
55
57
|
},
|
|
56
58
|
};
|
package/lib/permissions.js
CHANGED
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
|
|
48
48
|
var lazyRequire = require("./lazy-require");
|
|
49
49
|
var requestHelpers = require("./request-helpers");
|
|
50
|
+
var safeSql = require("./safe-sql");
|
|
50
51
|
var validateOpts = require("./validate-opts");
|
|
51
52
|
var { PermissionsError } = require("./framework-error");
|
|
52
53
|
|
|
@@ -116,7 +117,7 @@ function _validateScopePattern(scope, ctx) {
|
|
|
116
117
|
|
|
117
118
|
function _normalizeRoleEntry(name, entry) {
|
|
118
119
|
if (Array.isArray(entry)) {
|
|
119
|
-
return { extends: [], permissions: entry.slice() };
|
|
120
|
+
return { extends: [], permissions: entry.slice(), dbRole: null };
|
|
120
121
|
}
|
|
121
122
|
if (entry && typeof entry === "object") {
|
|
122
123
|
var ext = entry.extends || [];
|
|
@@ -127,9 +128,27 @@ function _normalizeRoleEntry(name, entry) {
|
|
|
127
128
|
if (!Array.isArray(perms)) {
|
|
128
129
|
throw _err("BAD_ROLE", "role '" + name + "': permissions must be an array of scope strings");
|
|
129
130
|
}
|
|
130
|
-
|
|
131
|
+
var dbRole = null;
|
|
132
|
+
if (entry.dbRole !== undefined && entry.dbRole !== null) {
|
|
133
|
+
if (typeof entry.dbRole !== "string" || entry.dbRole.length === 0) {
|
|
134
|
+
throw _err("BAD_ROLE",
|
|
135
|
+
"role '" + name + "': dbRole must be a non-empty string");
|
|
136
|
+
}
|
|
137
|
+
// dbRole feeds straight into externalDb backend pick + the
|
|
138
|
+
// dbRoleFor middleware's identifier check; validate at create()
|
|
139
|
+
// time so a typo surfaces at boot, not on the first request.
|
|
140
|
+
try {
|
|
141
|
+
safeSql.validateIdentifier(entry.dbRole, { allowReserved: false });
|
|
142
|
+
} catch (e) {
|
|
143
|
+
throw _err("BAD_ROLE",
|
|
144
|
+
"role '" + name + "': dbRole '" + entry.dbRole +
|
|
145
|
+
"' is not a valid SQL identifier: " + ((e && e.message) || String(e)));
|
|
146
|
+
}
|
|
147
|
+
dbRole = entry.dbRole;
|
|
148
|
+
}
|
|
149
|
+
return { extends: ext.slice(), permissions: perms.slice(), dbRole: dbRole };
|
|
131
150
|
}
|
|
132
|
-
throw _err("BAD_ROLE", "role '" + name + "' must be an array of scopes or { extends?, permissions }");
|
|
151
|
+
throw _err("BAD_ROLE", "role '" + name + "' must be an array of scopes or { extends?, permissions, dbRole? }");
|
|
133
152
|
}
|
|
134
153
|
|
|
135
154
|
function _validateRoles(roles) {
|
|
@@ -385,6 +404,44 @@ function create(opts) {
|
|
|
385
404
|
};
|
|
386
405
|
}
|
|
387
406
|
|
|
407
|
+
// dbRoleFor — walk the actor's roles in order and return the first
|
|
408
|
+
// declared dbRole. Composes with b.middleware.dbRoleFor so a single
|
|
409
|
+
// RBAC table drives both authorization scopes and request-time DB
|
|
410
|
+
// role binding.
|
|
411
|
+
//
|
|
412
|
+
// The arg can be the request (default resolver pulls actor from
|
|
413
|
+
// req.user / req.apiKey) OR an actor object directly. Returns null if
|
|
414
|
+
// no actor is found OR the actor's roles don't include any with a
|
|
415
|
+
// declared dbRole.
|
|
416
|
+
//
|
|
417
|
+
// Lookup order: extends are walked depth-first so a child role that
|
|
418
|
+
// overrides dbRole takes precedence over its parent. When multiple
|
|
419
|
+
// top-level roles are listed, the first wins (operators wanting a
|
|
420
|
+
// priority order should list more-specific roles first).
|
|
421
|
+
function dbRoleFor(reqOrActor) {
|
|
422
|
+
var actor = reqOrActor;
|
|
423
|
+
// Heuristic: a request shape carries headers / url; resolve through
|
|
424
|
+
// the configured resolver. An actor shape has roles / scopes
|
|
425
|
+
// directly.
|
|
426
|
+
if (actor && (actor.headers || actor.url || actor.method)) {
|
|
427
|
+
actor = resolver(actor);
|
|
428
|
+
}
|
|
429
|
+
if (!actor || typeof actor !== "object") return null;
|
|
430
|
+
var roleNames = Array.isArray(actor.roles) ? actor.roles : null;
|
|
431
|
+
if (!roleNames || roleNames.length === 0) return null;
|
|
432
|
+
// Walk the same DFS order expand() uses so the first-seen dbRole
|
|
433
|
+
// is consistent with how scopes are inherited.
|
|
434
|
+
var visited = new Set();
|
|
435
|
+
for (var i = 0; i < roleNames.length; i++) {
|
|
436
|
+
var name = roleNames[i];
|
|
437
|
+
if (typeof name !== "string") continue;
|
|
438
|
+
if (!Object.prototype.hasOwnProperty.call(roleTable, name)) continue;
|
|
439
|
+
var found = _findDbRole(name, roleTable, visited);
|
|
440
|
+
if (found) return found;
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
388
445
|
return {
|
|
389
446
|
require: function (scope) { return _middleware("single", scope); },
|
|
390
447
|
requireAll: function (scopes) { return _middleware("all", scopes); },
|
|
@@ -393,11 +450,27 @@ function create(opts) {
|
|
|
393
450
|
checkAll: checkAll,
|
|
394
451
|
checkAny: checkAny,
|
|
395
452
|
expand: expand,
|
|
453
|
+
dbRoleFor: dbRoleFor,
|
|
396
454
|
has: function (name) { return Object.prototype.hasOwnProperty.call(roleTable, name); },
|
|
397
455
|
roles: Object.freeze(Object.keys(roleTable)),
|
|
398
456
|
};
|
|
399
457
|
}
|
|
400
458
|
|
|
459
|
+
function _findDbRole(roleName, table, visited) {
|
|
460
|
+
if (visited.has(roleName)) return null;
|
|
461
|
+
visited.add(roleName);
|
|
462
|
+
var spec = table[roleName];
|
|
463
|
+
if (!spec) return null;
|
|
464
|
+
// Child overrides parent — check this role's own dbRole first, then
|
|
465
|
+
// recurse into extends.
|
|
466
|
+
if (spec.dbRole) return spec.dbRole;
|
|
467
|
+
for (var i = 0; i < spec.extends.length; i++) {
|
|
468
|
+
var found = _findDbRole(spec.extends[i], table, visited);
|
|
469
|
+
if (found) return found;
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
|
|
401
474
|
// ---- Helpers ----
|
|
402
475
|
|
|
403
476
|
function _labelize(requested) {
|