@blamejs/core 0.6.4 → 0.6.6
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/lib/db-declare-row-policy.js +272 -0
- package/lib/db-declare-view.js +424 -0
- package/lib/db-role-context.js +50 -0
- package/lib/db.js +10 -0
- package/lib/external-db-migrate.js +434 -0
- package/lib/external-db.js +172 -0
- package/lib/middleware/db-role-for.js +218 -0
- package/lib/middleware/index.js +2 -0
- package/lib/permissions.js +76 -3
- package/package.json +1 -1
package/lib/external-db.js
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
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");
|
|
@@ -65,6 +66,10 @@ var _err = ExternalDbError.factory;
|
|
|
65
66
|
var initialized = false;
|
|
66
67
|
var backends = {};
|
|
67
68
|
var defaultBackend = null;
|
|
69
|
+
// Operator-declared { role: backendName } map for request-time pool pick.
|
|
70
|
+
// Populated at init() from opts.dbRoleBackends. Read by _pickBackend
|
|
71
|
+
// when no explicit opts.backend is supplied AND the ALS scope has a role.
|
|
72
|
+
var dbRoleBackends = {};
|
|
68
73
|
|
|
69
74
|
// ---- Pool ----
|
|
70
75
|
//
|
|
@@ -166,6 +171,7 @@ function init(opts) {
|
|
|
166
171
|
if (!opts || !opts.backends) throw new Error("externalDb.init({ backends }) is required");
|
|
167
172
|
|
|
168
173
|
backends = {};
|
|
174
|
+
dbRoleBackends = {};
|
|
169
175
|
for (var name in opts.backends) {
|
|
170
176
|
var cfg = opts.backends[name];
|
|
171
177
|
if (typeof cfg.connect !== "function") {
|
|
@@ -174,8 +180,21 @@ function init(opts) {
|
|
|
174
180
|
if (typeof cfg.query !== "function") {
|
|
175
181
|
throw _err("INVALID_CONFIG", "backend '" + name + "' missing query() function", true);
|
|
176
182
|
}
|
|
183
|
+
// dialect — informational marker so dialect-specific consumers
|
|
184
|
+
// (e.g. b.db.declareView) can fail-fast at apply time. Defaults to
|
|
185
|
+
// "postgres" because that's the dominant blamejs externalDb target;
|
|
186
|
+
// operators on SQLite/MySQL/etc. set this explicitly so downstream
|
|
187
|
+
// primitives surface NOT_SUPPORTED with a clear message instead of
|
|
188
|
+
// emitting Postgres-flavored DDL into the wrong dialect.
|
|
189
|
+
var dialect = (cfg.dialect || "postgres").toLowerCase();
|
|
190
|
+
if (["postgres", "mysql", "sqlite", "mongodb", "other"].indexOf(dialect) === -1) {
|
|
191
|
+
throw _err("INVALID_CONFIG",
|
|
192
|
+
"backend '" + name + "': dialect must be one of " +
|
|
193
|
+
"'postgres' | 'mysql' | 'sqlite' | 'mongodb' | 'other', got '" + dialect + "'", true);
|
|
194
|
+
}
|
|
177
195
|
backends[name] = {
|
|
178
196
|
name: name,
|
|
197
|
+
dialect: dialect,
|
|
179
198
|
pool: new Pool(name, cfg),
|
|
180
199
|
query: cfg.query,
|
|
181
200
|
ping: cfg.ping || null,
|
|
@@ -195,6 +214,39 @@ function init(opts) {
|
|
|
195
214
|
}
|
|
196
215
|
|
|
197
216
|
defaultBackend = opts.defaultBackend || Object.keys(backends)[0];
|
|
217
|
+
|
|
218
|
+
// dbRoleBackends — request-time role → backend mapping. Each role name
|
|
219
|
+
// validates as a SQL identifier at init (matches the dbRoleFor
|
|
220
|
+
// middleware's runtime check) so a typo surfaces at boot rather than
|
|
221
|
+
// as a silent default-backend fallback at the first request.
|
|
222
|
+
if (opts.dbRoleBackends !== undefined && opts.dbRoleBackends !== null) {
|
|
223
|
+
if (typeof opts.dbRoleBackends !== "object" || Array.isArray(opts.dbRoleBackends)) {
|
|
224
|
+
throw _err("INVALID_CONFIG",
|
|
225
|
+
"dbRoleBackends must be an object map of role → backendName", true);
|
|
226
|
+
}
|
|
227
|
+
for (var role in opts.dbRoleBackends) {
|
|
228
|
+
if (!Object.prototype.hasOwnProperty.call(opts.dbRoleBackends, role)) continue;
|
|
229
|
+
try {
|
|
230
|
+
safeSql.validateIdentifier(role, { allowReserved: false });
|
|
231
|
+
} catch (e) {
|
|
232
|
+
throw _err("INVALID_CONFIG",
|
|
233
|
+
"dbRoleBackends: role '" + role + "' is not a valid SQL identifier: " +
|
|
234
|
+
((e && e.message) || String(e)), true);
|
|
235
|
+
}
|
|
236
|
+
var bn = opts.dbRoleBackends[role];
|
|
237
|
+
if (typeof bn !== "string" || bn.length === 0) {
|
|
238
|
+
throw _err("INVALID_CONFIG",
|
|
239
|
+
"dbRoleBackends['" + role + "']: backend name must be a non-empty string", true);
|
|
240
|
+
}
|
|
241
|
+
if (!Object.prototype.hasOwnProperty.call(backends, bn)) {
|
|
242
|
+
throw _err("INVALID_CONFIG",
|
|
243
|
+
"dbRoleBackends['" + role + "']: no backend named '" + bn + "' " +
|
|
244
|
+
"(declared backends: " + Object.keys(backends).join(", ") + ")", true);
|
|
245
|
+
}
|
|
246
|
+
dbRoleBackends[role] = bn;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
198
250
|
_validateResidency();
|
|
199
251
|
initialized = true;
|
|
200
252
|
}
|
|
@@ -219,6 +271,16 @@ function _validateResidency() {
|
|
|
219
271
|
}
|
|
220
272
|
|
|
221
273
|
// ---- Backend selection ----
|
|
274
|
+
//
|
|
275
|
+
// Pick precedence:
|
|
276
|
+
// 1. opts.backend — explicit override always wins
|
|
277
|
+
// 2. opts.classification — first backend serving that class
|
|
278
|
+
// 3. ALS-bound dbRole + dbRoleBackends — request-time auto-pick
|
|
279
|
+
// 4. defaultBackend — final fallback
|
|
280
|
+
//
|
|
281
|
+
// The ALS path matches the dbRoleFor middleware shape: middleware sets
|
|
282
|
+
// the role; deep async reads pick up the matching backend without having
|
|
283
|
+
// to thread `req` through every call site.
|
|
222
284
|
|
|
223
285
|
function _pickBackend(opts) {
|
|
224
286
|
opts = opts || {};
|
|
@@ -239,6 +301,10 @@ function _pickBackend(opts) {
|
|
|
239
301
|
throw _err("NO_BACKEND_FOR_CLASSIFICATION",
|
|
240
302
|
"no backend serves classification '" + classification + "'", true);
|
|
241
303
|
}
|
|
304
|
+
var role = dbRoleContext.getRole();
|
|
305
|
+
if (role && Object.prototype.hasOwnProperty.call(dbRoleBackends, role)) {
|
|
306
|
+
return backends[dbRoleBackends[role]];
|
|
307
|
+
}
|
|
242
308
|
return backends[defaultBackend] || null;
|
|
243
309
|
}
|
|
244
310
|
|
|
@@ -307,6 +373,16 @@ async function transaction(fn, opts) {
|
|
|
307
373
|
opts = opts || {};
|
|
308
374
|
var b = _pickBackend(opts);
|
|
309
375
|
|
|
376
|
+
// sessionGucs — per-transaction `SET LOCAL "name" = value` plumbing.
|
|
377
|
+
// Each name validates as a SQL identifier (Postgres GUC names follow
|
|
378
|
+
// the same NAMEDATALEN-shaped rules; dotted GUCs like 'app.tenant_id'
|
|
379
|
+
// validate per-segment via quoteQualified). Values are emitted as SQL
|
|
380
|
+
// string literals (single-quote escaped) for strings, raw for finite
|
|
381
|
+
// numbers. SET LOCAL ties the binding to the surrounding transaction
|
|
382
|
+
// so the tenant_id used by RLS policies resets cleanly at
|
|
383
|
+
// COMMIT/ROLLBACK without caller cleanup.
|
|
384
|
+
var prebuiltGucs = _buildSessionGucsStatements(opts.sessionGucs);
|
|
385
|
+
|
|
310
386
|
var t0 = Date.now();
|
|
311
387
|
return await b.breaker.wrap(async function () {
|
|
312
388
|
var client = await b.pool.acquire();
|
|
@@ -316,6 +392,9 @@ async function transaction(fn, opts) {
|
|
|
316
392
|
var committed = false;
|
|
317
393
|
try {
|
|
318
394
|
await b.beginTx(client);
|
|
395
|
+
for (var gi = 0; gi < prebuiltGucs.length; gi++) {
|
|
396
|
+
await b.query(client, prebuiltGucs[gi], []);
|
|
397
|
+
}
|
|
319
398
|
var result = await fn(txClient);
|
|
320
399
|
await b.commit(client);
|
|
321
400
|
committed = true;
|
|
@@ -372,6 +451,7 @@ function listBackends() {
|
|
|
372
451
|
var b = backends[name];
|
|
373
452
|
return {
|
|
374
453
|
name: name,
|
|
454
|
+
dialect: b.dialect,
|
|
375
455
|
classifications: b.classifications.slice(),
|
|
376
456
|
residencyTag: b.residencyTag,
|
|
377
457
|
breakerState: b.breaker.getState(),
|
|
@@ -396,6 +476,60 @@ async function shutdown() {
|
|
|
396
476
|
initialized = false;
|
|
397
477
|
}
|
|
398
478
|
|
|
479
|
+
// Build the SET LOCAL statements for a transaction's sessionGucs map.
|
|
480
|
+
// Identifier-validates each GUC name (per dot-segment so dotted names
|
|
481
|
+
// like 'app.tenant_id' work), quotes them with the Postgres dialect,
|
|
482
|
+
// and renders the value as either a SQL string literal (single-quoted,
|
|
483
|
+
// embedded quotes doubled) or a numeric literal for finite numbers.
|
|
484
|
+
// Bad shapes throw at the call site rather than as a confused Postgres
|
|
485
|
+
// error mid-transaction.
|
|
486
|
+
function _buildSessionGucsStatements(sessionGucs) {
|
|
487
|
+
if (sessionGucs === undefined || sessionGucs === null) return [];
|
|
488
|
+
if (typeof sessionGucs !== "object" || Array.isArray(sessionGucs)) {
|
|
489
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
490
|
+
"sessionGucs must be an object map of name → value", true);
|
|
491
|
+
}
|
|
492
|
+
var out = [];
|
|
493
|
+
for (var name in sessionGucs) {
|
|
494
|
+
if (!Object.prototype.hasOwnProperty.call(sessionGucs, name)) continue;
|
|
495
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
496
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
497
|
+
"sessionGucs: GUC name must be a non-empty string", true);
|
|
498
|
+
}
|
|
499
|
+
// Validate per-segment so dotted GUCs (Postgres custom GUC class.
|
|
500
|
+
// setting form) pass. quoteQualified handles both the validation
|
|
501
|
+
// and the dot-quoted rendering.
|
|
502
|
+
var qName;
|
|
503
|
+
try {
|
|
504
|
+
qName = safeSql.quoteQualified(name, "postgres");
|
|
505
|
+
} catch (e) {
|
|
506
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
507
|
+
"sessionGucs: name '" + name + "' is not a valid identifier: " +
|
|
508
|
+
((e && e.message) || String(e)), true);
|
|
509
|
+
}
|
|
510
|
+
var value = sessionGucs[name];
|
|
511
|
+
var literal;
|
|
512
|
+
if (typeof value === "number" && isFinite(value)) {
|
|
513
|
+
literal = String(value);
|
|
514
|
+
} else if (typeof value === "boolean") {
|
|
515
|
+
// Postgres SET accepts on/off/true/false — render true/false.
|
|
516
|
+
literal = value ? "true" : "false";
|
|
517
|
+
} else if (typeof value === "string") {
|
|
518
|
+
literal = "'" + value.replace(/'/g, "''") + "'";
|
|
519
|
+
} else if (value === null || value === undefined) {
|
|
520
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
521
|
+
"sessionGucs['" + name + "']: value must be a string, finite number, or boolean (got " +
|
|
522
|
+
(value === null ? "null" : "undefined") + ")", true);
|
|
523
|
+
} else {
|
|
524
|
+
throw _err("INVALID_SESSION_GUCS",
|
|
525
|
+
"sessionGucs['" + name + "']: value must be a string, finite number, or boolean (got " +
|
|
526
|
+
typeof value + ")", true);
|
|
527
|
+
}
|
|
528
|
+
out.push("SET LOCAL " + qName + " = " + literal);
|
|
529
|
+
}
|
|
530
|
+
return out;
|
|
531
|
+
}
|
|
532
|
+
|
|
399
533
|
// Fire-and-forget audit emission. We CANNOT await this in cluster mode:
|
|
400
534
|
// audit storage routes back through external-db when cluster mode is
|
|
401
535
|
// active, so awaiting would create a recursive dependency (every audit
|
|
@@ -580,6 +714,7 @@ function _resetForTest() {
|
|
|
580
714
|
});
|
|
581
715
|
backends = {};
|
|
582
716
|
defaultBackend = null;
|
|
717
|
+
dbRoleBackends = {};
|
|
583
718
|
initialized = false;
|
|
584
719
|
audit.reset();
|
|
585
720
|
db.reset();
|
|
@@ -777,6 +912,37 @@ function _adaptersConnectAs(connect, opts) {
|
|
|
777
912
|
return _connectAs(connect, query, roleOpts);
|
|
778
913
|
}
|
|
779
914
|
|
|
915
|
+
// ---- runAs / currentRole — out-of-request role binding ----
|
|
916
|
+
//
|
|
917
|
+
// Inside an HTTP request the dbRoleFor middleware already pushes the
|
|
918
|
+
// role into the shared db-role-context ALS. Background workers (jobs,
|
|
919
|
+
// schedulers, CLI commands) don't run under that middleware — they wrap
|
|
920
|
+
// their work in runAs(role, fn) so the same backend-pick logic applies.
|
|
921
|
+
//
|
|
922
|
+
// await b.externalDb.runAs("analytics_user", async function () {
|
|
923
|
+
// return await b.externalDb.read.query("SELECT ..."); // → analytics backend
|
|
924
|
+
// });
|
|
925
|
+
//
|
|
926
|
+
// currentRole() returns the active role (or null) — useful for diagnostic
|
|
927
|
+
// logs and observability labels.
|
|
928
|
+
function runAs(role, fn) {
|
|
929
|
+
if (typeof fn !== "function") {
|
|
930
|
+
throw _err("INVALID_FN", "externalDb.runAs: fn must be a function", true);
|
|
931
|
+
}
|
|
932
|
+
if (role !== null && role !== undefined) {
|
|
933
|
+
if (typeof role !== "string" || role.length === 0) {
|
|
934
|
+
throw _err("INVALID_ROLE",
|
|
935
|
+
"externalDb.runAs: role must be a non-empty string or null", true);
|
|
936
|
+
}
|
|
937
|
+
safeSql.validateIdentifier(role, { allowReserved: false });
|
|
938
|
+
}
|
|
939
|
+
return dbRoleContext.runWithRole(role || null, fn);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function currentRole() {
|
|
943
|
+
return dbRoleContext.getRole();
|
|
944
|
+
}
|
|
945
|
+
|
|
780
946
|
module.exports = {
|
|
781
947
|
init: init,
|
|
782
948
|
query: query,
|
|
@@ -787,9 +953,15 @@ module.exports = {
|
|
|
787
953
|
configurePool: configurePool,
|
|
788
954
|
read: read,
|
|
789
955
|
write: write,
|
|
956
|
+
runAs: runAs,
|
|
957
|
+
currentRole: currentRole,
|
|
790
958
|
adapters: {
|
|
791
959
|
connectAs: _adaptersConnectAs,
|
|
792
960
|
},
|
|
961
|
+
// Migration runner targeting an externalDb backend. Mirrors b.migrations
|
|
962
|
+
// (which targets local SQLite) but runs against externalDb. Tracking +
|
|
963
|
+
// lock tables live on the externalDb side. See lib/external-db-migrate.js.
|
|
964
|
+
migrate: require("./external-db-migrate"),
|
|
793
965
|
Pool: Pool,
|
|
794
966
|
_resetForTest: _resetForTest,
|
|
795
967
|
};
|
|
@@ -0,0 +1,218 @@
|
|
|
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 of `db.role.switched` (the cross-request transition
|
|
66
|
+
* record) lands in v0.6.7 — this middleware focuses on binding the role.
|
|
67
|
+
*/
|
|
68
|
+
var dbRoleContext = require("../db-role-context");
|
|
69
|
+
var lazyRequire = require("../lazy-require");
|
|
70
|
+
var safeSql = require("../safe-sql");
|
|
71
|
+
var validateOpts = require("../validate-opts");
|
|
72
|
+
var { defineClass } = require("../framework-error");
|
|
73
|
+
|
|
74
|
+
var observability = lazyRequire(function () { return require("../observability"); });
|
|
75
|
+
|
|
76
|
+
var DbRoleForError = defineClass("DbRoleForError", { alwaysPermanent: true });
|
|
77
|
+
var _err = function (code, message) { return new DbRoleForError(code, message); };
|
|
78
|
+
|
|
79
|
+
var ALLOWED_OPTS = [
|
|
80
|
+
"resolve", "permissions", "defaultRole",
|
|
81
|
+
"requireRole", "missingRoleStatus", "responder",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
function _emitEvent(name, value, labels) {
|
|
85
|
+
try { observability().event(name, value, labels || {}); }
|
|
86
|
+
catch (_e) { /* hot-path observability sink — drop silent by design */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function _validateRoleIdentifier(role, where) {
|
|
90
|
+
try {
|
|
91
|
+
safeSql.validateIdentifier(role, { allowReserved: false });
|
|
92
|
+
} catch (e) {
|
|
93
|
+
throw _err("db-role-for/bad-role",
|
|
94
|
+
where + ": role '" + role + "' is not a valid SQL identifier: " +
|
|
95
|
+
((e && e.message) || String(e)));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function _defaultResponder(req, res, status, info) {
|
|
100
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
101
|
+
res.end(JSON.stringify(info));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function create(opts) {
|
|
105
|
+
opts = opts || {};
|
|
106
|
+
validateOpts(opts, ALLOWED_OPTS, "middleware.dbRoleFor");
|
|
107
|
+
|
|
108
|
+
if (opts.resolve !== undefined && typeof opts.resolve !== "function") {
|
|
109
|
+
throw _err("db-role-for/bad-opt",
|
|
110
|
+
"middleware.dbRoleFor: resolve must be a function");
|
|
111
|
+
}
|
|
112
|
+
if (opts.responder !== undefined && typeof opts.responder !== "function") {
|
|
113
|
+
throw _err("db-role-for/bad-opt",
|
|
114
|
+
"middleware.dbRoleFor: responder must be a function");
|
|
115
|
+
}
|
|
116
|
+
if (opts.permissions !== undefined && opts.permissions !== null) {
|
|
117
|
+
if (typeof opts.permissions !== "object" ||
|
|
118
|
+
typeof opts.permissions.dbRoleFor !== "function") {
|
|
119
|
+
throw _err("db-role-for/bad-opt",
|
|
120
|
+
"middleware.dbRoleFor: permissions must be a b.permissions instance " +
|
|
121
|
+
"(missing dbRoleFor method)");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (opts.defaultRole !== undefined && opts.defaultRole !== null) {
|
|
125
|
+
if (typeof opts.defaultRole !== "string" || opts.defaultRole.length === 0) {
|
|
126
|
+
throw _err("db-role-for/bad-opt",
|
|
127
|
+
"middleware.dbRoleFor: defaultRole must be a non-empty string");
|
|
128
|
+
}
|
|
129
|
+
_validateRoleIdentifier(opts.defaultRole, "middleware.dbRoleFor: defaultRole");
|
|
130
|
+
}
|
|
131
|
+
if (opts.requireRole !== undefined && typeof opts.requireRole !== "boolean") {
|
|
132
|
+
throw _err("db-role-for/bad-opt",
|
|
133
|
+
"middleware.dbRoleFor: requireRole must be a boolean");
|
|
134
|
+
}
|
|
135
|
+
if (opts.missingRoleStatus !== undefined) {
|
|
136
|
+
if (typeof opts.missingRoleStatus !== "number" ||
|
|
137
|
+
!isFinite(opts.missingRoleStatus) ||
|
|
138
|
+
opts.missingRoleStatus < 100 || opts.missingRoleStatus > 599) {
|
|
139
|
+
throw _err("db-role-for/bad-opt",
|
|
140
|
+
"middleware.dbRoleFor: missingRoleStatus must be an HTTP status code (100-599)");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
var resolveFn = opts.resolve || null;
|
|
145
|
+
var perms = opts.permissions || null;
|
|
146
|
+
var defaultRole = opts.defaultRole || null;
|
|
147
|
+
var requireRole = !!opts.requireRole;
|
|
148
|
+
var missingRoleStatus = opts.missingRoleStatus || 401;
|
|
149
|
+
var responder = opts.responder || _defaultResponder;
|
|
150
|
+
|
|
151
|
+
return function dbRoleForMiddleware(req, res, next) {
|
|
152
|
+
var role = null;
|
|
153
|
+
var source = null;
|
|
154
|
+
|
|
155
|
+
if (resolveFn) {
|
|
156
|
+
var resolved;
|
|
157
|
+
try { resolved = resolveFn(req); }
|
|
158
|
+
catch (e) { return next(e); }
|
|
159
|
+
if (resolved !== undefined && resolved !== null && resolved !== "") {
|
|
160
|
+
role = resolved;
|
|
161
|
+
source = "resolver";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!role && perms) {
|
|
166
|
+
// permissions.dbRoleFor walks req.user.roles / req.apiKey.scopes via
|
|
167
|
+
// the configured resolver and returns the first declared dbRole.
|
|
168
|
+
var fromPerms;
|
|
169
|
+
try { fromPerms = perms.dbRoleFor(req); }
|
|
170
|
+
catch (e) { return next(e); }
|
|
171
|
+
if (fromPerms) {
|
|
172
|
+
role = fromPerms;
|
|
173
|
+
source = "permissions";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!role && defaultRole) {
|
|
178
|
+
role = defaultRole;
|
|
179
|
+
source = "default";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!role) {
|
|
183
|
+
if (requireRole) {
|
|
184
|
+
_emitEvent("db.role.missing", 1, {});
|
|
185
|
+
return responder(req, res, missingRoleStatus, {
|
|
186
|
+
error: "missing_db_role",
|
|
187
|
+
status: missingRoleStatus,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
// No binding — let externalDb fall back to its default backend.
|
|
191
|
+
req.dbRole = null;
|
|
192
|
+
return next();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (typeof role !== "string") {
|
|
196
|
+
return next(_err("db-role-for/bad-resolver-return",
|
|
197
|
+
"middleware.dbRoleFor: resolver returned non-string role: " + typeof role));
|
|
198
|
+
}
|
|
199
|
+
// Validate the resolver-supplied identifier at request time — a
|
|
200
|
+
// malformed identifier is a wiring bug, not a request-shape concern.
|
|
201
|
+
// Route the throw through next(err) so an operator's errorHandler
|
|
202
|
+
// reaches it instead of the request hanging.
|
|
203
|
+
try {
|
|
204
|
+
_validateRoleIdentifier(role, "middleware.dbRoleFor: resolver/" + source);
|
|
205
|
+
} catch (e) {
|
|
206
|
+
return next(e);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
req.dbRole = role;
|
|
210
|
+
_emitEvent("db.role.bound", 1, { role: role, source: source });
|
|
211
|
+
dbRoleContext.runWithRole(role, function () { next(); });
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = {
|
|
216
|
+
create: create,
|
|
217
|
+
DbRoleForError: DbRoleForError,
|
|
218
|
+
};
|
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) {
|