@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.
Files changed (167) hide show
  1. package/CHANGELOG.md +425 -423
  2. package/README.md +150 -150
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +310 -308
  5. package/lib/api-key.js +660 -660
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt-external.js +365 -0
  16. package/lib/auth/jwt.js +337 -311
  17. package/lib/auth/lockout.js +436 -436
  18. package/lib/auth/oauth.js +721 -721
  19. package/lib/auth/passkey.js +181 -181
  20. package/lib/auth/password.js +628 -594
  21. package/lib/backup/bundle.js +217 -217
  22. package/lib/backup/crypto.js +176 -176
  23. package/lib/backup/index.js +515 -515
  24. package/lib/backup/manifest.js +282 -282
  25. package/lib/break-glass.js +1338 -1338
  26. package/lib/bundler.js +441 -441
  27. package/lib/cache-redis.js +256 -256
  28. package/lib/cache.js +1206 -1206
  29. package/lib/canonical-json.js +115 -115
  30. package/lib/chain-writer.js +234 -234
  31. package/lib/cli-helpers.js +206 -206
  32. package/lib/cli.js +2334 -2334
  33. package/lib/cluster-provider-db.js +317 -317
  34. package/lib/cluster-storage.js +226 -226
  35. package/lib/cluster.js +703 -703
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -275
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -420
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/forms.js +422 -422
  56. package/lib/framework-error.js +293 -293
  57. package/lib/framework-schema.js +717 -717
  58. package/lib/handlers.js +350 -350
  59. package/lib/http-client-cookie-jar.js +508 -508
  60. package/lib/http-client.js +1195 -1195
  61. package/lib/i18n.js +878 -878
  62. package/lib/jobs.js +185 -185
  63. package/lib/log-stream-cloudwatch.js +369 -369
  64. package/lib/log-stream-local.js +146 -146
  65. package/lib/log-stream-otlp-grpc.js +410 -410
  66. package/lib/log-stream-otlp.js +286 -286
  67. package/lib/log-stream-syslog.js +302 -302
  68. package/lib/log-stream-webhook.js +199 -199
  69. package/lib/log-stream.js +330 -330
  70. package/lib/log.js +500 -500
  71. package/lib/mail-bounce.js +528 -528
  72. package/lib/mail-dkim.js +369 -369
  73. package/lib/mail.js +981 -981
  74. package/lib/metrics.js +683 -683
  75. package/lib/middleware/api-encrypt.js +936 -936
  76. package/lib/middleware/attach-user.js +157 -157
  77. package/lib/middleware/bearer-auth.js +152 -0
  78. package/lib/middleware/body-parser.js +1170 -1170
  79. package/lib/middleware/bot-guard.js +178 -178
  80. package/lib/middleware/compression.js +452 -452
  81. package/lib/middleware/cors.js +314 -314
  82. package/lib/middleware/csp-nonce.js +348 -348
  83. package/lib/middleware/csrf-protect.js +316 -316
  84. package/lib/middleware/db-role-for.js +264 -264
  85. package/lib/middleware/health.js +392 -392
  86. package/lib/middleware/index.js +82 -79
  87. package/lib/middleware/rate-limit.js +358 -358
  88. package/lib/middleware/request-id.js +61 -61
  89. package/lib/middleware/request-log.js +168 -168
  90. package/lib/middleware/require-auth.js +104 -104
  91. package/lib/middleware/security-headers.js +116 -116
  92. package/lib/middleware/sse.js +166 -166
  93. package/lib/migrations.js +383 -383
  94. package/lib/mtls-ca.js +518 -518
  95. package/lib/mtls-engine-default.js +481 -481
  96. package/lib/network-dns.js +632 -632
  97. package/lib/network-heartbeat.js +290 -290
  98. package/lib/network-nts.js +574 -574
  99. package/lib/network-proxy.js +265 -265
  100. package/lib/network-tls.js +328 -328
  101. package/lib/network.js +233 -233
  102. package/lib/notify.js +612 -612
  103. package/lib/ntp-check.js +229 -229
  104. package/lib/numeric-bounds.js +111 -111
  105. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  106. package/lib/object-store/azure-blob.js +488 -488
  107. package/lib/object-store/gcs-bucket-ops.js +351 -351
  108. package/lib/object-store/gcs.js +519 -519
  109. package/lib/object-store/http-put.js +153 -153
  110. package/lib/object-store/index.js +197 -197
  111. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  112. package/lib/object-store/sigv4.js +903 -903
  113. package/lib/observability.js +151 -151
  114. package/lib/otel-export.js +269 -269
  115. package/lib/pagination.js +464 -464
  116. package/lib/parsers/index.js +80 -80
  117. package/lib/parsers/safe-env.js +642 -642
  118. package/lib/parsers/safe-ini.js +292 -292
  119. package/lib/parsers/safe-toml.js +784 -784
  120. package/lib/parsers/safe-xml.js +390 -390
  121. package/lib/parsers/safe-yaml.js +1015 -1015
  122. package/lib/permissions.js +708 -708
  123. package/lib/pqc-agent.js +87 -87
  124. package/lib/pqc-gate.js +279 -279
  125. package/lib/protobuf-encoder.js +190 -190
  126. package/lib/protocol-dispatcher.js +161 -161
  127. package/lib/pubsub-redis.js +167 -167
  128. package/lib/pubsub.js +429 -429
  129. package/lib/queue-local.js +476 -476
  130. package/lib/queue-redis.js +745 -745
  131. package/lib/queue-sqs.js +319 -319
  132. package/lib/queue.js +695 -695
  133. package/lib/redis-client.js +519 -519
  134. package/lib/request-helpers.js +340 -340
  135. package/lib/restore-bundle.js +237 -237
  136. package/lib/restore-rollback.js +259 -259
  137. package/lib/restore.js +409 -409
  138. package/lib/retry.js +376 -376
  139. package/lib/router.js +748 -748
  140. package/lib/safe-async.js +735 -735
  141. package/lib/safe-buffer.js +237 -237
  142. package/lib/safe-json.js +541 -541
  143. package/lib/safe-schema.js +1266 -1266
  144. package/lib/safe-url.js +159 -159
  145. package/lib/scheduler.js +706 -706
  146. package/lib/security-assert.js +373 -373
  147. package/lib/seeders.js +618 -618
  148. package/lib/session.js +535 -478
  149. package/lib/slug.js +269 -269
  150. package/lib/ssrf-guard.js +401 -401
  151. package/lib/storage.js +471 -471
  152. package/lib/subject.js +281 -281
  153. package/lib/template.js +791 -791
  154. package/lib/testing.js +798 -798
  155. package/lib/time.js +310 -310
  156. package/lib/totp.js +302 -302
  157. package/lib/tracing.js +494 -494
  158. package/lib/uuid.js +132 -132
  159. package/lib/validate-opts.js +340 -340
  160. package/lib/vault/index.js +308 -308
  161. package/lib/vault/rotate.js +784 -784
  162. package/lib/vault/wrap.js +296 -296
  163. package/lib/vendor/noble-ciphers.cjs +9 -9
  164. package/lib/webhook.js +595 -595
  165. package/lib/websocket.js +1048 -1048
  166. package/package.json +77 -77
  167. package/sbom.cyclonedx.json +7 -7
@@ -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
+ };