@blamejs/core 0.7.4 → 0.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/CHANGELOG.md +423 -395
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -284
  5. package/lib/api-key.js +660 -663
  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.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -256
  27. package/lib/cache.js +1206 -1206
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  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 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  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/file-upload.js +213 -10
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -215
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -936
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -264
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -612
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -488
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -519
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -903
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -167
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -745
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -519
  146. package/lib/request-helpers.js +340 -340
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -618
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +184 -4
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -319
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -595
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. package/sbom.cyclonedx.json +7 -7
@@ -1,1338 +1,1338 @@
1
- "use strict";
2
- /**
3
- * break-glass — column-policy / row-enforcement step-up auth.
4
- *
5
- * Operator declares which columns of which tables are GLASS-LOCKED.
6
- * Reading the encrypted value on any row of a glass-locked column
7
- * requires the calling operator to:
8
- *
9
- * 1. Prove identity with a second factor (TOTP / passkey).
10
- * 2. Provide an operator-supplied REASON the audit chain captures.
11
- * 3. Hold a short-lived, scope-bounded GRANT.
12
- *
13
- * Each row read under a grant emits a per-row audit event. Default
14
- * `maxRowsPerGrant: 1` enforces row-by-row auth — each row access is
15
- * its own discrete authenticated event, compliance-defensible by
16
- * construction. Operators with batch workflows raise the cap per-table.
17
- *
18
- * Spec: memory/specs/blamejs-break-glass-spec.md
19
- *
20
- * Ships Model A (policy gate) + TOTP factor + config-time input
21
- * validation + 14 error codes + audit chain integration. Model B
22
- * (cryptographic gate via per-row K_row), passkey factor, service-
23
- * account bypass, and admin tools land in subsequent patches.
24
- *
25
- * Public API:
26
- *
27
- * b.breakGlass.init({ now? }) — boot once
28
- * b.breakGlass.policy.set(table, opts)
29
- * b.breakGlass.policy.get(table) — null if unset
30
- * b.breakGlass.policy.list()
31
- * b.breakGlass.policy.delete(table)
32
- *
33
- * b.breakGlass.grant({ req, table, reason, factor, columns? })
34
- * b.breakGlass.unsealRow(grant, table, rowId)
35
- * b.breakGlass.revoke(grantId, { reason })
36
- * b.breakGlass.listActive({ req })
37
- *
38
- * b.breakGlass.BreakGlassError
39
- */
40
- var audit = require("./audit");
41
- var C = require("./constants");
42
- var cache = require("./cache");
43
- var clusterStorage = require("./cluster-storage");
44
- var { generateBytes, generateToken, kdf, sha3Hash, encryptPacked, decryptPacked } = require("./crypto");
45
- var cryptoField = require("./crypto-field");
46
- var lazyRequire = require("./lazy-require");
47
- var observability = require("./observability");
48
- var requestHelpers = require("./request-helpers");
49
- var safeAsync = require("./safe-async");
50
- var safeJson = require("./safe-json");
51
- var safeSql = require("./safe-sql");
52
- var totp = require("./totp");
53
- var validateOpts = require("./validate-opts");
54
- var { defineClass } = require("./framework-error");
55
-
56
- var vault = lazyRequire(function () { return require("./vault"); });
57
-
58
- var lockout = lazyRequire(function () { return require("./auth/lockout"); });
59
- var passkey = lazyRequire(function () { return require("./auth/passkey"); });
60
-
61
- // Errors — all 14 codes documented in the spec. `permanent: true`
62
- // means caller's input is bad (config-time / call-site reject);
63
- // `permanent: false` means transient (factor failed, rate-limited)
64
- // — caller may retry.
65
- var BreakGlassError = defineClass("BreakGlassError", { alwaysPermanent: false });
66
-
67
- // ---- Defaults (matched to operator-locked decisions) ----
68
-
69
- // Symmetric byte counts: 32-byte cell-key (kdf output, XChaCha20 key
70
- // length); 32-byte DEK (AEAD key); 16-byte grant-id randomness (128-bit
71
- // uniqueness for cross-table grant lookups). All routed through C.BYTES
72
- // so the framework's byte math has a single source of truth.
73
- var CELL_KEY_BYTES = C.BYTES.bytes(32);
74
- var DEK_BYTES = C.BYTES.bytes(32);
75
- var GRANT_ID_BYTES = C.BYTES.bytes(16);
76
-
77
- var DEFAULT_GRANT_TTL_MS = C.TIME.minutes(15);
78
- var DEFAULT_MAX_ROWS = 1; // operator-locked: row-by-row auth
79
- var DEFAULT_REASON_MIN_LEN = 12;
80
- var DEFAULT_LOCKED_BEHAVIOR = "throw"; // or "redact"
81
- var DEFAULT_AUDIT_REASON = "cleartext";
82
- var ALLOWED_FACTORS = ["totp", "passkey"];
83
- var ALLOWED_REASON_STORAGE = ["cleartext", "hmac", "both"];
84
-
85
- // In-memory policy cache. Cluster-shared via the policies table; the
86
- // cache short-circuits the DB roundtrip on the unsealRow hot path.
87
- // Populated on first access per-table; invalidated on policy.set/delete.
88
- var policyCache = new Map(); // table -> policy
89
- var initialized = false;
90
- // Framework-wide trustProxy setting (set at init). When true, the
91
- // break-glass primitive consults X-Forwarded-For to populate the
92
- // grant row's `ip` field — same trust boundary as middleware.
93
- var _trustProxy = false;
94
-
95
- // Factor lockout — wrap auth.lockout so a hostile actor brute-forcing
96
- // TOTP codes against break-glass gets shut out after a few failures.
97
- // Lazy-init on first grant attempt so init() doesn't require the
98
- // cache primitive to be wired before break-glass loads.
99
- var _factorLockout = null;
100
- var _factorLockoutCache = null;
101
- function _ensureFactorLockout() {
102
- if (_factorLockout) return _factorLockout;
103
- _factorLockoutCache = cache.create({
104
- namespace: "breakglass.factor",
105
- backend: "memory",
106
- });
107
- _factorLockout = lockout().create({
108
- namespace: "breakglass.factor",
109
- cache: _factorLockoutCache,
110
- maxAttempts: 5,
111
- windowMs: C.TIME.minutes(15),
112
- audit: audit,
113
- });
114
- return _factorLockout;
115
- }
116
-
117
- // ---- Cryptographic mode (Model B) — per-cell encryption with context binding ----
118
- //
119
- // Each policy in cryptographic mode has a per-policy DEK (data
120
- // encryption key) generated at first use. The DEK is vault-sealed so
121
- // it survives restarts. At cell encrypt time, the framework derives a
122
- // per-cell key K_cell = SHAKE256(DEK || table || rowId || column) so
123
- // every (table, rowId, column) triple gets a unique key. Encryption
124
- // uses XChaCha20-Poly1305 with AAD = SHA3-512(table || rowId || column)
125
- // — the AEAD tag itself is bound to the encryption context, so a
126
- // ciphertext from row A literally cannot be decrypted as row B even
127
- // with the same DEK.
128
- //
129
- // THREAT MODEL HONESTY: this provides defense-in-depth via per-cell
130
- // keys + encryption-context binding (cross-cell tampering / accidental
131
- // row-swap fails closed). It does NOT defend against vault-key
132
- // compromise alone — the DEK is still vault-recoverable. True
133
- // second-factor cryptographic gating ships in v0.5.2 with passkey
134
- // integration (the passkey private key lives on the YubiKey, not in
135
- // the framework, so a vault leak alone can't unwrap).
136
-
137
- // In-memory DEK cache. Keyed by table name. Cleared on _resetForTest.
138
- var dekCache = new Map();
139
-
140
- function _aadFor(table, rowId, column) {
141
- return sha3Hash(table + "|" + String(rowId) + "|" + column);
142
- }
143
-
144
- function _kCell(dek, table, rowId, column) {
145
- return kdf(Buffer.concat([
146
- Buffer.isBuffer(dek) ? dek : Buffer.from(dek, "base64"),
147
- Buffer.from("breakglass.cell|" + table + "|" + String(rowId) + "|" + column, "utf8"),
148
- ]), CELL_KEY_BYTES);
149
- }
150
-
151
- async function _ensureDek(table) {
152
- if (dekCache.has(table)) return dekCache.get(table);
153
- // DEK is vault-sealed and stored in the policy row's `dekSealed`
154
- // column. Generated lazily on first use of cryptographic-mode for
155
- // the table. Cached in-memory after first read.
156
- var rows = await clusterStorage.executeAll(
157
- "SELECT dekSealed FROM _blamejs_break_glass_policies WHERE tableName = ?",
158
- [table]
159
- );
160
- if (!rows || rows.length === 0) {
161
- throw new BreakGlassError("breakglass/policy-not-set",
162
- "_ensureDek: no policy for table '" + table + "'", true);
163
- }
164
- var sealed = rows[0].dekSealed;
165
- var dek;
166
- if (sealed) {
167
- dek = Buffer.from(vault().unseal(sealed), "base64");
168
- } else {
169
- dek = generateBytes(DEK_BYTES);
170
- var sealedDek = vault().seal(dek.toString("base64"));
171
- await clusterStorage.execute(
172
- "UPDATE _blamejs_break_glass_policies SET dekSealed = ? WHERE tableName = ?",
173
- [sealedDek, table]
174
- );
175
- }
176
- dekCache.set(table, dek);
177
- return dek;
178
- }
179
-
180
- // Encrypt a single cell value with encryption context binding. Operators
181
- // in cryptographic mode use this at write time INSTEAD of letting
182
- // cryptoField.sealRow seal the column.
183
- async function encryptCell(plaintext, ctx) {
184
- _requireInit();
185
- if (!ctx || typeof ctx !== "object" ||
186
- typeof ctx.table !== "string" || ctx.table.length === 0 ||
187
- ctx.rowId === undefined || ctx.rowId === null ||
188
- typeof ctx.column !== "string" || ctx.column.length === 0) {
189
- throw new BreakGlassError("breakglass/bad-cell-ctx",
190
- "encryptCell: ctx must be { table, rowId, column }");
191
- }
192
- var policy = await policyGet(ctx.table);
193
- if (!policy || !policy.cryptographic) {
194
- throw new BreakGlassError("breakglass/policy-not-set",
195
- "encryptCell: table '" + ctx.table + "' is not in cryptographic mode " +
196
- "(set policy.cryptographic = true to opt in)", true);
197
- }
198
- if (policy.columns.indexOf(ctx.column) === -1) {
199
- throw new BreakGlassError("breakglass/grant-column-mismatch",
200
- "encryptCell: column '" + ctx.column + "' is not glass-locked on '" + ctx.table + "'", true);
201
- }
202
- var dek = await _ensureDek(ctx.table);
203
- var kCell = _kCell(dek, ctx.table, ctx.rowId, ctx.column);
204
- var aad = _aadFor(ctx.table, ctx.rowId, ctx.column);
205
- var pt = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), "utf8");
206
- var packed = encryptPacked(pt, kCell, aad);
207
- return "bgcell:1:" + packed.toString("base64");
208
- }
209
-
210
- // Decrypt a cell value. Caller must hold a valid grant covering the
211
- // (table, column) — caller passes through unsealRow which gates this.
212
- // The encryption context (table, rowId, column) is passed both to the
213
- // key derivation AND the AAD; if the caller passes the wrong rowId
214
- // trying to "swap" ciphertexts between rows, decryption fails closed.
215
- async function decryptCell(ciphertext, ctx) {
216
- _requireInit();
217
- if (typeof ciphertext !== "string" || ciphertext.indexOf("bgcell:1:") !== 0) {
218
- throw new BreakGlassError("breakglass/bad-ciphertext",
219
- "decryptCell: expected 'bgcell:1:<base64>' format");
220
- }
221
- if (!ctx || typeof ctx !== "object") {
222
- throw new BreakGlassError("breakglass/bad-cell-ctx",
223
- "decryptCell: ctx must be { table, rowId, column }");
224
- }
225
- var dek = await _ensureDek(ctx.table);
226
- var kCell = _kCell(dek, ctx.table, ctx.rowId, ctx.column);
227
- var aad = _aadFor(ctx.table, ctx.rowId, ctx.column);
228
- var packed = Buffer.from(ciphertext.slice("bgcell:1:".length), "base64");
229
- return decryptPacked(packed, kCell, aad).toString("utf8");
230
- }
231
-
232
- // ---- Migration support ----
233
- //
234
- // Operator runs `b.breakGlass.migrate(table, opts)` (or the CLI
235
- // equivalent) to convert existing Model-A-sealed rows into Model B
236
- // per-cell-encrypted form. Each row's glass-locked columns are
237
- // unsealed via cryptoField, re-encrypted via encryptCell, and written
238
- // back. The migration is idempotent — a row already in Model B form
239
- // (column starts with "bgcell:") is skipped.
240
-
241
- async function migrate(table, opts) {
242
- _requireInit();
243
- opts = opts || {};
244
- validateOpts(opts, ["batchSize", "callerOpts"], "breakGlass.migrate");
245
- var policy = await policyGet(table);
246
- if (!policy) {
247
- throw new BreakGlassError("breakglass/policy-not-set",
248
- "migrate: no policy for table '" + table + "'", true);
249
- }
250
- if (!policy.cryptographic) {
251
- throw new BreakGlassError("breakglass/bad-policy",
252
- "migrate: policy must be cryptographic: true to migrate to Model B", true);
253
- }
254
- var batchSize = opts.batchSize || 100;
255
- var totalRows = 0;
256
- var migratedRows = 0;
257
- var skippedRows = 0;
258
- var lastId = "";
259
- // Iterate via _id-keyset paging so we don't load the whole table into memory.
260
- while (true) {
261
- // table is already validated as a safe identifier shape via
262
- // _validatePolicySet — wrap in "..." per the framework's
263
- // identifier-quoting convention.
264
- var qTable = '"' + table + '"';
265
- var rows = await clusterStorage.executeAll(
266
- "SELECT * FROM " + qTable + " WHERE _id > ? ORDER BY _id ASC LIMIT ?",
267
- [lastId, batchSize]
268
- );
269
- if (!rows || rows.length === 0) break;
270
- for (var i = 0; i < rows.length; i++) {
271
- totalRows++;
272
- var row = rows[i];
273
- var unsealed = cryptoField.unsealRow(table, row);
274
- var anyChanged = false;
275
- var update = { _id: row._id };
276
- for (var c = 0; c < policy.columns.length; c++) {
277
- var col = policy.columns[c];
278
- var current = unsealed[col];
279
- if (current == null) continue;
280
- if (typeof current === "string" && current.indexOf("bgcell:") === 0) {
281
- continue; // already migrated
282
- }
283
- var encrypted = await encryptCell(current, { table: table, rowId: row._id, column: col });
284
- update[col] = encrypted;
285
- anyChanged = true;
286
- }
287
- if (anyChanged) {
288
- // Write Model B ciphertext directly — bypassing cryptoField so
289
- // the cell ciphertext stays as a literal string, not double-sealed.
290
- var setCols = Object.keys(update).filter(function (k) { return k !== "_id"; });
291
- if (setCols.length > 0) {
292
- // Column names came from the validated policy.columns —
293
- // also wrap each in "..." for the same identifier-quoting
294
- // convention.
295
- var setSql = setCols.map(function (k) { return '"' + k + '" = ?'; }).join(", ");
296
- var vals = setCols.map(function (k) { return update[k]; });
297
- vals.push(row._id);
298
- await clusterStorage.execute(
299
- "UPDATE " + qTable + " SET " + setSql + " WHERE _id = ?",
300
- vals
301
- );
302
- migratedRows++;
303
- }
304
- } else {
305
- skippedRows++;
306
- }
307
- lastId = row._id;
308
- }
309
- if (rows.length < batchSize) break;
310
- }
311
- audit.safeEmit({
312
- action: "breakglass.migrate",
313
- outcome: "success",
314
- actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
315
- metadata: {
316
- table: table,
317
- totalRows: totalRows,
318
- migratedRows: migratedRows,
319
- skippedRows: skippedRows,
320
- },
321
- });
322
- return { table: table, totalRows: totalRows, migratedRows: migratedRows, skippedRows: skippedRows };
323
- }
324
-
325
- // ---- init ----
326
-
327
- function init(opts) {
328
- opts = opts || {};
329
- validateOpts(opts, ["now", "trustProxy"], "breakGlass.init");
330
- initialized = true;
331
- policyCache.clear();
332
- _factorLockout = null;
333
- _trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
334
- ? opts.trustProxy : false;
335
- }
336
-
337
- function _resetForTest() {
338
- initialized = false;
339
- policyCache.clear();
340
- dekCache.clear();
341
- if (_factorLockoutCache && typeof _factorLockoutCache.close === "function") {
342
- try { _factorLockoutCache.close(); } catch (_e) { /* best-effort */ }
343
- }
344
- _factorLockout = null;
345
- _factorLockoutCache = null;
346
- _trustProxy = false;
347
- }
348
-
349
- function _requireInit() {
350
- if (!initialized) {
351
- throw new BreakGlassError("breakglass/not-initialized",
352
- "b.breakGlass.init() must be called before use");
353
- }
354
- }
355
-
356
- // ---- Policy CRUD ----
357
-
358
- function _validatePolicySet(table, opts) {
359
- if (typeof table !== "string" || table.length === 0) {
360
- throw new BreakGlassError("breakglass/bad-policy",
361
- "policy.set: table must be a non-empty string");
362
- }
363
- // Identifier safety: the table name flows raw into SQL via interpolation
364
- // in migrate() / unsealRowAsService(). safeSql.validateIdentifier closes
365
- // the shape so a malicious / mistyped name with embedded `"` or
366
- // SQL-keyword shape can't break out of the wrapping quotes.
367
- // allowReserved: true because every interpolation site quotes the
368
- // identifier, so reserved-word names work via the SQL standard quoting
369
- // rule.
370
- try {
371
- safeSql.validateIdentifier(table, { allowReserved: true });
372
- } catch (e) {
373
- throw new BreakGlassError("breakglass/bad-policy",
374
- "policy.set: table '" + table + "' is not a valid SQL identifier: " +
375
- ((e && e.message) || String(e)));
376
- }
377
- if (!opts || typeof opts !== "object") {
378
- throw new BreakGlassError("breakglass/bad-policy",
379
- "policy.set: opts is required");
380
- }
381
- validateOpts(opts, [
382
- "columns", "factors", "cryptographic", "grantTtl", "maxRowsPerGrant",
383
- "reasonRequired", "reasonMinLength", "pinIp", "sessionPin",
384
- "onLockedAccess", "requireScope", "serviceAccountBypass",
385
- "auditReasonStorage",
386
- ], "breakglass.policy.set");
387
- if (!Array.isArray(opts.columns) || opts.columns.length === 0) {
388
- throw new BreakGlassError("breakglass/bad-policy",
389
- "policy.set: columns must be a non-empty array");
390
- }
391
- for (var i = 0; i < opts.columns.length; i++) {
392
- var colName = opts.columns[i];
393
- if (typeof colName !== "string" || colName.length === 0) {
394
- throw new BreakGlassError("breakglass/bad-policy",
395
- "policy.set: columns[" + i + "] must be a non-empty string");
396
- }
397
- // Same identifier-shape check as the table — column names flow into
398
- // the migrate() UPDATE statement as bare names.
399
- try {
400
- safeSql.validateIdentifier(colName, { allowReserved: true });
401
- } catch (e) {
402
- throw new BreakGlassError("breakglass/bad-policy",
403
- "policy.set: columns[" + i + "]='" + colName + "' is not a valid SQL identifier: " +
404
- ((e && e.message) || String(e)));
405
- }
406
- }
407
- if (!Array.isArray(opts.factors) || opts.factors.length === 0) {
408
- throw new BreakGlassError("breakglass/bad-policy",
409
- "policy.set: factors must be a non-empty array");
410
- }
411
- for (var j = 0; j < opts.factors.length; j++) {
412
- if (ALLOWED_FACTORS.indexOf(opts.factors[j]) === -1) {
413
- throw new BreakGlassError("breakglass/bad-policy",
414
- "policy.set: factors[" + j + "] '" + opts.factors[j] +
415
- "' not in v0.5.0 allowed factors [" + ALLOWED_FACTORS.join(",") + "]" +
416
- " (passkey lands in v0.5.2)");
417
- }
418
- }
419
- // Model B (cryptographic mode) ships in v0.5.1. When enabled,
420
- // glass-locked columns must be encrypted with `b.breakGlass.encryptCell`
421
- // at write time (the framework can't auto-encrypt at write because
422
- // policy-set may post-date existing data; operators run the migration
423
- // CLI to convert existing rows). At unseal time, the row's
424
- // glass-locked columns are decrypted via decryptCell with encryption
425
- // context binding (table, rowId, column).
426
- validateOpts.optionalBoolean(opts.cryptographic, "policy.set: cryptographic", BreakGlassError, "breakglass/bad-policy");
427
- var grantTtl = opts.grantTtl != null ? opts.grantTtl : DEFAULT_GRANT_TTL_MS;
428
- if (typeof grantTtl !== "number" || !isFinite(grantTtl) || grantTtl <= 0) {
429
- throw new BreakGlassError("breakglass/bad-policy",
430
- "policy.set: grantTtl must be a positive number of milliseconds");
431
- }
432
- var maxRows = opts.maxRowsPerGrant != null ? opts.maxRowsPerGrant : DEFAULT_MAX_ROWS;
433
- if (!Number.isInteger(maxRows) || maxRows < 1) {
434
- throw new BreakGlassError("breakglass/bad-policy",
435
- "policy.set: maxRowsPerGrant must be a positive integer (default 1 — row-by-row auth)");
436
- }
437
- if (opts.onLockedAccess != null &&
438
- opts.onLockedAccess !== "throw" && opts.onLockedAccess !== "redact") {
439
- throw new BreakGlassError("breakglass/bad-policy",
440
- "policy.set: onLockedAccess must be 'throw' or 'redact'");
441
- }
442
- if (opts.auditReasonStorage != null &&
443
- ALLOWED_REASON_STORAGE.indexOf(opts.auditReasonStorage) === -1) {
444
- throw new BreakGlassError("breakglass/bad-policy",
445
- "policy.set: auditReasonStorage must be one of " + ALLOWED_REASON_STORAGE.join("/"));
446
- }
447
- // Service-account bypass: explicit opt-in per table. Operators
448
- // declare the apiKey ids that may bypass + a required role; the
449
- // framework requires BOTH to grant the bypass. Without this opt set,
450
- // there is NO bypass path — every read of a glass-locked column
451
- // requires a fresh grant.
452
- var serviceAccountBypass = null;
453
- if (opts.serviceAccountBypass != null && opts.serviceAccountBypass !== false) {
454
- var sab = opts.serviceAccountBypass;
455
- if (!sab || typeof sab !== "object") {
456
- throw new BreakGlassError("breakglass/bad-policy",
457
- "policy.set: serviceAccountBypass must be an object { enabled, apiKeyIds, requireRole }");
458
- }
459
- if (sab.enabled !== true) {
460
- throw new BreakGlassError("breakglass/bad-policy",
461
- "policy.set: serviceAccountBypass.enabled must be true (set serviceAccountBypass: false to disable)");
462
- }
463
- if (!Array.isArray(sab.apiKeyIds) || sab.apiKeyIds.length === 0) {
464
- throw new BreakGlassError("breakglass/bad-policy",
465
- "policy.set: serviceAccountBypass.apiKeyIds must be a non-empty array of apiKey ids");
466
- }
467
- if (typeof sab.requireRole !== "string" || sab.requireRole.length === 0) {
468
- throw new BreakGlassError("breakglass/bad-policy",
469
- "policy.set: serviceAccountBypass.requireRole must be a non-empty role / scope string");
470
- }
471
- serviceAccountBypass = {
472
- enabled: true,
473
- apiKeyIds: sab.apiKeyIds.slice(),
474
- requireRole: sab.requireRole,
475
- };
476
- }
477
- return {
478
- cryptographic: opts.cryptographic === true,
479
- grantTtl: grantTtl,
480
- maxRowsPerGrant: maxRows,
481
- reasonRequired: opts.reasonRequired !== false,
482
- reasonMinLength: opts.reasonMinLength != null ? opts.reasonMinLength : DEFAULT_REASON_MIN_LEN,
483
- pinIp: opts.pinIp !== false,
484
- sessionPin: opts.sessionPin !== false,
485
- onLockedAccess: opts.onLockedAccess || DEFAULT_LOCKED_BEHAVIOR,
486
- requireScope: opts.requireScope != null ? opts.requireScope : null,
487
- auditReasonStorage: opts.auditReasonStorage || DEFAULT_AUDIT_REASON,
488
- serviceAccountBypass: serviceAccountBypass,
489
- };
490
- }
491
-
492
- async function policySet(table, opts, callerOpts) {
493
- _requireInit();
494
- var validated = _validatePolicySet(table, opts);
495
- var policyRow = {
496
- tableName: table,
497
- columnsJson: JSON.stringify(opts.columns),
498
- factorsJson: JSON.stringify(opts.factors),
499
- cryptographic: validated.cryptographic ? 1 : 0,
500
- grantTtlMs: validated.grantTtl,
501
- maxRowsPerGrant: validated.maxRowsPerGrant,
502
- reasonRequired: validated.reasonRequired ? 1 : 0,
503
- reasonMinLength: validated.reasonMinLength,
504
- pinIp: validated.pinIp ? 1 : 0,
505
- sessionPin: validated.sessionPin ? 1 : 0,
506
- onLockedAccess: validated.onLockedAccess,
507
- requireScope: validated.requireScope,
508
- serviceAccountBypassJson: validated.serviceAccountBypass
509
- ? JSON.stringify(validated.serviceAccountBypass)
510
- : null,
511
- auditReasonStorage: validated.auditReasonStorage,
512
- updatedAt: Date.now(),
513
- };
514
- var sealed = cryptoField.sealRow("_blamejs_break_glass_policies", policyRow);
515
- // UPSERT — both Postgres and SQLite support ON CONFLICT.
516
- var keys = Object.keys(sealed);
517
- var cols = keys.join(", ");
518
- var qs = keys.map(function () { return "?"; }).join(", ");
519
- var setSql = keys.filter(function (k) { return k !== "tableName"; })
520
- .map(function (k) { return k + " = excluded." + k; }).join(", ");
521
- var sql = "INSERT INTO _blamejs_break_glass_policies (" + cols + ") " +
522
- "VALUES (" + qs + ") " +
523
- "ON CONFLICT (tableName) DO UPDATE SET " + setSql;
524
- await clusterStorage.execute(sql, keys.map(function (k) { return sealed[k]; }));
525
- policyCache.delete(table);
526
-
527
- audit.safeEmit({
528
- action: "breakglass.policy.set",
529
- outcome: "success",
530
- actor: requestHelpers.resolveActorWithOverride(callerOpts),
531
- metadata: {
532
- table: table,
533
- columnCount: opts.columns.length,
534
- factors: opts.factors,
535
- grantTtlMs: validated.grantTtl,
536
- maxRowsPerGrant: validated.maxRowsPerGrant,
537
- },
538
- });
539
- observability.event("breakglass.policy.set", { table: table });
540
- return { applied: true, table: table };
541
- }
542
-
543
- async function policyGet(table) {
544
- _requireInit();
545
- if (typeof table !== "string" || table.length === 0) return null;
546
- if (policyCache.has(table)) return policyCache.get(table);
547
- var rows = await clusterStorage.executeAll(
548
- "SELECT * FROM _blamejs_break_glass_policies WHERE tableName = ?",
549
- [table]
550
- );
551
- if (!rows || rows.length === 0) {
552
- policyCache.set(table, null);
553
- return null;
554
- }
555
- var unsealed = cryptoField.unsealRow("_blamejs_break_glass_policies", rows[0]);
556
- var policy = {
557
- table: unsealed.tableName,
558
- columns: safeJson.parse(unsealed.columnsJson, { maxBytes: C.BYTES.kib(64) }),
559
- factors: safeJson.parse(unsealed.factorsJson, { maxBytes: C.BYTES.kib(8) }),
560
- cryptographic: unsealed.cryptographic === 1,
561
- grantTtl: Number(unsealed.grantTtlMs),
562
- maxRowsPerGrant: Number(unsealed.maxRowsPerGrant),
563
- reasonRequired: unsealed.reasonRequired === 1,
564
- reasonMinLength: Number(unsealed.reasonMinLength),
565
- pinIp: unsealed.pinIp === 1,
566
- sessionPin: unsealed.sessionPin === 1,
567
- onLockedAccess: unsealed.onLockedAccess,
568
- requireScope: unsealed.requireScope,
569
- serviceAccountBypass: unsealed.serviceAccountBypassJson
570
- ? safeJson.parse(unsealed.serviceAccountBypassJson, { maxBytes: C.BYTES.kib(8) })
571
- : null,
572
- auditReasonStorage: unsealed.auditReasonStorage,
573
- updatedAt: Number(unsealed.updatedAt),
574
- };
575
- policyCache.set(table, policy);
576
- return policy;
577
- }
578
-
579
- async function policyList() {
580
- _requireInit();
581
- var rows = await clusterStorage.executeAll(
582
- "SELECT tableName FROM _blamejs_break_glass_policies ORDER BY tableName"
583
- );
584
- var out = [];
585
- for (var i = 0; i < (rows || []).length; i++) {
586
- var p = await policyGet(rows[i].tableName);
587
- if (p) out.push(p);
588
- }
589
- return out;
590
- }
591
-
592
- async function policyDelete(table, callerOpts) {
593
- _requireInit();
594
- if (typeof table !== "string" || table.length === 0) {
595
- throw new BreakGlassError("breakglass/bad-policy",
596
- "policy.delete: table must be a non-empty string");
597
- }
598
- await clusterStorage.execute(
599
- "DELETE FROM _blamejs_break_glass_policies WHERE tableName = ?",
600
- [table]
601
- );
602
- policyCache.delete(table);
603
- audit.safeEmit({
604
- action: "breakglass.policy.delete",
605
- outcome: "success",
606
- actor: requestHelpers.resolveActorWithOverride(callerOpts),
607
- metadata: { table: table },
608
- });
609
- return { deleted: true, table: table };
610
- }
611
-
612
- // ---- Grant issuance ----
613
-
614
- function _verifyTotpFactor(factor) {
615
- if (!factor || typeof factor !== "object") return { ok: false };
616
- if (typeof factor.secret !== "string" || factor.secret.length === 0) return { ok: false };
617
- if (typeof factor.code !== "string" || factor.code.length === 0) return { ok: false };
618
- var verified = totp.verify(factor.secret, factor.code);
619
- return { ok: verified !== false, step: verified };
620
- }
621
-
622
- // Passkey factor — operator presents a WebAuthn assertion plus the
623
- // challenge/origin/RPID + the previously-enrolled credential record.
624
- // Phishing-resistant; the private key lives on the YubiKey, not in
625
- // the framework's vault. v0.5.2 uses passkey for identity verification
626
- // to gate grant issuance; PRF-derived per-policy DEK material (which
627
- // would give true vault-key-alone-doesn't-decrypt defense) is a
628
- // follow-up.
629
- async function _verifyPasskeyFactor(factor) {
630
- if (!factor || typeof factor !== "object") return { ok: false };
631
- if (!factor.response || !factor.expectedChallenge ||
632
- !factor.expectedOrigin || !factor.expectedRPID || !factor.credential) {
633
- return { ok: false };
634
- }
635
- try {
636
- var result = await passkey().verifyAuthentication({
637
- response: factor.response,
638
- expectedChallenge: factor.expectedChallenge,
639
- expectedOrigin: factor.expectedOrigin,
640
- expectedRPID: factor.expectedRPID,
641
- credential: factor.credential,
642
- requireUserVerification: factor.requireUserVerification !== false,
643
- });
644
- return { ok: result && result.verified === true };
645
- } catch (_e) {
646
- return { ok: false };
647
- }
648
- }
649
-
650
- async function grant(opts) {
651
- _requireInit();
652
- if (!opts || typeof opts !== "object") {
653
- throw new BreakGlassError("breakglass/bad-grant-opts",
654
- "grant: opts is required");
655
- }
656
- validateOpts(opts, ["req", "table", "columns", "reason", "factor"], "breakGlass.grant");
657
-
658
- var table = opts.table;
659
- var policy = await policyGet(table);
660
- if (!policy) {
661
- throw new BreakGlassError("breakglass/policy-not-set",
662
- "no break-glass policy is configured for table '" + table + "'", true);
663
- }
664
-
665
- // Reason validation
666
- var reason = typeof opts.reason === "string" ? opts.reason : "";
667
- if (policy.reasonRequired && reason.length === 0) {
668
- throw new BreakGlassError("breakglass/missing-reason",
669
- "grant: reason is required for table '" + table + "'", true);
670
- }
671
- if (policy.reasonRequired && reason.length < policy.reasonMinLength) {
672
- throw new BreakGlassError("breakglass/short-reason",
673
- "grant: reason must be at least " + policy.reasonMinLength + " characters", true);
674
- }
675
-
676
- // Column scoping
677
- var requestedColumns = Array.isArray(opts.columns) && opts.columns.length > 0
678
- ? opts.columns.slice()
679
- : policy.columns.slice();
680
- for (var i = 0; i < requestedColumns.length; i++) {
681
- if (policy.columns.indexOf(requestedColumns[i]) === -1) {
682
- throw new BreakGlassError("breakglass/grant-column-mismatch",
683
- "grant: requested column '" + requestedColumns[i] +
684
- "' is not glass-locked on table '" + table + "'", true);
685
- }
686
- }
687
-
688
- // Actor identity
689
- var actor = requestHelpers.extractActorContext(opts.req);
690
- var actorId = actor.userId || (opts.req && opts.req.apiKey && opts.req.apiKey.id) || null;
691
- if (!actorId) {
692
- throw new BreakGlassError("breakglass/unauthorized",
693
- "grant: no authenticated actor on request (req.user.id / req.apiKey.id required)", true);
694
- }
695
-
696
- // Factor verification + lockout
697
- var factorType = opts.factor && opts.factor.type;
698
- if (!factorType || policy.factors.indexOf(factorType) === -1) {
699
- throw new BreakGlassError("breakglass/bad-factor",
700
- "grant: factor.type must be one of [" + policy.factors.join(",") + "]");
701
- }
702
- var fl = _ensureFactorLockout();
703
- var lockKey = actorId;
704
- var locked = await fl.check(lockKey);
705
- if (locked && locked.locked) {
706
- audit.safeEmit({
707
- action: "breakglass.grant.requested",
708
- outcome: "denied",
709
- actor: actor,
710
- reason: "factor-rate-limited",
711
- metadata: { table: table, factorType: factorType, lockUntil: locked.lockedUntil },
712
- });
713
- throw new BreakGlassError("breakglass/factor-rate-limited",
714
- "grant: too many recent factor failures; locked until " +
715
- new Date(locked.lockedUntil).toISOString());
716
- }
717
-
718
- var factorOk = false;
719
- if (factorType === "totp") {
720
- factorOk = _verifyTotpFactor(opts.factor).ok;
721
- } else if (factorType === "passkey") {
722
- factorOk = (await _verifyPasskeyFactor(opts.factor)).ok;
723
- }
724
-
725
- if (!factorOk) {
726
- await fl.recordFailure(lockKey, { reason: factorType + "-bad" });
727
- audit.safeEmit({
728
- action: "breakglass.grant.requested",
729
- outcome: "denied",
730
- actor: actor,
731
- reason: "bad-factor",
732
- metadata: { table: table, factorType: factorType, columns: requestedColumns },
733
- });
734
- throw new BreakGlassError("breakglass/bad-factor",
735
- "grant: " + factorType + " factor verification failed");
736
- }
737
- await fl.recordSuccess(lockKey);
738
-
739
- // Build + persist the grant row
740
- var nowMs = Date.now();
741
- var grantId = "bg-" + generateToken(GRANT_ID_BYTES);
742
- var sessionId = (opts.req && opts.req.session && opts.req.session.id) || null;
743
- // Honor the framework-wide trustProxy setting from init() — same
744
- // boundary as middleware. Without trustProxy, X-Forwarded-For is
745
- // ignored as attacker-forgeable, and the grant pins to the socket
746
- // remoteAddress only.
747
- var ipFromReq = requestHelpers.clientIp(opts.req, { trustProxy: _trustProxy });
748
-
749
- var grantRow = {
750
- _id: grantId,
751
- issuedToActorId: actorId,
752
- factorType: factorType,
753
- reasonSealed: reason,
754
- scopeTable: table,
755
- scopeColumnsJson: JSON.stringify(requestedColumns),
756
- issuedAt: nowMs,
757
- expiresAt: nowMs + policy.grantTtl,
758
- maxRowsPerGrant: policy.maxRowsPerGrant,
759
- rowsConsumed: 0,
760
- revokedAt: null,
761
- sessionId: sessionId,
762
- ip: ipFromReq,
763
- kwGrantHalf: null,
764
- };
765
- var sealed = cryptoField.sealRow("_blamejs_break_glass_grants", grantRow);
766
- var keys = Object.keys(sealed);
767
- var cols = keys.join(", ");
768
- var qs = keys.map(function () { return "?"; }).join(", ");
769
- await clusterStorage.execute(
770
- "INSERT INTO _blamejs_break_glass_grants (" + cols + ") VALUES (" + qs + ")",
771
- keys.map(function (k) { return sealed[k]; })
772
- );
773
-
774
- // Audit
775
- var reasonForAudit = _reasonForAudit(reason, policy.auditReasonStorage);
776
- audit.safeEmit({
777
- action: "breakglass.grant.requested",
778
- outcome: "success",
779
- actor: actor,
780
- reason: reasonForAudit.cleartext,
781
- metadata: {
782
- grantId: grantId,
783
- table: table,
784
- columns: requestedColumns,
785
- factorType: factorType,
786
- ttlMs: policy.grantTtl,
787
- maxRowsPerGrant: policy.maxRowsPerGrant,
788
- reasonHmac: reasonForAudit.hmac,
789
- },
790
- });
791
- observability.event("breakGlass.grant", { table: table });
792
-
793
- return {
794
- id: grantId,
795
- expiresAt: grantRow.expiresAt,
796
- rowsRemaining: policy.maxRowsPerGrant,
797
- scopeTable: table,
798
- scopeColumns: requestedColumns,
799
- };
800
- }
801
-
802
- function _reasonForAudit(reason, mode) {
803
- // HMAC variant uses SHA3-512 keyed by a stable framework-wide tag —
804
- // operators with multiple deployments can correlate via the hash
805
- // without re-deriving from the same secret. Cleartext is the default
806
- // (compliance reviewers WANT to read the reason).
807
- var out = { cleartext: null, hmac: null };
808
- if (mode === "cleartext" || mode === "both") out.cleartext = reason;
809
- if (mode === "hmac" || mode === "both") {
810
- out.hmac = sha3Hash("breakGlass.reason:" + reason);
811
- }
812
- return out;
813
- }
814
-
815
- // ---- Use a grant ----
816
-
817
- async function unsealRow(grantHandle, table, rowId, opts) {
818
- _requireInit();
819
- if (!grantHandle || typeof grantHandle !== "object" || typeof grantHandle.id !== "string") {
820
- throw new BreakGlassError("breakglass/bad-grant-opts",
821
- "unsealRow: grant handle is required (returned from b.breakGlass.grant())");
822
- }
823
- // Optional opts.req lets the caller thread the originating request
824
- // into per-row audit emits so the 5 W's (ip / userAgent / sessionId /
825
- // requestId / method / route) populate alongside the grant's actor
826
- // userId. Backward-compatible — calls without opts continue to work
827
- // and simply audit with userId-only actor.
828
- opts = opts || {};
829
- function _actorFor(grantRow) {
830
- return requestHelpers.extractActorContext(opts.req, {
831
- userId: grantRow.issuedToActorId,
832
- });
833
- }
834
- if (typeof table !== "string" || table.length === 0) {
835
- throw new BreakGlassError("breakglass/bad-grant-opts",
836
- "unsealRow: table must be a non-empty string");
837
- }
838
- if (rowId === undefined || rowId === null || rowId === "") {
839
- throw new BreakGlassError("breakglass/bad-grant-opts",
840
- "unsealRow: rowId is required");
841
- }
842
- var grantRows = await clusterStorage.executeAll(
843
- "SELECT * FROM _blamejs_break_glass_grants WHERE _id = ?",
844
- [grantHandle.id]
845
- );
846
- if (!grantRows || grantRows.length === 0) {
847
- throw new BreakGlassError("breakglass/grant-revoked",
848
- "unsealRow: grant " + grantHandle.id + " not found (deleted or never issued)", true);
849
- }
850
- var sealedGrant = grantRows[0];
851
- var grantRow = cryptoField.unsealRow("_blamejs_break_glass_grants", sealedGrant);
852
-
853
- // Table mismatch
854
- if (grantRow.scopeTable !== table) {
855
- audit.safeEmit({
856
- action: "breakglass.unsealrow",
857
- outcome: "denied",
858
- actor: _actorFor(grantRow),
859
- reason: "grant-table-mismatch",
860
- metadata: { grantId: grantRow._id, expectedTable: grantRow.scopeTable, gotTable: table, rowId: String(rowId) },
861
- });
862
- throw new BreakGlassError("breakglass/grant-table-mismatch",
863
- "unsealRow: grant " + grantHandle.id + " is scoped to '" +
864
- grantRow.scopeTable + "', not '" + table + "'", true);
865
- }
866
-
867
- // Revoked
868
- if (grantRow.revokedAt) {
869
- throw new BreakGlassError("breakglass/grant-revoked",
870
- "unsealRow: grant " + grantHandle.id + " was revoked at " +
871
- new Date(Number(grantRow.revokedAt)).toISOString(), true);
872
- }
873
-
874
- // Expired
875
- if (Number(grantRow.expiresAt) <= Date.now()) {
876
- audit.safeEmit({
877
- action: "breakglass.grant.expired",
878
- outcome: "success",
879
- actor: _actorFor(grantRow),
880
- metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
881
- });
882
- throw new BreakGlassError("breakglass/grant-expired",
883
- "unsealRow: grant " + grantHandle.id + " expired at " +
884
- new Date(Number(grantRow.expiresAt)).toISOString(), true);
885
- }
886
-
887
- // Exhausted
888
- if (Number(grantRow.rowsConsumed) >= Number(grantRow.maxRowsPerGrant)) {
889
- audit.safeEmit({
890
- action: "breakglass.grant.exhausted",
891
- outcome: "success",
892
- actor: _actorFor(grantRow),
893
- metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
894
- });
895
- throw new BreakGlassError("breakglass/grant-exhausted",
896
- "unsealRow: grant " + grantHandle.id + " has consumed all " +
897
- grantRow.maxRowsPerGrant + " allowed rows", true);
898
- }
899
-
900
- // Increment rowsConsumed (atomic UPDATE with WHERE rowsConsumed < cap
901
- // so concurrent unseals can't both pass the runtime check above).
902
- var updateRes = await clusterStorage.execute(
903
- "UPDATE _blamejs_break_glass_grants " +
904
- "SET rowsConsumed = rowsConsumed + 1 " +
905
- "WHERE _id = ? AND rowsConsumed < maxRowsPerGrant AND " +
906
- "(revokedAt IS NULL) AND expiresAt > ?",
907
- [grantHandle.id, Date.now()]
908
- );
909
- // executeAll-style result; some backends return rowsAffected, others a count.
910
- // Re-query to confirm the increment landed and get the post-increment counter.
911
- var postRows = await clusterStorage.executeAll(
912
- "SELECT rowsConsumed, revokedAt, expiresAt FROM _blamejs_break_glass_grants WHERE _id = ?",
913
- [grantHandle.id]
914
- );
915
- if (!postRows || postRows.length === 0) {
916
- throw new BreakGlassError("breakglass/grant-revoked",
917
- "unsealRow: grant " + grantHandle.id + " disappeared during unseal", true);
918
- }
919
- var postRowsConsumed = Number(postRows[0].rowsConsumed);
920
- // If the UPDATE didn't actually increment (race lost — another unseal
921
- // exhausted the grant or it was revoked / expired between our check
922
- // and the UPDATE), refuse this read.
923
- if (postRowsConsumed === Number(grantRow.rowsConsumed)) {
924
- throw new BreakGlassError("breakglass/grant-exhausted",
925
- "unsealRow: grant " + grantHandle.id + " was exhausted by a concurrent read", true);
926
- }
927
- void updateRes;
928
-
929
- // Fetch + unseal the target row. Model A goes straight through
930
- // cryptoField; Model B reads the row, lets cryptoField unseal the
931
- // non-glass-locked columns, and then decryptCell handles the
932
- // glass-locked columns separately (their ciphertext was written
933
- // by encryptCell at app-write time, not by cryptoField.sealRow).
934
- var rows = await clusterStorage.executeAll(
935
- "SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
936
- [String(rowId)]
937
- );
938
- if (!rows || rows.length === 0) {
939
- throw new BreakGlassError("breakglass/row-not-found",
940
- "unsealRow: " + table + "[" + rowId + "] not found", true);
941
- }
942
- var policy = await policyGet(table);
943
- var unsealedRow;
944
- if (policy && policy.cryptographic) {
945
- // Snapshot the raw glass-locked column ciphertexts BEFORE
946
- // cryptoField.unsealRow runs — cryptoField doesn't know about the
947
- // bgcell: format and would no-op (or error) on it. Then unseal the
948
- // rest of the row, then decrypt the glass-locked columns via
949
- // decryptCell with encryption-context binding.
950
- var rawCipher = {};
951
- for (var c = 0; c < policy.columns.length; c++) {
952
- rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
953
- }
954
- var rowMinusLocked = Object.assign({}, rows[0]);
955
- for (var c2 = 0; c2 < policy.columns.length; c2++) {
956
- delete rowMinusLocked[policy.columns[c2]];
957
- }
958
- unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
959
- for (var c3 = 0; c3 < policy.columns.length; c3++) {
960
- var col = policy.columns[c3];
961
- if (rawCipher[col] == null) continue;
962
- try {
963
- unsealedRow[col] = await decryptCell(rawCipher[col],
964
- { table: table, rowId: String(rowId), column: col });
965
- } catch (e) {
966
- throw new BreakGlassError("breakglass/cell-decrypt-failed",
967
- "unsealRow: cell decrypt failed for " + table + "[" + rowId +
968
- "]." + col + " (was the row migrated to Model B?): " +
969
- ((e && e.message) || String(e)), true);
970
- }
971
- }
972
- } else {
973
- unsealedRow = cryptoField.unsealRow(table, rows[0]);
974
- }
975
-
976
- // Per-row audit. The grant's reasonSealed is already cleartext after
977
- // unsealRow on the grant; pass it into the audit row honoring the
978
- // policy's auditReasonStorage mode.
979
- var reasonForAudit = _reasonForAudit(grantRow.reasonSealed || "",
980
- policy ? policy.auditReasonStorage : DEFAULT_AUDIT_REASON);
981
- audit.safeEmit({
982
- action: "breakglass.unsealrow",
983
- outcome: "success",
984
- actor: _actorFor(grantRow),
985
- reason: reasonForAudit.cleartext,
986
- metadata: {
987
- grantId: grantRow._id,
988
- table: table,
989
- rowId: String(rowId),
990
- columns: safeJson.parse(grantRow.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
991
- rowsRemaining: Number(grantRow.maxRowsPerGrant) - postRowsConsumed,
992
- reasonHmac: reasonForAudit.hmac,
993
- },
994
- });
995
- observability.event("breakglass.unsealrow", { table: table });
996
-
997
- return unsealedRow;
998
- }
999
-
1000
- // ---- Revoke ----
1001
-
1002
- async function revoke(grantId, opts) {
1003
- _requireInit();
1004
- if (typeof grantId !== "string" || grantId.length === 0) {
1005
- throw new BreakGlassError("breakglass/bad-grant-opts",
1006
- "revoke: grantId is required");
1007
- }
1008
- opts = opts || {};
1009
- var nowMs = Date.now();
1010
- await clusterStorage.execute(
1011
- "UPDATE _blamejs_break_glass_grants SET revokedAt = ? " +
1012
- "WHERE _id = ? AND revokedAt IS NULL",
1013
- [nowMs, grantId]
1014
- );
1015
- audit.safeEmit({
1016
- action: "breakglass.grant.revoked",
1017
- outcome: "success",
1018
- actor: requestHelpers.resolveActorWithOverride(opts),
1019
- reason: typeof opts.reason === "string" ? opts.reason : null,
1020
- metadata: { grantId: grantId },
1021
- });
1022
- return { revoked: true, grantId: grantId };
1023
- }
1024
-
1025
- // ---- listActive ----
1026
-
1027
- async function listActive(opts) {
1028
- _requireInit();
1029
- opts = opts || {};
1030
- var actor = requestHelpers.extractActorContext(opts.req);
1031
- var actorId = actor.userId || (opts.req && opts.req.apiKey && opts.req.apiKey.id) || null;
1032
- if (!actorId) return [];
1033
- // Use cryptoField's computeDerived so the hash matches the table's
1034
- // hashNamespace prefix — raw sha3Hash would produce a different value.
1035
- var derived = cryptoField.computeDerived(
1036
- "_blamejs_break_glass_grants", "issuedToActorId", actorId
1037
- );
1038
- if (!derived) return [];
1039
- var nowMs = Date.now();
1040
- var rows = await clusterStorage.executeAll(
1041
- "SELECT * FROM _blamejs_break_glass_grants " +
1042
- "WHERE issuedToActorHash = ? AND (revokedAt IS NULL) AND expiresAt > ? AND rowsConsumed < maxRowsPerGrant " +
1043
- "ORDER BY issuedAt DESC",
1044
- [derived.value, nowMs]
1045
- );
1046
- var out = [];
1047
- for (var i = 0; i < (rows || []).length; i++) {
1048
- var u = cryptoField.unsealRow("_blamejs_break_glass_grants", rows[i]);
1049
- out.push({
1050
- id: u._id,
1051
- scopeTable: u.scopeTable,
1052
- scopeColumns: safeJson.parse(u.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
1053
- issuedAt: Number(u.issuedAt),
1054
- expiresAt: Number(u.expiresAt),
1055
- rowsRemaining: Number(u.maxRowsPerGrant) - Number(u.rowsConsumed),
1056
- factorType: u.factorType,
1057
- });
1058
- }
1059
- return out;
1060
- }
1061
-
1062
- // ---- Service-account bypass ----
1063
- //
1064
- // Some legitimate workloads (nightly de-identification, scheduled
1065
- // compliance reports) need PHI access without a human at the
1066
- // keyboard. The framework refuses to silently bypass — operators
1067
- // declare explicit service-account bypasses per-table with an apiKey
1068
- // allowlist + required role. Both must match (verified apiKey id is
1069
- // in the allowlist AND the apiKey carries the required role/scope)
1070
- // before the bypass is granted. Each bypass emits its own distinct
1071
- // audit row so post-incident review can distinguish operator-initiated
1072
- // from service-initiated reads.
1073
-
1074
- async function unsealRowAsService(req, table, rowId, opts) {
1075
- _requireInit();
1076
- opts = opts || {};
1077
- if (!req || typeof req !== "object") {
1078
- throw new BreakGlassError("breakglass/bad-grant-opts",
1079
- "unsealRowAsService: req is required (with verified req.apiKey)");
1080
- }
1081
- if (typeof table !== "string" || table.length === 0) {
1082
- throw new BreakGlassError("breakglass/bad-grant-opts",
1083
- "unsealRowAsService: table must be a non-empty string");
1084
- }
1085
- var policy = await policyGet(table);
1086
- if (!policy) {
1087
- throw new BreakGlassError("breakglass/policy-not-set",
1088
- "unsealRowAsService: no break-glass policy for table '" + table + "'", true);
1089
- }
1090
- if (!policy.serviceAccountBypass) {
1091
- throw new BreakGlassError("breakglass/bypass-not-configured",
1092
- "unsealRowAsService: serviceAccountBypass is not configured for '" + table + "'", true);
1093
- }
1094
- var apiKeyOnReq = req.apiKey;
1095
- if (!apiKeyOnReq || typeof apiKeyOnReq.id !== "string") {
1096
- throw new BreakGlassError("breakglass/bypass-no-apikey",
1097
- "unsealRowAsService: req.apiKey.id is required (operator must run b.middleware.requireApiKey before this path)", true);
1098
- }
1099
- if (policy.serviceAccountBypass.apiKeyIds.indexOf(apiKeyOnReq.id) === -1) {
1100
- audit.safeEmit({
1101
- action: "breakglass.grant.bypass",
1102
- outcome: "denied",
1103
- actor: requestHelpers.extractActorContext(req),
1104
- reason: "apikey-not-in-allowlist",
1105
- metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id },
1106
- });
1107
- throw new BreakGlassError("breakglass/bypass-unauthorized",
1108
- "unsealRowAsService: apiKey '" + apiKeyOnReq.id +
1109
- "' is not in the bypass allowlist for '" + table + "'", true);
1110
- }
1111
- // Role check — actor must carry policy.serviceAccountBypass.requireRole
1112
- // either as a direct scope or via b.permissions.check resolution.
1113
- var actorScopes = Array.isArray(apiKeyOnReq.scopes) ? apiKeyOnReq.scopes :
1114
- Array.isArray(apiKeyOnReq.roles) ? apiKeyOnReq.roles :
1115
- [];
1116
- var requiredRole = policy.serviceAccountBypass.requireRole;
1117
- var hasRole = actorScopes.indexOf(requiredRole) !== -1;
1118
- if (!hasRole) {
1119
- audit.safeEmit({
1120
- action: "breakglass.grant.bypass",
1121
- outcome: "denied",
1122
- actor: requestHelpers.extractActorContext(req),
1123
- reason: "missing-role",
1124
- metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id, requiredRole: requiredRole },
1125
- });
1126
- throw new BreakGlassError("breakglass/bypass-unauthorized",
1127
- "unsealRowAsService: apiKey '" + apiKeyOnReq.id +
1128
- "' lacks required role '" + requiredRole + "'", true);
1129
- }
1130
-
1131
- // Fetch + unseal the row (Model A or Model B path, same as
1132
- // operator-initiated unsealRow).
1133
- var rows = await clusterStorage.executeAll(
1134
- "SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
1135
- [String(rowId)]
1136
- );
1137
- if (!rows || rows.length === 0) {
1138
- throw new BreakGlassError("breakglass/row-not-found",
1139
- "unsealRowAsService: " + table + "[" + rowId + "] not found", true);
1140
- }
1141
- var unsealedRow;
1142
- if (policy.cryptographic) {
1143
- var rawCipher = {};
1144
- for (var c = 0; c < policy.columns.length; c++) rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
1145
- var rowMinusLocked = Object.assign({}, rows[0]);
1146
- for (var c2 = 0; c2 < policy.columns.length; c2++) delete rowMinusLocked[policy.columns[c2]];
1147
- unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
1148
- for (var c3 = 0; c3 < policy.columns.length; c3++) {
1149
- var col = policy.columns[c3];
1150
- if (rawCipher[col] == null) continue;
1151
- unsealedRow[col] = await decryptCell(rawCipher[col],
1152
- { table: table, rowId: String(rowId), column: col });
1153
- }
1154
- } else {
1155
- unsealedRow = cryptoField.unsealRow(table, rows[0]);
1156
- }
1157
-
1158
- audit.safeEmit({
1159
- action: "breakglass.grant.bypass",
1160
- outcome: "success",
1161
- actor: requestHelpers.extractActorContext(req),
1162
- reason: typeof opts.reason === "string" ? opts.reason : null,
1163
- metadata: {
1164
- table: table,
1165
- rowId: String(rowId),
1166
- apiKeyId: apiKeyOnReq.id,
1167
- requiredRole: requiredRole,
1168
- columns: policy.columns.slice(),
1169
- },
1170
- });
1171
- observability.event("breakglass.grant.bypass", { table: table });
1172
- return unsealedRow;
1173
- }
1174
-
1175
- // ---- Admin tools ----
1176
- //
1177
- // `listActiveAll` returns every active grant (across all actors) —
1178
- // for security-team dashboards and offboarding workflows.
1179
- // `revokeAll` mass-revokes grants matching criteria — used by IR
1180
- // teams when an account is suspected compromised. Both require
1181
- // admin scope (operator wires via opts.requireScope or their own gate).
1182
-
1183
- async function listActiveAll(opts) {
1184
- _requireInit();
1185
- opts = opts || {};
1186
- var nowMs = Date.now();
1187
- var clauses = ["(revokedAt IS NULL)", "expiresAt > ?", "rowsConsumed < maxRowsPerGrant"];
1188
- var params = [nowMs];
1189
- if (opts.table) {
1190
- clauses.push("scopeTable = ?");
1191
- params.push(opts.table);
1192
- }
1193
- if (opts.since) {
1194
- clauses.push("issuedAt >= ?");
1195
- params.push(opts.since);
1196
- }
1197
- var rows = await clusterStorage.executeAll(
1198
- "SELECT * FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND ") +
1199
- " ORDER BY issuedAt DESC",
1200
- params
1201
- );
1202
- var out = [];
1203
- for (var i = 0; i < (rows || []).length; i++) {
1204
- var u = cryptoField.unsealRow("_blamejs_break_glass_grants", rows[i]);
1205
- out.push({
1206
- id: u._id,
1207
- issuedToActorId: u.issuedToActorId,
1208
- scopeTable: u.scopeTable,
1209
- scopeColumns: safeJson.parse(u.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
1210
- factorType: u.factorType,
1211
- issuedAt: Number(u.issuedAt),
1212
- expiresAt: Number(u.expiresAt),
1213
- rowsRemaining: Number(u.maxRowsPerGrant) - Number(u.rowsConsumed),
1214
- });
1215
- }
1216
- audit.safeEmit({
1217
- action: "breakglass.admin.listactiveall",
1218
- outcome: "success",
1219
- actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
1220
- metadata: { resultCount: out.length, filterTable: opts.table || null },
1221
- });
1222
- return out;
1223
- }
1224
-
1225
- async function revokeAll(criteria, opts) {
1226
- _requireInit();
1227
- if (!criteria || typeof criteria !== "object") {
1228
- throw new BreakGlassError("breakglass/bad-revoke-criteria",
1229
- "revokeAll: criteria is required ({ actorId?, table? })");
1230
- }
1231
- if (!criteria.actorId && !criteria.table) {
1232
- throw new BreakGlassError("breakglass/bad-revoke-criteria",
1233
- "revokeAll: at least one of { actorId, table } is required (refusing to mass-revoke without scope)");
1234
- }
1235
- opts = opts || {};
1236
- var clauses = ["revokedAt IS NULL"];
1237
- var params = [];
1238
- if (criteria.actorId) {
1239
- var derived = cryptoField.computeDerived(
1240
- "_blamejs_break_glass_grants", "issuedToActorId", criteria.actorId
1241
- );
1242
- if (derived) {
1243
- clauses.push("issuedToActorHash = ?");
1244
- params.push(derived.value);
1245
- }
1246
- }
1247
- if (criteria.table) {
1248
- clauses.push("scopeTable = ?");
1249
- params.push(criteria.table);
1250
- }
1251
- // Snapshot the to-be-revoked grant ids first so audit captures specifics.
1252
- var ids = await clusterStorage.executeAll(
1253
- "SELECT _id FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND "),
1254
- params
1255
- );
1256
- var nowMs = Date.now();
1257
- await clusterStorage.execute(
1258
- "UPDATE _blamejs_break_glass_grants SET revokedAt = ? WHERE " + clauses.join(" AND "),
1259
- [nowMs].concat(params)
1260
- );
1261
- audit.safeEmit({
1262
- action: "breakglass.admin.revokeall",
1263
- outcome: "success",
1264
- actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
1265
- reason: typeof criteria.reason === "string" ? criteria.reason : null,
1266
- metadata: {
1267
- filterActorId: criteria.actorId || null,
1268
- filterTable: criteria.table || null,
1269
- revokedCount: (ids || []).length,
1270
- revokedIds: (ids || []).map(function (r) { return r._id; }),
1271
- },
1272
- });
1273
- return { revokedCount: (ids || []).length };
1274
- }
1275
-
1276
- // ---- Sweep (best-effort cleanup of expired grants) ----
1277
-
1278
- async function _sweepExpired(opts) {
1279
- opts = opts || {};
1280
- var nowMs = Date.now();
1281
- var expired = await clusterStorage.executeAll(
1282
- "SELECT _id, issuedToActorId, scopeTable, rowsConsumed FROM _blamejs_break_glass_grants " +
1283
- "WHERE revokedAt IS NULL AND expiresAt <= ?",
1284
- [nowMs]
1285
- );
1286
- for (var i = 0; i < (expired || []).length; i++) {
1287
- var row = expired[i];
1288
- audit.safeEmit({
1289
- action: "breakglass.grant.expired",
1290
- outcome: "success",
1291
- actor: { userId: row.issuedToActorId },
1292
- metadata: { grantId: row._id, table: row.scopeTable, rowsConsumed: Number(row.rowsConsumed) },
1293
- });
1294
- }
1295
- await clusterStorage.execute(
1296
- "UPDATE _blamejs_break_glass_grants SET revokedAt = ? " +
1297
- "WHERE revokedAt IS NULL AND expiresAt <= ?",
1298
- [nowMs, nowMs]
1299
- );
1300
- return { expired: (expired || []).length };
1301
- }
1302
-
1303
- void safeAsync; // kept import for future grant-async ops in v0.5.1+
1304
-
1305
- module.exports = {
1306
- init: init,
1307
- policy: {
1308
- set: policySet,
1309
- get: policyGet,
1310
- list: policyList,
1311
- delete: policyDelete,
1312
- },
1313
- grant: grant,
1314
- unsealRow: unsealRow,
1315
- revoke: revoke,
1316
- listActive: listActive,
1317
- // Cryptographic mode (Model B) — per-cell encryption with context
1318
- // binding. Operators in cryptographic mode call encryptCell at write
1319
- // time; unsealRow auto-routes to decryptCell for glass-locked columns.
1320
- encryptCell: encryptCell,
1321
- decryptCell: decryptCell,
1322
- migrate: migrate,
1323
- // Service-account bypass — operator-declared per-table, gated by
1324
- // (apiKey id in allowlist) AND (apiKey carries required role).
1325
- unsealRowAsService: unsealRowAsService,
1326
- // Admin tools — for security-team dashboards (listActiveAll) and
1327
- // incident-response offboarding (revokeAll). Operators wire their
1328
- // own gate (requireScope or middleware) on the calling routes.
1329
- listActiveAll: listActiveAll,
1330
- revokeAll: revokeAll,
1331
- BreakGlassError: BreakGlassError,
1332
-
1333
- // Test-only / sweep — operators with active grant volume wire this
1334
- // into a scheduler; the framework doesn't auto-start the timer so
1335
- // boot doesn't depend on anything firing in the background.
1336
- _sweepExpiredForTest: _sweepExpired,
1337
- _resetForTest: _resetForTest,
1338
- };
1
+ "use strict";
2
+ /**
3
+ * break-glass — column-policy / row-enforcement step-up auth.
4
+ *
5
+ * Operator declares which columns of which tables are GLASS-LOCKED.
6
+ * Reading the encrypted value on any row of a glass-locked column
7
+ * requires the calling operator to:
8
+ *
9
+ * 1. Prove identity with a second factor (TOTP / passkey).
10
+ * 2. Provide an operator-supplied REASON the audit chain captures.
11
+ * 3. Hold a short-lived, scope-bounded GRANT.
12
+ *
13
+ * Each row read under a grant emits a per-row audit event. Default
14
+ * `maxRowsPerGrant: 1` enforces row-by-row auth — each row access is
15
+ * its own discrete authenticated event, compliance-defensible by
16
+ * construction. Operators with batch workflows raise the cap per-table.
17
+ *
18
+ * Spec: memory/specs/blamejs-break-glass-spec.md
19
+ *
20
+ * Ships Model A (policy gate) + TOTP factor + config-time input
21
+ * validation + 14 error codes + audit chain integration. Model B
22
+ * (cryptographic gate via per-row K_row), passkey factor, service-
23
+ * account bypass, and admin tools land in subsequent patches.
24
+ *
25
+ * Public API:
26
+ *
27
+ * b.breakGlass.init({ now? }) — boot once
28
+ * b.breakGlass.policy.set(table, opts)
29
+ * b.breakGlass.policy.get(table) — null if unset
30
+ * b.breakGlass.policy.list()
31
+ * b.breakGlass.policy.delete(table)
32
+ *
33
+ * b.breakGlass.grant({ req, table, reason, factor, columns? })
34
+ * b.breakGlass.unsealRow(grant, table, rowId)
35
+ * b.breakGlass.revoke(grantId, { reason })
36
+ * b.breakGlass.listActive({ req })
37
+ *
38
+ * b.breakGlass.BreakGlassError
39
+ */
40
+ var audit = require("./audit");
41
+ var C = require("./constants");
42
+ var cache = require("./cache");
43
+ var clusterStorage = require("./cluster-storage");
44
+ var { generateBytes, generateToken, kdf, sha3Hash, encryptPacked, decryptPacked } = require("./crypto");
45
+ var cryptoField = require("./crypto-field");
46
+ var lazyRequire = require("./lazy-require");
47
+ var observability = require("./observability");
48
+ var requestHelpers = require("./request-helpers");
49
+ var safeAsync = require("./safe-async");
50
+ var safeJson = require("./safe-json");
51
+ var safeSql = require("./safe-sql");
52
+ var totp = require("./totp");
53
+ var validateOpts = require("./validate-opts");
54
+ var { defineClass } = require("./framework-error");
55
+
56
+ var vault = lazyRequire(function () { return require("./vault"); });
57
+
58
+ var lockout = lazyRequire(function () { return require("./auth/lockout"); });
59
+ var passkey = lazyRequire(function () { return require("./auth/passkey"); });
60
+
61
+ // Errors — all 14 codes documented in the spec. `permanent: true`
62
+ // means caller's input is bad (config-time / call-site reject);
63
+ // `permanent: false` means transient (factor failed, rate-limited)
64
+ // — caller may retry.
65
+ var BreakGlassError = defineClass("BreakGlassError", { alwaysPermanent: false });
66
+
67
+ // ---- Defaults (matched to operator-locked decisions) ----
68
+
69
+ // Symmetric byte counts: 32-byte cell-key (kdf output, XChaCha20 key
70
+ // length); 32-byte DEK (AEAD key); 16-byte grant-id randomness (128-bit
71
+ // uniqueness for cross-table grant lookups). All routed through C.BYTES
72
+ // so the framework's byte math has a single source of truth.
73
+ var CELL_KEY_BYTES = C.BYTES.bytes(32);
74
+ var DEK_BYTES = C.BYTES.bytes(32);
75
+ var GRANT_ID_BYTES = C.BYTES.bytes(16);
76
+
77
+ var DEFAULT_GRANT_TTL_MS = C.TIME.minutes(15);
78
+ var DEFAULT_MAX_ROWS = 1; // operator-locked: row-by-row auth
79
+ var DEFAULT_REASON_MIN_LEN = 12;
80
+ var DEFAULT_LOCKED_BEHAVIOR = "throw"; // or "redact"
81
+ var DEFAULT_AUDIT_REASON = "cleartext";
82
+ var ALLOWED_FACTORS = ["totp", "passkey"];
83
+ var ALLOWED_REASON_STORAGE = ["cleartext", "hmac", "both"];
84
+
85
+ // In-memory policy cache. Cluster-shared via the policies table; the
86
+ // cache short-circuits the DB roundtrip on the unsealRow hot path.
87
+ // Populated on first access per-table; invalidated on policy.set/delete.
88
+ var policyCache = new Map(); // table -> policy
89
+ var initialized = false;
90
+ // Framework-wide trustProxy setting (set at init). When true, the
91
+ // break-glass primitive consults X-Forwarded-For to populate the
92
+ // grant row's `ip` field — same trust boundary as middleware.
93
+ var _trustProxy = false;
94
+
95
+ // Factor lockout — wrap auth.lockout so a hostile actor brute-forcing
96
+ // TOTP codes against break-glass gets shut out after a few failures.
97
+ // Lazy-init on first grant attempt so init() doesn't require the
98
+ // cache primitive to be wired before break-glass loads.
99
+ var _factorLockout = null;
100
+ var _factorLockoutCache = null;
101
+ function _ensureFactorLockout() {
102
+ if (_factorLockout) return _factorLockout;
103
+ _factorLockoutCache = cache.create({
104
+ namespace: "breakglass.factor",
105
+ backend: "memory",
106
+ });
107
+ _factorLockout = lockout().create({
108
+ namespace: "breakglass.factor",
109
+ cache: _factorLockoutCache,
110
+ maxAttempts: 5,
111
+ windowMs: C.TIME.minutes(15),
112
+ audit: audit,
113
+ });
114
+ return _factorLockout;
115
+ }
116
+
117
+ // ---- Cryptographic mode (Model B) — per-cell encryption with context binding ----
118
+ //
119
+ // Each policy in cryptographic mode has a per-policy DEK (data
120
+ // encryption key) generated at first use. The DEK is vault-sealed so
121
+ // it survives restarts. At cell encrypt time, the framework derives a
122
+ // per-cell key K_cell = SHAKE256(DEK || table || rowId || column) so
123
+ // every (table, rowId, column) triple gets a unique key. Encryption
124
+ // uses XChaCha20-Poly1305 with AAD = SHA3-512(table || rowId || column)
125
+ // — the AEAD tag itself is bound to the encryption context, so a
126
+ // ciphertext from row A literally cannot be decrypted as row B even
127
+ // with the same DEK.
128
+ //
129
+ // THREAT MODEL HONESTY: this provides defense-in-depth via per-cell
130
+ // keys + encryption-context binding (cross-cell tampering / accidental
131
+ // row-swap fails closed). It does NOT defend against vault-key
132
+ // compromise alone — the DEK is still vault-recoverable. True
133
+ // second-factor cryptographic gating ships in v0.5.2 with passkey
134
+ // integration (the passkey private key lives on the YubiKey, not in
135
+ // the framework, so a vault leak alone can't unwrap).
136
+
137
+ // In-memory DEK cache. Keyed by table name. Cleared on _resetForTest.
138
+ var dekCache = new Map();
139
+
140
+ function _aadFor(table, rowId, column) {
141
+ return sha3Hash(table + "|" + String(rowId) + "|" + column);
142
+ }
143
+
144
+ function _kCell(dek, table, rowId, column) {
145
+ return kdf(Buffer.concat([
146
+ Buffer.isBuffer(dek) ? dek : Buffer.from(dek, "base64"),
147
+ Buffer.from("breakglass.cell|" + table + "|" + String(rowId) + "|" + column, "utf8"),
148
+ ]), CELL_KEY_BYTES);
149
+ }
150
+
151
+ async function _ensureDek(table) {
152
+ if (dekCache.has(table)) return dekCache.get(table);
153
+ // DEK is vault-sealed and stored in the policy row's `dekSealed`
154
+ // column. Generated lazily on first use of cryptographic-mode for
155
+ // the table. Cached in-memory after first read.
156
+ var rows = await clusterStorage.executeAll(
157
+ "SELECT dekSealed FROM _blamejs_break_glass_policies WHERE tableName = ?",
158
+ [table]
159
+ );
160
+ if (!rows || rows.length === 0) {
161
+ throw new BreakGlassError("breakglass/policy-not-set",
162
+ "_ensureDek: no policy for table '" + table + "'", true);
163
+ }
164
+ var sealed = rows[0].dekSealed;
165
+ var dek;
166
+ if (sealed) {
167
+ dek = Buffer.from(vault().unseal(sealed), "base64");
168
+ } else {
169
+ dek = generateBytes(DEK_BYTES);
170
+ var sealedDek = vault().seal(dek.toString("base64"));
171
+ await clusterStorage.execute(
172
+ "UPDATE _blamejs_break_glass_policies SET dekSealed = ? WHERE tableName = ?",
173
+ [sealedDek, table]
174
+ );
175
+ }
176
+ dekCache.set(table, dek);
177
+ return dek;
178
+ }
179
+
180
+ // Encrypt a single cell value with encryption context binding. Operators
181
+ // in cryptographic mode use this at write time INSTEAD of letting
182
+ // cryptoField.sealRow seal the column.
183
+ async function encryptCell(plaintext, ctx) {
184
+ _requireInit();
185
+ if (!ctx || typeof ctx !== "object" ||
186
+ typeof ctx.table !== "string" || ctx.table.length === 0 ||
187
+ ctx.rowId === undefined || ctx.rowId === null ||
188
+ typeof ctx.column !== "string" || ctx.column.length === 0) {
189
+ throw new BreakGlassError("breakglass/bad-cell-ctx",
190
+ "encryptCell: ctx must be { table, rowId, column }");
191
+ }
192
+ var policy = await policyGet(ctx.table);
193
+ if (!policy || !policy.cryptographic) {
194
+ throw new BreakGlassError("breakglass/policy-not-set",
195
+ "encryptCell: table '" + ctx.table + "' is not in cryptographic mode " +
196
+ "(set policy.cryptographic = true to opt in)", true);
197
+ }
198
+ if (policy.columns.indexOf(ctx.column) === -1) {
199
+ throw new BreakGlassError("breakglass/grant-column-mismatch",
200
+ "encryptCell: column '" + ctx.column + "' is not glass-locked on '" + ctx.table + "'", true);
201
+ }
202
+ var dek = await _ensureDek(ctx.table);
203
+ var kCell = _kCell(dek, ctx.table, ctx.rowId, ctx.column);
204
+ var aad = _aadFor(ctx.table, ctx.rowId, ctx.column);
205
+ var pt = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), "utf8");
206
+ var packed = encryptPacked(pt, kCell, aad);
207
+ return "bgcell:1:" + packed.toString("base64");
208
+ }
209
+
210
+ // Decrypt a cell value. Caller must hold a valid grant covering the
211
+ // (table, column) — caller passes through unsealRow which gates this.
212
+ // The encryption context (table, rowId, column) is passed both to the
213
+ // key derivation AND the AAD; if the caller passes the wrong rowId
214
+ // trying to "swap" ciphertexts between rows, decryption fails closed.
215
+ async function decryptCell(ciphertext, ctx) {
216
+ _requireInit();
217
+ if (typeof ciphertext !== "string" || ciphertext.indexOf("bgcell:1:") !== 0) {
218
+ throw new BreakGlassError("breakglass/bad-ciphertext",
219
+ "decryptCell: expected 'bgcell:1:<base64>' format");
220
+ }
221
+ if (!ctx || typeof ctx !== "object") {
222
+ throw new BreakGlassError("breakglass/bad-cell-ctx",
223
+ "decryptCell: ctx must be { table, rowId, column }");
224
+ }
225
+ var dek = await _ensureDek(ctx.table);
226
+ var kCell = _kCell(dek, ctx.table, ctx.rowId, ctx.column);
227
+ var aad = _aadFor(ctx.table, ctx.rowId, ctx.column);
228
+ var packed = Buffer.from(ciphertext.slice("bgcell:1:".length), "base64");
229
+ return decryptPacked(packed, kCell, aad).toString("utf8");
230
+ }
231
+
232
+ // ---- Migration support ----
233
+ //
234
+ // Operator runs `b.breakGlass.migrate(table, opts)` (or the CLI
235
+ // equivalent) to convert existing Model-A-sealed rows into Model B
236
+ // per-cell-encrypted form. Each row's glass-locked columns are
237
+ // unsealed via cryptoField, re-encrypted via encryptCell, and written
238
+ // back. The migration is idempotent — a row already in Model B form
239
+ // (column starts with "bgcell:") is skipped.
240
+
241
+ async function migrate(table, opts) {
242
+ _requireInit();
243
+ opts = opts || {};
244
+ validateOpts(opts, ["batchSize", "callerOpts"], "breakGlass.migrate");
245
+ var policy = await policyGet(table);
246
+ if (!policy) {
247
+ throw new BreakGlassError("breakglass/policy-not-set",
248
+ "migrate: no policy for table '" + table + "'", true);
249
+ }
250
+ if (!policy.cryptographic) {
251
+ throw new BreakGlassError("breakglass/bad-policy",
252
+ "migrate: policy must be cryptographic: true to migrate to Model B", true);
253
+ }
254
+ var batchSize = opts.batchSize || 100;
255
+ var totalRows = 0;
256
+ var migratedRows = 0;
257
+ var skippedRows = 0;
258
+ var lastId = "";
259
+ // Iterate via _id-keyset paging so we don't load the whole table into memory.
260
+ while (true) {
261
+ // table is already validated as a safe identifier shape via
262
+ // _validatePolicySet — wrap in "..." per the framework's
263
+ // identifier-quoting convention.
264
+ var qTable = '"' + table + '"';
265
+ var rows = await clusterStorage.executeAll(
266
+ "SELECT * FROM " + qTable + " WHERE _id > ? ORDER BY _id ASC LIMIT ?",
267
+ [lastId, batchSize]
268
+ );
269
+ if (!rows || rows.length === 0) break;
270
+ for (var i = 0; i < rows.length; i++) {
271
+ totalRows++;
272
+ var row = rows[i];
273
+ var unsealed = cryptoField.unsealRow(table, row);
274
+ var anyChanged = false;
275
+ var update = { _id: row._id };
276
+ for (var c = 0; c < policy.columns.length; c++) {
277
+ var col = policy.columns[c];
278
+ var current = unsealed[col];
279
+ if (current == null) continue;
280
+ if (typeof current === "string" && current.indexOf("bgcell:") === 0) {
281
+ continue; // already migrated
282
+ }
283
+ var encrypted = await encryptCell(current, { table: table, rowId: row._id, column: col });
284
+ update[col] = encrypted;
285
+ anyChanged = true;
286
+ }
287
+ if (anyChanged) {
288
+ // Write Model B ciphertext directly — bypassing cryptoField so
289
+ // the cell ciphertext stays as a literal string, not double-sealed.
290
+ var setCols = Object.keys(update).filter(function (k) { return k !== "_id"; });
291
+ if (setCols.length > 0) {
292
+ // Column names came from the validated policy.columns —
293
+ // also wrap each in "..." for the same identifier-quoting
294
+ // convention.
295
+ var setSql = setCols.map(function (k) { return '"' + k + '" = ?'; }).join(", ");
296
+ var vals = setCols.map(function (k) { return update[k]; });
297
+ vals.push(row._id);
298
+ await clusterStorage.execute(
299
+ "UPDATE " + qTable + " SET " + setSql + " WHERE _id = ?",
300
+ vals
301
+ );
302
+ migratedRows++;
303
+ }
304
+ } else {
305
+ skippedRows++;
306
+ }
307
+ lastId = row._id;
308
+ }
309
+ if (rows.length < batchSize) break;
310
+ }
311
+ audit.safeEmit({
312
+ action: "breakglass.migrate",
313
+ outcome: "success",
314
+ actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
315
+ metadata: {
316
+ table: table,
317
+ totalRows: totalRows,
318
+ migratedRows: migratedRows,
319
+ skippedRows: skippedRows,
320
+ },
321
+ });
322
+ return { table: table, totalRows: totalRows, migratedRows: migratedRows, skippedRows: skippedRows };
323
+ }
324
+
325
+ // ---- init ----
326
+
327
+ function init(opts) {
328
+ opts = opts || {};
329
+ validateOpts(opts, ["now", "trustProxy"], "breakGlass.init");
330
+ initialized = true;
331
+ policyCache.clear();
332
+ _factorLockout = null;
333
+ _trustProxy = opts.trustProxy === true || typeof opts.trustProxy === "number"
334
+ ? opts.trustProxy : false;
335
+ }
336
+
337
+ function _resetForTest() {
338
+ initialized = false;
339
+ policyCache.clear();
340
+ dekCache.clear();
341
+ if (_factorLockoutCache && typeof _factorLockoutCache.close === "function") {
342
+ try { _factorLockoutCache.close(); } catch (_e) { /* best-effort */ }
343
+ }
344
+ _factorLockout = null;
345
+ _factorLockoutCache = null;
346
+ _trustProxy = false;
347
+ }
348
+
349
+ function _requireInit() {
350
+ if (!initialized) {
351
+ throw new BreakGlassError("breakglass/not-initialized",
352
+ "b.breakGlass.init() must be called before use");
353
+ }
354
+ }
355
+
356
+ // ---- Policy CRUD ----
357
+
358
+ function _validatePolicySet(table, opts) {
359
+ if (typeof table !== "string" || table.length === 0) {
360
+ throw new BreakGlassError("breakglass/bad-policy",
361
+ "policy.set: table must be a non-empty string");
362
+ }
363
+ // Identifier safety: the table name flows raw into SQL via interpolation
364
+ // in migrate() / unsealRowAsService(). safeSql.validateIdentifier closes
365
+ // the shape so a malicious / mistyped name with embedded `"` or
366
+ // SQL-keyword shape can't break out of the wrapping quotes.
367
+ // allowReserved: true because every interpolation site quotes the
368
+ // identifier, so reserved-word names work via the SQL standard quoting
369
+ // rule.
370
+ try {
371
+ safeSql.validateIdentifier(table, { allowReserved: true });
372
+ } catch (e) {
373
+ throw new BreakGlassError("breakglass/bad-policy",
374
+ "policy.set: table '" + table + "' is not a valid SQL identifier: " +
375
+ ((e && e.message) || String(e)));
376
+ }
377
+ if (!opts || typeof opts !== "object") {
378
+ throw new BreakGlassError("breakglass/bad-policy",
379
+ "policy.set: opts is required");
380
+ }
381
+ validateOpts(opts, [
382
+ "columns", "factors", "cryptographic", "grantTtl", "maxRowsPerGrant",
383
+ "reasonRequired", "reasonMinLength", "pinIp", "sessionPin",
384
+ "onLockedAccess", "requireScope", "serviceAccountBypass",
385
+ "auditReasonStorage",
386
+ ], "breakglass.policy.set");
387
+ if (!Array.isArray(opts.columns) || opts.columns.length === 0) {
388
+ throw new BreakGlassError("breakglass/bad-policy",
389
+ "policy.set: columns must be a non-empty array");
390
+ }
391
+ for (var i = 0; i < opts.columns.length; i++) {
392
+ var colName = opts.columns[i];
393
+ if (typeof colName !== "string" || colName.length === 0) {
394
+ throw new BreakGlassError("breakglass/bad-policy",
395
+ "policy.set: columns[" + i + "] must be a non-empty string");
396
+ }
397
+ // Same identifier-shape check as the table — column names flow into
398
+ // the migrate() UPDATE statement as bare names.
399
+ try {
400
+ safeSql.validateIdentifier(colName, { allowReserved: true });
401
+ } catch (e) {
402
+ throw new BreakGlassError("breakglass/bad-policy",
403
+ "policy.set: columns[" + i + "]='" + colName + "' is not a valid SQL identifier: " +
404
+ ((e && e.message) || String(e)));
405
+ }
406
+ }
407
+ if (!Array.isArray(opts.factors) || opts.factors.length === 0) {
408
+ throw new BreakGlassError("breakglass/bad-policy",
409
+ "policy.set: factors must be a non-empty array");
410
+ }
411
+ for (var j = 0; j < opts.factors.length; j++) {
412
+ if (ALLOWED_FACTORS.indexOf(opts.factors[j]) === -1) {
413
+ throw new BreakGlassError("breakglass/bad-policy",
414
+ "policy.set: factors[" + j + "] '" + opts.factors[j] +
415
+ "' not in v0.5.0 allowed factors [" + ALLOWED_FACTORS.join(",") + "]" +
416
+ " (passkey lands in v0.5.2)");
417
+ }
418
+ }
419
+ // Model B (cryptographic mode) ships in v0.5.1. When enabled,
420
+ // glass-locked columns must be encrypted with `b.breakGlass.encryptCell`
421
+ // at write time (the framework can't auto-encrypt at write because
422
+ // policy-set may post-date existing data; operators run the migration
423
+ // CLI to convert existing rows). At unseal time, the row's
424
+ // glass-locked columns are decrypted via decryptCell with encryption
425
+ // context binding (table, rowId, column).
426
+ validateOpts.optionalBoolean(opts.cryptographic, "policy.set: cryptographic", BreakGlassError, "breakglass/bad-policy");
427
+ var grantTtl = opts.grantTtl != null ? opts.grantTtl : DEFAULT_GRANT_TTL_MS;
428
+ if (typeof grantTtl !== "number" || !isFinite(grantTtl) || grantTtl <= 0) {
429
+ throw new BreakGlassError("breakglass/bad-policy",
430
+ "policy.set: grantTtl must be a positive number of milliseconds");
431
+ }
432
+ var maxRows = opts.maxRowsPerGrant != null ? opts.maxRowsPerGrant : DEFAULT_MAX_ROWS;
433
+ if (!Number.isInteger(maxRows) || maxRows < 1) {
434
+ throw new BreakGlassError("breakglass/bad-policy",
435
+ "policy.set: maxRowsPerGrant must be a positive integer (default 1 — row-by-row auth)");
436
+ }
437
+ if (opts.onLockedAccess != null &&
438
+ opts.onLockedAccess !== "throw" && opts.onLockedAccess !== "redact") {
439
+ throw new BreakGlassError("breakglass/bad-policy",
440
+ "policy.set: onLockedAccess must be 'throw' or 'redact'");
441
+ }
442
+ if (opts.auditReasonStorage != null &&
443
+ ALLOWED_REASON_STORAGE.indexOf(opts.auditReasonStorage) === -1) {
444
+ throw new BreakGlassError("breakglass/bad-policy",
445
+ "policy.set: auditReasonStorage must be one of " + ALLOWED_REASON_STORAGE.join("/"));
446
+ }
447
+ // Service-account bypass: explicit opt-in per table. Operators
448
+ // declare the apiKey ids that may bypass + a required role; the
449
+ // framework requires BOTH to grant the bypass. Without this opt set,
450
+ // there is NO bypass path — every read of a glass-locked column
451
+ // requires a fresh grant.
452
+ var serviceAccountBypass = null;
453
+ if (opts.serviceAccountBypass != null && opts.serviceAccountBypass !== false) {
454
+ var sab = opts.serviceAccountBypass;
455
+ if (!sab || typeof sab !== "object") {
456
+ throw new BreakGlassError("breakglass/bad-policy",
457
+ "policy.set: serviceAccountBypass must be an object { enabled, apiKeyIds, requireRole }");
458
+ }
459
+ if (sab.enabled !== true) {
460
+ throw new BreakGlassError("breakglass/bad-policy",
461
+ "policy.set: serviceAccountBypass.enabled must be true (set serviceAccountBypass: false to disable)");
462
+ }
463
+ if (!Array.isArray(sab.apiKeyIds) || sab.apiKeyIds.length === 0) {
464
+ throw new BreakGlassError("breakglass/bad-policy",
465
+ "policy.set: serviceAccountBypass.apiKeyIds must be a non-empty array of apiKey ids");
466
+ }
467
+ if (typeof sab.requireRole !== "string" || sab.requireRole.length === 0) {
468
+ throw new BreakGlassError("breakglass/bad-policy",
469
+ "policy.set: serviceAccountBypass.requireRole must be a non-empty role / scope string");
470
+ }
471
+ serviceAccountBypass = {
472
+ enabled: true,
473
+ apiKeyIds: sab.apiKeyIds.slice(),
474
+ requireRole: sab.requireRole,
475
+ };
476
+ }
477
+ return {
478
+ cryptographic: opts.cryptographic === true,
479
+ grantTtl: grantTtl,
480
+ maxRowsPerGrant: maxRows,
481
+ reasonRequired: opts.reasonRequired !== false,
482
+ reasonMinLength: opts.reasonMinLength != null ? opts.reasonMinLength : DEFAULT_REASON_MIN_LEN,
483
+ pinIp: opts.pinIp !== false,
484
+ sessionPin: opts.sessionPin !== false,
485
+ onLockedAccess: opts.onLockedAccess || DEFAULT_LOCKED_BEHAVIOR,
486
+ requireScope: opts.requireScope != null ? opts.requireScope : null,
487
+ auditReasonStorage: opts.auditReasonStorage || DEFAULT_AUDIT_REASON,
488
+ serviceAccountBypass: serviceAccountBypass,
489
+ };
490
+ }
491
+
492
+ async function policySet(table, opts, callerOpts) {
493
+ _requireInit();
494
+ var validated = _validatePolicySet(table, opts);
495
+ var policyRow = {
496
+ tableName: table,
497
+ columnsJson: JSON.stringify(opts.columns),
498
+ factorsJson: JSON.stringify(opts.factors),
499
+ cryptographic: validated.cryptographic ? 1 : 0,
500
+ grantTtlMs: validated.grantTtl,
501
+ maxRowsPerGrant: validated.maxRowsPerGrant,
502
+ reasonRequired: validated.reasonRequired ? 1 : 0,
503
+ reasonMinLength: validated.reasonMinLength,
504
+ pinIp: validated.pinIp ? 1 : 0,
505
+ sessionPin: validated.sessionPin ? 1 : 0,
506
+ onLockedAccess: validated.onLockedAccess,
507
+ requireScope: validated.requireScope,
508
+ serviceAccountBypassJson: validated.serviceAccountBypass
509
+ ? JSON.stringify(validated.serviceAccountBypass)
510
+ : null,
511
+ auditReasonStorage: validated.auditReasonStorage,
512
+ updatedAt: Date.now(),
513
+ };
514
+ var sealed = cryptoField.sealRow("_blamejs_break_glass_policies", policyRow);
515
+ // UPSERT — both Postgres and SQLite support ON CONFLICT.
516
+ var keys = Object.keys(sealed);
517
+ var cols = keys.join(", ");
518
+ var qs = keys.map(function () { return "?"; }).join(", ");
519
+ var setSql = keys.filter(function (k) { return k !== "tableName"; })
520
+ .map(function (k) { return k + " = excluded." + k; }).join(", ");
521
+ var sql = "INSERT INTO _blamejs_break_glass_policies (" + cols + ") " +
522
+ "VALUES (" + qs + ") " +
523
+ "ON CONFLICT (tableName) DO UPDATE SET " + setSql;
524
+ await clusterStorage.execute(sql, keys.map(function (k) { return sealed[k]; }));
525
+ policyCache.delete(table);
526
+
527
+ audit.safeEmit({
528
+ action: "breakglass.policy.set",
529
+ outcome: "success",
530
+ actor: requestHelpers.resolveActorWithOverride(callerOpts),
531
+ metadata: {
532
+ table: table,
533
+ columnCount: opts.columns.length,
534
+ factors: opts.factors,
535
+ grantTtlMs: validated.grantTtl,
536
+ maxRowsPerGrant: validated.maxRowsPerGrant,
537
+ },
538
+ });
539
+ observability.event("breakglass.policy.set", { table: table });
540
+ return { applied: true, table: table };
541
+ }
542
+
543
+ async function policyGet(table) {
544
+ _requireInit();
545
+ if (typeof table !== "string" || table.length === 0) return null;
546
+ if (policyCache.has(table)) return policyCache.get(table);
547
+ var rows = await clusterStorage.executeAll(
548
+ "SELECT * FROM _blamejs_break_glass_policies WHERE tableName = ?",
549
+ [table]
550
+ );
551
+ if (!rows || rows.length === 0) {
552
+ policyCache.set(table, null);
553
+ return null;
554
+ }
555
+ var unsealed = cryptoField.unsealRow("_blamejs_break_glass_policies", rows[0]);
556
+ var policy = {
557
+ table: unsealed.tableName,
558
+ columns: safeJson.parse(unsealed.columnsJson, { maxBytes: C.BYTES.kib(64) }),
559
+ factors: safeJson.parse(unsealed.factorsJson, { maxBytes: C.BYTES.kib(8) }),
560
+ cryptographic: unsealed.cryptographic === 1,
561
+ grantTtl: Number(unsealed.grantTtlMs),
562
+ maxRowsPerGrant: Number(unsealed.maxRowsPerGrant),
563
+ reasonRequired: unsealed.reasonRequired === 1,
564
+ reasonMinLength: Number(unsealed.reasonMinLength),
565
+ pinIp: unsealed.pinIp === 1,
566
+ sessionPin: unsealed.sessionPin === 1,
567
+ onLockedAccess: unsealed.onLockedAccess,
568
+ requireScope: unsealed.requireScope,
569
+ serviceAccountBypass: unsealed.serviceAccountBypassJson
570
+ ? safeJson.parse(unsealed.serviceAccountBypassJson, { maxBytes: C.BYTES.kib(8) })
571
+ : null,
572
+ auditReasonStorage: unsealed.auditReasonStorage,
573
+ updatedAt: Number(unsealed.updatedAt),
574
+ };
575
+ policyCache.set(table, policy);
576
+ return policy;
577
+ }
578
+
579
+ async function policyList() {
580
+ _requireInit();
581
+ var rows = await clusterStorage.executeAll(
582
+ "SELECT tableName FROM _blamejs_break_glass_policies ORDER BY tableName"
583
+ );
584
+ var out = [];
585
+ for (var i = 0; i < (rows || []).length; i++) {
586
+ var p = await policyGet(rows[i].tableName);
587
+ if (p) out.push(p);
588
+ }
589
+ return out;
590
+ }
591
+
592
+ async function policyDelete(table, callerOpts) {
593
+ _requireInit();
594
+ if (typeof table !== "string" || table.length === 0) {
595
+ throw new BreakGlassError("breakglass/bad-policy",
596
+ "policy.delete: table must be a non-empty string");
597
+ }
598
+ await clusterStorage.execute(
599
+ "DELETE FROM _blamejs_break_glass_policies WHERE tableName = ?",
600
+ [table]
601
+ );
602
+ policyCache.delete(table);
603
+ audit.safeEmit({
604
+ action: "breakglass.policy.delete",
605
+ outcome: "success",
606
+ actor: requestHelpers.resolveActorWithOverride(callerOpts),
607
+ metadata: { table: table },
608
+ });
609
+ return { deleted: true, table: table };
610
+ }
611
+
612
+ // ---- Grant issuance ----
613
+
614
+ function _verifyTotpFactor(factor) {
615
+ if (!factor || typeof factor !== "object") return { ok: false };
616
+ if (typeof factor.secret !== "string" || factor.secret.length === 0) return { ok: false };
617
+ if (typeof factor.code !== "string" || factor.code.length === 0) return { ok: false };
618
+ var verified = totp.verify(factor.secret, factor.code);
619
+ return { ok: verified !== false, step: verified };
620
+ }
621
+
622
+ // Passkey factor — operator presents a WebAuthn assertion plus the
623
+ // challenge/origin/RPID + the previously-enrolled credential record.
624
+ // Phishing-resistant; the private key lives on the YubiKey, not in
625
+ // the framework's vault. v0.5.2 uses passkey for identity verification
626
+ // to gate grant issuance; PRF-derived per-policy DEK material (which
627
+ // would give true vault-key-alone-doesn't-decrypt defense) is a
628
+ // follow-up.
629
+ async function _verifyPasskeyFactor(factor) {
630
+ if (!factor || typeof factor !== "object") return { ok: false };
631
+ if (!factor.response || !factor.expectedChallenge ||
632
+ !factor.expectedOrigin || !factor.expectedRPID || !factor.credential) {
633
+ return { ok: false };
634
+ }
635
+ try {
636
+ var result = await passkey().verifyAuthentication({
637
+ response: factor.response,
638
+ expectedChallenge: factor.expectedChallenge,
639
+ expectedOrigin: factor.expectedOrigin,
640
+ expectedRPID: factor.expectedRPID,
641
+ credential: factor.credential,
642
+ requireUserVerification: factor.requireUserVerification !== false,
643
+ });
644
+ return { ok: result && result.verified === true };
645
+ } catch (_e) {
646
+ return { ok: false };
647
+ }
648
+ }
649
+
650
+ async function grant(opts) {
651
+ _requireInit();
652
+ if (!opts || typeof opts !== "object") {
653
+ throw new BreakGlassError("breakglass/bad-grant-opts",
654
+ "grant: opts is required");
655
+ }
656
+ validateOpts(opts, ["req", "table", "columns", "reason", "factor"], "breakGlass.grant");
657
+
658
+ var table = opts.table;
659
+ var policy = await policyGet(table);
660
+ if (!policy) {
661
+ throw new BreakGlassError("breakglass/policy-not-set",
662
+ "no break-glass policy is configured for table '" + table + "'", true);
663
+ }
664
+
665
+ // Reason validation
666
+ var reason = typeof opts.reason === "string" ? opts.reason : "";
667
+ if (policy.reasonRequired && reason.length === 0) {
668
+ throw new BreakGlassError("breakglass/missing-reason",
669
+ "grant: reason is required for table '" + table + "'", true);
670
+ }
671
+ if (policy.reasonRequired && reason.length < policy.reasonMinLength) {
672
+ throw new BreakGlassError("breakglass/short-reason",
673
+ "grant: reason must be at least " + policy.reasonMinLength + " characters", true);
674
+ }
675
+
676
+ // Column scoping
677
+ var requestedColumns = Array.isArray(opts.columns) && opts.columns.length > 0
678
+ ? opts.columns.slice()
679
+ : policy.columns.slice();
680
+ for (var i = 0; i < requestedColumns.length; i++) {
681
+ if (policy.columns.indexOf(requestedColumns[i]) === -1) {
682
+ throw new BreakGlassError("breakglass/grant-column-mismatch",
683
+ "grant: requested column '" + requestedColumns[i] +
684
+ "' is not glass-locked on table '" + table + "'", true);
685
+ }
686
+ }
687
+
688
+ // Actor identity
689
+ var actor = requestHelpers.extractActorContext(opts.req);
690
+ var actorId = actor.userId || (opts.req && opts.req.apiKey && opts.req.apiKey.id) || null;
691
+ if (!actorId) {
692
+ throw new BreakGlassError("breakglass/unauthorized",
693
+ "grant: no authenticated actor on request (req.user.id / req.apiKey.id required)", true);
694
+ }
695
+
696
+ // Factor verification + lockout
697
+ var factorType = opts.factor && opts.factor.type;
698
+ if (!factorType || policy.factors.indexOf(factorType) === -1) {
699
+ throw new BreakGlassError("breakglass/bad-factor",
700
+ "grant: factor.type must be one of [" + policy.factors.join(",") + "]");
701
+ }
702
+ var fl = _ensureFactorLockout();
703
+ var lockKey = actorId;
704
+ var locked = await fl.check(lockKey);
705
+ if (locked && locked.locked) {
706
+ audit.safeEmit({
707
+ action: "breakglass.grant.requested",
708
+ outcome: "denied",
709
+ actor: actor,
710
+ reason: "factor-rate-limited",
711
+ metadata: { table: table, factorType: factorType, lockUntil: locked.lockedUntil },
712
+ });
713
+ throw new BreakGlassError("breakglass/factor-rate-limited",
714
+ "grant: too many recent factor failures; locked until " +
715
+ new Date(locked.lockedUntil).toISOString());
716
+ }
717
+
718
+ var factorOk = false;
719
+ if (factorType === "totp") {
720
+ factorOk = _verifyTotpFactor(opts.factor).ok;
721
+ } else if (factorType === "passkey") {
722
+ factorOk = (await _verifyPasskeyFactor(opts.factor)).ok;
723
+ }
724
+
725
+ if (!factorOk) {
726
+ await fl.recordFailure(lockKey, { reason: factorType + "-bad" });
727
+ audit.safeEmit({
728
+ action: "breakglass.grant.requested",
729
+ outcome: "denied",
730
+ actor: actor,
731
+ reason: "bad-factor",
732
+ metadata: { table: table, factorType: factorType, columns: requestedColumns },
733
+ });
734
+ throw new BreakGlassError("breakglass/bad-factor",
735
+ "grant: " + factorType + " factor verification failed");
736
+ }
737
+ await fl.recordSuccess(lockKey);
738
+
739
+ // Build + persist the grant row
740
+ var nowMs = Date.now();
741
+ var grantId = "bg-" + generateToken(GRANT_ID_BYTES);
742
+ var sessionId = (opts.req && opts.req.session && opts.req.session.id) || null;
743
+ // Honor the framework-wide trustProxy setting from init() — same
744
+ // boundary as middleware. Without trustProxy, X-Forwarded-For is
745
+ // ignored as attacker-forgeable, and the grant pins to the socket
746
+ // remoteAddress only.
747
+ var ipFromReq = requestHelpers.clientIp(opts.req, { trustProxy: _trustProxy });
748
+
749
+ var grantRow = {
750
+ _id: grantId,
751
+ issuedToActorId: actorId,
752
+ factorType: factorType,
753
+ reasonSealed: reason,
754
+ scopeTable: table,
755
+ scopeColumnsJson: JSON.stringify(requestedColumns),
756
+ issuedAt: nowMs,
757
+ expiresAt: nowMs + policy.grantTtl,
758
+ maxRowsPerGrant: policy.maxRowsPerGrant,
759
+ rowsConsumed: 0,
760
+ revokedAt: null,
761
+ sessionId: sessionId,
762
+ ip: ipFromReq,
763
+ kwGrantHalf: null,
764
+ };
765
+ var sealed = cryptoField.sealRow("_blamejs_break_glass_grants", grantRow);
766
+ var keys = Object.keys(sealed);
767
+ var cols = keys.join(", ");
768
+ var qs = keys.map(function () { return "?"; }).join(", ");
769
+ await clusterStorage.execute(
770
+ "INSERT INTO _blamejs_break_glass_grants (" + cols + ") VALUES (" + qs + ")",
771
+ keys.map(function (k) { return sealed[k]; })
772
+ );
773
+
774
+ // Audit
775
+ var reasonForAudit = _reasonForAudit(reason, policy.auditReasonStorage);
776
+ audit.safeEmit({
777
+ action: "breakglass.grant.requested",
778
+ outcome: "success",
779
+ actor: actor,
780
+ reason: reasonForAudit.cleartext,
781
+ metadata: {
782
+ grantId: grantId,
783
+ table: table,
784
+ columns: requestedColumns,
785
+ factorType: factorType,
786
+ ttlMs: policy.grantTtl,
787
+ maxRowsPerGrant: policy.maxRowsPerGrant,
788
+ reasonHmac: reasonForAudit.hmac,
789
+ },
790
+ });
791
+ observability.event("breakGlass.grant", { table: table });
792
+
793
+ return {
794
+ id: grantId,
795
+ expiresAt: grantRow.expiresAt,
796
+ rowsRemaining: policy.maxRowsPerGrant,
797
+ scopeTable: table,
798
+ scopeColumns: requestedColumns,
799
+ };
800
+ }
801
+
802
+ function _reasonForAudit(reason, mode) {
803
+ // HMAC variant uses SHA3-512 keyed by a stable framework-wide tag —
804
+ // operators with multiple deployments can correlate via the hash
805
+ // without re-deriving from the same secret. Cleartext is the default
806
+ // (compliance reviewers WANT to read the reason).
807
+ var out = { cleartext: null, hmac: null };
808
+ if (mode === "cleartext" || mode === "both") out.cleartext = reason;
809
+ if (mode === "hmac" || mode === "both") {
810
+ out.hmac = sha3Hash("breakGlass.reason:" + reason);
811
+ }
812
+ return out;
813
+ }
814
+
815
+ // ---- Use a grant ----
816
+
817
+ async function unsealRow(grantHandle, table, rowId, opts) {
818
+ _requireInit();
819
+ if (!grantHandle || typeof grantHandle !== "object" || typeof grantHandle.id !== "string") {
820
+ throw new BreakGlassError("breakglass/bad-grant-opts",
821
+ "unsealRow: grant handle is required (returned from b.breakGlass.grant())");
822
+ }
823
+ // Optional opts.req lets the caller thread the originating request
824
+ // into per-row audit emits so the 5 W's (ip / userAgent / sessionId /
825
+ // requestId / method / route) populate alongside the grant's actor
826
+ // userId. Backward-compatible — calls without opts continue to work
827
+ // and simply audit with userId-only actor.
828
+ opts = opts || {};
829
+ function _actorFor(grantRow) {
830
+ return requestHelpers.extractActorContext(opts.req, {
831
+ userId: grantRow.issuedToActorId,
832
+ });
833
+ }
834
+ if (typeof table !== "string" || table.length === 0) {
835
+ throw new BreakGlassError("breakglass/bad-grant-opts",
836
+ "unsealRow: table must be a non-empty string");
837
+ }
838
+ if (rowId === undefined || rowId === null || rowId === "") {
839
+ throw new BreakGlassError("breakglass/bad-grant-opts",
840
+ "unsealRow: rowId is required");
841
+ }
842
+ var grantRows = await clusterStorage.executeAll(
843
+ "SELECT * FROM _blamejs_break_glass_grants WHERE _id = ?",
844
+ [grantHandle.id]
845
+ );
846
+ if (!grantRows || grantRows.length === 0) {
847
+ throw new BreakGlassError("breakglass/grant-revoked",
848
+ "unsealRow: grant " + grantHandle.id + " not found (deleted or never issued)", true);
849
+ }
850
+ var sealedGrant = grantRows[0];
851
+ var grantRow = cryptoField.unsealRow("_blamejs_break_glass_grants", sealedGrant);
852
+
853
+ // Table mismatch
854
+ if (grantRow.scopeTable !== table) {
855
+ audit.safeEmit({
856
+ action: "breakglass.unsealrow",
857
+ outcome: "denied",
858
+ actor: _actorFor(grantRow),
859
+ reason: "grant-table-mismatch",
860
+ metadata: { grantId: grantRow._id, expectedTable: grantRow.scopeTable, gotTable: table, rowId: String(rowId) },
861
+ });
862
+ throw new BreakGlassError("breakglass/grant-table-mismatch",
863
+ "unsealRow: grant " + grantHandle.id + " is scoped to '" +
864
+ grantRow.scopeTable + "', not '" + table + "'", true);
865
+ }
866
+
867
+ // Revoked
868
+ if (grantRow.revokedAt) {
869
+ throw new BreakGlassError("breakglass/grant-revoked",
870
+ "unsealRow: grant " + grantHandle.id + " was revoked at " +
871
+ new Date(Number(grantRow.revokedAt)).toISOString(), true);
872
+ }
873
+
874
+ // Expired
875
+ if (Number(grantRow.expiresAt) <= Date.now()) {
876
+ audit.safeEmit({
877
+ action: "breakglass.grant.expired",
878
+ outcome: "success",
879
+ actor: _actorFor(grantRow),
880
+ metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
881
+ });
882
+ throw new BreakGlassError("breakglass/grant-expired",
883
+ "unsealRow: grant " + grantHandle.id + " expired at " +
884
+ new Date(Number(grantRow.expiresAt)).toISOString(), true);
885
+ }
886
+
887
+ // Exhausted
888
+ if (Number(grantRow.rowsConsumed) >= Number(grantRow.maxRowsPerGrant)) {
889
+ audit.safeEmit({
890
+ action: "breakglass.grant.exhausted",
891
+ outcome: "success",
892
+ actor: _actorFor(grantRow),
893
+ metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
894
+ });
895
+ throw new BreakGlassError("breakglass/grant-exhausted",
896
+ "unsealRow: grant " + grantHandle.id + " has consumed all " +
897
+ grantRow.maxRowsPerGrant + " allowed rows", true);
898
+ }
899
+
900
+ // Increment rowsConsumed (atomic UPDATE with WHERE rowsConsumed < cap
901
+ // so concurrent unseals can't both pass the runtime check above).
902
+ var updateRes = await clusterStorage.execute(
903
+ "UPDATE _blamejs_break_glass_grants " +
904
+ "SET rowsConsumed = rowsConsumed + 1 " +
905
+ "WHERE _id = ? AND rowsConsumed < maxRowsPerGrant AND " +
906
+ "(revokedAt IS NULL) AND expiresAt > ?",
907
+ [grantHandle.id, Date.now()]
908
+ );
909
+ // executeAll-style result; some backends return rowsAffected, others a count.
910
+ // Re-query to confirm the increment landed and get the post-increment counter.
911
+ var postRows = await clusterStorage.executeAll(
912
+ "SELECT rowsConsumed, revokedAt, expiresAt FROM _blamejs_break_glass_grants WHERE _id = ?",
913
+ [grantHandle.id]
914
+ );
915
+ if (!postRows || postRows.length === 0) {
916
+ throw new BreakGlassError("breakglass/grant-revoked",
917
+ "unsealRow: grant " + grantHandle.id + " disappeared during unseal", true);
918
+ }
919
+ var postRowsConsumed = Number(postRows[0].rowsConsumed);
920
+ // If the UPDATE didn't actually increment (race lost — another unseal
921
+ // exhausted the grant or it was revoked / expired between our check
922
+ // and the UPDATE), refuse this read.
923
+ if (postRowsConsumed === Number(grantRow.rowsConsumed)) {
924
+ throw new BreakGlassError("breakglass/grant-exhausted",
925
+ "unsealRow: grant " + grantHandle.id + " was exhausted by a concurrent read", true);
926
+ }
927
+ void updateRes;
928
+
929
+ // Fetch + unseal the target row. Model A goes straight through
930
+ // cryptoField; Model B reads the row, lets cryptoField unseal the
931
+ // non-glass-locked columns, and then decryptCell handles the
932
+ // glass-locked columns separately (their ciphertext was written
933
+ // by encryptCell at app-write time, not by cryptoField.sealRow).
934
+ var rows = await clusterStorage.executeAll(
935
+ "SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
936
+ [String(rowId)]
937
+ );
938
+ if (!rows || rows.length === 0) {
939
+ throw new BreakGlassError("breakglass/row-not-found",
940
+ "unsealRow: " + table + "[" + rowId + "] not found", true);
941
+ }
942
+ var policy = await policyGet(table);
943
+ var unsealedRow;
944
+ if (policy && policy.cryptographic) {
945
+ // Snapshot the raw glass-locked column ciphertexts BEFORE
946
+ // cryptoField.unsealRow runs — cryptoField doesn't know about the
947
+ // bgcell: format and would no-op (or error) on it. Then unseal the
948
+ // rest of the row, then decrypt the glass-locked columns via
949
+ // decryptCell with encryption-context binding.
950
+ var rawCipher = {};
951
+ for (var c = 0; c < policy.columns.length; c++) {
952
+ rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
953
+ }
954
+ var rowMinusLocked = Object.assign({}, rows[0]);
955
+ for (var c2 = 0; c2 < policy.columns.length; c2++) {
956
+ delete rowMinusLocked[policy.columns[c2]];
957
+ }
958
+ unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
959
+ for (var c3 = 0; c3 < policy.columns.length; c3++) {
960
+ var col = policy.columns[c3];
961
+ if (rawCipher[col] == null) continue;
962
+ try {
963
+ unsealedRow[col] = await decryptCell(rawCipher[col],
964
+ { table: table, rowId: String(rowId), column: col });
965
+ } catch (e) {
966
+ throw new BreakGlassError("breakglass/cell-decrypt-failed",
967
+ "unsealRow: cell decrypt failed for " + table + "[" + rowId +
968
+ "]." + col + " (was the row migrated to Model B?): " +
969
+ ((e && e.message) || String(e)), true);
970
+ }
971
+ }
972
+ } else {
973
+ unsealedRow = cryptoField.unsealRow(table, rows[0]);
974
+ }
975
+
976
+ // Per-row audit. The grant's reasonSealed is already cleartext after
977
+ // unsealRow on the grant; pass it into the audit row honoring the
978
+ // policy's auditReasonStorage mode.
979
+ var reasonForAudit = _reasonForAudit(grantRow.reasonSealed || "",
980
+ policy ? policy.auditReasonStorage : DEFAULT_AUDIT_REASON);
981
+ audit.safeEmit({
982
+ action: "breakglass.unsealrow",
983
+ outcome: "success",
984
+ actor: _actorFor(grantRow),
985
+ reason: reasonForAudit.cleartext,
986
+ metadata: {
987
+ grantId: grantRow._id,
988
+ table: table,
989
+ rowId: String(rowId),
990
+ columns: safeJson.parse(grantRow.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
991
+ rowsRemaining: Number(grantRow.maxRowsPerGrant) - postRowsConsumed,
992
+ reasonHmac: reasonForAudit.hmac,
993
+ },
994
+ });
995
+ observability.event("breakglass.unsealrow", { table: table });
996
+
997
+ return unsealedRow;
998
+ }
999
+
1000
+ // ---- Revoke ----
1001
+
1002
+ async function revoke(grantId, opts) {
1003
+ _requireInit();
1004
+ if (typeof grantId !== "string" || grantId.length === 0) {
1005
+ throw new BreakGlassError("breakglass/bad-grant-opts",
1006
+ "revoke: grantId is required");
1007
+ }
1008
+ opts = opts || {};
1009
+ var nowMs = Date.now();
1010
+ await clusterStorage.execute(
1011
+ "UPDATE _blamejs_break_glass_grants SET revokedAt = ? " +
1012
+ "WHERE _id = ? AND revokedAt IS NULL",
1013
+ [nowMs, grantId]
1014
+ );
1015
+ audit.safeEmit({
1016
+ action: "breakglass.grant.revoked",
1017
+ outcome: "success",
1018
+ actor: requestHelpers.resolveActorWithOverride(opts),
1019
+ reason: typeof opts.reason === "string" ? opts.reason : null,
1020
+ metadata: { grantId: grantId },
1021
+ });
1022
+ return { revoked: true, grantId: grantId };
1023
+ }
1024
+
1025
+ // ---- listActive ----
1026
+
1027
+ async function listActive(opts) {
1028
+ _requireInit();
1029
+ opts = opts || {};
1030
+ var actor = requestHelpers.extractActorContext(opts.req);
1031
+ var actorId = actor.userId || (opts.req && opts.req.apiKey && opts.req.apiKey.id) || null;
1032
+ if (!actorId) return [];
1033
+ // Use cryptoField's computeDerived so the hash matches the table's
1034
+ // hashNamespace prefix — raw sha3Hash would produce a different value.
1035
+ var derived = cryptoField.computeDerived(
1036
+ "_blamejs_break_glass_grants", "issuedToActorId", actorId
1037
+ );
1038
+ if (!derived) return [];
1039
+ var nowMs = Date.now();
1040
+ var rows = await clusterStorage.executeAll(
1041
+ "SELECT * FROM _blamejs_break_glass_grants " +
1042
+ "WHERE issuedToActorHash = ? AND (revokedAt IS NULL) AND expiresAt > ? AND rowsConsumed < maxRowsPerGrant " +
1043
+ "ORDER BY issuedAt DESC",
1044
+ [derived.value, nowMs]
1045
+ );
1046
+ var out = [];
1047
+ for (var i = 0; i < (rows || []).length; i++) {
1048
+ var u = cryptoField.unsealRow("_blamejs_break_glass_grants", rows[i]);
1049
+ out.push({
1050
+ id: u._id,
1051
+ scopeTable: u.scopeTable,
1052
+ scopeColumns: safeJson.parse(u.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
1053
+ issuedAt: Number(u.issuedAt),
1054
+ expiresAt: Number(u.expiresAt),
1055
+ rowsRemaining: Number(u.maxRowsPerGrant) - Number(u.rowsConsumed),
1056
+ factorType: u.factorType,
1057
+ });
1058
+ }
1059
+ return out;
1060
+ }
1061
+
1062
+ // ---- Service-account bypass ----
1063
+ //
1064
+ // Some legitimate workloads (nightly de-identification, scheduled
1065
+ // compliance reports) need PHI access without a human at the
1066
+ // keyboard. The framework refuses to silently bypass — operators
1067
+ // declare explicit service-account bypasses per-table with an apiKey
1068
+ // allowlist + required role. Both must match (verified apiKey id is
1069
+ // in the allowlist AND the apiKey carries the required role/scope)
1070
+ // before the bypass is granted. Each bypass emits its own distinct
1071
+ // audit row so post-incident review can distinguish operator-initiated
1072
+ // from service-initiated reads.
1073
+
1074
+ async function unsealRowAsService(req, table, rowId, opts) {
1075
+ _requireInit();
1076
+ opts = opts || {};
1077
+ if (!req || typeof req !== "object") {
1078
+ throw new BreakGlassError("breakglass/bad-grant-opts",
1079
+ "unsealRowAsService: req is required (with verified req.apiKey)");
1080
+ }
1081
+ if (typeof table !== "string" || table.length === 0) {
1082
+ throw new BreakGlassError("breakglass/bad-grant-opts",
1083
+ "unsealRowAsService: table must be a non-empty string");
1084
+ }
1085
+ var policy = await policyGet(table);
1086
+ if (!policy) {
1087
+ throw new BreakGlassError("breakglass/policy-not-set",
1088
+ "unsealRowAsService: no break-glass policy for table '" + table + "'", true);
1089
+ }
1090
+ if (!policy.serviceAccountBypass) {
1091
+ throw new BreakGlassError("breakglass/bypass-not-configured",
1092
+ "unsealRowAsService: serviceAccountBypass is not configured for '" + table + "'", true);
1093
+ }
1094
+ var apiKeyOnReq = req.apiKey;
1095
+ if (!apiKeyOnReq || typeof apiKeyOnReq.id !== "string") {
1096
+ throw new BreakGlassError("breakglass/bypass-no-apikey",
1097
+ "unsealRowAsService: req.apiKey.id is required (operator must run b.middleware.requireApiKey before this path)", true);
1098
+ }
1099
+ if (policy.serviceAccountBypass.apiKeyIds.indexOf(apiKeyOnReq.id) === -1) {
1100
+ audit.safeEmit({
1101
+ action: "breakglass.grant.bypass",
1102
+ outcome: "denied",
1103
+ actor: requestHelpers.extractActorContext(req),
1104
+ reason: "apikey-not-in-allowlist",
1105
+ metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id },
1106
+ });
1107
+ throw new BreakGlassError("breakglass/bypass-unauthorized",
1108
+ "unsealRowAsService: apiKey '" + apiKeyOnReq.id +
1109
+ "' is not in the bypass allowlist for '" + table + "'", true);
1110
+ }
1111
+ // Role check — actor must carry policy.serviceAccountBypass.requireRole
1112
+ // either as a direct scope or via b.permissions.check resolution.
1113
+ var actorScopes = Array.isArray(apiKeyOnReq.scopes) ? apiKeyOnReq.scopes :
1114
+ Array.isArray(apiKeyOnReq.roles) ? apiKeyOnReq.roles :
1115
+ [];
1116
+ var requiredRole = policy.serviceAccountBypass.requireRole;
1117
+ var hasRole = actorScopes.indexOf(requiredRole) !== -1;
1118
+ if (!hasRole) {
1119
+ audit.safeEmit({
1120
+ action: "breakglass.grant.bypass",
1121
+ outcome: "denied",
1122
+ actor: requestHelpers.extractActorContext(req),
1123
+ reason: "missing-role",
1124
+ metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id, requiredRole: requiredRole },
1125
+ });
1126
+ throw new BreakGlassError("breakglass/bypass-unauthorized",
1127
+ "unsealRowAsService: apiKey '" + apiKeyOnReq.id +
1128
+ "' lacks required role '" + requiredRole + "'", true);
1129
+ }
1130
+
1131
+ // Fetch + unseal the row (Model A or Model B path, same as
1132
+ // operator-initiated unsealRow).
1133
+ var rows = await clusterStorage.executeAll(
1134
+ "SELECT * FROM " + '"' + table + '"' + " WHERE _id = ?",
1135
+ [String(rowId)]
1136
+ );
1137
+ if (!rows || rows.length === 0) {
1138
+ throw new BreakGlassError("breakglass/row-not-found",
1139
+ "unsealRowAsService: " + table + "[" + rowId + "] not found", true);
1140
+ }
1141
+ var unsealedRow;
1142
+ if (policy.cryptographic) {
1143
+ var rawCipher = {};
1144
+ for (var c = 0; c < policy.columns.length; c++) rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
1145
+ var rowMinusLocked = Object.assign({}, rows[0]);
1146
+ for (var c2 = 0; c2 < policy.columns.length; c2++) delete rowMinusLocked[policy.columns[c2]];
1147
+ unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
1148
+ for (var c3 = 0; c3 < policy.columns.length; c3++) {
1149
+ var col = policy.columns[c3];
1150
+ if (rawCipher[col] == null) continue;
1151
+ unsealedRow[col] = await decryptCell(rawCipher[col],
1152
+ { table: table, rowId: String(rowId), column: col });
1153
+ }
1154
+ } else {
1155
+ unsealedRow = cryptoField.unsealRow(table, rows[0]);
1156
+ }
1157
+
1158
+ audit.safeEmit({
1159
+ action: "breakglass.grant.bypass",
1160
+ outcome: "success",
1161
+ actor: requestHelpers.extractActorContext(req),
1162
+ reason: typeof opts.reason === "string" ? opts.reason : null,
1163
+ metadata: {
1164
+ table: table,
1165
+ rowId: String(rowId),
1166
+ apiKeyId: apiKeyOnReq.id,
1167
+ requiredRole: requiredRole,
1168
+ columns: policy.columns.slice(),
1169
+ },
1170
+ });
1171
+ observability.event("breakglass.grant.bypass", { table: table });
1172
+ return unsealedRow;
1173
+ }
1174
+
1175
+ // ---- Admin tools ----
1176
+ //
1177
+ // `listActiveAll` returns every active grant (across all actors) —
1178
+ // for security-team dashboards and offboarding workflows.
1179
+ // `revokeAll` mass-revokes grants matching criteria — used by IR
1180
+ // teams when an account is suspected compromised. Both require
1181
+ // admin scope (operator wires via opts.requireScope or their own gate).
1182
+
1183
+ async function listActiveAll(opts) {
1184
+ _requireInit();
1185
+ opts = opts || {};
1186
+ var nowMs = Date.now();
1187
+ var clauses = ["(revokedAt IS NULL)", "expiresAt > ?", "rowsConsumed < maxRowsPerGrant"];
1188
+ var params = [nowMs];
1189
+ if (opts.table) {
1190
+ clauses.push("scopeTable = ?");
1191
+ params.push(opts.table);
1192
+ }
1193
+ if (opts.since) {
1194
+ clauses.push("issuedAt >= ?");
1195
+ params.push(opts.since);
1196
+ }
1197
+ var rows = await clusterStorage.executeAll(
1198
+ "SELECT * FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND ") +
1199
+ " ORDER BY issuedAt DESC",
1200
+ params
1201
+ );
1202
+ var out = [];
1203
+ for (var i = 0; i < (rows || []).length; i++) {
1204
+ var u = cryptoField.unsealRow("_blamejs_break_glass_grants", rows[i]);
1205
+ out.push({
1206
+ id: u._id,
1207
+ issuedToActorId: u.issuedToActorId,
1208
+ scopeTable: u.scopeTable,
1209
+ scopeColumns: safeJson.parse(u.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
1210
+ factorType: u.factorType,
1211
+ issuedAt: Number(u.issuedAt),
1212
+ expiresAt: Number(u.expiresAt),
1213
+ rowsRemaining: Number(u.maxRowsPerGrant) - Number(u.rowsConsumed),
1214
+ });
1215
+ }
1216
+ audit.safeEmit({
1217
+ action: "breakglass.admin.listactiveall",
1218
+ outcome: "success",
1219
+ actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
1220
+ metadata: { resultCount: out.length, filterTable: opts.table || null },
1221
+ });
1222
+ return out;
1223
+ }
1224
+
1225
+ async function revokeAll(criteria, opts) {
1226
+ _requireInit();
1227
+ if (!criteria || typeof criteria !== "object") {
1228
+ throw new BreakGlassError("breakglass/bad-revoke-criteria",
1229
+ "revokeAll: criteria is required ({ actorId?, table? })");
1230
+ }
1231
+ if (!criteria.actorId && !criteria.table) {
1232
+ throw new BreakGlassError("breakglass/bad-revoke-criteria",
1233
+ "revokeAll: at least one of { actorId, table } is required (refusing to mass-revoke without scope)");
1234
+ }
1235
+ opts = opts || {};
1236
+ var clauses = ["revokedAt IS NULL"];
1237
+ var params = [];
1238
+ if (criteria.actorId) {
1239
+ var derived = cryptoField.computeDerived(
1240
+ "_blamejs_break_glass_grants", "issuedToActorId", criteria.actorId
1241
+ );
1242
+ if (derived) {
1243
+ clauses.push("issuedToActorHash = ?");
1244
+ params.push(derived.value);
1245
+ }
1246
+ }
1247
+ if (criteria.table) {
1248
+ clauses.push("scopeTable = ?");
1249
+ params.push(criteria.table);
1250
+ }
1251
+ // Snapshot the to-be-revoked grant ids first so audit captures specifics.
1252
+ var ids = await clusterStorage.executeAll(
1253
+ "SELECT _id FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND "),
1254
+ params
1255
+ );
1256
+ var nowMs = Date.now();
1257
+ await clusterStorage.execute(
1258
+ "UPDATE _blamejs_break_glass_grants SET revokedAt = ? WHERE " + clauses.join(" AND "),
1259
+ [nowMs].concat(params)
1260
+ );
1261
+ audit.safeEmit({
1262
+ action: "breakglass.admin.revokeall",
1263
+ outcome: "success",
1264
+ actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
1265
+ reason: typeof criteria.reason === "string" ? criteria.reason : null,
1266
+ metadata: {
1267
+ filterActorId: criteria.actorId || null,
1268
+ filterTable: criteria.table || null,
1269
+ revokedCount: (ids || []).length,
1270
+ revokedIds: (ids || []).map(function (r) { return r._id; }),
1271
+ },
1272
+ });
1273
+ return { revokedCount: (ids || []).length };
1274
+ }
1275
+
1276
+ // ---- Sweep (best-effort cleanup of expired grants) ----
1277
+
1278
+ async function _sweepExpired(opts) {
1279
+ opts = opts || {};
1280
+ var nowMs = Date.now();
1281
+ var expired = await clusterStorage.executeAll(
1282
+ "SELECT _id, issuedToActorId, scopeTable, rowsConsumed FROM _blamejs_break_glass_grants " +
1283
+ "WHERE revokedAt IS NULL AND expiresAt <= ?",
1284
+ [nowMs]
1285
+ );
1286
+ for (var i = 0; i < (expired || []).length; i++) {
1287
+ var row = expired[i];
1288
+ audit.safeEmit({
1289
+ action: "breakglass.grant.expired",
1290
+ outcome: "success",
1291
+ actor: { userId: row.issuedToActorId },
1292
+ metadata: { grantId: row._id, table: row.scopeTable, rowsConsumed: Number(row.rowsConsumed) },
1293
+ });
1294
+ }
1295
+ await clusterStorage.execute(
1296
+ "UPDATE _blamejs_break_glass_grants SET revokedAt = ? " +
1297
+ "WHERE revokedAt IS NULL AND expiresAt <= ?",
1298
+ [nowMs, nowMs]
1299
+ );
1300
+ return { expired: (expired || []).length };
1301
+ }
1302
+
1303
+ void safeAsync; // kept import for future grant-async ops in v0.5.1+
1304
+
1305
+ module.exports = {
1306
+ init: init,
1307
+ policy: {
1308
+ set: policySet,
1309
+ get: policyGet,
1310
+ list: policyList,
1311
+ delete: policyDelete,
1312
+ },
1313
+ grant: grant,
1314
+ unsealRow: unsealRow,
1315
+ revoke: revoke,
1316
+ listActive: listActive,
1317
+ // Cryptographic mode (Model B) — per-cell encryption with context
1318
+ // binding. Operators in cryptographic mode call encryptCell at write
1319
+ // time; unsealRow auto-routes to decryptCell for glass-locked columns.
1320
+ encryptCell: encryptCell,
1321
+ decryptCell: decryptCell,
1322
+ migrate: migrate,
1323
+ // Service-account bypass — operator-declared per-table, gated by
1324
+ // (apiKey id in allowlist) AND (apiKey carries required role).
1325
+ unsealRowAsService: unsealRowAsService,
1326
+ // Admin tools — for security-team dashboards (listActiveAll) and
1327
+ // incident-response offboarding (revokeAll). Operators wire their
1328
+ // own gate (requireScope or middleware) on the calling routes.
1329
+ listActiveAll: listActiveAll,
1330
+ revokeAll: revokeAll,
1331
+ BreakGlassError: BreakGlassError,
1332
+
1333
+ // Test-only / sweep — operators with active grant volume wire this
1334
+ // into a scheduler; the framework doesn't auto-start the timer so
1335
+ // boot doesn't depend on anything firing in the background.
1336
+ _sweepExpiredForTest: _sweepExpired,
1337
+ _resetForTest: _resetForTest,
1338
+ };