@blamejs/core 0.7.4 → 0.7.18
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 +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
|
@@ -1,264 +1,264 @@
|
|
|
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(n, v, l) { observability().safeEvent(n, v, l || {}); }
|
|
94
|
-
|
|
95
|
-
function _validateRoleIdentifier(role, where) {
|
|
96
|
-
try {
|
|
97
|
-
safeSql.validateIdentifier(role, { allowReserved: false });
|
|
98
|
-
} catch (e) {
|
|
99
|
-
throw _err("db-role-for/bad-role",
|
|
100
|
-
where + ": role '" + role + "' is not a valid SQL identifier: " +
|
|
101
|
-
((e && e.message) || String(e)));
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function _defaultResponder(req, res, status, info) {
|
|
106
|
-
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
107
|
-
res.end(JSON.stringify(info));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function create(opts) {
|
|
111
|
-
opts = opts || {};
|
|
112
|
-
validateOpts(opts, ALLOWED_OPTS, "middleware.dbRoleFor");
|
|
113
|
-
|
|
114
|
-
validateOpts.optionalFunction(opts.resolve, "middleware.dbRoleFor: resolve", DbRoleForError, "db-role-for/bad-opt");
|
|
115
|
-
validateOpts.optionalFunction(opts.responder, "middleware.dbRoleFor: responder", DbRoleForError, "db-role-for/bad-opt");
|
|
116
|
-
validateOpts.optionalObjectWithMethod(opts.permissions, "dbRoleFor",
|
|
117
|
-
"middleware.dbRoleFor: permissions", DbRoleForError, "db-role-for/bad-opt",
|
|
118
|
-
"must be a b.permissions instance (missing dbRoleFor method)");
|
|
119
|
-
if (opts.defaultRole !== undefined && opts.defaultRole !== null) {
|
|
120
|
-
if (typeof opts.defaultRole !== "string" || opts.defaultRole.length === 0) {
|
|
121
|
-
throw _err("db-role-for/bad-opt",
|
|
122
|
-
"middleware.dbRoleFor: defaultRole must be a non-empty string");
|
|
123
|
-
}
|
|
124
|
-
_validateRoleIdentifier(opts.defaultRole, "middleware.dbRoleFor: defaultRole");
|
|
125
|
-
}
|
|
126
|
-
validateOpts.optionalBoolean(opts.requireRole, "middleware.dbRoleFor: requireRole", DbRoleForError, "db-role-for/bad-opt");
|
|
127
|
-
if (opts.missingRoleStatus !== undefined) {
|
|
128
|
-
if (typeof opts.missingRoleStatus !== "number" ||
|
|
129
|
-
!isFinite(opts.missingRoleStatus) ||
|
|
130
|
-
opts.missingRoleStatus < 100 || opts.missingRoleStatus > 599) {
|
|
131
|
-
throw _err("db-role-for/bad-opt",
|
|
132
|
-
"middleware.dbRoleFor: missingRoleStatus must be an HTTP status code (100-599)");
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
validateOpts.auditShape(opts.audit, "middleware.dbRoleFor", DbRoleForError, "db-role-for/bad-opt");
|
|
136
|
-
validateOpts.optionalBoolean(opts.auditFailures, "middleware.dbRoleFor: auditFailures", DbRoleForError, "db-role-for/bad-opt");
|
|
137
|
-
validateOpts.optionalBoolean(opts.auditSuccess, "middleware.dbRoleFor: auditSuccess", DbRoleForError, "db-role-for/bad-opt");
|
|
138
|
-
|
|
139
|
-
var resolveFn = opts.resolve || null;
|
|
140
|
-
var perms = opts.permissions || null;
|
|
141
|
-
var defaultRole = opts.defaultRole || null;
|
|
142
|
-
var requireRole = !!opts.requireRole;
|
|
143
|
-
var missingRoleStatus = opts.missingRoleStatus || 401;
|
|
144
|
-
var responder = opts.responder || _defaultResponder;
|
|
145
|
-
// Audit defaults match permissions: the role-binding decision IS the
|
|
146
|
-
// audit-worthy act. Operators with extreme volume opt out via
|
|
147
|
-
// auditSuccess: false; failures stay on regardless. The audit sink
|
|
148
|
-
// defaults to the framework's b.audit; operators with multiple audit
|
|
149
|
-
// chains pass their own (matches captureAudit's shape).
|
|
150
|
-
var auditSink = opts.audit || null;
|
|
151
|
-
var auditFailures = (opts.auditFailures === undefined) ? true : opts.auditFailures;
|
|
152
|
-
var auditSuccess = (opts.auditSuccess === undefined) ? true : opts.auditSuccess;
|
|
153
|
-
|
|
154
|
-
return function dbRoleForMiddleware(req, res, next) {
|
|
155
|
-
var role = null;
|
|
156
|
-
var source = null;
|
|
157
|
-
|
|
158
|
-
if (resolveFn) {
|
|
159
|
-
var resolved;
|
|
160
|
-
try { resolved = resolveFn(req); }
|
|
161
|
-
catch (e) { return next(e); }
|
|
162
|
-
if (resolved !== undefined && resolved !== null && resolved !== "") {
|
|
163
|
-
role = resolved;
|
|
164
|
-
source = "resolver";
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
if (!role && perms) {
|
|
169
|
-
// permissions.dbRoleFor walks req.user.roles / req.apiKey.scopes via
|
|
170
|
-
// the configured resolver and returns the first declared dbRole.
|
|
171
|
-
var fromPerms;
|
|
172
|
-
try { fromPerms = perms.dbRoleFor(req); }
|
|
173
|
-
catch (e) { return next(e); }
|
|
174
|
-
if (fromPerms) {
|
|
175
|
-
role = fromPerms;
|
|
176
|
-
source = "permissions";
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (!role && defaultRole) {
|
|
181
|
-
role = defaultRole;
|
|
182
|
-
source = "default";
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (!role) {
|
|
186
|
-
if (requireRole) {
|
|
187
|
-
_emitEvent("db.role.missing", 1, {});
|
|
188
|
-
if (auditFailures) {
|
|
189
|
-
_auditSwitch(auditSink, req, {
|
|
190
|
-
previousRole: dbRoleContext.getRole(),
|
|
191
|
-
newRole: null,
|
|
192
|
-
source: "middleware",
|
|
193
|
-
outcome: "failure",
|
|
194
|
-
reason: "no-role",
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
return responder(req, res, missingRoleStatus, {
|
|
198
|
-
error: "missing_db_role",
|
|
199
|
-
status: missingRoleStatus,
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
// No binding — let externalDb fall back to its default backend.
|
|
203
|
-
req.dbRole = null;
|
|
204
|
-
return next();
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (typeof role !== "string") {
|
|
208
|
-
return next(_err("db-role-for/bad-resolver-return",
|
|
209
|
-
"middleware.dbRoleFor: resolver returned non-string role: " + typeof role));
|
|
210
|
-
}
|
|
211
|
-
// Validate the resolver-supplied identifier at request time — a
|
|
212
|
-
// malformed identifier is a wiring bug, not a request-shape concern.
|
|
213
|
-
// Route the throw through next(err) so an operator's errorHandler
|
|
214
|
-
// reaches it instead of the request hanging.
|
|
215
|
-
try {
|
|
216
|
-
_validateRoleIdentifier(role, "middleware.dbRoleFor: resolver/" + source);
|
|
217
|
-
} catch (e) {
|
|
218
|
-
return next(e);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
var previousRole = dbRoleContext.getRole();
|
|
222
|
-
req.dbRole = role;
|
|
223
|
-
_emitEvent("db.role.bound", 1, { role: role, source: source });
|
|
224
|
-
if (auditSuccess) {
|
|
225
|
-
_auditSwitch(auditSink, req, {
|
|
226
|
-
previousRole: previousRole,
|
|
227
|
-
newRole: role,
|
|
228
|
-
source: "middleware",
|
|
229
|
-
outcome: "success",
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
dbRoleContext.runWithRole(role, function () { next(); });
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Emit the db.role.switched audit row. Fire-and-forget — the audit
|
|
237
|
-
// handler's own try/catch keeps a momentary outage from breaking the
|
|
238
|
-
// request. The actor 5 W's come from extractActorContext (req-driven);
|
|
239
|
-
// metadata carries the previous + new role + binding source so a
|
|
240
|
-
// forensic walker can reconstruct "which role read which row when."
|
|
241
|
-
// The sink defaults to the framework's b.audit when the operator
|
|
242
|
-
// didn't pass an explicit instance.
|
|
243
|
-
function _auditSwitch(sink, req, info) {
|
|
244
|
-
try {
|
|
245
|
-
var emitter = sink || audit();
|
|
246
|
-
emitter.safeEmit({
|
|
247
|
-
action: "db.role.switched",
|
|
248
|
-
actor: requestHelpers.extractActorContext(req),
|
|
249
|
-
resource: { kind: "db.role", id: info.newRole || "(none)" },
|
|
250
|
-
outcome: info.outcome || "success",
|
|
251
|
-
reason: info.reason || null,
|
|
252
|
-
metadata: {
|
|
253
|
-
previousRole: info.previousRole || null,
|
|
254
|
-
newRole: info.newRole || null,
|
|
255
|
-
source: info.source,
|
|
256
|
-
},
|
|
257
|
-
});
|
|
258
|
-
} catch (_e) { /* audit best-effort */ }
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
module.exports = {
|
|
262
|
-
create: create,
|
|
263
|
-
DbRoleForError: DbRoleForError,
|
|
264
|
-
};
|
|
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(n, v, l) { observability().safeEvent(n, v, l || {}); }
|
|
94
|
+
|
|
95
|
+
function _validateRoleIdentifier(role, where) {
|
|
96
|
+
try {
|
|
97
|
+
safeSql.validateIdentifier(role, { allowReserved: false });
|
|
98
|
+
} catch (e) {
|
|
99
|
+
throw _err("db-role-for/bad-role",
|
|
100
|
+
where + ": role '" + role + "' is not a valid SQL identifier: " +
|
|
101
|
+
((e && e.message) || String(e)));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _defaultResponder(req, res, status, info) {
|
|
106
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
107
|
+
res.end(JSON.stringify(info));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function create(opts) {
|
|
111
|
+
opts = opts || {};
|
|
112
|
+
validateOpts(opts, ALLOWED_OPTS, "middleware.dbRoleFor");
|
|
113
|
+
|
|
114
|
+
validateOpts.optionalFunction(opts.resolve, "middleware.dbRoleFor: resolve", DbRoleForError, "db-role-for/bad-opt");
|
|
115
|
+
validateOpts.optionalFunction(opts.responder, "middleware.dbRoleFor: responder", DbRoleForError, "db-role-for/bad-opt");
|
|
116
|
+
validateOpts.optionalObjectWithMethod(opts.permissions, "dbRoleFor",
|
|
117
|
+
"middleware.dbRoleFor: permissions", DbRoleForError, "db-role-for/bad-opt",
|
|
118
|
+
"must be a b.permissions instance (missing dbRoleFor method)");
|
|
119
|
+
if (opts.defaultRole !== undefined && opts.defaultRole !== null) {
|
|
120
|
+
if (typeof opts.defaultRole !== "string" || opts.defaultRole.length === 0) {
|
|
121
|
+
throw _err("db-role-for/bad-opt",
|
|
122
|
+
"middleware.dbRoleFor: defaultRole must be a non-empty string");
|
|
123
|
+
}
|
|
124
|
+
_validateRoleIdentifier(opts.defaultRole, "middleware.dbRoleFor: defaultRole");
|
|
125
|
+
}
|
|
126
|
+
validateOpts.optionalBoolean(opts.requireRole, "middleware.dbRoleFor: requireRole", DbRoleForError, "db-role-for/bad-opt");
|
|
127
|
+
if (opts.missingRoleStatus !== undefined) {
|
|
128
|
+
if (typeof opts.missingRoleStatus !== "number" ||
|
|
129
|
+
!isFinite(opts.missingRoleStatus) ||
|
|
130
|
+
opts.missingRoleStatus < 100 || opts.missingRoleStatus > 599) {
|
|
131
|
+
throw _err("db-role-for/bad-opt",
|
|
132
|
+
"middleware.dbRoleFor: missingRoleStatus must be an HTTP status code (100-599)");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
validateOpts.auditShape(opts.audit, "middleware.dbRoleFor", DbRoleForError, "db-role-for/bad-opt");
|
|
136
|
+
validateOpts.optionalBoolean(opts.auditFailures, "middleware.dbRoleFor: auditFailures", DbRoleForError, "db-role-for/bad-opt");
|
|
137
|
+
validateOpts.optionalBoolean(opts.auditSuccess, "middleware.dbRoleFor: auditSuccess", DbRoleForError, "db-role-for/bad-opt");
|
|
138
|
+
|
|
139
|
+
var resolveFn = opts.resolve || null;
|
|
140
|
+
var perms = opts.permissions || null;
|
|
141
|
+
var defaultRole = opts.defaultRole || null;
|
|
142
|
+
var requireRole = !!opts.requireRole;
|
|
143
|
+
var missingRoleStatus = opts.missingRoleStatus || 401;
|
|
144
|
+
var responder = opts.responder || _defaultResponder;
|
|
145
|
+
// Audit defaults match permissions: the role-binding decision IS the
|
|
146
|
+
// audit-worthy act. Operators with extreme volume opt out via
|
|
147
|
+
// auditSuccess: false; failures stay on regardless. The audit sink
|
|
148
|
+
// defaults to the framework's b.audit; operators with multiple audit
|
|
149
|
+
// chains pass their own (matches captureAudit's shape).
|
|
150
|
+
var auditSink = opts.audit || null;
|
|
151
|
+
var auditFailures = (opts.auditFailures === undefined) ? true : opts.auditFailures;
|
|
152
|
+
var auditSuccess = (opts.auditSuccess === undefined) ? true : opts.auditSuccess;
|
|
153
|
+
|
|
154
|
+
return function dbRoleForMiddleware(req, res, next) {
|
|
155
|
+
var role = null;
|
|
156
|
+
var source = null;
|
|
157
|
+
|
|
158
|
+
if (resolveFn) {
|
|
159
|
+
var resolved;
|
|
160
|
+
try { resolved = resolveFn(req); }
|
|
161
|
+
catch (e) { return next(e); }
|
|
162
|
+
if (resolved !== undefined && resolved !== null && resolved !== "") {
|
|
163
|
+
role = resolved;
|
|
164
|
+
source = "resolver";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!role && perms) {
|
|
169
|
+
// permissions.dbRoleFor walks req.user.roles / req.apiKey.scopes via
|
|
170
|
+
// the configured resolver and returns the first declared dbRole.
|
|
171
|
+
var fromPerms;
|
|
172
|
+
try { fromPerms = perms.dbRoleFor(req); }
|
|
173
|
+
catch (e) { return next(e); }
|
|
174
|
+
if (fromPerms) {
|
|
175
|
+
role = fromPerms;
|
|
176
|
+
source = "permissions";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!role && defaultRole) {
|
|
181
|
+
role = defaultRole;
|
|
182
|
+
source = "default";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!role) {
|
|
186
|
+
if (requireRole) {
|
|
187
|
+
_emitEvent("db.role.missing", 1, {});
|
|
188
|
+
if (auditFailures) {
|
|
189
|
+
_auditSwitch(auditSink, req, {
|
|
190
|
+
previousRole: dbRoleContext.getRole(),
|
|
191
|
+
newRole: null,
|
|
192
|
+
source: "middleware",
|
|
193
|
+
outcome: "failure",
|
|
194
|
+
reason: "no-role",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return responder(req, res, missingRoleStatus, {
|
|
198
|
+
error: "missing_db_role",
|
|
199
|
+
status: missingRoleStatus,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
// No binding — let externalDb fall back to its default backend.
|
|
203
|
+
req.dbRole = null;
|
|
204
|
+
return next();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (typeof role !== "string") {
|
|
208
|
+
return next(_err("db-role-for/bad-resolver-return",
|
|
209
|
+
"middleware.dbRoleFor: resolver returned non-string role: " + typeof role));
|
|
210
|
+
}
|
|
211
|
+
// Validate the resolver-supplied identifier at request time — a
|
|
212
|
+
// malformed identifier is a wiring bug, not a request-shape concern.
|
|
213
|
+
// Route the throw through next(err) so an operator's errorHandler
|
|
214
|
+
// reaches it instead of the request hanging.
|
|
215
|
+
try {
|
|
216
|
+
_validateRoleIdentifier(role, "middleware.dbRoleFor: resolver/" + source);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
return next(e);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
var previousRole = dbRoleContext.getRole();
|
|
222
|
+
req.dbRole = role;
|
|
223
|
+
_emitEvent("db.role.bound", 1, { role: role, source: source });
|
|
224
|
+
if (auditSuccess) {
|
|
225
|
+
_auditSwitch(auditSink, req, {
|
|
226
|
+
previousRole: previousRole,
|
|
227
|
+
newRole: role,
|
|
228
|
+
source: "middleware",
|
|
229
|
+
outcome: "success",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
dbRoleContext.runWithRole(role, function () { next(); });
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Emit the db.role.switched audit row. Fire-and-forget — the audit
|
|
237
|
+
// handler's own try/catch keeps a momentary outage from breaking the
|
|
238
|
+
// request. The actor 5 W's come from extractActorContext (req-driven);
|
|
239
|
+
// metadata carries the previous + new role + binding source so a
|
|
240
|
+
// forensic walker can reconstruct "which role read which row when."
|
|
241
|
+
// The sink defaults to the framework's b.audit when the operator
|
|
242
|
+
// didn't pass an explicit instance.
|
|
243
|
+
function _auditSwitch(sink, req, info) {
|
|
244
|
+
try {
|
|
245
|
+
var emitter = sink || audit();
|
|
246
|
+
emitter.safeEmit({
|
|
247
|
+
action: "db.role.switched",
|
|
248
|
+
actor: requestHelpers.extractActorContext(req),
|
|
249
|
+
resource: { kind: "db.role", id: info.newRole || "(none)" },
|
|
250
|
+
outcome: info.outcome || "success",
|
|
251
|
+
reason: info.reason || null,
|
|
252
|
+
metadata: {
|
|
253
|
+
previousRole: info.previousRole || null,
|
|
254
|
+
newRole: info.newRole || null,
|
|
255
|
+
source: info.source,
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
} catch (_e) { /* audit best-effort */ }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
create: create,
|
|
263
|
+
DbRoleForError: DbRoleForError,
|
|
264
|
+
};
|