@blamejs/core 0.4.29 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,8 +6,13 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.5.x
10
+
11
+ - **0.5.0** (2026-04-30) — b.breakGlass: column-policy / row-enforcement step-up auth
12
+
9
13
  ## v0.4.x
10
14
 
15
+ - **0.4.29** (2026-04-30) — primitive-drift sweep: second-pass remediation
11
16
  - **0.4.28** (2026-04-30) — primitive-drift sweep: inline-require hoisting + safeAsync.sleep + time-math
12
17
  - **0.4.27** (2026-04-30) — primitive-drift sweep: regex + escape consolidation, IPv6 completion
13
18
  - **0.4.26** (2026-04-30) — primitive-drift sweep: middleware audit context + safeUrl
package/index.js CHANGED
@@ -105,6 +105,7 @@ var staticServe = require("./lib/static");
105
105
  var forms = require("./lib/forms");
106
106
  var app = require("./lib/app");
107
107
  var jobs = require("./lib/jobs");
108
+ var breakGlass = require("./lib/break-glass");
108
109
  var mail = require("./lib/mail");
109
110
  var mailBounce = require("./lib/mail-bounce");
110
111
  var websocketChannels = require("./lib/websocket-channels");
@@ -206,6 +207,7 @@ module.exports = {
206
207
  forms: forms,
207
208
  createApp: app.createApp,
208
209
  jobs: jobs,
210
+ breakGlass: breakGlass,
209
211
  mail: mail,
210
212
  mailBounce: mailBounce,
211
213
  websocketChannels: websocketChannels,
package/lib/audit.js CHANGED
@@ -201,6 +201,7 @@ var FRAMEWORK_NAMESPACES = [
201
201
  // Per-primitive namespaces — keep alphabetical
202
202
  "apikey", // b.apiKey
203
203
  "backup", // b.backup
204
+ "breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
204
205
  "cache", // b.cache
205
206
  "dkim", // b.mail.dkim (DKIM-Signature generation events)
206
207
  "mail", // b.mail (b.mail-bounce uses "system.mail.*")
@@ -0,0 +1,994 @@
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
+ * v0.5.0 ships Model A (policy gate) + TOTP factor + Tier-A validation +
21
+ * 14 error codes + audit chain integration. Model B (cryptographic
22
+ * gate via per-row K_row), passkey factor, service-account bypass,
23
+ * and admin tools land in v0.5.1 / v0.5.2 per the phasing plan.
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 clusterStorage = require("./cluster-storage");
43
+ var { generateBytes, generateToken, kdf, sha3Hash, encryptPacked, decryptPacked } = require("./crypto");
44
+ var cryptoField = require("./crypto-field");
45
+ var lazyRequire = require("./lazy-require");
46
+ var observability = require("./observability");
47
+ var requestHelpers = require("./request-helpers");
48
+ var safeAsync = require("./safe-async");
49
+ var safeJson = require("./safe-json");
50
+ var totp = require("./totp");
51
+ var validateOpts = require("./validate-opts");
52
+ var { defineClass } = require("./framework-error");
53
+
54
+ var vault = lazyRequire(function () { return require("./vault"); });
55
+
56
+ var lockout = lazyRequire(function () { return require("./auth/lockout"); });
57
+
58
+ // Errors — all 14 codes documented in the spec. `permanent: true`
59
+ // means caller's input is bad (Tier-A); `permanent: false` means
60
+ // transient (factor failed, rate-limited) — caller may retry.
61
+ var BreakGlassError = defineClass("BreakGlassError", { alwaysPermanent: false });
62
+
63
+ // ---- Defaults (matched to operator-locked decisions) ----
64
+
65
+ var DEFAULT_GRANT_TTL_MS = C.TIME.minutes(15);
66
+ var DEFAULT_MAX_ROWS = 1; // operator-locked: row-by-row auth
67
+ var DEFAULT_REASON_MIN_LEN = 12;
68
+ var DEFAULT_LOCKED_BEHAVIOR = "throw"; // or "redact"
69
+ var DEFAULT_AUDIT_REASON = "cleartext";
70
+ var ALLOWED_FACTORS = ["totp"]; // passkey added in v0.5.2
71
+ var ALLOWED_REASON_STORAGE = ["cleartext", "hmac", "both"];
72
+
73
+ // In-memory policy cache. Cluster-shared via the policies table; the
74
+ // cache short-circuits the DB roundtrip on the unsealRow hot path.
75
+ // Populated on first access per-table; invalidated on policy.set/delete.
76
+ var policyCache = new Map(); // table -> policy
77
+ var initialized = false;
78
+
79
+ // Factor lockout — wrap auth.lockout so a hostile actor brute-forcing
80
+ // TOTP codes against break-glass gets shut out after a few failures.
81
+ // Lazy-init on first grant attempt so init() doesn't require the
82
+ // cache primitive to be wired before break-glass loads.
83
+ var _factorLockout = null;
84
+ var _factorLockoutCache = null;
85
+ function _ensureFactorLockout() {
86
+ if (_factorLockout) return _factorLockout;
87
+ var cache = require("./cache");
88
+ _factorLockoutCache = cache.create({
89
+ namespace: "breakglass.factor",
90
+ backend: "memory",
91
+ });
92
+ _factorLockout = lockout().create({
93
+ namespace: "breakglass.factor",
94
+ cache: _factorLockoutCache,
95
+ maxAttempts: 5,
96
+ windowMs: C.TIME.minutes(15),
97
+ audit: audit,
98
+ });
99
+ return _factorLockout;
100
+ }
101
+
102
+ // ---- Cryptographic mode (Model B) — per-cell encryption with context binding ----
103
+ //
104
+ // Each policy in cryptographic mode has a per-policy DEK (data
105
+ // encryption key) generated at first use. The DEK is vault-sealed so
106
+ // it survives restarts. At cell encrypt time, the framework derives a
107
+ // per-cell key K_cell = SHAKE256(DEK || table || rowId || column) so
108
+ // every (table, rowId, column) triple gets a unique key. Encryption
109
+ // uses XChaCha20-Poly1305 with AAD = SHA3-512(table || rowId || column)
110
+ // — the AEAD tag itself is bound to the encryption context, so a
111
+ // ciphertext from row A literally cannot be decrypted as row B even
112
+ // with the same DEK.
113
+ //
114
+ // THREAT MODEL HONESTY: this provides defense-in-depth via per-cell
115
+ // keys + encryption-context binding (cross-cell tampering / accidental
116
+ // row-swap fails closed). It does NOT defend against vault-key
117
+ // compromise alone — the DEK is still vault-recoverable. True
118
+ // second-factor cryptographic gating ships in v0.5.2 with passkey
119
+ // integration (the passkey private key lives on the YubiKey, not in
120
+ // the framework, so a vault leak alone can't unwrap).
121
+
122
+ // In-memory DEK cache. Keyed by table name. Cleared on _resetForTest.
123
+ var dekCache = new Map();
124
+
125
+ function _aadFor(table, rowId, column) {
126
+ return sha3Hash(table + "|" + String(rowId) + "|" + column);
127
+ }
128
+
129
+ function _kCell(dek, table, rowId, column) {
130
+ return kdf(Buffer.concat([
131
+ Buffer.isBuffer(dek) ? dek : Buffer.from(dek, "base64"),
132
+ Buffer.from("breakglass.cell|" + table + "|" + String(rowId) + "|" + column, "utf8"),
133
+ ]), 32);
134
+ }
135
+
136
+ async function _ensureDek(table) {
137
+ if (dekCache.has(table)) return dekCache.get(table);
138
+ // The DEK lives in the policy row's serviceAccountBypassJson slot
139
+ // (repurposed in Model A; v0.5.0 explicitly rejected the slot, so
140
+ // it's unused in Model A rows). For Model B rows, we vault-seal the
141
+ // DEK and store it there. Pre-v1 schema: clean and simple.
142
+ var rows = await clusterStorage.executeAll(
143
+ "SELECT serviceAccountBypassJson FROM _blamejs_break_glass_policies WHERE tableName = ?",
144
+ [table]
145
+ );
146
+ if (!rows || rows.length === 0) {
147
+ throw new BreakGlassError("breakglass/policy-not-set",
148
+ "_ensureDek: no policy for table '" + table + "'", true);
149
+ }
150
+ var sealed = rows[0].serviceAccountBypassJson;
151
+ var dek;
152
+ if (sealed) {
153
+ dek = Buffer.from(vault().unseal(sealed), "base64");
154
+ } else {
155
+ dek = generateBytes(32);
156
+ var sealedDek = vault().seal(dek.toString("base64"));
157
+ await clusterStorage.execute(
158
+ "UPDATE _blamejs_break_glass_policies SET serviceAccountBypassJson = ? WHERE tableName = ?",
159
+ [sealedDek, table]
160
+ );
161
+ }
162
+ dekCache.set(table, dek);
163
+ return dek;
164
+ }
165
+
166
+ // Encrypt a single cell value with encryption context binding. Operators
167
+ // in cryptographic mode use this at write time INSTEAD of letting
168
+ // cryptoField.sealRow seal the column.
169
+ async function encryptCell(plaintext, ctx) {
170
+ _requireInit();
171
+ if (!ctx || typeof ctx !== "object" ||
172
+ typeof ctx.table !== "string" || ctx.table.length === 0 ||
173
+ ctx.rowId === undefined || ctx.rowId === null ||
174
+ typeof ctx.column !== "string" || ctx.column.length === 0) {
175
+ throw new BreakGlassError("breakglass/bad-cell-ctx",
176
+ "encryptCell: ctx must be { table, rowId, column }");
177
+ }
178
+ var policy = await policyGet(ctx.table);
179
+ if (!policy || !policy.cryptographic) {
180
+ throw new BreakGlassError("breakglass/policy-not-set",
181
+ "encryptCell: table '" + ctx.table + "' is not in cryptographic mode " +
182
+ "(set policy.cryptographic = true to opt in)", true);
183
+ }
184
+ if (policy.columns.indexOf(ctx.column) === -1) {
185
+ throw new BreakGlassError("breakglass/grant-column-mismatch",
186
+ "encryptCell: column '" + ctx.column + "' is not glass-locked on '" + ctx.table + "'", true);
187
+ }
188
+ var dek = await _ensureDek(ctx.table);
189
+ var kCell = _kCell(dek, ctx.table, ctx.rowId, ctx.column);
190
+ var aad = _aadFor(ctx.table, ctx.rowId, ctx.column);
191
+ var pt = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), "utf8");
192
+ var packed = encryptPacked(pt, kCell, aad);
193
+ return "bgcell:1:" + packed.toString("base64");
194
+ }
195
+
196
+ // Decrypt a cell value. Caller must hold a valid grant covering the
197
+ // (table, column) — caller passes through unsealRow which gates this.
198
+ // The encryption context (table, rowId, column) is passed both to the
199
+ // key derivation AND the AAD; if the caller passes the wrong rowId
200
+ // trying to "swap" ciphertexts between rows, decryption fails closed.
201
+ async function decryptCell(ciphertext, ctx) {
202
+ _requireInit();
203
+ if (typeof ciphertext !== "string" || ciphertext.indexOf("bgcell:1:") !== 0) {
204
+ throw new BreakGlassError("breakglass/bad-ciphertext",
205
+ "decryptCell: expected 'bgcell:1:<base64>' format");
206
+ }
207
+ if (!ctx || typeof ctx !== "object") {
208
+ throw new BreakGlassError("breakglass/bad-cell-ctx",
209
+ "decryptCell: ctx must be { table, rowId, column }");
210
+ }
211
+ var dek = await _ensureDek(ctx.table);
212
+ var kCell = _kCell(dek, ctx.table, ctx.rowId, ctx.column);
213
+ var aad = _aadFor(ctx.table, ctx.rowId, ctx.column);
214
+ var packed = Buffer.from(ciphertext.slice("bgcell:1:".length), "base64");
215
+ return decryptPacked(packed, kCell, aad).toString("utf8");
216
+ }
217
+
218
+ // ---- Migration support ----
219
+ //
220
+ // Operator runs `b.breakGlass.migrate(table, opts)` (or the CLI
221
+ // equivalent) to convert existing Model-A-sealed rows into Model B
222
+ // per-cell-encrypted form. Each row's glass-locked columns are
223
+ // unsealed via cryptoField, re-encrypted via encryptCell, and written
224
+ // back. The migration is idempotent — a row already in Model B form
225
+ // (column starts with "bgcell:") is skipped.
226
+
227
+ async function migrate(table, opts) {
228
+ _requireInit();
229
+ opts = opts || {};
230
+ validateOpts(opts, ["batchSize", "callerOpts"], "breakGlass.migrate");
231
+ var policy = await policyGet(table);
232
+ if (!policy) {
233
+ throw new BreakGlassError("breakglass/policy-not-set",
234
+ "migrate: no policy for table '" + table + "'", true);
235
+ }
236
+ if (!policy.cryptographic) {
237
+ throw new BreakGlassError("breakglass/bad-policy",
238
+ "migrate: policy must be cryptographic: true to migrate to Model B", true);
239
+ }
240
+ var batchSize = opts.batchSize || 100;
241
+ var totalRows = 0;
242
+ var migratedRows = 0;
243
+ var skippedRows = 0;
244
+ var lastId = "";
245
+ // Iterate via _id-keyset paging so we don't load the whole table into memory.
246
+ while (true) {
247
+ var rows = await clusterStorage.executeAll(
248
+ "SELECT * FROM " + table + " WHERE _id > ? ORDER BY _id ASC LIMIT ?",
249
+ [lastId, batchSize]
250
+ );
251
+ if (!rows || rows.length === 0) break;
252
+ for (var i = 0; i < rows.length; i++) {
253
+ totalRows++;
254
+ var row = rows[i];
255
+ var unsealed = cryptoField.unsealRow(table, row);
256
+ var anyChanged = false;
257
+ var update = { _id: row._id };
258
+ for (var c = 0; c < policy.columns.length; c++) {
259
+ var col = policy.columns[c];
260
+ var current = unsealed[col];
261
+ if (current == null) continue;
262
+ if (typeof current === "string" && current.indexOf("bgcell:") === 0) {
263
+ continue; // already migrated
264
+ }
265
+ var encrypted = await encryptCell(current, { table: table, rowId: row._id, column: col });
266
+ update[col] = encrypted;
267
+ anyChanged = true;
268
+ }
269
+ if (anyChanged) {
270
+ // Write Model B ciphertext directly — bypassing cryptoField so
271
+ // the cell ciphertext stays as a literal string, not double-sealed.
272
+ var setCols = Object.keys(update).filter(function (k) { return k !== "_id"; });
273
+ if (setCols.length > 0) {
274
+ var setSql = setCols.map(function (k) { return k + " = ?"; }).join(", ");
275
+ var vals = setCols.map(function (k) { return update[k]; });
276
+ vals.push(row._id);
277
+ await clusterStorage.execute(
278
+ "UPDATE " + table + " SET " + setSql + " WHERE _id = ?",
279
+ vals
280
+ );
281
+ migratedRows++;
282
+ }
283
+ } else {
284
+ skippedRows++;
285
+ }
286
+ lastId = row._id;
287
+ }
288
+ if (rows.length < batchSize) break;
289
+ }
290
+ audit.safeEmit({
291
+ action: "breakglass.migrate",
292
+ outcome: "success",
293
+ actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
294
+ metadata: {
295
+ table: table,
296
+ totalRows: totalRows,
297
+ migratedRows: migratedRows,
298
+ skippedRows: skippedRows,
299
+ },
300
+ });
301
+ return { table: table, totalRows: totalRows, migratedRows: migratedRows, skippedRows: skippedRows };
302
+ }
303
+
304
+ // ---- init ----
305
+
306
+ function init(opts) {
307
+ opts = opts || {};
308
+ validateOpts(opts, ["now"], "breakGlass.init");
309
+ initialized = true;
310
+ policyCache.clear();
311
+ _factorLockout = null;
312
+ }
313
+
314
+ function _resetForTest() {
315
+ initialized = false;
316
+ policyCache.clear();
317
+ dekCache.clear();
318
+ if (_factorLockoutCache && typeof _factorLockoutCache.close === "function") {
319
+ try { _factorLockoutCache.close(); } catch (_e) { /* best-effort */ }
320
+ }
321
+ _factorLockout = null;
322
+ _factorLockoutCache = null;
323
+ }
324
+
325
+ function _requireInit() {
326
+ if (!initialized) {
327
+ throw new BreakGlassError("breakglass/not-initialized",
328
+ "b.breakGlass.init() must be called before use");
329
+ }
330
+ }
331
+
332
+ // ---- Policy CRUD ----
333
+
334
+ function _validatePolicySet(table, opts) {
335
+ if (typeof table !== "string" || table.length === 0) {
336
+ throw new BreakGlassError("breakglass/bad-policy",
337
+ "policy.set: table must be a non-empty string");
338
+ }
339
+ if (!opts || typeof opts !== "object") {
340
+ throw new BreakGlassError("breakglass/bad-policy",
341
+ "policy.set: opts is required");
342
+ }
343
+ validateOpts(opts, [
344
+ "columns", "factors", "cryptographic", "grantTtl", "maxRowsPerGrant",
345
+ "reasonRequired", "reasonMinLength", "pinIp", "sessionPin",
346
+ "onLockedAccess", "requireScope", "serviceAccountBypass",
347
+ "auditReasonStorage",
348
+ ], "breakglass.policy.set");
349
+ if (!Array.isArray(opts.columns) || opts.columns.length === 0) {
350
+ throw new BreakGlassError("breakglass/bad-policy",
351
+ "policy.set: columns must be a non-empty array");
352
+ }
353
+ for (var i = 0; i < opts.columns.length; i++) {
354
+ if (typeof opts.columns[i] !== "string" || opts.columns[i].length === 0) {
355
+ throw new BreakGlassError("breakglass/bad-policy",
356
+ "policy.set: columns[" + i + "] must be a non-empty string");
357
+ }
358
+ }
359
+ if (!Array.isArray(opts.factors) || opts.factors.length === 0) {
360
+ throw new BreakGlassError("breakglass/bad-policy",
361
+ "policy.set: factors must be a non-empty array");
362
+ }
363
+ for (var j = 0; j < opts.factors.length; j++) {
364
+ if (ALLOWED_FACTORS.indexOf(opts.factors[j]) === -1) {
365
+ throw new BreakGlassError("breakglass/bad-policy",
366
+ "policy.set: factors[" + j + "] '" + opts.factors[j] +
367
+ "' not in v0.5.0 allowed factors [" + ALLOWED_FACTORS.join(",") + "]" +
368
+ " (passkey lands in v0.5.2)");
369
+ }
370
+ }
371
+ // Model B (cryptographic mode) ships in v0.5.1. When enabled,
372
+ // glass-locked columns must be encrypted with `b.breakGlass.encryptCell`
373
+ // at write time (the framework can't auto-encrypt at write because
374
+ // policy-set may post-date existing data; operators run the migration
375
+ // CLI to convert existing rows). At unseal time, the row's
376
+ // glass-locked columns are decrypted via decryptCell with encryption
377
+ // context binding (table, rowId, column).
378
+ if (opts.cryptographic !== undefined && typeof opts.cryptographic !== "boolean") {
379
+ throw new BreakGlassError("breakglass/bad-policy",
380
+ "policy.set: cryptographic must be a boolean");
381
+ }
382
+ var grantTtl = opts.grantTtl != null ? opts.grantTtl : DEFAULT_GRANT_TTL_MS;
383
+ if (typeof grantTtl !== "number" || !isFinite(grantTtl) || grantTtl <= 0) {
384
+ throw new BreakGlassError("breakglass/bad-policy",
385
+ "policy.set: grantTtl must be a positive number of milliseconds");
386
+ }
387
+ var maxRows = opts.maxRowsPerGrant != null ? opts.maxRowsPerGrant : DEFAULT_MAX_ROWS;
388
+ if (!Number.isInteger(maxRows) || maxRows < 1) {
389
+ throw new BreakGlassError("breakglass/bad-policy",
390
+ "policy.set: maxRowsPerGrant must be a positive integer (default 1 — row-by-row auth)");
391
+ }
392
+ if (opts.onLockedAccess != null &&
393
+ opts.onLockedAccess !== "throw" && opts.onLockedAccess !== "redact") {
394
+ throw new BreakGlassError("breakglass/bad-policy",
395
+ "policy.set: onLockedAccess must be 'throw' or 'redact'");
396
+ }
397
+ if (opts.auditReasonStorage != null &&
398
+ ALLOWED_REASON_STORAGE.indexOf(opts.auditReasonStorage) === -1) {
399
+ throw new BreakGlassError("breakglass/bad-policy",
400
+ "policy.set: auditReasonStorage must be one of " + ALLOWED_REASON_STORAGE.join("/"));
401
+ }
402
+ if (opts.serviceAccountBypass != null && opts.serviceAccountBypass !== false) {
403
+ throw new BreakGlassError("breakglass/bad-policy",
404
+ "policy.set: serviceAccountBypass ships in v0.5.2 — leave unset for v0.5.0");
405
+ }
406
+ return {
407
+ cryptographic: opts.cryptographic === true,
408
+ grantTtl: grantTtl,
409
+ maxRowsPerGrant: maxRows,
410
+ reasonRequired: opts.reasonRequired !== false,
411
+ reasonMinLength: opts.reasonMinLength != null ? opts.reasonMinLength : DEFAULT_REASON_MIN_LEN,
412
+ pinIp: opts.pinIp !== false,
413
+ sessionPin: opts.sessionPin !== false,
414
+ onLockedAccess: opts.onLockedAccess || DEFAULT_LOCKED_BEHAVIOR,
415
+ requireScope: opts.requireScope != null ? opts.requireScope : null,
416
+ auditReasonStorage: opts.auditReasonStorage || DEFAULT_AUDIT_REASON,
417
+ };
418
+ }
419
+
420
+ async function policySet(table, opts, callerOpts) {
421
+ _requireInit();
422
+ var validated = _validatePolicySet(table, opts);
423
+ var policyRow = {
424
+ tableName: table,
425
+ columnsJson: JSON.stringify(opts.columns),
426
+ factorsJson: JSON.stringify(opts.factors),
427
+ cryptographic: validated.cryptographic ? 1 : 0,
428
+ grantTtlMs: validated.grantTtl,
429
+ maxRowsPerGrant: validated.maxRowsPerGrant,
430
+ reasonRequired: validated.reasonRequired ? 1 : 0,
431
+ reasonMinLength: validated.reasonMinLength,
432
+ pinIp: validated.pinIp ? 1 : 0,
433
+ sessionPin: validated.sessionPin ? 1 : 0,
434
+ onLockedAccess: validated.onLockedAccess,
435
+ requireScope: validated.requireScope,
436
+ serviceAccountBypassJson: null,
437
+ auditReasonStorage: validated.auditReasonStorage,
438
+ updatedAt: Date.now(),
439
+ };
440
+ var sealed = cryptoField.sealRow("_blamejs_break_glass_policies", policyRow);
441
+ // UPSERT — both Postgres and SQLite support ON CONFLICT.
442
+ var keys = Object.keys(sealed);
443
+ var cols = keys.join(", ");
444
+ var qs = keys.map(function () { return "?"; }).join(", ");
445
+ var setSql = keys.filter(function (k) { return k !== "tableName"; })
446
+ .map(function (k) { return k + " = excluded." + k; }).join(", ");
447
+ var sql = "INSERT INTO _blamejs_break_glass_policies (" + cols + ") " +
448
+ "VALUES (" + qs + ") " +
449
+ "ON CONFLICT (tableName) DO UPDATE SET " + setSql;
450
+ await clusterStorage.execute(sql, keys.map(function (k) { return sealed[k]; }));
451
+ policyCache.delete(table);
452
+
453
+ audit.safeEmit({
454
+ action: "breakglass.policy.set",
455
+ outcome: "success",
456
+ actor: requestHelpers.resolveActorWithOverride(callerOpts),
457
+ metadata: {
458
+ table: table,
459
+ columnCount: opts.columns.length,
460
+ factors: opts.factors,
461
+ grantTtlMs: validated.grantTtl,
462
+ maxRowsPerGrant: validated.maxRowsPerGrant,
463
+ },
464
+ });
465
+ observability.event("breakglass.policy.set", { table: table });
466
+ return { applied: true, table: table };
467
+ }
468
+
469
+ async function policyGet(table) {
470
+ _requireInit();
471
+ if (typeof table !== "string" || table.length === 0) return null;
472
+ if (policyCache.has(table)) return policyCache.get(table);
473
+ var rows = await clusterStorage.executeAll(
474
+ "SELECT * FROM _blamejs_break_glass_policies WHERE tableName = ?",
475
+ [table]
476
+ );
477
+ if (!rows || rows.length === 0) {
478
+ policyCache.set(table, null);
479
+ return null;
480
+ }
481
+ var unsealed = cryptoField.unsealRow("_blamejs_break_glass_policies", rows[0]);
482
+ var policy = {
483
+ table: unsealed.tableName,
484
+ columns: safeJson.parse(unsealed.columnsJson, { maxBytes: C.BYTES.kib(64) }),
485
+ factors: safeJson.parse(unsealed.factorsJson, { maxBytes: C.BYTES.kib(8) }),
486
+ cryptographic: unsealed.cryptographic === 1,
487
+ grantTtl: Number(unsealed.grantTtlMs),
488
+ maxRowsPerGrant: Number(unsealed.maxRowsPerGrant),
489
+ reasonRequired: unsealed.reasonRequired === 1,
490
+ reasonMinLength: Number(unsealed.reasonMinLength),
491
+ pinIp: unsealed.pinIp === 1,
492
+ sessionPin: unsealed.sessionPin === 1,
493
+ onLockedAccess: unsealed.onLockedAccess,
494
+ requireScope: unsealed.requireScope,
495
+ auditReasonStorage: unsealed.auditReasonStorage,
496
+ updatedAt: Number(unsealed.updatedAt),
497
+ };
498
+ policyCache.set(table, policy);
499
+ return policy;
500
+ }
501
+
502
+ async function policyList() {
503
+ _requireInit();
504
+ var rows = await clusterStorage.executeAll(
505
+ "SELECT tableName FROM _blamejs_break_glass_policies ORDER BY tableName"
506
+ );
507
+ var out = [];
508
+ for (var i = 0; i < (rows || []).length; i++) {
509
+ var p = await policyGet(rows[i].tableName);
510
+ if (p) out.push(p);
511
+ }
512
+ return out;
513
+ }
514
+
515
+ async function policyDelete(table, callerOpts) {
516
+ _requireInit();
517
+ if (typeof table !== "string" || table.length === 0) {
518
+ throw new BreakGlassError("breakglass/bad-policy",
519
+ "policy.delete: table must be a non-empty string");
520
+ }
521
+ await clusterStorage.execute(
522
+ "DELETE FROM _blamejs_break_glass_policies WHERE tableName = ?",
523
+ [table]
524
+ );
525
+ policyCache.delete(table);
526
+ audit.safeEmit({
527
+ action: "breakglass.policy.delete",
528
+ outcome: "success",
529
+ actor: requestHelpers.resolveActorWithOverride(callerOpts),
530
+ metadata: { table: table },
531
+ });
532
+ return { deleted: true, table: table };
533
+ }
534
+
535
+ // ---- Grant issuance ----
536
+
537
+ function _verifyTotpFactor(factor) {
538
+ if (!factor || typeof factor !== "object") return { ok: false };
539
+ if (typeof factor.secret !== "string" || factor.secret.length === 0) return { ok: false };
540
+ if (typeof factor.code !== "string" || factor.code.length === 0) return { ok: false };
541
+ var verified = totp.verify(factor.secret, factor.code);
542
+ return { ok: verified !== false, step: verified };
543
+ }
544
+
545
+ async function grant(opts) {
546
+ _requireInit();
547
+ if (!opts || typeof opts !== "object") {
548
+ throw new BreakGlassError("breakglass/bad-grant-opts",
549
+ "grant: opts is required");
550
+ }
551
+ validateOpts(opts, ["req", "table", "columns", "reason", "factor"], "breakGlass.grant");
552
+
553
+ var table = opts.table;
554
+ var policy = await policyGet(table);
555
+ if (!policy) {
556
+ throw new BreakGlassError("breakglass/policy-not-set",
557
+ "no break-glass policy is configured for table '" + table + "'", true);
558
+ }
559
+
560
+ // Reason validation
561
+ var reason = typeof opts.reason === "string" ? opts.reason : "";
562
+ if (policy.reasonRequired && reason.length === 0) {
563
+ throw new BreakGlassError("breakglass/missing-reason",
564
+ "grant: reason is required for table '" + table + "'", true);
565
+ }
566
+ if (policy.reasonRequired && reason.length < policy.reasonMinLength) {
567
+ throw new BreakGlassError("breakglass/short-reason",
568
+ "grant: reason must be at least " + policy.reasonMinLength + " characters", true);
569
+ }
570
+
571
+ // Column scoping
572
+ var requestedColumns = Array.isArray(opts.columns) && opts.columns.length > 0
573
+ ? opts.columns.slice()
574
+ : policy.columns.slice();
575
+ for (var i = 0; i < requestedColumns.length; i++) {
576
+ if (policy.columns.indexOf(requestedColumns[i]) === -1) {
577
+ throw new BreakGlassError("breakglass/grant-column-mismatch",
578
+ "grant: requested column '" + requestedColumns[i] +
579
+ "' is not glass-locked on table '" + table + "'", true);
580
+ }
581
+ }
582
+
583
+ // Actor identity
584
+ var actor = requestHelpers.extractActorContext(opts.req);
585
+ var actorId = actor.userId || (opts.req && opts.req.apiKey && opts.req.apiKey.id) || null;
586
+ if (!actorId) {
587
+ throw new BreakGlassError("breakglass/unauthorized",
588
+ "grant: no authenticated actor on request (req.user.id / req.apiKey.id required)", true);
589
+ }
590
+
591
+ // Factor verification + lockout
592
+ var factorType = opts.factor && opts.factor.type;
593
+ if (!factorType || policy.factors.indexOf(factorType) === -1) {
594
+ throw new BreakGlassError("breakglass/bad-factor",
595
+ "grant: factor.type must be one of [" + policy.factors.join(",") + "]");
596
+ }
597
+ var fl = _ensureFactorLockout();
598
+ var lockKey = actorId;
599
+ var locked = await fl.check(lockKey);
600
+ if (locked && locked.locked) {
601
+ audit.safeEmit({
602
+ action: "breakglass.grant.requested",
603
+ outcome: "denied",
604
+ actor: actor,
605
+ reason: "factor-rate-limited",
606
+ metadata: { table: table, factorType: factorType, lockUntil: locked.lockedUntil },
607
+ });
608
+ throw new BreakGlassError("breakglass/factor-rate-limited",
609
+ "grant: too many recent factor failures; locked until " +
610
+ new Date(locked.lockedUntil).toISOString());
611
+ }
612
+
613
+ var factorOk = false;
614
+ if (factorType === "totp") {
615
+ factorOk = _verifyTotpFactor(opts.factor).ok;
616
+ }
617
+
618
+ if (!factorOk) {
619
+ await fl.recordFailure(lockKey, { reason: factorType + "-bad" });
620
+ audit.safeEmit({
621
+ action: "breakglass.grant.requested",
622
+ outcome: "denied",
623
+ actor: actor,
624
+ reason: "bad-factor",
625
+ metadata: { table: table, factorType: factorType, columns: requestedColumns },
626
+ });
627
+ throw new BreakGlassError("breakglass/bad-factor",
628
+ "grant: " + factorType + " factor verification failed");
629
+ }
630
+ await fl.recordSuccess(lockKey);
631
+
632
+ // Build + persist the grant row
633
+ var nowMs = Date.now();
634
+ var grantId = "bg-" + generateToken(16);
635
+ var sessionId = (opts.req && opts.req.session && opts.req.session.id) || null;
636
+ var ipFromReq = (opts.req && opts.req.socket && opts.req.socket.remoteAddress) || null;
637
+
638
+ var grantRow = {
639
+ _id: grantId,
640
+ issuedToActorId: actorId,
641
+ factorType: factorType,
642
+ reasonSealed: reason,
643
+ scopeTable: table,
644
+ scopeColumnsJson: JSON.stringify(requestedColumns),
645
+ issuedAt: nowMs,
646
+ expiresAt: nowMs + policy.grantTtl,
647
+ maxRowsPerGrant: policy.maxRowsPerGrant,
648
+ rowsConsumed: 0,
649
+ revokedAt: null,
650
+ sessionId: sessionId,
651
+ ip: ipFromReq,
652
+ kwGrantHalf: null,
653
+ };
654
+ var sealed = cryptoField.sealRow("_blamejs_break_glass_grants", grantRow);
655
+ var keys = Object.keys(sealed);
656
+ var cols = keys.join(", ");
657
+ var qs = keys.map(function () { return "?"; }).join(", ");
658
+ await clusterStorage.execute(
659
+ "INSERT INTO _blamejs_break_glass_grants (" + cols + ") VALUES (" + qs + ")",
660
+ keys.map(function (k) { return sealed[k]; })
661
+ );
662
+
663
+ // Audit
664
+ var reasonForAudit = _reasonForAudit(reason, policy.auditReasonStorage);
665
+ audit.safeEmit({
666
+ action: "breakglass.grant.requested",
667
+ outcome: "success",
668
+ actor: actor,
669
+ reason: reasonForAudit.cleartext,
670
+ metadata: {
671
+ grantId: grantId,
672
+ table: table,
673
+ columns: requestedColumns,
674
+ factorType: factorType,
675
+ ttlMs: policy.grantTtl,
676
+ maxRowsPerGrant: policy.maxRowsPerGrant,
677
+ reasonHmac: reasonForAudit.hmac,
678
+ },
679
+ });
680
+ observability.event("breakGlass.grant", { table: table });
681
+
682
+ return {
683
+ id: grantId,
684
+ expiresAt: grantRow.expiresAt,
685
+ rowsRemaining: policy.maxRowsPerGrant,
686
+ scopeTable: table,
687
+ scopeColumns: requestedColumns,
688
+ };
689
+ }
690
+
691
+ function _reasonForAudit(reason, mode) {
692
+ // HMAC variant uses SHA3-512 keyed by a stable framework-wide tag —
693
+ // operators with multiple deployments can correlate via the hash
694
+ // without re-deriving from the same secret. Cleartext is the default
695
+ // (compliance reviewers WANT to read the reason).
696
+ var out = { cleartext: null, hmac: null };
697
+ if (mode === "cleartext" || mode === "both") out.cleartext = reason;
698
+ if (mode === "hmac" || mode === "both") {
699
+ out.hmac = sha3Hash("breakGlass.reason:" + reason);
700
+ }
701
+ return out;
702
+ }
703
+
704
+ // ---- Use a grant ----
705
+
706
+ async function unsealRow(grantHandle, table, rowId) {
707
+ _requireInit();
708
+ if (!grantHandle || typeof grantHandle !== "object" || typeof grantHandle.id !== "string") {
709
+ throw new BreakGlassError("breakglass/bad-grant-opts",
710
+ "unsealRow: grant handle is required (returned from b.breakGlass.grant())");
711
+ }
712
+ if (typeof table !== "string" || table.length === 0) {
713
+ throw new BreakGlassError("breakglass/bad-grant-opts",
714
+ "unsealRow: table must be a non-empty string");
715
+ }
716
+ if (rowId === undefined || rowId === null || rowId === "") {
717
+ throw new BreakGlassError("breakglass/bad-grant-opts",
718
+ "unsealRow: rowId is required");
719
+ }
720
+ var grantRows = await clusterStorage.executeAll(
721
+ "SELECT * FROM _blamejs_break_glass_grants WHERE _id = ?",
722
+ [grantHandle.id]
723
+ );
724
+ if (!grantRows || grantRows.length === 0) {
725
+ throw new BreakGlassError("breakglass/grant-revoked",
726
+ "unsealRow: grant " + grantHandle.id + " not found (deleted or never issued)", true);
727
+ }
728
+ var sealedGrant = grantRows[0];
729
+ var grantRow = cryptoField.unsealRow("_blamejs_break_glass_grants", sealedGrant);
730
+
731
+ // Table mismatch
732
+ if (grantRow.scopeTable !== table) {
733
+ audit.safeEmit({
734
+ action: "breakglass.unsealrow",
735
+ outcome: "denied",
736
+ actor: { userId: grantRow.issuedToActorId },
737
+ reason: "grant-table-mismatch",
738
+ metadata: { grantId: grantRow._id, expectedTable: grantRow.scopeTable, gotTable: table, rowId: String(rowId) },
739
+ });
740
+ throw new BreakGlassError("breakglass/grant-table-mismatch",
741
+ "unsealRow: grant " + grantHandle.id + " is scoped to '" +
742
+ grantRow.scopeTable + "', not '" + table + "'", true);
743
+ }
744
+
745
+ // Revoked
746
+ if (grantRow.revokedAt) {
747
+ throw new BreakGlassError("breakglass/grant-revoked",
748
+ "unsealRow: grant " + grantHandle.id + " was revoked at " +
749
+ new Date(Number(grantRow.revokedAt)).toISOString(), true);
750
+ }
751
+
752
+ // Expired
753
+ if (Number(grantRow.expiresAt) <= Date.now()) {
754
+ audit.safeEmit({
755
+ action: "breakglass.grant.expired",
756
+ outcome: "success",
757
+ actor: { userId: grantRow.issuedToActorId },
758
+ metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
759
+ });
760
+ throw new BreakGlassError("breakglass/grant-expired",
761
+ "unsealRow: grant " + grantHandle.id + " expired at " +
762
+ new Date(Number(grantRow.expiresAt)).toISOString(), true);
763
+ }
764
+
765
+ // Exhausted
766
+ if (Number(grantRow.rowsConsumed) >= Number(grantRow.maxRowsPerGrant)) {
767
+ audit.safeEmit({
768
+ action: "breakglass.grant.exhausted",
769
+ outcome: "success",
770
+ actor: { userId: grantRow.issuedToActorId },
771
+ metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
772
+ });
773
+ throw new BreakGlassError("breakglass/grant-exhausted",
774
+ "unsealRow: grant " + grantHandle.id + " has consumed all " +
775
+ grantRow.maxRowsPerGrant + " allowed rows", true);
776
+ }
777
+
778
+ // Increment rowsConsumed (atomic UPDATE with WHERE rowsConsumed < cap
779
+ // so concurrent unseals can't both pass the runtime check above).
780
+ var updateRes = await clusterStorage.execute(
781
+ "UPDATE _blamejs_break_glass_grants " +
782
+ "SET rowsConsumed = rowsConsumed + 1 " +
783
+ "WHERE _id = ? AND rowsConsumed < maxRowsPerGrant AND " +
784
+ "(revokedAt IS NULL) AND expiresAt > ?",
785
+ [grantHandle.id, Date.now()]
786
+ );
787
+ // executeAll-style result; some backends return rowsAffected, others a count.
788
+ // Re-query to confirm the increment landed and get the post-increment counter.
789
+ var postRows = await clusterStorage.executeAll(
790
+ "SELECT rowsConsumed, revokedAt, expiresAt FROM _blamejs_break_glass_grants WHERE _id = ?",
791
+ [grantHandle.id]
792
+ );
793
+ if (!postRows || postRows.length === 0) {
794
+ throw new BreakGlassError("breakglass/grant-revoked",
795
+ "unsealRow: grant " + grantHandle.id + " disappeared during unseal", true);
796
+ }
797
+ var postRowsConsumed = Number(postRows[0].rowsConsumed);
798
+ // If the UPDATE didn't actually increment (race lost — another unseal
799
+ // exhausted the grant or it was revoked / expired between our check
800
+ // and the UPDATE), refuse this read.
801
+ if (postRowsConsumed === Number(grantRow.rowsConsumed)) {
802
+ throw new BreakGlassError("breakglass/grant-exhausted",
803
+ "unsealRow: grant " + grantHandle.id + " was exhausted by a concurrent read", true);
804
+ }
805
+ void updateRes;
806
+
807
+ // Fetch + unseal the target row. Model A goes straight through
808
+ // cryptoField; Model B reads the row, lets cryptoField unseal the
809
+ // non-glass-locked columns, and then decryptCell handles the
810
+ // glass-locked columns separately (their ciphertext was written
811
+ // by encryptCell at app-write time, not by cryptoField.sealRow).
812
+ var rows = await clusterStorage.executeAll(
813
+ "SELECT * FROM " + table + " WHERE _id = ?",
814
+ [String(rowId)]
815
+ );
816
+ if (!rows || rows.length === 0) {
817
+ throw new BreakGlassError("breakglass/row-not-found",
818
+ "unsealRow: " + table + "[" + rowId + "] not found", true);
819
+ }
820
+ var policy = await policyGet(table);
821
+ var unsealedRow;
822
+ if (policy && policy.cryptographic) {
823
+ // Snapshot the raw glass-locked column ciphertexts BEFORE
824
+ // cryptoField.unsealRow runs — cryptoField doesn't know about the
825
+ // bgcell: format and would no-op (or error) on it. Then unseal the
826
+ // rest of the row, then decrypt the glass-locked columns via
827
+ // decryptCell with encryption-context binding.
828
+ var rawCipher = {};
829
+ for (var c = 0; c < policy.columns.length; c++) {
830
+ rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
831
+ }
832
+ var rowMinusLocked = Object.assign({}, rows[0]);
833
+ for (var c2 = 0; c2 < policy.columns.length; c2++) {
834
+ delete rowMinusLocked[policy.columns[c2]];
835
+ }
836
+ unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
837
+ for (var c3 = 0; c3 < policy.columns.length; c3++) {
838
+ var col = policy.columns[c3];
839
+ if (rawCipher[col] == null) continue;
840
+ try {
841
+ unsealedRow[col] = await decryptCell(rawCipher[col],
842
+ { table: table, rowId: String(rowId), column: col });
843
+ } catch (e) {
844
+ throw new BreakGlassError("breakglass/cell-decrypt-failed",
845
+ "unsealRow: cell decrypt failed for " + table + "[" + rowId +
846
+ "]." + col + " (was the row migrated to Model B?): " +
847
+ ((e && e.message) || String(e)), true);
848
+ }
849
+ }
850
+ } else {
851
+ unsealedRow = cryptoField.unsealRow(table, rows[0]);
852
+ }
853
+
854
+ // Per-row audit. The grant's reasonSealed is already cleartext after
855
+ // unsealRow on the grant; pass it into the audit row honoring the
856
+ // policy's auditReasonStorage mode.
857
+ var reasonForAudit = _reasonForAudit(grantRow.reasonSealed || "",
858
+ policy ? policy.auditReasonStorage : DEFAULT_AUDIT_REASON);
859
+ audit.safeEmit({
860
+ action: "breakglass.unsealrow",
861
+ outcome: "success",
862
+ actor: { userId: grantRow.issuedToActorId },
863
+ reason: reasonForAudit.cleartext,
864
+ metadata: {
865
+ grantId: grantRow._id,
866
+ table: table,
867
+ rowId: String(rowId),
868
+ columns: safeJson.parse(grantRow.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
869
+ rowsRemaining: Number(grantRow.maxRowsPerGrant) - postRowsConsumed,
870
+ reasonHmac: reasonForAudit.hmac,
871
+ },
872
+ });
873
+ observability.event("breakglass.unsealrow", { table: table });
874
+
875
+ return unsealedRow;
876
+ }
877
+
878
+ // ---- Revoke ----
879
+
880
+ async function revoke(grantId, opts) {
881
+ _requireInit();
882
+ if (typeof grantId !== "string" || grantId.length === 0) {
883
+ throw new BreakGlassError("breakglass/bad-grant-opts",
884
+ "revoke: grantId is required");
885
+ }
886
+ opts = opts || {};
887
+ var nowMs = Date.now();
888
+ await clusterStorage.execute(
889
+ "UPDATE _blamejs_break_glass_grants SET revokedAt = ? " +
890
+ "WHERE _id = ? AND revokedAt IS NULL",
891
+ [nowMs, grantId]
892
+ );
893
+ audit.safeEmit({
894
+ action: "breakglass.grant.revoked",
895
+ outcome: "success",
896
+ actor: requestHelpers.resolveActorWithOverride(opts),
897
+ reason: typeof opts.reason === "string" ? opts.reason : null,
898
+ metadata: { grantId: grantId },
899
+ });
900
+ return { revoked: true, grantId: grantId };
901
+ }
902
+
903
+ // ---- listActive ----
904
+
905
+ async function listActive(opts) {
906
+ _requireInit();
907
+ opts = opts || {};
908
+ var actor = requestHelpers.extractActorContext(opts.req);
909
+ var actorId = actor.userId || (opts.req && opts.req.apiKey && opts.req.apiKey.id) || null;
910
+ if (!actorId) return [];
911
+ // Use cryptoField's computeDerived so the hash matches the table's
912
+ // hashNamespace prefix — raw sha3Hash would produce a different value.
913
+ var derived = cryptoField.computeDerived(
914
+ "_blamejs_break_glass_grants", "issuedToActorId", actorId
915
+ );
916
+ if (!derived) return [];
917
+ var nowMs = Date.now();
918
+ var rows = await clusterStorage.executeAll(
919
+ "SELECT * FROM _blamejs_break_glass_grants " +
920
+ "WHERE issuedToActorHash = ? AND (revokedAt IS NULL) AND expiresAt > ? AND rowsConsumed < maxRowsPerGrant " +
921
+ "ORDER BY issuedAt DESC",
922
+ [derived.value, nowMs]
923
+ );
924
+ var out = [];
925
+ for (var i = 0; i < (rows || []).length; i++) {
926
+ var u = cryptoField.unsealRow("_blamejs_break_glass_grants", rows[i]);
927
+ out.push({
928
+ id: u._id,
929
+ scopeTable: u.scopeTable,
930
+ scopeColumns: safeJson.parse(u.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
931
+ issuedAt: Number(u.issuedAt),
932
+ expiresAt: Number(u.expiresAt),
933
+ rowsRemaining: Number(u.maxRowsPerGrant) - Number(u.rowsConsumed),
934
+ factorType: u.factorType,
935
+ });
936
+ }
937
+ return out;
938
+ }
939
+
940
+ // ---- Sweep (best-effort cleanup of expired grants) ----
941
+
942
+ async function _sweepExpired(opts) {
943
+ opts = opts || {};
944
+ var nowMs = Date.now();
945
+ var expired = await clusterStorage.executeAll(
946
+ "SELECT _id, issuedToActorId, scopeTable, rowsConsumed FROM _blamejs_break_glass_grants " +
947
+ "WHERE revokedAt IS NULL AND expiresAt <= ?",
948
+ [nowMs]
949
+ );
950
+ for (var i = 0; i < (expired || []).length; i++) {
951
+ var row = expired[i];
952
+ audit.safeEmit({
953
+ action: "breakglass.grant.expired",
954
+ outcome: "success",
955
+ actor: { userId: row.issuedToActorId },
956
+ metadata: { grantId: row._id, table: row.scopeTable, rowsConsumed: Number(row.rowsConsumed) },
957
+ });
958
+ }
959
+ await clusterStorage.execute(
960
+ "UPDATE _blamejs_break_glass_grants SET revokedAt = ? " +
961
+ "WHERE revokedAt IS NULL AND expiresAt <= ?",
962
+ [nowMs, nowMs]
963
+ );
964
+ return { expired: (expired || []).length };
965
+ }
966
+
967
+ void safeAsync; // kept import for future grant-async ops in v0.5.1+
968
+
969
+ module.exports = {
970
+ init: init,
971
+ policy: {
972
+ set: policySet,
973
+ get: policyGet,
974
+ list: policyList,
975
+ delete: policyDelete,
976
+ },
977
+ grant: grant,
978
+ unsealRow: unsealRow,
979
+ revoke: revoke,
980
+ listActive: listActive,
981
+ // Cryptographic mode (Model B) — per-cell encryption with context
982
+ // binding. Operators in cryptographic mode call encryptCell at write
983
+ // time; unsealRow auto-routes to decryptCell for glass-locked columns.
984
+ encryptCell: encryptCell,
985
+ decryptCell: decryptCell,
986
+ migrate: migrate,
987
+ BreakGlassError: BreakGlassError,
988
+
989
+ // Test-only / sweep — operators with active grant volume wire this
990
+ // into a scheduler; the framework doesn't auto-start the timer so
991
+ // boot doesn't depend on anything firing in the background.
992
+ _sweepExpiredForTest: _sweepExpired,
993
+ _resetForTest: _resetForTest,
994
+ };
package/lib/crypto.js CHANGED
@@ -206,9 +206,16 @@ function decryptEnvelope(packed, privateKeys) {
206
206
  }
207
207
 
208
208
  // ---- Symmetric buffer encrypt/decrypt (for storage) ----
209
- function encryptPacked(buffer, key) {
209
+ //
210
+ // Optional `aad` (additional authenticated data) is mixed into the
211
+ // Poly1305 tag — encrypt-time and decrypt-time AAD must match exactly
212
+ // or decrypt fails. Used by primitives that want encryption-context
213
+ // binding (b.breakGlass.encryptCell binds (table, rowId, column) so a
214
+ // ciphertext from row A literally cannot decrypt as row B even with
215
+ // the same key).
216
+ function encryptPacked(buffer, key, aad) {
210
217
  var nonce = random(24);
211
- var ct = xchacha20poly1305(key, nonce).encrypt(buffer);
218
+ var ct = xchacha20poly1305(key, nonce, aad ? Buffer.from(aad) : undefined).encrypt(buffer);
212
219
  return Buffer.concat([
213
220
  Buffer.from([C.FORMAT.XCHACHA20_POLY1305]),
214
221
  Buffer.from(nonce),
@@ -216,12 +223,13 @@ function encryptPacked(buffer, key) {
216
223
  ]);
217
224
  }
218
225
 
219
- function decryptPacked(packed, key) {
226
+ function decryptPacked(packed, key, aad) {
220
227
  if (packed[0] !== C.FORMAT.XCHACHA20_POLY1305) {
221
228
  throw new Error("Invalid packed format: unsupported version");
222
229
  }
223
230
  return Buffer.from(
224
- xchacha20poly1305(key, packed.subarray(1, 25)).decrypt(packed.subarray(25))
231
+ xchacha20poly1305(key, packed.subarray(1, 25), aad ? Buffer.from(aad) : undefined)
232
+ .decrypt(packed.subarray(25))
225
233
  );
226
234
  }
227
235
 
package/lib/db.js CHANGED
@@ -109,6 +109,8 @@ var RESERVED_TABLE_NAMES = new Set([
109
109
  "_blamejs_cache",
110
110
  "_blamejs_seeders",
111
111
  "_blamejs_seeders_lock",
112
+ "_blamejs_break_glass_policies",
113
+ "_blamejs_break_glass_grants",
112
114
  ]);
113
115
 
114
116
  var FRAMEWORK_SCHEMA = [
@@ -423,6 +425,67 @@ var FRAMEWORK_SCHEMA = [
423
425
  },
424
426
  sealedFields: [],
425
427
  },
428
+ {
429
+ // _blamejs_break_glass_policies — column-level break-glass policy
430
+ // registry. One row per (table) declares which columns are
431
+ // glass-locked and what the operator's grant rules are. Sealed
432
+ // columns hold the column-list, factor-list, and bypass config so
433
+ // policy contents aren't browsable in cleartext.
434
+ name: "_blamejs_break_glass_policies",
435
+ columns: {
436
+ tableName: "TEXT PRIMARY KEY",
437
+ columnsJson: "TEXT NOT NULL",
438
+ factorsJson: "TEXT NOT NULL",
439
+ cryptographic: "INTEGER NOT NULL DEFAULT 0",
440
+ grantTtlMs: "INTEGER NOT NULL",
441
+ maxRowsPerGrant: "INTEGER NOT NULL DEFAULT 1",
442
+ reasonRequired: "INTEGER NOT NULL DEFAULT 1",
443
+ reasonMinLength: "INTEGER NOT NULL DEFAULT 12",
444
+ pinIp: "INTEGER NOT NULL DEFAULT 1",
445
+ sessionPin: "INTEGER NOT NULL DEFAULT 1",
446
+ onLockedAccess: "TEXT NOT NULL DEFAULT 'throw'",
447
+ requireScope: "TEXT",
448
+ serviceAccountBypassJson: "TEXT",
449
+ auditReasonStorage: "TEXT NOT NULL DEFAULT 'cleartext'",
450
+ updatedAt: "INTEGER NOT NULL",
451
+ },
452
+ indexes: [],
453
+ sealedFields: ["columnsJson", "factorsJson", "serviceAccountBypassJson"],
454
+ },
455
+ {
456
+ // _blamejs_break_glass_grants — issued grants. Each successful
457
+ // step-up creates one row; each row read decrements rowsRemaining.
458
+ // Default maxRowsPerGrant=1 enforces "row by row" auth per the
459
+ // operator-confirmed shape (each row access = its own grant).
460
+ // Sealed columns hold reason + scopeColumns so audit-readable
461
+ // metadata doesn't leak in cleartext.
462
+ name: "_blamejs_break_glass_grants",
463
+ columns: {
464
+ _id: "TEXT PRIMARY KEY",
465
+ issuedToActorId: "TEXT NOT NULL",
466
+ issuedToActorHash: "TEXT NOT NULL",
467
+ factorType: "TEXT NOT NULL",
468
+ reasonSealed: "TEXT",
469
+ scopeTable: "TEXT NOT NULL",
470
+ scopeColumnsJson: "TEXT NOT NULL",
471
+ issuedAt: "INTEGER NOT NULL",
472
+ expiresAt: "INTEGER NOT NULL",
473
+ maxRowsPerGrant: "INTEGER NOT NULL",
474
+ rowsConsumed: "INTEGER NOT NULL DEFAULT 0",
475
+ revokedAt: "INTEGER",
476
+ sessionId: "TEXT",
477
+ ip: "TEXT",
478
+ kwGrantHalf: "TEXT",
479
+ },
480
+ indexes: [
481
+ { name: "idx_bg_grants_actor", columns: ["issuedToActorHash"] },
482
+ { name: "idx_bg_grants_table", columns: ["scopeTable"] },
483
+ "expiresAt",
484
+ "revokedAt",
485
+ ],
486
+ derivedHashes: { issuedToActorHash: { from: "issuedToActorId" } },
487
+ sealedFields: ["reasonSealed", "scopeColumnsJson"],
488
+ },
426
489
  ];
427
490
 
428
491
  var log = boot("db");
@@ -1118,6 +1181,7 @@ module.exports = {
1118
1181
  try { require("./storage")._resetForTest(); } catch (_e) {}
1119
1182
  try { require("./audit-sign")._resetForTest(); } catch (_e) {}
1120
1183
  try { require("./queue")._resetForTest(); } catch (_e) {}
1184
+ try { require("./break-glass")._resetForTest(); } catch (_e) {}
1121
1185
  try { require("./log-stream")._resetForTest(); } catch (_e) {}
1122
1186
  try { require("./redact")._resetForTest(); } catch (_e) {}
1123
1187
  try { require("./external-db")._resetForTest(); } catch (_e) {}
@@ -120,6 +120,11 @@ var LOCAL_TO_EXTERNAL = Object.freeze({
120
120
  // FRAMEWORK_SCHEMA so cluster-storage.execute routes to either side.
121
121
  _blamejs_seeders: "_blamejs_seeders",
122
122
  _blamejs_seeders_lock: "_blamejs_seeders_lock",
123
+ // Break-glass policy + grant tables. Cluster-shared so a grant
124
+ // issued on node A is honored on node B; policies updated on the
125
+ // leader propagate to all followers via the shared table.
126
+ _blamejs_break_glass_policies: "_blamejs_break_glass_policies",
127
+ _blamejs_break_glass_grants: "_blamejs_break_glass_grants",
123
128
  });
124
129
 
125
130
  function tableName(localName) {
@@ -556,6 +561,70 @@ function _cacheDDL(dialect) {
556
561
  };
557
562
  }
558
563
 
564
+ // _blamejs_break_glass_policies — column-level break-glass policy
565
+ // registry. One row per (table) declares which columns are
566
+ // glass-locked + the operator's grant rules. Sealed columns hide
567
+ // column-list / factor-list / bypass config from cleartext browsing.
568
+ function _breakGlassPoliciesDDL(dialect) {
569
+ var t = _types(dialect);
570
+ var name = LOCAL_TO_EXTERNAL._blamejs_break_glass_policies;
571
+ return {
572
+ create:
573
+ "CREATE TABLE IF NOT EXISTS " + name + " (" +
574
+ " tableName TEXT PRIMARY KEY," +
575
+ " columnsJson TEXT NOT NULL," +
576
+ " factorsJson TEXT NOT NULL," +
577
+ " cryptographic " + t.INT + " NOT NULL DEFAULT 0," +
578
+ " grantTtlMs " + t.INT + " NOT NULL," +
579
+ " maxRowsPerGrant " + t.INT + " NOT NULL DEFAULT 1," +
580
+ " reasonRequired " + t.INT + " NOT NULL DEFAULT 1," +
581
+ " reasonMinLength " + t.INT + " NOT NULL DEFAULT 12," +
582
+ " pinIp " + t.INT + " NOT NULL DEFAULT 1," +
583
+ " sessionPin " + t.INT + " NOT NULL DEFAULT 1," +
584
+ " onLockedAccess TEXT NOT NULL DEFAULT 'throw'," +
585
+ " requireScope TEXT," +
586
+ " serviceAccountBypassJson TEXT," +
587
+ " auditReasonStorage TEXT NOT NULL DEFAULT 'cleartext'," +
588
+ " updatedAt " + t.INT + " NOT NULL" +
589
+ ")",
590
+ indexes: [],
591
+ };
592
+ }
593
+
594
+ // _blamejs_break_glass_grants — issued grants. One row per successful
595
+ // step-up. Default maxRowsPerGrant=1 enforces row-by-row auth per the
596
+ // operator-confirmed shape ("each row access = its own grant").
597
+ function _breakGlassGrantsDDL(dialect) {
598
+ var t = _types(dialect);
599
+ var name = LOCAL_TO_EXTERNAL._blamejs_break_glass_grants;
600
+ return {
601
+ create:
602
+ "CREATE TABLE IF NOT EXISTS " + name + " (" +
603
+ " _id TEXT PRIMARY KEY," +
604
+ " issuedToActorId TEXT NOT NULL," +
605
+ " issuedToActorHash TEXT NOT NULL," +
606
+ " factorType TEXT NOT NULL," +
607
+ " reasonSealed TEXT," +
608
+ " scopeTable TEXT NOT NULL," +
609
+ " scopeColumnsJson TEXT NOT NULL," +
610
+ " issuedAt " + t.INT + " NOT NULL," +
611
+ " expiresAt " + t.INT + " NOT NULL," +
612
+ " maxRowsPerGrant " + t.INT + " NOT NULL," +
613
+ " rowsConsumed " + t.INT + " NOT NULL DEFAULT 0," +
614
+ " revokedAt " + t.INT + "," +
615
+ " sessionId TEXT," +
616
+ " ip TEXT," +
617
+ " kwGrantHalf TEXT" +
618
+ ")",
619
+ indexes: [
620
+ "CREATE INDEX IF NOT EXISTS idx_" + name + "_actor ON " + name + " (issuedToActorHash)",
621
+ "CREATE INDEX IF NOT EXISTS idx_" + name + "_table ON " + name + " (scopeTable)",
622
+ "CREATE INDEX IF NOT EXISTS idx_" + name + "_expires ON " + name + " (expiresAt)",
623
+ "CREATE INDEX IF NOT EXISTS idx_" + name + "_revoked ON " + name + " (revokedAt)",
624
+ ],
625
+ };
626
+ }
627
+
559
628
  // ---- ensureSchema ----
560
629
 
561
630
  async function ensureSchema(opts) {
@@ -590,6 +659,8 @@ async function ensureSchema(opts) {
590
659
  _cacheDDL(dialect),
591
660
  _seedersDDL(dialect),
592
661
  _seedersLockDDL(dialect),
662
+ _breakGlassPoliciesDDL(dialect),
663
+ _breakGlassGrantsDDL(dialect),
593
664
  ];
594
665
 
595
666
  var created = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.4.29",
3
+ "version": "0.5.1",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",