@blamejs/core 0.7.18 → 0.7.19
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 +425 -423
- package/README.md +150 -150
- package/bin/blamejs.js +0 -0
- package/index.js +310 -308
- package/lib/api-key.js +660 -660
- 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-external.js +365 -0
- package/lib/auth/jwt.js +337 -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 +628 -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/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 -275
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -420
- 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/forms.js +422 -422
- package/lib/framework-error.js +293 -293
- package/lib/framework-schema.js +717 -717
- 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 -369
- package/lib/mail.js +981 -981
- 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/bearer-auth.js +152 -0
- package/lib/middleware/body-parser.js +1170 -1170
- 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 +82 -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 -111
- 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 +535 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- 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 -340
- 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
package/lib/permissions.js
CHANGED
|
@@ -1,708 +1,708 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* b.permissions — RBAC primitive.
|
|
4
|
-
*
|
|
5
|
-
* var perms = b.permissions.create({
|
|
6
|
-
* roles: {
|
|
7
|
-
* admin: { extends: ["editor"], permissions: ["users:delete"] },
|
|
8
|
-
* editor: ["users:read", "users:write", "posts:*"],
|
|
9
|
-
* viewer: ["*:read"],
|
|
10
|
-
* },
|
|
11
|
-
* audit: b.audit, // optional
|
|
12
|
-
* });
|
|
13
|
-
*
|
|
14
|
-
* router.delete("/users/:id",
|
|
15
|
-
* authMiddleware, // populates req.user / req.apiKey
|
|
16
|
-
* perms.require("users:delete"),
|
|
17
|
-
* deleteUserHandler);
|
|
18
|
-
*
|
|
19
|
-
* The default resolver chain reads the actor from the request:
|
|
20
|
-
*
|
|
21
|
-
* req.apiKey.scopes → { scopes: [...] } (b.apiKey.verify output)
|
|
22
|
-
* req.user.scopes → { scopes: [...] } (operator-set)
|
|
23
|
-
* req.user.roles → { roles: [...] } (operator-set)
|
|
24
|
-
*
|
|
25
|
-
* Operators with non-default request shapes pass `resolver` to create().
|
|
26
|
-
*
|
|
27
|
-
* Wildcard semantics (b.permissions.match):
|
|
28
|
-
* "*" matches any scope (greedy)
|
|
29
|
-
* "users:*" matches "users:read", "users:read:detail", etc. (trailing * is greedy)
|
|
30
|
-
* "*:read" matches "users:read", "posts:read"
|
|
31
|
-
* "users:*:read" matches "users:foo:read" (per-segment *)
|
|
32
|
-
* "users:read" matches "users:read" only — no implicit sub-resource grant
|
|
33
|
-
*
|
|
34
|
-
* Validation policy:
|
|
35
|
-
*
|
|
36
|
-
* - create() role table / scope formats → throw at app init
|
|
37
|
-
* - require(scope) registration arg → throw at route declaration
|
|
38
|
-
* - check(actor, scope) bad actor → return false (tolerant read)
|
|
39
|
-
* - resolver returns null in middleware → 401 (missingActorStatus)
|
|
40
|
-
* - actor lacks scope in middleware → 403 (denyStatus)
|
|
41
|
-
* - audit/observability emit failures → drop silent (hot-path sink)
|
|
42
|
-
*
|
|
43
|
-
* Audit defaults follow the framework's security-defaults stance
|
|
44
|
-
* default: `auditFailures: true`
|
|
45
|
-
* (deny is a security signal), `auditSuccess: false` (per-request noise).
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
var C = require("./constants");
|
|
49
|
-
var lazyRequire = require("./lazy-require");
|
|
50
|
-
var requestHelpers = require("./request-helpers");
|
|
51
|
-
var safeSql = require("./safe-sql");
|
|
52
|
-
var validateOpts = require("./validate-opts");
|
|
53
|
-
var { PermissionsError } = require("./framework-error");
|
|
54
|
-
|
|
55
|
-
var _err = PermissionsError.factory;
|
|
56
|
-
|
|
57
|
-
var observability = lazyRequire(function () { return require("./observability"); });
|
|
58
|
-
|
|
59
|
-
function _emitEvent(n, v, l) { observability().safeEvent(n, v, l || {}); }
|
|
60
|
-
|
|
61
|
-
// Lowercase tokens, digits, dash, underscore, and `*` allowed per
|
|
62
|
-
// segment. Scope format is segments separated by `:`.
|
|
63
|
-
var SCOPE_RE = /^[a-z0-9_*-]+(:[a-z0-9_*-]+)*$/;
|
|
64
|
-
// Bound the regex engine on operator-supplied scope strings. 256 chars
|
|
65
|
-
// holds any realistic real-world scope (typical scopes run 8-32 chars);
|
|
66
|
-
// rejecting longer keeps the regex linear regardless of input shape.
|
|
67
|
-
var SCOPE_MAX_LENGTH = C.BYTES.bytes(256);
|
|
68
|
-
|
|
69
|
-
// Audit defaults: BOTH success and failure default ON for permissions.
|
|
70
|
-
// Unlike api-key.verify (which is gate-keeping for a downstream action
|
|
71
|
-
// the application separately audits), a permissions.check IS the
|
|
72
|
-
// authorization decision — there's no further-downstream audit event.
|
|
73
|
-
// "user X granted users:delete at time T" is exactly what compliance
|
|
74
|
-
// auditors ask for. Operators with extreme volume opt out via
|
|
75
|
-
// auditSuccess: false; failures remain on regardless.
|
|
76
|
-
var DEFAULTS = Object.freeze({
|
|
77
|
-
auditFailures: true,
|
|
78
|
-
auditSuccess: true,
|
|
79
|
-
denyStatus: 403,
|
|
80
|
-
missingActorStatus: 401,
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
// ---- Wildcard matcher ----
|
|
84
|
-
|
|
85
|
-
function match(granted, required) {
|
|
86
|
-
if (typeof granted !== "string" || typeof required !== "string") return false;
|
|
87
|
-
if (granted.length === 0 || required.length === 0) return false;
|
|
88
|
-
var gParts = granted.split(":");
|
|
89
|
-
var rParts = required.split(":");
|
|
90
|
-
for (var i = 0; i < gParts.length; i++) {
|
|
91
|
-
var g = gParts[i];
|
|
92
|
-
if (g === "*") {
|
|
93
|
-
// Trailing * is greedy — matches the rest of required.
|
|
94
|
-
if (i === gParts.length - 1) return true;
|
|
95
|
-
// Per-segment * — matches THIS segment of required (any value),
|
|
96
|
-
// continue to next segment. Required must have a segment here.
|
|
97
|
-
if (i >= rParts.length) return false;
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
if (i >= rParts.length) return false; // granted is more specific than required
|
|
101
|
-
if (g !== rParts[i]) return false;
|
|
102
|
-
}
|
|
103
|
-
// Reached end of granted without wildcard. Lengths must match exactly
|
|
104
|
-
// (no implicit sub-resource grant).
|
|
105
|
-
return rParts.length === gParts.length;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ---- Role table validation + expansion ----
|
|
109
|
-
|
|
110
|
-
function _validateScopePattern(scope, ctx) {
|
|
111
|
-
if (typeof scope !== "string" || scope.length === 0) {
|
|
112
|
-
throw _err("BAD_SCOPE", ctx + ": scope must be a non-empty string, got " + typeof scope);
|
|
113
|
-
}
|
|
114
|
-
// Length cap before the regex test — bound the engine on hostile
|
|
115
|
-
// input lengths even though SCOPE_RE is anchored.
|
|
116
|
-
if (scope.length > SCOPE_MAX_LENGTH || !SCOPE_RE.test(scope)) {
|
|
117
|
-
throw _err("BAD_SCOPE", ctx + ": scope '" + scope +
|
|
118
|
-
"' is empty, too long, or doesn't match " + SCOPE_RE +
|
|
119
|
-
" (lowercase tokens with optional `*`)");
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function _normalizeRoleEntry(name, entry) {
|
|
124
|
-
if (Array.isArray(entry)) {
|
|
125
|
-
return { extends: [], permissions: entry.slice(), dbRole: null,
|
|
126
|
-
requireMfa: false, mfaWindowMs: null };
|
|
127
|
-
}
|
|
128
|
-
if (entry && typeof entry === "object") {
|
|
129
|
-
var ext = entry.extends || [];
|
|
130
|
-
var perms = entry.permissions || [];
|
|
131
|
-
if (!Array.isArray(ext)) {
|
|
132
|
-
throw _err("BAD_ROLE", "role '" + name + "': extends must be an array of role names");
|
|
133
|
-
}
|
|
134
|
-
if (!Array.isArray(perms)) {
|
|
135
|
-
throw _err("BAD_ROLE", "role '" + name + "': permissions must be an array of scope strings");
|
|
136
|
-
}
|
|
137
|
-
var dbRole = null;
|
|
138
|
-
if (entry.dbRole !== undefined && entry.dbRole !== null) {
|
|
139
|
-
if (typeof entry.dbRole !== "string" || entry.dbRole.length === 0) {
|
|
140
|
-
throw _err("BAD_ROLE",
|
|
141
|
-
"role '" + name + "': dbRole must be a non-empty string");
|
|
142
|
-
}
|
|
143
|
-
// dbRole feeds straight into externalDb backend pick + the
|
|
144
|
-
// dbRoleFor middleware's identifier check; validate at create()
|
|
145
|
-
// time so a typo surfaces at boot, not on the first request.
|
|
146
|
-
try {
|
|
147
|
-
safeSql.validateIdentifier(entry.dbRole, { allowReserved: false });
|
|
148
|
-
} catch (e) {
|
|
149
|
-
throw _err("BAD_ROLE",
|
|
150
|
-
"role '" + name + "': dbRole '" + entry.dbRole +
|
|
151
|
-
"' is not a valid SQL identifier: " + ((e && e.message) || String(e)));
|
|
152
|
-
}
|
|
153
|
-
dbRole = entry.dbRole;
|
|
154
|
-
}
|
|
155
|
-
var requireMfa = entry.requireMfa === true;
|
|
156
|
-
var mfaWindowMs = null;
|
|
157
|
-
if (entry.mfaWindowMs !== undefined && entry.mfaWindowMs !== null) {
|
|
158
|
-
if (typeof entry.mfaWindowMs !== "number" || !isFinite(entry.mfaWindowMs) || entry.mfaWindowMs <= 0) {
|
|
159
|
-
throw _err("BAD_ROLE",
|
|
160
|
-
"role '" + name + "': mfaWindowMs must be a positive finite number");
|
|
161
|
-
}
|
|
162
|
-
mfaWindowMs = entry.mfaWindowMs;
|
|
163
|
-
}
|
|
164
|
-
return { extends: ext.slice(), permissions: perms.slice(), dbRole: dbRole,
|
|
165
|
-
requireMfa: requireMfa, mfaWindowMs: mfaWindowMs };
|
|
166
|
-
}
|
|
167
|
-
throw _err("BAD_ROLE", "role '" + name + "' must be an array of scopes or { extends?, permissions, dbRole?, requireMfa?, mfaWindowMs? }");
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function _validateRoles(roles) {
|
|
171
|
-
if (!roles || typeof roles !== "object" || Array.isArray(roles)) {
|
|
172
|
-
throw _err("BAD_OPT", "permissions.create: roles must be an object map of name → spec");
|
|
173
|
-
}
|
|
174
|
-
var names = Object.keys(roles);
|
|
175
|
-
if (names.length === 0) {
|
|
176
|
-
throw _err("BAD_OPT", "permissions.create: roles map must have at least one role");
|
|
177
|
-
}
|
|
178
|
-
var normalized = {};
|
|
179
|
-
for (var i = 0; i < names.length; i++) {
|
|
180
|
-
var name = names[i];
|
|
181
|
-
if (typeof name !== "string" || name.length === 0) {
|
|
182
|
-
throw _err("BAD_ROLE", "role name must be a non-empty string");
|
|
183
|
-
}
|
|
184
|
-
var spec = _normalizeRoleEntry(name, roles[name]);
|
|
185
|
-
for (var j = 0; j < spec.permissions.length; j++) {
|
|
186
|
-
_validateScopePattern(spec.permissions[j], "role '" + name + "'");
|
|
187
|
-
}
|
|
188
|
-
for (var k = 0; k < spec.extends.length; k++) {
|
|
189
|
-
if (typeof spec.extends[k] !== "string" || spec.extends[k].length === 0) {
|
|
190
|
-
throw _err("BAD_ROLE", "role '" + name + "': extends entry must be a non-empty string");
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
normalized[name] = spec;
|
|
194
|
-
}
|
|
195
|
-
// Check extends references resolve to known roles
|
|
196
|
-
for (var n = 0; n < names.length; n++) {
|
|
197
|
-
var spec2 = normalized[names[n]];
|
|
198
|
-
for (var m = 0; m < spec2.extends.length; m++) {
|
|
199
|
-
if (!Object.prototype.hasOwnProperty.call(normalized, spec2.extends[m])) {
|
|
200
|
-
throw _err("UNKNOWN_ROLE", "role '" + names[n] + "': extends references unknown role '" +
|
|
201
|
-
spec2.extends[m] + "'");
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
// Cycle detection via DFS
|
|
206
|
-
for (var p = 0; p < names.length; p++) {
|
|
207
|
-
_detectCycle(names[p], normalized, []);
|
|
208
|
-
}
|
|
209
|
-
return normalized;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function _detectCycle(roleName, table, stack) {
|
|
213
|
-
if (stack.indexOf(roleName) !== -1) {
|
|
214
|
-
throw _err("CYCLE", "permissions.create: cycle in extends chain: " +
|
|
215
|
-
stack.concat([roleName]).join(" → "));
|
|
216
|
-
}
|
|
217
|
-
var spec = table[roleName];
|
|
218
|
-
for (var i = 0; i < spec.extends.length; i++) {
|
|
219
|
-
_detectCycle(spec.extends[i], table, stack.concat([roleName]));
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function _expandOne(roleName, table, visited, out) {
|
|
224
|
-
if (visited.has(roleName)) return;
|
|
225
|
-
visited.add(roleName);
|
|
226
|
-
var spec = table[roleName];
|
|
227
|
-
if (!spec) return;
|
|
228
|
-
for (var i = 0; i < spec.extends.length; i++) {
|
|
229
|
-
_expandOne(spec.extends[i], table, visited, out);
|
|
230
|
-
}
|
|
231
|
-
for (var j = 0; j < spec.permissions.length; j++) {
|
|
232
|
-
if (out.indexOf(spec.permissions[j]) === -1) out.push(spec.permissions[j]);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ---- Default resolver ----
|
|
237
|
-
|
|
238
|
-
function _defaultResolver(req) {
|
|
239
|
-
if (!req || typeof req !== "object") return null;
|
|
240
|
-
if (req.apiKey && Array.isArray(req.apiKey.scopes)) return { scopes: req.apiKey.scopes };
|
|
241
|
-
if (req.user && Array.isArray(req.user.scopes)) return { scopes: req.user.scopes };
|
|
242
|
-
if (req.user && Array.isArray(req.user.roles)) return { roles: req.user.roles };
|
|
243
|
-
return null;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ---- Validation: create opts ----
|
|
247
|
-
|
|
248
|
-
function _validateCreateOpts(opts) {
|
|
249
|
-
validateOpts.requireObject(opts, "permissions.create", PermissionsError);
|
|
250
|
-
validateOpts.optionalFunction(opts.resolver, "permissions.create: resolver", PermissionsError);
|
|
251
|
-
validateOpts.auditShape(opts.audit, "permissions.create", PermissionsError);
|
|
252
|
-
validateOpts.optionalBoolean(opts.auditFailures, "permissions.create: auditFailures", PermissionsError);
|
|
253
|
-
validateOpts.optionalBoolean(opts.auditSuccess, "permissions.create: auditSuccess", PermissionsError);
|
|
254
|
-
if (opts.denyStatus !== undefined &&
|
|
255
|
-
(typeof opts.denyStatus !== "number" || !isFinite(opts.denyStatus) || opts.denyStatus < 100 || opts.denyStatus > 599)) {
|
|
256
|
-
throw _err("BAD_OPT", "permissions.create: denyStatus must be an HTTP status code (100-599)");
|
|
257
|
-
}
|
|
258
|
-
if (opts.missingActorStatus !== undefined &&
|
|
259
|
-
(typeof opts.missingActorStatus !== "number" || !isFinite(opts.missingActorStatus) ||
|
|
260
|
-
opts.missingActorStatus < 100 || opts.missingActorStatus > 599)) {
|
|
261
|
-
throw _err("BAD_OPT", "permissions.create: missingActorStatus must be an HTTP status code (100-599)");
|
|
262
|
-
}
|
|
263
|
-
validateOpts.optionalFunction(opts.responder, "permissions.create: responder", PermissionsError);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ---- Registry ----
|
|
267
|
-
|
|
268
|
-
function create(opts) {
|
|
269
|
-
opts = opts || {};
|
|
270
|
-
validateOpts(opts, [
|
|
271
|
-
"roles", "resolver", "audit", "auditFailures", "auditSuccess",
|
|
272
|
-
"denyStatus", "missingActorStatus", "responder",
|
|
273
|
-
], "permissions");
|
|
274
|
-
_validateCreateOpts(opts);
|
|
275
|
-
var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
|
|
276
|
-
var roleTable = _validateRoles(opts.roles);
|
|
277
|
-
var resolver = opts.resolver || _defaultResolver;
|
|
278
|
-
var audit = opts.audit || null;
|
|
279
|
-
var auditFailures = cfg.auditFailures;
|
|
280
|
-
var auditSuccess = cfg.auditSuccess;
|
|
281
|
-
var denyStatus = cfg.denyStatus;
|
|
282
|
-
var missingActorStatus = cfg.missingActorStatus;
|
|
283
|
-
var responder = opts.responder || _defaultResponder;
|
|
284
|
-
|
|
285
|
-
// ABAC predicate registry. Each entry: scope-string → async predicate
|
|
286
|
-
// function (actor, context) → boolean. The middleware evaluates the
|
|
287
|
-
// predicate AFTER the RBAC scope check passes — so a route protected
|
|
288
|
-
// by `perms.require("orders.read")` first checks the actor has the
|
|
289
|
-
// orders:read scope, then (if the scope has a policy registered)
|
|
290
|
-
// evaluates the predicate with the actor + a per-request context
|
|
291
|
-
// built by the route's `context` middleware opt. ABAC + RBAC stack
|
|
292
|
-
// — a route needs to pass BOTH layers when both are configured.
|
|
293
|
-
var policies = {};
|
|
294
|
-
|
|
295
|
-
function policy(scope, predicate) {
|
|
296
|
-
_validateScopePattern(scope, "permissions.policy");
|
|
297
|
-
if (typeof predicate !== "function") {
|
|
298
|
-
throw _err("BAD_OPT", "permissions.policy: predicate must be a function (actor, context) -> bool");
|
|
299
|
-
}
|
|
300
|
-
if (policies[scope]) {
|
|
301
|
-
throw _err("DUPLICATE_POLICY", "permissions.policy: '" + scope + "' is already registered");
|
|
302
|
-
}
|
|
303
|
-
policies[scope] = predicate;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function _findPolicy(requestedScope) {
|
|
307
|
-
// Exact match wins; no wildcard expansion (a wildcard policy
|
|
308
|
-
// gating arbitrary scopes is too easy to misconfigure).
|
|
309
|
-
return policies[requestedScope] || null;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
var _emitRaw = validateOpts.makeAuditEmitter(audit);
|
|
313
|
-
function _auditEmit(action, info) {
|
|
314
|
-
if (info && info.outcome === "success" && !auditSuccess) return;
|
|
315
|
-
if (info && info.outcome !== "success" && !auditFailures) return;
|
|
316
|
-
_emitRaw(action, info);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function expand(roleNames) {
|
|
320
|
-
if (!Array.isArray(roleNames)) return [];
|
|
321
|
-
var visited = new Set();
|
|
322
|
-
var out = [];
|
|
323
|
-
for (var i = 0; i < roleNames.length; i++) {
|
|
324
|
-
if (typeof roleNames[i] === "string" && Object.prototype.hasOwnProperty.call(roleTable, roleNames[i])) {
|
|
325
|
-
_expandOne(roleNames[i], roleTable, visited, out);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
return out;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function _actorScopes(actor) {
|
|
332
|
-
if (!actor || typeof actor !== "object") return [];
|
|
333
|
-
if (Array.isArray(actor.scopes)) return actor.scopes;
|
|
334
|
-
if (Array.isArray(actor.roles)) return expand(actor.roles);
|
|
335
|
-
return [];
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function check(actor, requiredScope) {
|
|
339
|
-
var scopes = _actorScopes(actor);
|
|
340
|
-
for (var i = 0; i < scopes.length; i++) {
|
|
341
|
-
if (typeof scopes[i] === "string" && match(scopes[i], requiredScope)) return true;
|
|
342
|
-
}
|
|
343
|
-
return false;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function checkAll(actor, requiredScopes) {
|
|
347
|
-
if (!Array.isArray(requiredScopes)) return false;
|
|
348
|
-
for (var i = 0; i < requiredScopes.length; i++) {
|
|
349
|
-
if (!check(actor, requiredScopes[i])) return false;
|
|
350
|
-
}
|
|
351
|
-
return requiredScopes.length > 0;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function checkAny(actor, requiredScopes) {
|
|
355
|
-
if (!Array.isArray(requiredScopes)) return false;
|
|
356
|
-
for (var i = 0; i < requiredScopes.length; i++) {
|
|
357
|
-
if (check(actor, requiredScopes[i])) return true;
|
|
358
|
-
}
|
|
359
|
-
return false;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// Middleware factory. `mode` is "single" | "all" | "any"; `requested`
|
|
363
|
-
// is the scope or scope list. Throw at registration time on bad shape.
|
|
364
|
-
function _middleware(mode, requested, mwOpts) {
|
|
365
|
-
if (mode === "single") {
|
|
366
|
-
_validateScopePattern(requested, "permissions.require");
|
|
367
|
-
} else {
|
|
368
|
-
if (!Array.isArray(requested) || requested.length === 0) {
|
|
369
|
-
throw _err("BAD_OPT", "permissions." + (mode === "all" ? "requireAll" : "requireAny") +
|
|
370
|
-
": scopes must be a non-empty array");
|
|
371
|
-
}
|
|
372
|
-
for (var i = 0; i < requested.length; i++) {
|
|
373
|
-
_validateScopePattern(requested[i], "permissions." + (mode === "all" ? "requireAll" : "requireAny"));
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// Per-route MFA enforcement opts: { requireMfa, mfaWindowMs }.
|
|
378
|
-
// When set, the middleware blocks unless the actor's mfaAuthenticated
|
|
379
|
-
// flag is truthy AND (when mfaWindowMs is set) actor.mfaAt is fresher
|
|
380
|
-
// than (now - mfaWindowMs). The actor signal is operator-set: after
|
|
381
|
-
// a successful TOTP / passkey step-up, the route handler stamps
|
|
382
|
-
// req.user.mfaAuthenticated = true and req.user.mfaAt = Date.now().
|
|
383
|
-
mwOpts = mwOpts || {};
|
|
384
|
-
var routeRequireMfa = mwOpts.requireMfa === true;
|
|
385
|
-
var routeMfaWindowMs = null;
|
|
386
|
-
if (mwOpts.mfaWindowMs !== undefined && mwOpts.mfaWindowMs !== null) {
|
|
387
|
-
if (typeof mwOpts.mfaWindowMs !== "number" || !isFinite(mwOpts.mfaWindowMs) || mwOpts.mfaWindowMs <= 0) {
|
|
388
|
-
throw _err("BAD_OPT", "permissions middleware: mfaWindowMs must be a positive finite number");
|
|
389
|
-
}
|
|
390
|
-
routeMfaWindowMs = mwOpts.mfaWindowMs;
|
|
391
|
-
}
|
|
392
|
-
// ABAC context provider — operator-supplied function (req)→object.
|
|
393
|
-
// The function runs once per request, AFTER scope/MFA pass, BEFORE
|
|
394
|
-
// the policy predicate. Whatever it returns is passed to the
|
|
395
|
-
// policy as `context`. Async functions are awaited.
|
|
396
|
-
var contextProvider = mwOpts.context;
|
|
397
|
-
if (contextProvider !== undefined && typeof contextProvider !== "function") {
|
|
398
|
-
throw _err("BAD_OPT", "permissions middleware: context must be a function (req) -> object");
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
return async function permissionsMiddleware(req, res, next) {
|
|
402
|
-
var actor = resolver(req);
|
|
403
|
-
if (!actor) {
|
|
404
|
-
// Diagnostic: the most common cause of a null actor is that
|
|
405
|
-
// attachUser/auth wasn't mounted before this middleware, so
|
|
406
|
-
// req.user / req.apiKey are still undefined. Emit a hint —
|
|
407
|
-
// operators tracing a 401 here see exactly what to check first.
|
|
408
|
-
var hint = (req && (req.user || req.apiKey))
|
|
409
|
-
? "actor present on req but resolver returned null — check resolver implementation"
|
|
410
|
-
: "no req.user or req.apiKey — confirm attachUser / apiKey-verify middleware is mounted before perms.require()";
|
|
411
|
-
_emitEvent("permissions.missing_actor", 1,
|
|
412
|
-
{ requested: _labelize(requested) });
|
|
413
|
-
_auditEmit("permissions.missing_actor", {
|
|
414
|
-
actor: _actorAuditShape(null, req),
|
|
415
|
-
resource: { kind: "permission", id: _labelize(requested) },
|
|
416
|
-
outcome: "failure",
|
|
417
|
-
reason: "no-actor",
|
|
418
|
-
metadata: { hint: hint },
|
|
419
|
-
});
|
|
420
|
-
return responder(req, res, missingActorStatus, {
|
|
421
|
-
error: "missing_actor",
|
|
422
|
-
status: missingActorStatus,
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
var ok;
|
|
427
|
-
if (mode === "single") ok = check(actor, requested);
|
|
428
|
-
else if (mode === "all") ok = checkAll(actor, requested);
|
|
429
|
-
else ok = checkAny(actor, requested);
|
|
430
|
-
|
|
431
|
-
if (!ok) {
|
|
432
|
-
_emitEvent("permissions.check", 1,
|
|
433
|
-
{ outcome: "deny", requested: _labelize(requested), mode: mode });
|
|
434
|
-
_auditEmit("permissions.check.deny", {
|
|
435
|
-
actor: _actorAuditShape(actor, req),
|
|
436
|
-
resource: { kind: "permission", id: _labelize(requested) },
|
|
437
|
-
outcome: "failure",
|
|
438
|
-
reason: "forbidden",
|
|
439
|
-
metadata: { mode: mode },
|
|
440
|
-
});
|
|
441
|
-
return responder(req, res, denyStatus, {
|
|
442
|
-
error: "forbidden",
|
|
443
|
-
status: denyStatus,
|
|
444
|
-
requested: _labelize(requested),
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// MFA enforcement gate. Two sources of "this needs MFA":
|
|
449
|
-
// 1. Per-route opt: perms.require("scope", { requireMfa: true })
|
|
450
|
-
// 2. Per-role flag: a role spec with requireMfa:true that
|
|
451
|
-
// contributes to satisfying the requested scope
|
|
452
|
-
// Either source enabling MFA forces the gate. mfaWindowMs (per-route
|
|
453
|
-
// OR per-role, route wins on conflict) bounds freshness — without
|
|
454
|
-
// it, ANY past MFA stamp counts (which is too permissive for high-
|
|
455
|
-
// value routes; operators set a window like C.TIME.minutes(15)).
|
|
456
|
-
var enforceMfa = routeRequireMfa;
|
|
457
|
-
var enforceWindowMs = routeMfaWindowMs;
|
|
458
|
-
if (!enforceMfa) {
|
|
459
|
-
// Walk the actor's roles and check whether any role with
|
|
460
|
-
// requireMfa=true contributes a permission that matches the
|
|
461
|
-
// requested scope. If so, MFA is required regardless of the
|
|
462
|
-
// route-level opt.
|
|
463
|
-
var actorRoles = Array.isArray(actor.roles) ? actor.roles : [];
|
|
464
|
-
for (var ri = 0; ri < actorRoles.length; ri++) {
|
|
465
|
-
var rname = actorRoles[ri];
|
|
466
|
-
if (typeof rname !== "string") continue;
|
|
467
|
-
var rspec = roleTable[rname];
|
|
468
|
-
if (!rspec || !rspec.requireMfa) continue;
|
|
469
|
-
// Cheap match: if the role grants any scope that satisfies the
|
|
470
|
-
// requested scope (single mode) or any of the requested
|
|
471
|
-
// (all/any modes), MFA is required for this route.
|
|
472
|
-
var visited = new Set();
|
|
473
|
-
var roleScopes = [];
|
|
474
|
-
_expandOne(rname, roleTable, visited, roleScopes);
|
|
475
|
-
var roleMatches = false;
|
|
476
|
-
var requestedList = mode === "single" ? [requested] : requested;
|
|
477
|
-
outer: for (var rj = 0; rj < roleScopes.length; rj++) {
|
|
478
|
-
for (var rk = 0; rk < requestedList.length; rk++) {
|
|
479
|
-
if (match(roleScopes[rj], requestedList[rk])) {
|
|
480
|
-
roleMatches = true; break outer;
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
if (roleMatches) {
|
|
485
|
-
enforceMfa = true;
|
|
486
|
-
if (enforceWindowMs === null && rspec.mfaWindowMs !== null) {
|
|
487
|
-
enforceWindowMs = rspec.mfaWindowMs;
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
if (enforceMfa) {
|
|
494
|
-
var mfaOk = actor.mfaAuthenticated === true;
|
|
495
|
-
if (mfaOk && enforceWindowMs !== null) {
|
|
496
|
-
var mfaAt = typeof actor.mfaAt === "number" ? actor.mfaAt : 0;
|
|
497
|
-
if (Date.now() - mfaAt > enforceWindowMs) {
|
|
498
|
-
mfaOk = false;
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
if (!mfaOk) {
|
|
502
|
-
_emitEvent("permissions.mfa_required", 1,
|
|
503
|
-
{ requested: _labelize(requested), mode: mode });
|
|
504
|
-
_auditEmit("permissions.mfa.required", {
|
|
505
|
-
actor: _actorAuditShape(actor, req),
|
|
506
|
-
resource: { kind: "permission", id: _labelize(requested) },
|
|
507
|
-
outcome: "denied",
|
|
508
|
-
reason: "mfa-required",
|
|
509
|
-
metadata: { mode: mode, windowMs: enforceWindowMs },
|
|
510
|
-
});
|
|
511
|
-
return responder(req, res, denyStatus, {
|
|
512
|
-
error: "mfa_required",
|
|
513
|
-
status: denyStatus,
|
|
514
|
-
requested: _labelize(requested),
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// ABAC layer fires for every requested scope that has a
|
|
520
|
-
// registered policy predicate. Single-mode evaluates the one
|
|
521
|
-
// scope; requireAll evaluates each scope's policy (every must
|
|
522
|
-
// pass); requireAny evaluates only the policies on scopes the
|
|
523
|
-
// actor's RBAC layer satisfied (so a failing policy on a scope
|
|
524
|
-
// the actor doesn't even hold doesn't leak the policy's
|
|
525
|
-
// existence). Each predicate failure short-circuits with a
|
|
526
|
-
// policy.deny audit row naming the failing scope.
|
|
527
|
-
var policyTargets = [];
|
|
528
|
-
if (mode === "single" && _findPolicy(requested)) {
|
|
529
|
-
policyTargets.push(requested);
|
|
530
|
-
} else if (mode === "all" || mode === "any") {
|
|
531
|
-
for (var pi = 0; pi < requested.length; pi++) {
|
|
532
|
-
if (_findPolicy(requested[pi])) {
|
|
533
|
-
if (mode === "any" && !check(actor, requested[pi])) continue;
|
|
534
|
-
policyTargets.push(requested[pi]);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
if (policyTargets.length > 0) {
|
|
539
|
-
var policyContext = null;
|
|
540
|
-
if (contextProvider) {
|
|
541
|
-
try {
|
|
542
|
-
policyContext = await contextProvider(req);
|
|
543
|
-
} catch (e) {
|
|
544
|
-
_emitEvent("permissions.policy_context_error", 1,
|
|
545
|
-
{ requested: _labelize(requested) });
|
|
546
|
-
_auditEmit("permissions.policy.error", {
|
|
547
|
-
actor: _actorAuditShape(actor, req),
|
|
548
|
-
resource: { kind: "permission", id: _labelize(requested) },
|
|
549
|
-
outcome: "failure",
|
|
550
|
-
reason: "context-provider-threw",
|
|
551
|
-
metadata: { error: (e && e.message) || String(e), mode: mode },
|
|
552
|
-
});
|
|
553
|
-
return responder(req, res, denyStatus, {
|
|
554
|
-
error: "policy_context_error",
|
|
555
|
-
status: denyStatus,
|
|
556
|
-
requested: _labelize(requested),
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
for (var pti = 0; pti < policyTargets.length; pti++) {
|
|
561
|
-
var thisScope = policyTargets[pti];
|
|
562
|
-
var pred = _findPolicy(thisScope);
|
|
563
|
-
var verdict;
|
|
564
|
-
try {
|
|
565
|
-
verdict = await pred(actor, policyContext);
|
|
566
|
-
} catch (e2) {
|
|
567
|
-
_emitEvent("permissions.policy_error", 1, { requested: thisScope });
|
|
568
|
-
_auditEmit("permissions.policy.error", {
|
|
569
|
-
actor: _actorAuditShape(actor, req),
|
|
570
|
-
resource: { kind: "permission", id: thisScope },
|
|
571
|
-
outcome: "failure",
|
|
572
|
-
reason: "predicate-threw",
|
|
573
|
-
metadata: { error: (e2 && e2.message) || String(e2), mode: mode },
|
|
574
|
-
});
|
|
575
|
-
return responder(req, res, denyStatus, {
|
|
576
|
-
error: "policy_error",
|
|
577
|
-
status: denyStatus,
|
|
578
|
-
requested: thisScope,
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
if (verdict !== true) {
|
|
582
|
-
_emitEvent("permissions.policy_denied", 1, { requested: thisScope });
|
|
583
|
-
_auditEmit("permissions.policy.deny", {
|
|
584
|
-
actor: _actorAuditShape(actor, req),
|
|
585
|
-
resource: { kind: "permission", id: thisScope },
|
|
586
|
-
outcome: "failure",
|
|
587
|
-
reason: "policy-predicate-returned-falsy",
|
|
588
|
-
metadata: { mode: mode, scopeIndex: pti },
|
|
589
|
-
});
|
|
590
|
-
return responder(req, res, denyStatus, {
|
|
591
|
-
error: "policy_denied",
|
|
592
|
-
status: denyStatus,
|
|
593
|
-
requested: thisScope,
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
_emitEvent("permissions.check", 1,
|
|
600
|
-
{ outcome: "success", mode: mode });
|
|
601
|
-
_auditEmit("permissions.check.success", {
|
|
602
|
-
actor: _actorAuditShape(actor, req),
|
|
603
|
-
resource: { kind: "permission", id: _labelize(requested) },
|
|
604
|
-
outcome: "success",
|
|
605
|
-
metadata: { mode: mode, mfaEnforced: enforceMfa },
|
|
606
|
-
});
|
|
607
|
-
next();
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
// dbRoleFor — walk the actor's roles in order and return the first
|
|
612
|
-
// declared dbRole. Composes with b.middleware.dbRoleFor so a single
|
|
613
|
-
// RBAC table drives both authorization scopes and request-time DB
|
|
614
|
-
// role binding.
|
|
615
|
-
//
|
|
616
|
-
// The arg can be the request (default resolver pulls actor from
|
|
617
|
-
// req.user / req.apiKey) OR an actor object directly. Returns null if
|
|
618
|
-
// no actor is found OR the actor's roles don't include any with a
|
|
619
|
-
// declared dbRole.
|
|
620
|
-
//
|
|
621
|
-
// Lookup order: extends are walked depth-first so a child role that
|
|
622
|
-
// overrides dbRole takes precedence over its parent. When multiple
|
|
623
|
-
// top-level roles are listed, the first wins (operators wanting a
|
|
624
|
-
// priority order should list more-specific roles first).
|
|
625
|
-
function dbRoleFor(reqOrActor) {
|
|
626
|
-
var actor = reqOrActor;
|
|
627
|
-
// Heuristic: a request shape carries headers / url; resolve through
|
|
628
|
-
// the configured resolver. An actor shape has roles / scopes
|
|
629
|
-
// directly.
|
|
630
|
-
if (actor && (actor.headers || actor.url || actor.method)) {
|
|
631
|
-
actor = resolver(actor);
|
|
632
|
-
}
|
|
633
|
-
if (!actor || typeof actor !== "object") return null;
|
|
634
|
-
var roleNames = Array.isArray(actor.roles) ? actor.roles : null;
|
|
635
|
-
if (!roleNames || roleNames.length === 0) return null;
|
|
636
|
-
// Walk the same DFS order expand() uses so the first-seen dbRole
|
|
637
|
-
// is consistent with how scopes are inherited.
|
|
638
|
-
var visited = new Set();
|
|
639
|
-
for (var i = 0; i < roleNames.length; i++) {
|
|
640
|
-
var name = roleNames[i];
|
|
641
|
-
if (typeof name !== "string") continue;
|
|
642
|
-
if (!Object.prototype.hasOwnProperty.call(roleTable, name)) continue;
|
|
643
|
-
var found = _findDbRole(name, roleTable, visited);
|
|
644
|
-
if (found) return found;
|
|
645
|
-
}
|
|
646
|
-
return null;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
return {
|
|
650
|
-
require: function (scope, mwOpts) { return _middleware("single", scope, mwOpts); },
|
|
651
|
-
requireAll: function (scopes, mwOpts) { return _middleware("all", scopes, mwOpts); },
|
|
652
|
-
requireAny: function (scopes, mwOpts) { return _middleware("any", scopes, mwOpts); },
|
|
653
|
-
policy: policy,
|
|
654
|
-
check: check,
|
|
655
|
-
checkAll: checkAll,
|
|
656
|
-
checkAny: checkAny,
|
|
657
|
-
expand: expand,
|
|
658
|
-
dbRoleFor: dbRoleFor,
|
|
659
|
-
has: function (name) { return Object.prototype.hasOwnProperty.call(roleTable, name); },
|
|
660
|
-
roles: Object.freeze(Object.keys(roleTable)),
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function _findDbRole(roleName, table, visited) {
|
|
665
|
-
if (visited.has(roleName)) return null;
|
|
666
|
-
visited.add(roleName);
|
|
667
|
-
var spec = table[roleName];
|
|
668
|
-
if (!spec) return null;
|
|
669
|
-
// Child overrides parent — check this role's own dbRole first, then
|
|
670
|
-
// recurse into extends.
|
|
671
|
-
if (spec.dbRole) return spec.dbRole;
|
|
672
|
-
for (var i = 0; i < spec.extends.length; i++) {
|
|
673
|
-
var found = _findDbRole(spec.extends[i], table, visited);
|
|
674
|
-
if (found) return found;
|
|
675
|
-
}
|
|
676
|
-
return null;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
// ---- Helpers ----
|
|
680
|
-
|
|
681
|
-
function _labelize(requested) {
|
|
682
|
-
return Array.isArray(requested) ? requested.join(",") : String(requested);
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
function _actorAuditShape(actor, req) {
|
|
686
|
-
// Pull the 5 W's (WHO/WHERE/HOW) from the request, then layer the
|
|
687
|
-
// resolver-supplied actor identity on top so userId/roles/scopes
|
|
688
|
-
// aren't lost when the request itself doesn't carry them.
|
|
689
|
-
var base = requestHelpers.extractActorContext(req);
|
|
690
|
-
if (actor) {
|
|
691
|
-
if (actor.userId) base.userId = actor.userId;
|
|
692
|
-
if (Array.isArray(actor.roles)) base.roles = actor.roles.slice();
|
|
693
|
-
if (Array.isArray(actor.scopes)) base.scopes = actor.scopes.slice();
|
|
694
|
-
}
|
|
695
|
-
return base;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
function _defaultResponder(req, res, status, info) {
|
|
699
|
-
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
700
|
-
res.end(JSON.stringify(info));
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
module.exports = {
|
|
704
|
-
create: create,
|
|
705
|
-
match: match,
|
|
706
|
-
PermissionsError: PermissionsError,
|
|
707
|
-
DEFAULTS: DEFAULTS,
|
|
708
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.permissions — RBAC primitive.
|
|
4
|
+
*
|
|
5
|
+
* var perms = b.permissions.create({
|
|
6
|
+
* roles: {
|
|
7
|
+
* admin: { extends: ["editor"], permissions: ["users:delete"] },
|
|
8
|
+
* editor: ["users:read", "users:write", "posts:*"],
|
|
9
|
+
* viewer: ["*:read"],
|
|
10
|
+
* },
|
|
11
|
+
* audit: b.audit, // optional
|
|
12
|
+
* });
|
|
13
|
+
*
|
|
14
|
+
* router.delete("/users/:id",
|
|
15
|
+
* authMiddleware, // populates req.user / req.apiKey
|
|
16
|
+
* perms.require("users:delete"),
|
|
17
|
+
* deleteUserHandler);
|
|
18
|
+
*
|
|
19
|
+
* The default resolver chain reads the actor from the request:
|
|
20
|
+
*
|
|
21
|
+
* req.apiKey.scopes → { scopes: [...] } (b.apiKey.verify output)
|
|
22
|
+
* req.user.scopes → { scopes: [...] } (operator-set)
|
|
23
|
+
* req.user.roles → { roles: [...] } (operator-set)
|
|
24
|
+
*
|
|
25
|
+
* Operators with non-default request shapes pass `resolver` to create().
|
|
26
|
+
*
|
|
27
|
+
* Wildcard semantics (b.permissions.match):
|
|
28
|
+
* "*" matches any scope (greedy)
|
|
29
|
+
* "users:*" matches "users:read", "users:read:detail", etc. (trailing * is greedy)
|
|
30
|
+
* "*:read" matches "users:read", "posts:read"
|
|
31
|
+
* "users:*:read" matches "users:foo:read" (per-segment *)
|
|
32
|
+
* "users:read" matches "users:read" only — no implicit sub-resource grant
|
|
33
|
+
*
|
|
34
|
+
* Validation policy:
|
|
35
|
+
*
|
|
36
|
+
* - create() role table / scope formats → throw at app init
|
|
37
|
+
* - require(scope) registration arg → throw at route declaration
|
|
38
|
+
* - check(actor, scope) bad actor → return false (tolerant read)
|
|
39
|
+
* - resolver returns null in middleware → 401 (missingActorStatus)
|
|
40
|
+
* - actor lacks scope in middleware → 403 (denyStatus)
|
|
41
|
+
* - audit/observability emit failures → drop silent (hot-path sink)
|
|
42
|
+
*
|
|
43
|
+
* Audit defaults follow the framework's security-defaults stance
|
|
44
|
+
* default: `auditFailures: true`
|
|
45
|
+
* (deny is a security signal), `auditSuccess: false` (per-request noise).
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
var C = require("./constants");
|
|
49
|
+
var lazyRequire = require("./lazy-require");
|
|
50
|
+
var requestHelpers = require("./request-helpers");
|
|
51
|
+
var safeSql = require("./safe-sql");
|
|
52
|
+
var validateOpts = require("./validate-opts");
|
|
53
|
+
var { PermissionsError } = require("./framework-error");
|
|
54
|
+
|
|
55
|
+
var _err = PermissionsError.factory;
|
|
56
|
+
|
|
57
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
58
|
+
|
|
59
|
+
function _emitEvent(n, v, l) { observability().safeEvent(n, v, l || {}); }
|
|
60
|
+
|
|
61
|
+
// Lowercase tokens, digits, dash, underscore, and `*` allowed per
|
|
62
|
+
// segment. Scope format is segments separated by `:`.
|
|
63
|
+
var SCOPE_RE = /^[a-z0-9_*-]+(:[a-z0-9_*-]+)*$/;
|
|
64
|
+
// Bound the regex engine on operator-supplied scope strings. 256 chars
|
|
65
|
+
// holds any realistic real-world scope (typical scopes run 8-32 chars);
|
|
66
|
+
// rejecting longer keeps the regex linear regardless of input shape.
|
|
67
|
+
var SCOPE_MAX_LENGTH = C.BYTES.bytes(256);
|
|
68
|
+
|
|
69
|
+
// Audit defaults: BOTH success and failure default ON for permissions.
|
|
70
|
+
// Unlike api-key.verify (which is gate-keeping for a downstream action
|
|
71
|
+
// the application separately audits), a permissions.check IS the
|
|
72
|
+
// authorization decision — there's no further-downstream audit event.
|
|
73
|
+
// "user X granted users:delete at time T" is exactly what compliance
|
|
74
|
+
// auditors ask for. Operators with extreme volume opt out via
|
|
75
|
+
// auditSuccess: false; failures remain on regardless.
|
|
76
|
+
var DEFAULTS = Object.freeze({
|
|
77
|
+
auditFailures: true,
|
|
78
|
+
auditSuccess: true,
|
|
79
|
+
denyStatus: 403,
|
|
80
|
+
missingActorStatus: 401,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---- Wildcard matcher ----
|
|
84
|
+
|
|
85
|
+
function match(granted, required) {
|
|
86
|
+
if (typeof granted !== "string" || typeof required !== "string") return false;
|
|
87
|
+
if (granted.length === 0 || required.length === 0) return false;
|
|
88
|
+
var gParts = granted.split(":");
|
|
89
|
+
var rParts = required.split(":");
|
|
90
|
+
for (var i = 0; i < gParts.length; i++) {
|
|
91
|
+
var g = gParts[i];
|
|
92
|
+
if (g === "*") {
|
|
93
|
+
// Trailing * is greedy — matches the rest of required.
|
|
94
|
+
if (i === gParts.length - 1) return true;
|
|
95
|
+
// Per-segment * — matches THIS segment of required (any value),
|
|
96
|
+
// continue to next segment. Required must have a segment here.
|
|
97
|
+
if (i >= rParts.length) return false;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (i >= rParts.length) return false; // granted is more specific than required
|
|
101
|
+
if (g !== rParts[i]) return false;
|
|
102
|
+
}
|
|
103
|
+
// Reached end of granted without wildcard. Lengths must match exactly
|
|
104
|
+
// (no implicit sub-resource grant).
|
|
105
|
+
return rParts.length === gParts.length;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- Role table validation + expansion ----
|
|
109
|
+
|
|
110
|
+
function _validateScopePattern(scope, ctx) {
|
|
111
|
+
if (typeof scope !== "string" || scope.length === 0) {
|
|
112
|
+
throw _err("BAD_SCOPE", ctx + ": scope must be a non-empty string, got " + typeof scope);
|
|
113
|
+
}
|
|
114
|
+
// Length cap before the regex test — bound the engine on hostile
|
|
115
|
+
// input lengths even though SCOPE_RE is anchored.
|
|
116
|
+
if (scope.length > SCOPE_MAX_LENGTH || !SCOPE_RE.test(scope)) {
|
|
117
|
+
throw _err("BAD_SCOPE", ctx + ": scope '" + scope +
|
|
118
|
+
"' is empty, too long, or doesn't match " + SCOPE_RE +
|
|
119
|
+
" (lowercase tokens with optional `*`)");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _normalizeRoleEntry(name, entry) {
|
|
124
|
+
if (Array.isArray(entry)) {
|
|
125
|
+
return { extends: [], permissions: entry.slice(), dbRole: null,
|
|
126
|
+
requireMfa: false, mfaWindowMs: null };
|
|
127
|
+
}
|
|
128
|
+
if (entry && typeof entry === "object") {
|
|
129
|
+
var ext = entry.extends || [];
|
|
130
|
+
var perms = entry.permissions || [];
|
|
131
|
+
if (!Array.isArray(ext)) {
|
|
132
|
+
throw _err("BAD_ROLE", "role '" + name + "': extends must be an array of role names");
|
|
133
|
+
}
|
|
134
|
+
if (!Array.isArray(perms)) {
|
|
135
|
+
throw _err("BAD_ROLE", "role '" + name + "': permissions must be an array of scope strings");
|
|
136
|
+
}
|
|
137
|
+
var dbRole = null;
|
|
138
|
+
if (entry.dbRole !== undefined && entry.dbRole !== null) {
|
|
139
|
+
if (typeof entry.dbRole !== "string" || entry.dbRole.length === 0) {
|
|
140
|
+
throw _err("BAD_ROLE",
|
|
141
|
+
"role '" + name + "': dbRole must be a non-empty string");
|
|
142
|
+
}
|
|
143
|
+
// dbRole feeds straight into externalDb backend pick + the
|
|
144
|
+
// dbRoleFor middleware's identifier check; validate at create()
|
|
145
|
+
// time so a typo surfaces at boot, not on the first request.
|
|
146
|
+
try {
|
|
147
|
+
safeSql.validateIdentifier(entry.dbRole, { allowReserved: false });
|
|
148
|
+
} catch (e) {
|
|
149
|
+
throw _err("BAD_ROLE",
|
|
150
|
+
"role '" + name + "': dbRole '" + entry.dbRole +
|
|
151
|
+
"' is not a valid SQL identifier: " + ((e && e.message) || String(e)));
|
|
152
|
+
}
|
|
153
|
+
dbRole = entry.dbRole;
|
|
154
|
+
}
|
|
155
|
+
var requireMfa = entry.requireMfa === true;
|
|
156
|
+
var mfaWindowMs = null;
|
|
157
|
+
if (entry.mfaWindowMs !== undefined && entry.mfaWindowMs !== null) {
|
|
158
|
+
if (typeof entry.mfaWindowMs !== "number" || !isFinite(entry.mfaWindowMs) || entry.mfaWindowMs <= 0) {
|
|
159
|
+
throw _err("BAD_ROLE",
|
|
160
|
+
"role '" + name + "': mfaWindowMs must be a positive finite number");
|
|
161
|
+
}
|
|
162
|
+
mfaWindowMs = entry.mfaWindowMs;
|
|
163
|
+
}
|
|
164
|
+
return { extends: ext.slice(), permissions: perms.slice(), dbRole: dbRole,
|
|
165
|
+
requireMfa: requireMfa, mfaWindowMs: mfaWindowMs };
|
|
166
|
+
}
|
|
167
|
+
throw _err("BAD_ROLE", "role '" + name + "' must be an array of scopes or { extends?, permissions, dbRole?, requireMfa?, mfaWindowMs? }");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _validateRoles(roles) {
|
|
171
|
+
if (!roles || typeof roles !== "object" || Array.isArray(roles)) {
|
|
172
|
+
throw _err("BAD_OPT", "permissions.create: roles must be an object map of name → spec");
|
|
173
|
+
}
|
|
174
|
+
var names = Object.keys(roles);
|
|
175
|
+
if (names.length === 0) {
|
|
176
|
+
throw _err("BAD_OPT", "permissions.create: roles map must have at least one role");
|
|
177
|
+
}
|
|
178
|
+
var normalized = {};
|
|
179
|
+
for (var i = 0; i < names.length; i++) {
|
|
180
|
+
var name = names[i];
|
|
181
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
182
|
+
throw _err("BAD_ROLE", "role name must be a non-empty string");
|
|
183
|
+
}
|
|
184
|
+
var spec = _normalizeRoleEntry(name, roles[name]);
|
|
185
|
+
for (var j = 0; j < spec.permissions.length; j++) {
|
|
186
|
+
_validateScopePattern(spec.permissions[j], "role '" + name + "'");
|
|
187
|
+
}
|
|
188
|
+
for (var k = 0; k < spec.extends.length; k++) {
|
|
189
|
+
if (typeof spec.extends[k] !== "string" || spec.extends[k].length === 0) {
|
|
190
|
+
throw _err("BAD_ROLE", "role '" + name + "': extends entry must be a non-empty string");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
normalized[name] = spec;
|
|
194
|
+
}
|
|
195
|
+
// Check extends references resolve to known roles
|
|
196
|
+
for (var n = 0; n < names.length; n++) {
|
|
197
|
+
var spec2 = normalized[names[n]];
|
|
198
|
+
for (var m = 0; m < spec2.extends.length; m++) {
|
|
199
|
+
if (!Object.prototype.hasOwnProperty.call(normalized, spec2.extends[m])) {
|
|
200
|
+
throw _err("UNKNOWN_ROLE", "role '" + names[n] + "': extends references unknown role '" +
|
|
201
|
+
spec2.extends[m] + "'");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Cycle detection via DFS
|
|
206
|
+
for (var p = 0; p < names.length; p++) {
|
|
207
|
+
_detectCycle(names[p], normalized, []);
|
|
208
|
+
}
|
|
209
|
+
return normalized;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function _detectCycle(roleName, table, stack) {
|
|
213
|
+
if (stack.indexOf(roleName) !== -1) {
|
|
214
|
+
throw _err("CYCLE", "permissions.create: cycle in extends chain: " +
|
|
215
|
+
stack.concat([roleName]).join(" → "));
|
|
216
|
+
}
|
|
217
|
+
var spec = table[roleName];
|
|
218
|
+
for (var i = 0; i < spec.extends.length; i++) {
|
|
219
|
+
_detectCycle(spec.extends[i], table, stack.concat([roleName]));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function _expandOne(roleName, table, visited, out) {
|
|
224
|
+
if (visited.has(roleName)) return;
|
|
225
|
+
visited.add(roleName);
|
|
226
|
+
var spec = table[roleName];
|
|
227
|
+
if (!spec) return;
|
|
228
|
+
for (var i = 0; i < spec.extends.length; i++) {
|
|
229
|
+
_expandOne(spec.extends[i], table, visited, out);
|
|
230
|
+
}
|
|
231
|
+
for (var j = 0; j < spec.permissions.length; j++) {
|
|
232
|
+
if (out.indexOf(spec.permissions[j]) === -1) out.push(spec.permissions[j]);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- Default resolver ----
|
|
237
|
+
|
|
238
|
+
function _defaultResolver(req) {
|
|
239
|
+
if (!req || typeof req !== "object") return null;
|
|
240
|
+
if (req.apiKey && Array.isArray(req.apiKey.scopes)) return { scopes: req.apiKey.scopes };
|
|
241
|
+
if (req.user && Array.isArray(req.user.scopes)) return { scopes: req.user.scopes };
|
|
242
|
+
if (req.user && Array.isArray(req.user.roles)) return { roles: req.user.roles };
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---- Validation: create opts ----
|
|
247
|
+
|
|
248
|
+
function _validateCreateOpts(opts) {
|
|
249
|
+
validateOpts.requireObject(opts, "permissions.create", PermissionsError);
|
|
250
|
+
validateOpts.optionalFunction(opts.resolver, "permissions.create: resolver", PermissionsError);
|
|
251
|
+
validateOpts.auditShape(opts.audit, "permissions.create", PermissionsError);
|
|
252
|
+
validateOpts.optionalBoolean(opts.auditFailures, "permissions.create: auditFailures", PermissionsError);
|
|
253
|
+
validateOpts.optionalBoolean(opts.auditSuccess, "permissions.create: auditSuccess", PermissionsError);
|
|
254
|
+
if (opts.denyStatus !== undefined &&
|
|
255
|
+
(typeof opts.denyStatus !== "number" || !isFinite(opts.denyStatus) || opts.denyStatus < 100 || opts.denyStatus > 599)) {
|
|
256
|
+
throw _err("BAD_OPT", "permissions.create: denyStatus must be an HTTP status code (100-599)");
|
|
257
|
+
}
|
|
258
|
+
if (opts.missingActorStatus !== undefined &&
|
|
259
|
+
(typeof opts.missingActorStatus !== "number" || !isFinite(opts.missingActorStatus) ||
|
|
260
|
+
opts.missingActorStatus < 100 || opts.missingActorStatus > 599)) {
|
|
261
|
+
throw _err("BAD_OPT", "permissions.create: missingActorStatus must be an HTTP status code (100-599)");
|
|
262
|
+
}
|
|
263
|
+
validateOpts.optionalFunction(opts.responder, "permissions.create: responder", PermissionsError);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- Registry ----
|
|
267
|
+
|
|
268
|
+
function create(opts) {
|
|
269
|
+
opts = opts || {};
|
|
270
|
+
validateOpts(opts, [
|
|
271
|
+
"roles", "resolver", "audit", "auditFailures", "auditSuccess",
|
|
272
|
+
"denyStatus", "missingActorStatus", "responder",
|
|
273
|
+
], "permissions");
|
|
274
|
+
_validateCreateOpts(opts);
|
|
275
|
+
var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
|
|
276
|
+
var roleTable = _validateRoles(opts.roles);
|
|
277
|
+
var resolver = opts.resolver || _defaultResolver;
|
|
278
|
+
var audit = opts.audit || null;
|
|
279
|
+
var auditFailures = cfg.auditFailures;
|
|
280
|
+
var auditSuccess = cfg.auditSuccess;
|
|
281
|
+
var denyStatus = cfg.denyStatus;
|
|
282
|
+
var missingActorStatus = cfg.missingActorStatus;
|
|
283
|
+
var responder = opts.responder || _defaultResponder;
|
|
284
|
+
|
|
285
|
+
// ABAC predicate registry. Each entry: scope-string → async predicate
|
|
286
|
+
// function (actor, context) → boolean. The middleware evaluates the
|
|
287
|
+
// predicate AFTER the RBAC scope check passes — so a route protected
|
|
288
|
+
// by `perms.require("orders.read")` first checks the actor has the
|
|
289
|
+
// orders:read scope, then (if the scope has a policy registered)
|
|
290
|
+
// evaluates the predicate with the actor + a per-request context
|
|
291
|
+
// built by the route's `context` middleware opt. ABAC + RBAC stack
|
|
292
|
+
// — a route needs to pass BOTH layers when both are configured.
|
|
293
|
+
var policies = {};
|
|
294
|
+
|
|
295
|
+
function policy(scope, predicate) {
|
|
296
|
+
_validateScopePattern(scope, "permissions.policy");
|
|
297
|
+
if (typeof predicate !== "function") {
|
|
298
|
+
throw _err("BAD_OPT", "permissions.policy: predicate must be a function (actor, context) -> bool");
|
|
299
|
+
}
|
|
300
|
+
if (policies[scope]) {
|
|
301
|
+
throw _err("DUPLICATE_POLICY", "permissions.policy: '" + scope + "' is already registered");
|
|
302
|
+
}
|
|
303
|
+
policies[scope] = predicate;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function _findPolicy(requestedScope) {
|
|
307
|
+
// Exact match wins; no wildcard expansion (a wildcard policy
|
|
308
|
+
// gating arbitrary scopes is too easy to misconfigure).
|
|
309
|
+
return policies[requestedScope] || null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
var _emitRaw = validateOpts.makeAuditEmitter(audit);
|
|
313
|
+
function _auditEmit(action, info) {
|
|
314
|
+
if (info && info.outcome === "success" && !auditSuccess) return;
|
|
315
|
+
if (info && info.outcome !== "success" && !auditFailures) return;
|
|
316
|
+
_emitRaw(action, info);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function expand(roleNames) {
|
|
320
|
+
if (!Array.isArray(roleNames)) return [];
|
|
321
|
+
var visited = new Set();
|
|
322
|
+
var out = [];
|
|
323
|
+
for (var i = 0; i < roleNames.length; i++) {
|
|
324
|
+
if (typeof roleNames[i] === "string" && Object.prototype.hasOwnProperty.call(roleTable, roleNames[i])) {
|
|
325
|
+
_expandOne(roleNames[i], roleTable, visited, out);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function _actorScopes(actor) {
|
|
332
|
+
if (!actor || typeof actor !== "object") return [];
|
|
333
|
+
if (Array.isArray(actor.scopes)) return actor.scopes;
|
|
334
|
+
if (Array.isArray(actor.roles)) return expand(actor.roles);
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function check(actor, requiredScope) {
|
|
339
|
+
var scopes = _actorScopes(actor);
|
|
340
|
+
for (var i = 0; i < scopes.length; i++) {
|
|
341
|
+
if (typeof scopes[i] === "string" && match(scopes[i], requiredScope)) return true;
|
|
342
|
+
}
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function checkAll(actor, requiredScopes) {
|
|
347
|
+
if (!Array.isArray(requiredScopes)) return false;
|
|
348
|
+
for (var i = 0; i < requiredScopes.length; i++) {
|
|
349
|
+
if (!check(actor, requiredScopes[i])) return false;
|
|
350
|
+
}
|
|
351
|
+
return requiredScopes.length > 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function checkAny(actor, requiredScopes) {
|
|
355
|
+
if (!Array.isArray(requiredScopes)) return false;
|
|
356
|
+
for (var i = 0; i < requiredScopes.length; i++) {
|
|
357
|
+
if (check(actor, requiredScopes[i])) return true;
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Middleware factory. `mode` is "single" | "all" | "any"; `requested`
|
|
363
|
+
// is the scope or scope list. Throw at registration time on bad shape.
|
|
364
|
+
function _middleware(mode, requested, mwOpts) {
|
|
365
|
+
if (mode === "single") {
|
|
366
|
+
_validateScopePattern(requested, "permissions.require");
|
|
367
|
+
} else {
|
|
368
|
+
if (!Array.isArray(requested) || requested.length === 0) {
|
|
369
|
+
throw _err("BAD_OPT", "permissions." + (mode === "all" ? "requireAll" : "requireAny") +
|
|
370
|
+
": scopes must be a non-empty array");
|
|
371
|
+
}
|
|
372
|
+
for (var i = 0; i < requested.length; i++) {
|
|
373
|
+
_validateScopePattern(requested[i], "permissions." + (mode === "all" ? "requireAll" : "requireAny"));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Per-route MFA enforcement opts: { requireMfa, mfaWindowMs }.
|
|
378
|
+
// When set, the middleware blocks unless the actor's mfaAuthenticated
|
|
379
|
+
// flag is truthy AND (when mfaWindowMs is set) actor.mfaAt is fresher
|
|
380
|
+
// than (now - mfaWindowMs). The actor signal is operator-set: after
|
|
381
|
+
// a successful TOTP / passkey step-up, the route handler stamps
|
|
382
|
+
// req.user.mfaAuthenticated = true and req.user.mfaAt = Date.now().
|
|
383
|
+
mwOpts = mwOpts || {};
|
|
384
|
+
var routeRequireMfa = mwOpts.requireMfa === true;
|
|
385
|
+
var routeMfaWindowMs = null;
|
|
386
|
+
if (mwOpts.mfaWindowMs !== undefined && mwOpts.mfaWindowMs !== null) {
|
|
387
|
+
if (typeof mwOpts.mfaWindowMs !== "number" || !isFinite(mwOpts.mfaWindowMs) || mwOpts.mfaWindowMs <= 0) {
|
|
388
|
+
throw _err("BAD_OPT", "permissions middleware: mfaWindowMs must be a positive finite number");
|
|
389
|
+
}
|
|
390
|
+
routeMfaWindowMs = mwOpts.mfaWindowMs;
|
|
391
|
+
}
|
|
392
|
+
// ABAC context provider — operator-supplied function (req)→object.
|
|
393
|
+
// The function runs once per request, AFTER scope/MFA pass, BEFORE
|
|
394
|
+
// the policy predicate. Whatever it returns is passed to the
|
|
395
|
+
// policy as `context`. Async functions are awaited.
|
|
396
|
+
var contextProvider = mwOpts.context;
|
|
397
|
+
if (contextProvider !== undefined && typeof contextProvider !== "function") {
|
|
398
|
+
throw _err("BAD_OPT", "permissions middleware: context must be a function (req) -> object");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return async function permissionsMiddleware(req, res, next) {
|
|
402
|
+
var actor = resolver(req);
|
|
403
|
+
if (!actor) {
|
|
404
|
+
// Diagnostic: the most common cause of a null actor is that
|
|
405
|
+
// attachUser/auth wasn't mounted before this middleware, so
|
|
406
|
+
// req.user / req.apiKey are still undefined. Emit a hint —
|
|
407
|
+
// operators tracing a 401 here see exactly what to check first.
|
|
408
|
+
var hint = (req && (req.user || req.apiKey))
|
|
409
|
+
? "actor present on req but resolver returned null — check resolver implementation"
|
|
410
|
+
: "no req.user or req.apiKey — confirm attachUser / apiKey-verify middleware is mounted before perms.require()";
|
|
411
|
+
_emitEvent("permissions.missing_actor", 1,
|
|
412
|
+
{ requested: _labelize(requested) });
|
|
413
|
+
_auditEmit("permissions.missing_actor", {
|
|
414
|
+
actor: _actorAuditShape(null, req),
|
|
415
|
+
resource: { kind: "permission", id: _labelize(requested) },
|
|
416
|
+
outcome: "failure",
|
|
417
|
+
reason: "no-actor",
|
|
418
|
+
metadata: { hint: hint },
|
|
419
|
+
});
|
|
420
|
+
return responder(req, res, missingActorStatus, {
|
|
421
|
+
error: "missing_actor",
|
|
422
|
+
status: missingActorStatus,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
var ok;
|
|
427
|
+
if (mode === "single") ok = check(actor, requested);
|
|
428
|
+
else if (mode === "all") ok = checkAll(actor, requested);
|
|
429
|
+
else ok = checkAny(actor, requested);
|
|
430
|
+
|
|
431
|
+
if (!ok) {
|
|
432
|
+
_emitEvent("permissions.check", 1,
|
|
433
|
+
{ outcome: "deny", requested: _labelize(requested), mode: mode });
|
|
434
|
+
_auditEmit("permissions.check.deny", {
|
|
435
|
+
actor: _actorAuditShape(actor, req),
|
|
436
|
+
resource: { kind: "permission", id: _labelize(requested) },
|
|
437
|
+
outcome: "failure",
|
|
438
|
+
reason: "forbidden",
|
|
439
|
+
metadata: { mode: mode },
|
|
440
|
+
});
|
|
441
|
+
return responder(req, res, denyStatus, {
|
|
442
|
+
error: "forbidden",
|
|
443
|
+
status: denyStatus,
|
|
444
|
+
requested: _labelize(requested),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// MFA enforcement gate. Two sources of "this needs MFA":
|
|
449
|
+
// 1. Per-route opt: perms.require("scope", { requireMfa: true })
|
|
450
|
+
// 2. Per-role flag: a role spec with requireMfa:true that
|
|
451
|
+
// contributes to satisfying the requested scope
|
|
452
|
+
// Either source enabling MFA forces the gate. mfaWindowMs (per-route
|
|
453
|
+
// OR per-role, route wins on conflict) bounds freshness — without
|
|
454
|
+
// it, ANY past MFA stamp counts (which is too permissive for high-
|
|
455
|
+
// value routes; operators set a window like C.TIME.minutes(15)).
|
|
456
|
+
var enforceMfa = routeRequireMfa;
|
|
457
|
+
var enforceWindowMs = routeMfaWindowMs;
|
|
458
|
+
if (!enforceMfa) {
|
|
459
|
+
// Walk the actor's roles and check whether any role with
|
|
460
|
+
// requireMfa=true contributes a permission that matches the
|
|
461
|
+
// requested scope. If so, MFA is required regardless of the
|
|
462
|
+
// route-level opt.
|
|
463
|
+
var actorRoles = Array.isArray(actor.roles) ? actor.roles : [];
|
|
464
|
+
for (var ri = 0; ri < actorRoles.length; ri++) {
|
|
465
|
+
var rname = actorRoles[ri];
|
|
466
|
+
if (typeof rname !== "string") continue;
|
|
467
|
+
var rspec = roleTable[rname];
|
|
468
|
+
if (!rspec || !rspec.requireMfa) continue;
|
|
469
|
+
// Cheap match: if the role grants any scope that satisfies the
|
|
470
|
+
// requested scope (single mode) or any of the requested
|
|
471
|
+
// (all/any modes), MFA is required for this route.
|
|
472
|
+
var visited = new Set();
|
|
473
|
+
var roleScopes = [];
|
|
474
|
+
_expandOne(rname, roleTable, visited, roleScopes);
|
|
475
|
+
var roleMatches = false;
|
|
476
|
+
var requestedList = mode === "single" ? [requested] : requested;
|
|
477
|
+
outer: for (var rj = 0; rj < roleScopes.length; rj++) {
|
|
478
|
+
for (var rk = 0; rk < requestedList.length; rk++) {
|
|
479
|
+
if (match(roleScopes[rj], requestedList[rk])) {
|
|
480
|
+
roleMatches = true; break outer;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (roleMatches) {
|
|
485
|
+
enforceMfa = true;
|
|
486
|
+
if (enforceWindowMs === null && rspec.mfaWindowMs !== null) {
|
|
487
|
+
enforceWindowMs = rspec.mfaWindowMs;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (enforceMfa) {
|
|
494
|
+
var mfaOk = actor.mfaAuthenticated === true;
|
|
495
|
+
if (mfaOk && enforceWindowMs !== null) {
|
|
496
|
+
var mfaAt = typeof actor.mfaAt === "number" ? actor.mfaAt : 0;
|
|
497
|
+
if (Date.now() - mfaAt > enforceWindowMs) {
|
|
498
|
+
mfaOk = false;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (!mfaOk) {
|
|
502
|
+
_emitEvent("permissions.mfa_required", 1,
|
|
503
|
+
{ requested: _labelize(requested), mode: mode });
|
|
504
|
+
_auditEmit("permissions.mfa.required", {
|
|
505
|
+
actor: _actorAuditShape(actor, req),
|
|
506
|
+
resource: { kind: "permission", id: _labelize(requested) },
|
|
507
|
+
outcome: "denied",
|
|
508
|
+
reason: "mfa-required",
|
|
509
|
+
metadata: { mode: mode, windowMs: enforceWindowMs },
|
|
510
|
+
});
|
|
511
|
+
return responder(req, res, denyStatus, {
|
|
512
|
+
error: "mfa_required",
|
|
513
|
+
status: denyStatus,
|
|
514
|
+
requested: _labelize(requested),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ABAC layer fires for every requested scope that has a
|
|
520
|
+
// registered policy predicate. Single-mode evaluates the one
|
|
521
|
+
// scope; requireAll evaluates each scope's policy (every must
|
|
522
|
+
// pass); requireAny evaluates only the policies on scopes the
|
|
523
|
+
// actor's RBAC layer satisfied (so a failing policy on a scope
|
|
524
|
+
// the actor doesn't even hold doesn't leak the policy's
|
|
525
|
+
// existence). Each predicate failure short-circuits with a
|
|
526
|
+
// policy.deny audit row naming the failing scope.
|
|
527
|
+
var policyTargets = [];
|
|
528
|
+
if (mode === "single" && _findPolicy(requested)) {
|
|
529
|
+
policyTargets.push(requested);
|
|
530
|
+
} else if (mode === "all" || mode === "any") {
|
|
531
|
+
for (var pi = 0; pi < requested.length; pi++) {
|
|
532
|
+
if (_findPolicy(requested[pi])) {
|
|
533
|
+
if (mode === "any" && !check(actor, requested[pi])) continue;
|
|
534
|
+
policyTargets.push(requested[pi]);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (policyTargets.length > 0) {
|
|
539
|
+
var policyContext = null;
|
|
540
|
+
if (contextProvider) {
|
|
541
|
+
try {
|
|
542
|
+
policyContext = await contextProvider(req);
|
|
543
|
+
} catch (e) {
|
|
544
|
+
_emitEvent("permissions.policy_context_error", 1,
|
|
545
|
+
{ requested: _labelize(requested) });
|
|
546
|
+
_auditEmit("permissions.policy.error", {
|
|
547
|
+
actor: _actorAuditShape(actor, req),
|
|
548
|
+
resource: { kind: "permission", id: _labelize(requested) },
|
|
549
|
+
outcome: "failure",
|
|
550
|
+
reason: "context-provider-threw",
|
|
551
|
+
metadata: { error: (e && e.message) || String(e), mode: mode },
|
|
552
|
+
});
|
|
553
|
+
return responder(req, res, denyStatus, {
|
|
554
|
+
error: "policy_context_error",
|
|
555
|
+
status: denyStatus,
|
|
556
|
+
requested: _labelize(requested),
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
for (var pti = 0; pti < policyTargets.length; pti++) {
|
|
561
|
+
var thisScope = policyTargets[pti];
|
|
562
|
+
var pred = _findPolicy(thisScope);
|
|
563
|
+
var verdict;
|
|
564
|
+
try {
|
|
565
|
+
verdict = await pred(actor, policyContext);
|
|
566
|
+
} catch (e2) {
|
|
567
|
+
_emitEvent("permissions.policy_error", 1, { requested: thisScope });
|
|
568
|
+
_auditEmit("permissions.policy.error", {
|
|
569
|
+
actor: _actorAuditShape(actor, req),
|
|
570
|
+
resource: { kind: "permission", id: thisScope },
|
|
571
|
+
outcome: "failure",
|
|
572
|
+
reason: "predicate-threw",
|
|
573
|
+
metadata: { error: (e2 && e2.message) || String(e2), mode: mode },
|
|
574
|
+
});
|
|
575
|
+
return responder(req, res, denyStatus, {
|
|
576
|
+
error: "policy_error",
|
|
577
|
+
status: denyStatus,
|
|
578
|
+
requested: thisScope,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
if (verdict !== true) {
|
|
582
|
+
_emitEvent("permissions.policy_denied", 1, { requested: thisScope });
|
|
583
|
+
_auditEmit("permissions.policy.deny", {
|
|
584
|
+
actor: _actorAuditShape(actor, req),
|
|
585
|
+
resource: { kind: "permission", id: thisScope },
|
|
586
|
+
outcome: "failure",
|
|
587
|
+
reason: "policy-predicate-returned-falsy",
|
|
588
|
+
metadata: { mode: mode, scopeIndex: pti },
|
|
589
|
+
});
|
|
590
|
+
return responder(req, res, denyStatus, {
|
|
591
|
+
error: "policy_denied",
|
|
592
|
+
status: denyStatus,
|
|
593
|
+
requested: thisScope,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
_emitEvent("permissions.check", 1,
|
|
600
|
+
{ outcome: "success", mode: mode });
|
|
601
|
+
_auditEmit("permissions.check.success", {
|
|
602
|
+
actor: _actorAuditShape(actor, req),
|
|
603
|
+
resource: { kind: "permission", id: _labelize(requested) },
|
|
604
|
+
outcome: "success",
|
|
605
|
+
metadata: { mode: mode, mfaEnforced: enforceMfa },
|
|
606
|
+
});
|
|
607
|
+
next();
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// dbRoleFor — walk the actor's roles in order and return the first
|
|
612
|
+
// declared dbRole. Composes with b.middleware.dbRoleFor so a single
|
|
613
|
+
// RBAC table drives both authorization scopes and request-time DB
|
|
614
|
+
// role binding.
|
|
615
|
+
//
|
|
616
|
+
// The arg can be the request (default resolver pulls actor from
|
|
617
|
+
// req.user / req.apiKey) OR an actor object directly. Returns null if
|
|
618
|
+
// no actor is found OR the actor's roles don't include any with a
|
|
619
|
+
// declared dbRole.
|
|
620
|
+
//
|
|
621
|
+
// Lookup order: extends are walked depth-first so a child role that
|
|
622
|
+
// overrides dbRole takes precedence over its parent. When multiple
|
|
623
|
+
// top-level roles are listed, the first wins (operators wanting a
|
|
624
|
+
// priority order should list more-specific roles first).
|
|
625
|
+
function dbRoleFor(reqOrActor) {
|
|
626
|
+
var actor = reqOrActor;
|
|
627
|
+
// Heuristic: a request shape carries headers / url; resolve through
|
|
628
|
+
// the configured resolver. An actor shape has roles / scopes
|
|
629
|
+
// directly.
|
|
630
|
+
if (actor && (actor.headers || actor.url || actor.method)) {
|
|
631
|
+
actor = resolver(actor);
|
|
632
|
+
}
|
|
633
|
+
if (!actor || typeof actor !== "object") return null;
|
|
634
|
+
var roleNames = Array.isArray(actor.roles) ? actor.roles : null;
|
|
635
|
+
if (!roleNames || roleNames.length === 0) return null;
|
|
636
|
+
// Walk the same DFS order expand() uses so the first-seen dbRole
|
|
637
|
+
// is consistent with how scopes are inherited.
|
|
638
|
+
var visited = new Set();
|
|
639
|
+
for (var i = 0; i < roleNames.length; i++) {
|
|
640
|
+
var name = roleNames[i];
|
|
641
|
+
if (typeof name !== "string") continue;
|
|
642
|
+
if (!Object.prototype.hasOwnProperty.call(roleTable, name)) continue;
|
|
643
|
+
var found = _findDbRole(name, roleTable, visited);
|
|
644
|
+
if (found) return found;
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
require: function (scope, mwOpts) { return _middleware("single", scope, mwOpts); },
|
|
651
|
+
requireAll: function (scopes, mwOpts) { return _middleware("all", scopes, mwOpts); },
|
|
652
|
+
requireAny: function (scopes, mwOpts) { return _middleware("any", scopes, mwOpts); },
|
|
653
|
+
policy: policy,
|
|
654
|
+
check: check,
|
|
655
|
+
checkAll: checkAll,
|
|
656
|
+
checkAny: checkAny,
|
|
657
|
+
expand: expand,
|
|
658
|
+
dbRoleFor: dbRoleFor,
|
|
659
|
+
has: function (name) { return Object.prototype.hasOwnProperty.call(roleTable, name); },
|
|
660
|
+
roles: Object.freeze(Object.keys(roleTable)),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function _findDbRole(roleName, table, visited) {
|
|
665
|
+
if (visited.has(roleName)) return null;
|
|
666
|
+
visited.add(roleName);
|
|
667
|
+
var spec = table[roleName];
|
|
668
|
+
if (!spec) return null;
|
|
669
|
+
// Child overrides parent — check this role's own dbRole first, then
|
|
670
|
+
// recurse into extends.
|
|
671
|
+
if (spec.dbRole) return spec.dbRole;
|
|
672
|
+
for (var i = 0; i < spec.extends.length; i++) {
|
|
673
|
+
var found = _findDbRole(spec.extends[i], table, visited);
|
|
674
|
+
if (found) return found;
|
|
675
|
+
}
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// ---- Helpers ----
|
|
680
|
+
|
|
681
|
+
function _labelize(requested) {
|
|
682
|
+
return Array.isArray(requested) ? requested.join(",") : String(requested);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function _actorAuditShape(actor, req) {
|
|
686
|
+
// Pull the 5 W's (WHO/WHERE/HOW) from the request, then layer the
|
|
687
|
+
// resolver-supplied actor identity on top so userId/roles/scopes
|
|
688
|
+
// aren't lost when the request itself doesn't carry them.
|
|
689
|
+
var base = requestHelpers.extractActorContext(req);
|
|
690
|
+
if (actor) {
|
|
691
|
+
if (actor.userId) base.userId = actor.userId;
|
|
692
|
+
if (Array.isArray(actor.roles)) base.roles = actor.roles.slice();
|
|
693
|
+
if (Array.isArray(actor.scopes)) base.scopes = actor.scopes.slice();
|
|
694
|
+
}
|
|
695
|
+
return base;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function _defaultResponder(req, res, status, info) {
|
|
699
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
700
|
+
res.end(JSON.stringify(info));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
module.exports = {
|
|
704
|
+
create: create,
|
|
705
|
+
match: match,
|
|
706
|
+
PermissionsError: PermissionsError,
|
|
707
|
+
DEFAULTS: DEFAULTS,
|
|
708
|
+
};
|