@blamejs/core 0.8.42 → 0.8.49
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 +93 -0
- package/README.md +10 -10
- package/index.js +52 -0
- package/lib/a2a.js +159 -34
- package/lib/acme.js +762 -0
- package/lib/ai-pref.js +166 -43
- package/lib/api-key.js +108 -47
- package/lib/api-snapshot.js +157 -40
- package/lib/app-shutdown.js +113 -77
- package/lib/archive.js +337 -40
- package/lib/arg-parser.js +697 -0
- package/lib/asyncapi.js +99 -55
- package/lib/atomic-file.js +465 -104
- package/lib/audit-chain.js +123 -34
- package/lib/audit-daily-review.js +389 -0
- package/lib/audit-sign.js +302 -56
- package/lib/audit-tools.js +412 -63
- package/lib/audit.js +656 -35
- package/lib/auth/jwt-external.js +17 -0
- package/lib/auth/oauth.js +7 -0
- package/lib/auth-bot-challenge.js +505 -0
- package/lib/auth-header.js +92 -25
- package/lib/backup/bundle.js +26 -0
- package/lib/backup/index.js +512 -89
- package/lib/backup/manifest.js +168 -7
- package/lib/break-glass.js +415 -39
- package/lib/budr.js +103 -30
- package/lib/bundler.js +86 -66
- package/lib/cache.js +192 -72
- package/lib/chain-writer.js +65 -40
- package/lib/circuit-breaker.js +56 -33
- package/lib/cli-helpers.js +106 -75
- package/lib/cli.js +6 -30
- package/lib/cloud-events.js +99 -32
- package/lib/cluster-storage.js +162 -37
- package/lib/cluster.js +340 -49
- package/lib/codepoint-class.js +66 -0
- package/lib/compliance.js +424 -24
- package/lib/config-drift.js +111 -46
- package/lib/config.js +94 -40
- package/lib/consent.js +165 -18
- package/lib/constants.js +1 -0
- package/lib/content-credentials.js +153 -48
- package/lib/cookies.js +154 -62
- package/lib/credential-hash.js +133 -61
- package/lib/crypto-field.js +702 -18
- package/lib/crypto-hpke.js +256 -0
- package/lib/crypto.js +744 -22
- package/lib/csv.js +178 -35
- package/lib/daemon.js +456 -0
- package/lib/dark-patterns.js +186 -55
- package/lib/db-query.js +79 -2
- package/lib/db.js +1431 -60
- package/lib/ddl-change-control.js +523 -0
- package/lib/deprecate.js +195 -40
- package/lib/dev.js +82 -39
- package/lib/dora.js +67 -48
- package/lib/dr-runbook.js +368 -0
- package/lib/dsr.js +142 -11
- package/lib/dual-control.js +91 -56
- package/lib/events.js +120 -41
- package/lib/external-db-migrate.js +192 -2
- package/lib/external-db.js +795 -50
- package/lib/fapi2.js +122 -1
- package/lib/fda-21cfr11.js +395 -0
- package/lib/fdx.js +132 -2
- package/lib/file-type.js +87 -0
- package/lib/file-upload.js +93 -0
- package/lib/flag.js +82 -20
- package/lib/forms.js +132 -29
- package/lib/framework-error.js +169 -0
- package/lib/framework-schema.js +163 -35
- package/lib/gate-contract.js +849 -175
- package/lib/graphql-federation.js +68 -7
- package/lib/guard-all.js +172 -55
- package/lib/guard-archive.js +286 -124
- package/lib/guard-auth.js +194 -21
- package/lib/guard-cidr.js +190 -28
- package/lib/guard-csv.js +397 -51
- package/lib/guard-domain.js +213 -91
- package/lib/guard-email.js +236 -29
- package/lib/guard-filename.js +307 -75
- package/lib/guard-graphql.js +263 -30
- package/lib/guard-html.js +310 -116
- package/lib/guard-image.js +243 -30
- package/lib/guard-json.js +260 -54
- package/lib/guard-jsonpath.js +235 -23
- package/lib/guard-jwt.js +284 -30
- package/lib/guard-markdown.js +204 -22
- package/lib/guard-mime.js +190 -26
- package/lib/guard-oauth.js +277 -28
- package/lib/guard-pdf.js +251 -27
- package/lib/guard-regex.js +226 -18
- package/lib/guard-shell.js +229 -26
- package/lib/guard-svg.js +177 -10
- package/lib/guard-template.js +232 -21
- package/lib/guard-time.js +195 -29
- package/lib/guard-uuid.js +189 -30
- package/lib/guard-xml.js +259 -36
- package/lib/guard-yaml.js +241 -44
- package/lib/honeytoken.js +63 -27
- package/lib/html-balance.js +83 -0
- package/lib/http-client.js +486 -59
- package/lib/http-message-signature.js +582 -0
- package/lib/i18n.js +102 -49
- package/lib/iab-mspa.js +112 -32
- package/lib/iab-tcf.js +107 -2
- package/lib/inbox.js +90 -52
- package/lib/keychain.js +865 -0
- package/lib/legal-hold.js +374 -0
- package/lib/local-db-thin.js +320 -0
- package/lib/log-stream.js +281 -51
- package/lib/log.js +184 -86
- package/lib/mail-bounce.js +107 -62
- package/lib/mail.js +295 -58
- package/lib/mcp.js +108 -27
- package/lib/metrics.js +98 -89
- package/lib/middleware/age-gate.js +36 -0
- package/lib/middleware/ai-act-disclosure.js +37 -0
- package/lib/middleware/api-encrypt.js +45 -0
- package/lib/middleware/assetlinks.js +40 -0
- package/lib/middleware/asyncapi-serve.js +35 -0
- package/lib/middleware/attach-user.js +40 -0
- package/lib/middleware/bearer-auth.js +40 -0
- package/lib/middleware/body-parser.js +230 -0
- package/lib/middleware/bot-disclose.js +34 -0
- package/lib/middleware/bot-guard.js +39 -0
- package/lib/middleware/compression.js +37 -0
- package/lib/middleware/cookies.js +32 -0
- package/lib/middleware/cors.js +40 -0
- package/lib/middleware/csp-nonce.js +40 -0
- package/lib/middleware/csp-report.js +34 -0
- package/lib/middleware/csrf-protect.js +43 -0
- package/lib/middleware/daily-byte-quota.js +53 -85
- package/lib/middleware/db-role-for.js +40 -0
- package/lib/middleware/dpop.js +40 -0
- package/lib/middleware/error-handler.js +37 -14
- package/lib/middleware/fetch-metadata.js +39 -0
- package/lib/middleware/flag-context.js +34 -0
- package/lib/middleware/gpc.js +33 -0
- package/lib/middleware/headers.js +35 -0
- package/lib/middleware/health.js +46 -0
- package/lib/middleware/host-allowlist.js +30 -0
- package/lib/middleware/network-allowlist.js +38 -0
- package/lib/middleware/openapi-serve.js +34 -0
- package/lib/middleware/rate-limit.js +160 -18
- package/lib/middleware/request-id.js +36 -18
- package/lib/middleware/request-log.js +37 -0
- package/lib/middleware/require-aal.js +29 -0
- package/lib/middleware/require-auth.js +32 -0
- package/lib/middleware/require-bound-key.js +41 -0
- package/lib/middleware/require-content-type.js +32 -0
- package/lib/middleware/require-methods.js +27 -0
- package/lib/middleware/require-mtls.js +33 -0
- package/lib/middleware/require-step-up.js +37 -0
- package/lib/middleware/security-headers.js +44 -0
- package/lib/middleware/security-txt.js +38 -0
- package/lib/middleware/span-http-server.js +37 -0
- package/lib/middleware/sse.js +36 -0
- package/lib/middleware/trace-log-correlation.js +33 -0
- package/lib/middleware/trace-propagate.js +32 -0
- package/lib/middleware/tus-upload.js +90 -0
- package/lib/middleware/web-app-manifest.js +53 -0
- package/lib/mtls-ca.js +100 -70
- package/lib/network-byte-quota.js +308 -0
- package/lib/network-heartbeat.js +135 -0
- package/lib/network-tls.js +534 -4
- package/lib/network.js +103 -0
- package/lib/notify.js +114 -43
- package/lib/ntp-check.js +192 -51
- package/lib/observability.js +145 -47
- package/lib/openapi.js +90 -44
- package/lib/outbox.js +99 -1
- package/lib/pagination.js +168 -86
- package/lib/parsers/index.js +16 -5
- package/lib/permissions.js +93 -40
- package/lib/pqc-agent.js +84 -8
- package/lib/pqc-software.js +94 -60
- package/lib/process-spawn.js +95 -21
- package/lib/pubsub.js +96 -66
- package/lib/queue.js +375 -54
- package/lib/redact.js +793 -21
- package/lib/render.js +139 -47
- package/lib/request-helpers.js +485 -121
- package/lib/restore-bundle.js +142 -39
- package/lib/restore-rollback.js +136 -45
- package/lib/retention.js +178 -50
- package/lib/retry.js +116 -33
- package/lib/router.js +475 -23
- package/lib/safe-async.js +543 -94
- package/lib/safe-buffer.js +337 -41
- package/lib/safe-json.js +467 -62
- package/lib/safe-jsonpath.js +285 -0
- package/lib/safe-schema.js +631 -87
- package/lib/safe-sql.js +221 -59
- package/lib/safe-url.js +278 -46
- package/lib/sandbox-worker.js +135 -0
- package/lib/sandbox.js +358 -0
- package/lib/scheduler.js +135 -70
- package/lib/self-update.js +647 -0
- package/lib/session-device-binding.js +431 -0
- package/lib/session.js +259 -49
- package/lib/slug.js +138 -26
- package/lib/ssrf-guard.js +316 -56
- package/lib/storage.js +433 -70
- package/lib/subject.js +405 -23
- package/lib/template.js +148 -8
- package/lib/tenant-quota.js +545 -0
- package/lib/testing.js +440 -53
- package/lib/time.js +291 -23
- package/lib/tls-exporter.js +239 -0
- package/lib/tracing.js +90 -74
- package/lib/uuid.js +97 -22
- package/lib/vault/index.js +284 -22
- package/lib/vault/seal-pem-file.js +66 -0
- package/lib/watcher.js +368 -0
- package/lib/webhook.js +196 -63
- package/lib/websocket.js +393 -68
- package/lib/wiki-concepts.js +338 -0
- package/lib/worker-pool.js +464 -0
- package/package.json +3 -3
- package/sbom.cyclonedx.json +7 -7
package/lib/break-glass.js
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* @module b.breakGlass
|
|
4
|
+
* @nav Identity
|
|
5
|
+
* @title Break Glass
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Column-policy / row-enforcement step-up auth — PHI / PCI columns
|
|
9
|
+
* require a fresh second-factor grant + operator-supplied reason;
|
|
10
|
+
* every unseal is audited row-by-row.
|
|
8
11
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
+
* The operator declares which columns of which tables are
|
|
13
|
+
* GLASS-LOCKED. Reading a glass-locked column on any row requires
|
|
14
|
+
* the caller to (1) prove identity with a fresh second factor (TOTP
|
|
15
|
+
* or passkey), (2) supply a reason the audit chain captures, and
|
|
16
|
+
* (3) hold a short-lived scope-bounded grant. Each row read emits a
|
|
17
|
+
* per-row audit event; the default `maxRowsPerGrant: 1` enforces
|
|
18
|
+
* row-by-row auth so every PHI / PCI access is its own discrete
|
|
19
|
+
* authenticated event.
|
|
12
20
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
21
|
+
* Two crypto models ship side-by-side. Model A — the default — is a
|
|
22
|
+
* policy gate: glass-locked columns sit in the regular cryptoField
|
|
23
|
+
* sealed-row pipeline, and break-glass enforces the grant + audit
|
|
24
|
+
* contract on every read path. Model B (`cryptographic: true` on the
|
|
25
|
+
* policy) layers per-cell encryption on top: every (table, rowId,
|
|
26
|
+
* column) triple gets its own key derived `K_cell = SHAKE256(DEK ||
|
|
27
|
+
* table || rowId || column)`, AEAD-bound to AAD = `SHA3-512(table ||
|
|
28
|
+
* rowId || column)` so swapping ciphertexts between rows fails
|
|
29
|
+
* closed. Operators opt into Model B per-policy, then run
|
|
30
|
+
* `b.breakGlass.migrate(table)` to convert existing rows.
|
|
17
31
|
*
|
|
18
|
-
*
|
|
32
|
+
* Service-account bypass (`policy.serviceAccountBypass`) is opt-in
|
|
33
|
+
* per-table — both an apiKey-id allowlist and a required role must
|
|
34
|
+
* match. Admin tools (`listActiveAll`, `revokeAll`) cover security-
|
|
35
|
+
* team dashboards and incident-response offboarding.
|
|
19
36
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
|
37
|
+
* @card
|
|
38
|
+
* Column-policy / row-enforcement step-up auth — PHI / PCI columns require a fresh second-factor grant + operator-supplied reason; every unseal is audited row-by-row.
|
|
39
39
|
*/
|
|
40
40
|
var audit = require("./audit");
|
|
41
41
|
var C = require("./constants");
|
|
@@ -177,9 +177,41 @@ async function _ensureDek(table) {
|
|
|
177
177
|
return dek;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
/**
|
|
181
|
+
* @primitive b.breakGlass.encryptCell
|
|
182
|
+
* @signature b.breakGlass.encryptCell(plaintext, ctx)
|
|
183
|
+
* @since 0.5.1
|
|
184
|
+
* @status stable
|
|
185
|
+
* @related b.breakGlass.decryptCell, b.breakGlass.migrate, b.breakGlass.policy.set
|
|
186
|
+
*
|
|
187
|
+
* Encrypt a single glass-locked cell value with encryption-context
|
|
188
|
+
* binding. Operators running a policy in `cryptographic: true` mode
|
|
189
|
+
* call this at write time INSTEAD of letting `cryptoField.sealRow`
|
|
190
|
+
* seal the column. The framework derives a per-cell key from the
|
|
191
|
+
* policy's vault-sealed DEK plus `(table, rowId, column)`, encrypts
|
|
192
|
+
* with XChaCha20-Poly1305, and sets AAD to `SHA3-512(table || rowId
|
|
193
|
+
* || column)` so a ciphertext literally cannot be decrypted under a
|
|
194
|
+
* different row identifier even with the same DEK.
|
|
195
|
+
*
|
|
196
|
+
* Returns a string of the form `bgcell:1:<base64>` ready to write
|
|
197
|
+
* back to the column. Throws `breakglass/policy-not-set` when the
|
|
198
|
+
* table has no policy or the policy isn't in cryptographic mode, and
|
|
199
|
+
* `breakglass/grant-column-mismatch` when the column isn't glass-
|
|
200
|
+
* locked on the policy.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* await b.breakGlass.policy.set("patients", {
|
|
204
|
+
* columns: ["ssn"],
|
|
205
|
+
* factors: ["totp"],
|
|
206
|
+
* cryptographic: true,
|
|
207
|
+
* });
|
|
208
|
+
* var sealed = await b.breakGlass.encryptCell("123-45-6789", {
|
|
209
|
+
* table: "patients",
|
|
210
|
+
* rowId: "patient-001",
|
|
211
|
+
* column: "ssn",
|
|
212
|
+
* });
|
|
213
|
+
* // → "bgcell:1:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8="
|
|
214
|
+
*/
|
|
183
215
|
async function encryptCell(plaintext, ctx) {
|
|
184
216
|
_requireInit();
|
|
185
217
|
if (!ctx || typeof ctx !== "object" ||
|
|
@@ -207,11 +239,32 @@ async function encryptCell(plaintext, ctx) {
|
|
|
207
239
|
return "bgcell:1:" + packed.toString("base64");
|
|
208
240
|
}
|
|
209
241
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
242
|
+
/**
|
|
243
|
+
* @primitive b.breakGlass.decryptCell
|
|
244
|
+
* @signature b.breakGlass.decryptCell(ciphertext, ctx)
|
|
245
|
+
* @since 0.5.1
|
|
246
|
+
* @status stable
|
|
247
|
+
* @related b.breakGlass.encryptCell, b.breakGlass.unsealRow
|
|
248
|
+
*
|
|
249
|
+
* Decrypt a Model-B `bgcell:1:<base64>` cell value. Internal — the
|
|
250
|
+
* caller must already hold a valid grant covering the (table,
|
|
251
|
+
* column); operator-facing reads route through `b.breakGlass.unsealRow`
|
|
252
|
+
* which gates this call. The encryption context (`table, rowId,
|
|
253
|
+
* column`) is fed into BOTH the per-cell key derivation AND the AEAD
|
|
254
|
+
* AAD, so a caller passing the wrong `rowId` trying to swap
|
|
255
|
+
* ciphertexts between rows fails closed at the AEAD verify step.
|
|
256
|
+
*
|
|
257
|
+
* @example
|
|
258
|
+
* // unsealRow routes here automatically for cryptographic-mode
|
|
259
|
+
* // policies; calling decryptCell directly is rare and only for
|
|
260
|
+
* // operator tooling that's already enforced its own grant gate.
|
|
261
|
+
* var plaintext = await b.breakGlass.decryptCell(sealed, {
|
|
262
|
+
* table: "patients",
|
|
263
|
+
* rowId: "patient-001",
|
|
264
|
+
* column: "ssn",
|
|
265
|
+
* });
|
|
266
|
+
* // → "123-45-6789"
|
|
267
|
+
*/
|
|
215
268
|
async function decryptCell(ciphertext, ctx) {
|
|
216
269
|
_requireInit();
|
|
217
270
|
if (typeof ciphertext !== "string" || ciphertext.indexOf("bgcell:1:") !== 0) {
|
|
@@ -238,6 +291,38 @@ async function decryptCell(ciphertext, ctx) {
|
|
|
238
291
|
// back. The migration is idempotent — a row already in Model B form
|
|
239
292
|
// (column starts with "bgcell:") is skipped.
|
|
240
293
|
|
|
294
|
+
/**
|
|
295
|
+
* @primitive b.breakGlass.migrate
|
|
296
|
+
* @signature b.breakGlass.migrate(table, opts)
|
|
297
|
+
* @since 0.5.1
|
|
298
|
+
* @status stable
|
|
299
|
+
* @related b.breakGlass.encryptCell, b.breakGlass.policy.set
|
|
300
|
+
*
|
|
301
|
+
* One-shot migration that converts every existing row of a glass-
|
|
302
|
+
* locked table from Model A (cryptoField.sealRow only) into Model B
|
|
303
|
+
* per-cell ciphertext. Iterates via `_id`-keyset paging so memory
|
|
304
|
+
* stays bounded; rows already in Model B (column starts with
|
|
305
|
+
* `bgcell:`) are skipped, making the migration idempotent and safe
|
|
306
|
+
* to re-run after a partial failure.
|
|
307
|
+
*
|
|
308
|
+
* Emits a `breakglass.migrate` audit event on completion with totals
|
|
309
|
+
* and skipped counts. Refuses to run when the policy isn't in
|
|
310
|
+
* cryptographic mode — operator must `policy.set({ cryptographic:
|
|
311
|
+
* true })` first.
|
|
312
|
+
*
|
|
313
|
+
* @opts
|
|
314
|
+
* batchSize: number, // _id-keyset page size (default 100)
|
|
315
|
+
* callerOpts: object, // forwarded to audit actor resolution
|
|
316
|
+
*
|
|
317
|
+
* @example
|
|
318
|
+
* await b.breakGlass.policy.set("patients", {
|
|
319
|
+
* columns: ["ssn", "dob"],
|
|
320
|
+
* factors: ["totp"],
|
|
321
|
+
* cryptographic: true,
|
|
322
|
+
* });
|
|
323
|
+
* var summary = await b.breakGlass.migrate("patients", { batchSize: 250 });
|
|
324
|
+
* // → { table: "patients", totalRows: 1200, migratedRows: 1198, skippedRows: 2 }
|
|
325
|
+
*/
|
|
241
326
|
async function migrate(table, opts) {
|
|
242
327
|
_requireInit();
|
|
243
328
|
opts = opts || {};
|
|
@@ -324,6 +409,29 @@ async function migrate(table, opts) {
|
|
|
324
409
|
|
|
325
410
|
// ---- init ----
|
|
326
411
|
|
|
412
|
+
/**
|
|
413
|
+
* @primitive b.breakGlass.init
|
|
414
|
+
* @signature b.breakGlass.init(opts)
|
|
415
|
+
* @since 0.5.0
|
|
416
|
+
* @status stable
|
|
417
|
+
* @related b.breakGlass.policy.set, b.breakGlass.grant
|
|
418
|
+
*
|
|
419
|
+
* One-shot boot wiring. Clears the in-memory policy cache, resets the
|
|
420
|
+
* factor-lockout counter, and records the framework-wide trustProxy
|
|
421
|
+
* boundary so subsequent `grant()` calls populate the grant row's `ip`
|
|
422
|
+
* field from `X-Forwarded-For` only when proxies are trusted. Operators
|
|
423
|
+
* call this once at boot, before any policy / grant / unseal call —
|
|
424
|
+
* every other primitive throws `breakglass/not-initialized` until init
|
|
425
|
+
* has run.
|
|
426
|
+
*
|
|
427
|
+
* @opts
|
|
428
|
+
* now: number, // testing-only override of Date.now (fixtures)
|
|
429
|
+
* trustProxy: boolean, // honor X-Forwarded-For when populating grant.ip (default false)
|
|
430
|
+
*
|
|
431
|
+
* @example
|
|
432
|
+
* b.breakGlass.init({ trustProxy: true });
|
|
433
|
+
* // → undefined (init returns nothing; throws on bad opts)
|
|
434
|
+
*/
|
|
327
435
|
function init(opts) {
|
|
328
436
|
opts = opts || {};
|
|
329
437
|
validateOpts(opts, ["now", "trustProxy"], "breakGlass.init");
|
|
@@ -489,6 +597,47 @@ function _validatePolicySet(table, opts) {
|
|
|
489
597
|
};
|
|
490
598
|
}
|
|
491
599
|
|
|
600
|
+
/**
|
|
601
|
+
* @primitive b.breakGlass.policy.set
|
|
602
|
+
* @signature b.breakGlass.policy.set(table, opts, callerOpts)
|
|
603
|
+
* @since 0.5.0
|
|
604
|
+
* @status stable
|
|
605
|
+
* @compliance hipaa, pci-dss, gdpr, soc2
|
|
606
|
+
* @related b.breakGlass.policy.get, b.breakGlass.policy.delete, b.breakGlass.grant
|
|
607
|
+
*
|
|
608
|
+
* Declare the column-policy that gates step-up auth on the named
|
|
609
|
+
* table. The listed columns become GLASS-LOCKED — every read of one of
|
|
610
|
+
* those columns on any row requires the caller to hold a fresh
|
|
611
|
+
* second-factor grant whose scope covers the column. Stored
|
|
612
|
+
* cluster-wide in `_blamejs_break_glass_policies` (sealed via
|
|
613
|
+
* cryptoField) so every node honors the same gate. Re-runs UPSERT;
|
|
614
|
+
* the policy cache flushes for the table.
|
|
615
|
+
*
|
|
616
|
+
* @opts
|
|
617
|
+
* columns: Array<string>, // glass-locked column names (required, ≥1)
|
|
618
|
+
* factors: Array<string>, // allowed second factors: "totp" / "passkey"
|
|
619
|
+
* cryptographic: boolean, // opt into Model B per-cell encryption (default false)
|
|
620
|
+
* grantTtl: number, // grant lifetime in ms (default 15 minutes)
|
|
621
|
+
* maxRowsPerGrant: number, // rows a grant may unseal (default 1 — row-by-row)
|
|
622
|
+
* reasonRequired: boolean, // require operator reason on grant (default true)
|
|
623
|
+
* reasonMinLength: number, // minimum reason length in chars (default 12)
|
|
624
|
+
* pinIp: boolean, // bind grant to issuing IP (default true)
|
|
625
|
+
* sessionPin: boolean, // bind grant to issuing session (default true)
|
|
626
|
+
* onLockedAccess: string, // "throw" | "redact" on unauthorized read (default "throw")
|
|
627
|
+
* requireScope: string, // actor scope required before grant mints (e.g. "phi:admin")
|
|
628
|
+
* serviceAccountBypass: object, // { enabled, apiKeyIds, requireRole } — opt-in machine bypass
|
|
629
|
+
* auditReasonStorage: string, // "cleartext" | "hmac" | "both" (default "cleartext")
|
|
630
|
+
*
|
|
631
|
+
* @example
|
|
632
|
+
* await b.breakGlass.policy.set("patients", {
|
|
633
|
+
* columns: ["ssn", "dob"],
|
|
634
|
+
* factors: ["totp"],
|
|
635
|
+
* grantTtl: 600000,
|
|
636
|
+
* maxRowsPerGrant: 1,
|
|
637
|
+
* requireScope: "phi:admin",
|
|
638
|
+
* });
|
|
639
|
+
* // → { applied: true, table: "patients" }
|
|
640
|
+
*/
|
|
492
641
|
async function policySet(table, opts, callerOpts) {
|
|
493
642
|
_requireInit();
|
|
494
643
|
var validated = _validatePolicySet(table, opts);
|
|
@@ -540,6 +689,26 @@ async function policySet(table, opts, callerOpts) {
|
|
|
540
689
|
return { applied: true, table: table };
|
|
541
690
|
}
|
|
542
691
|
|
|
692
|
+
/**
|
|
693
|
+
* @primitive b.breakGlass.policy.get
|
|
694
|
+
* @signature b.breakGlass.policy.get(table)
|
|
695
|
+
* @since 0.5.0
|
|
696
|
+
* @status stable
|
|
697
|
+
* @related b.breakGlass.policy.set, b.breakGlass.policy.list
|
|
698
|
+
*
|
|
699
|
+
* Read the current break-glass policy for `table` from the cluster-
|
|
700
|
+
* shared policies table, with an in-process cache that short-circuits
|
|
701
|
+
* the DB roundtrip on the unsealRow hot path. Returns `null` when the
|
|
702
|
+
* table has no policy declared (a non-glass-locked table). The cache
|
|
703
|
+
* invalidates on `policy.set` / `policy.delete`.
|
|
704
|
+
*
|
|
705
|
+
* @example
|
|
706
|
+
* var policy = await b.breakGlass.policy.get("patients");
|
|
707
|
+
* // → { table: "patients", columns: ["ssn", "dob"], factors: ["totp"], ... }
|
|
708
|
+
*
|
|
709
|
+
* var none = await b.breakGlass.policy.get("posts");
|
|
710
|
+
* // → null
|
|
711
|
+
*/
|
|
543
712
|
async function policyGet(table) {
|
|
544
713
|
_requireInit();
|
|
545
714
|
if (typeof table !== "string" || table.length === 0) return null;
|
|
@@ -576,6 +745,24 @@ async function policyGet(table) {
|
|
|
576
745
|
return policy;
|
|
577
746
|
}
|
|
578
747
|
|
|
748
|
+
/**
|
|
749
|
+
* @primitive b.breakGlass.policy.list
|
|
750
|
+
* @signature b.breakGlass.policy.list()
|
|
751
|
+
* @since 0.5.0
|
|
752
|
+
* @status stable
|
|
753
|
+
* @related b.breakGlass.policy.get, b.breakGlass.policy.set
|
|
754
|
+
*
|
|
755
|
+
* Enumerate every glass-locked table the cluster knows about. Used by
|
|
756
|
+
* compliance dashboards (which tables hold PHI / PCI?) and migration
|
|
757
|
+
* tooling that needs to walk the full set. Returns hydrated policy
|
|
758
|
+
* objects in `tableName` order — no abbreviated row form.
|
|
759
|
+
*
|
|
760
|
+
* @example
|
|
761
|
+
* var policies = await b.breakGlass.policy.list();
|
|
762
|
+
* // → [{ table: "patients", columns: ["ssn", "dob"], ... }, { table: "cards", ... }]
|
|
763
|
+
* policies.length;
|
|
764
|
+
* // → 2
|
|
765
|
+
*/
|
|
579
766
|
async function policyList() {
|
|
580
767
|
_requireInit();
|
|
581
768
|
var rows = await clusterStorage.executeAll(
|
|
@@ -589,6 +776,23 @@ async function policyList() {
|
|
|
589
776
|
return out;
|
|
590
777
|
}
|
|
591
778
|
|
|
779
|
+
/**
|
|
780
|
+
* @primitive b.breakGlass.policy.delete
|
|
781
|
+
* @signature b.breakGlass.policy.delete(table, callerOpts)
|
|
782
|
+
* @since 0.5.0
|
|
783
|
+
* @status stable
|
|
784
|
+
* @related b.breakGlass.policy.set
|
|
785
|
+
*
|
|
786
|
+
* Remove the break-glass policy for `table`. Subsequent reads of the
|
|
787
|
+
* previously glass-locked columns no longer require a grant — operators
|
|
788
|
+
* call this only when a column genuinely stops being PHI / PCI (rare;
|
|
789
|
+
* almost always the operator wants `policy.set` with a revised column
|
|
790
|
+
* list instead). Emits a `breakglass.policy.delete` audit event.
|
|
791
|
+
*
|
|
792
|
+
* @example
|
|
793
|
+
* await b.breakGlass.policy.delete("legacy_patients");
|
|
794
|
+
* // → { deleted: true, table: "legacy_patients" }
|
|
795
|
+
*/
|
|
592
796
|
async function policyDelete(table, callerOpts) {
|
|
593
797
|
_requireInit();
|
|
594
798
|
if (typeof table !== "string" || table.length === 0) {
|
|
@@ -647,6 +851,39 @@ async function _verifyPasskeyFactor(factor) {
|
|
|
647
851
|
}
|
|
648
852
|
}
|
|
649
853
|
|
|
854
|
+
/**
|
|
855
|
+
* @primitive b.breakGlass.grant
|
|
856
|
+
* @signature b.breakGlass.grant(opts)
|
|
857
|
+
* @since 0.5.0
|
|
858
|
+
* @status stable
|
|
859
|
+
* @compliance hipaa, pci-dss, soc2
|
|
860
|
+
* @related b.breakGlass.unsealRow, b.breakGlass.revoke, b.breakGlass.policy.set
|
|
861
|
+
*
|
|
862
|
+
* Mint a short-lived, scope-bounded break-glass grant. The framework
|
|
863
|
+
* verifies the operator's second factor (TOTP code or passkey
|
|
864
|
+
* assertion), records the operator-supplied reason into the audit
|
|
865
|
+
* chain, and issues a grant whose scope covers the named columns of
|
|
866
|
+
* the named table for `policy.grantTtl` ms or `policy.maxRowsPerGrant`
|
|
867
|
+
* row reads — whichever ends first. Failures emit a denied-grant audit
|
|
868
|
+
* row; repeated factor failures trigger the lockout primitive.
|
|
869
|
+
*
|
|
870
|
+
* @opts
|
|
871
|
+
* req: object, // the active request (carries actor identity, ip, session)
|
|
872
|
+
* table: string, // glass-locked table the grant scopes to
|
|
873
|
+
* columns: Array<string>, // optional subset of policy.columns (default = full policy)
|
|
874
|
+
* reason: string, // operator-supplied reason (length-gated by policy.reasonMinLength)
|
|
875
|
+
* factor: object, // { type: "totp", secret, code } or { type: "passkey", response, ... }
|
|
876
|
+
*
|
|
877
|
+
* @example
|
|
878
|
+
* var handle = await b.breakGlass.grant({
|
|
879
|
+
* req: req,
|
|
880
|
+
* table: "patients",
|
|
881
|
+
* columns: ["ssn"],
|
|
882
|
+
* reason: "ER admit verifying identity for patient-001",
|
|
883
|
+
* factor: { type: "totp", secret: req.user.totpSecret, code: "123456" },
|
|
884
|
+
* });
|
|
885
|
+
* // → { id: "bg-...", expiresAt: 1735000000000, rowsRemaining: 1, scopeTable: "patients", scopeColumns: ["ssn"] }
|
|
886
|
+
*/
|
|
650
887
|
async function grant(opts) {
|
|
651
888
|
_requireInit();
|
|
652
889
|
if (!opts || typeof opts !== "object") {
|
|
@@ -853,6 +1090,30 @@ function _reasonForAudit(reason, mode) {
|
|
|
853
1090
|
|
|
854
1091
|
// ---- Use a grant ----
|
|
855
1092
|
|
|
1093
|
+
/**
|
|
1094
|
+
* @primitive b.breakGlass.unsealRow
|
|
1095
|
+
* @signature b.breakGlass.unsealRow(grantHandle, table, rowId, opts)
|
|
1096
|
+
* @since 0.5.0
|
|
1097
|
+
* @status stable
|
|
1098
|
+
* @compliance hipaa, pci-dss, soc2
|
|
1099
|
+
* @related b.breakGlass.grant, b.breakGlass.decryptCell, b.breakGlass.revoke
|
|
1100
|
+
*
|
|
1101
|
+
* Read one row's glass-locked columns under an active grant. The
|
|
1102
|
+
* framework validates the grant (not revoked, not expired, not
|
|
1103
|
+
* exhausted, scope matches the table), atomically increments
|
|
1104
|
+
* `rowsConsumed`, fetches and unseals the row, and emits a per-row
|
|
1105
|
+
* `breakglass.unsealrow` audit event carrying the reason + actor +
|
|
1106
|
+
* remaining rows. For Model B (cryptographic) policies, glass-locked
|
|
1107
|
+
* columns route through `decryptCell` with encryption-context binding
|
|
1108
|
+
* — a swapped ciphertext from another row fails closed at AEAD verify.
|
|
1109
|
+
*
|
|
1110
|
+
* @opts
|
|
1111
|
+
* req: object, // optional originating request — populates ip / userAgent / sessionId / requestId on the audit row
|
|
1112
|
+
*
|
|
1113
|
+
* @example
|
|
1114
|
+
* var row = await b.breakGlass.unsealRow(handle, "patients", "patient-001", { req: req });
|
|
1115
|
+
* // → { _id: "patient-001", name: "Alice", ssn: "123-45-6789", dob: "1980-04-12", ... }
|
|
1116
|
+
*/
|
|
856
1117
|
async function unsealRow(grantHandle, table, rowId, opts) {
|
|
857
1118
|
_requireInit();
|
|
858
1119
|
if (!grantHandle || typeof grantHandle !== "object" || typeof grantHandle.id !== "string") {
|
|
@@ -1038,6 +1299,26 @@ async function unsealRow(grantHandle, table, rowId, opts) {
|
|
|
1038
1299
|
|
|
1039
1300
|
// ---- Revoke ----
|
|
1040
1301
|
|
|
1302
|
+
/**
|
|
1303
|
+
* @primitive b.breakGlass.revoke
|
|
1304
|
+
* @signature b.breakGlass.revoke(grantId, opts)
|
|
1305
|
+
* @since 0.5.0
|
|
1306
|
+
* @status stable
|
|
1307
|
+
* @related b.breakGlass.grant, b.breakGlass.revokeAll
|
|
1308
|
+
*
|
|
1309
|
+
* Mark a single grant revoked. Subsequent `unsealRow` calls against the
|
|
1310
|
+
* grant id throw `breakglass/grant-revoked`. Idempotent — already-
|
|
1311
|
+
* revoked grants stay at their original `revokedAt` timestamp because
|
|
1312
|
+
* the UPDATE clause is gated on `revokedAt IS NULL`.
|
|
1313
|
+
*
|
|
1314
|
+
* @opts
|
|
1315
|
+
* reason: string, // operator note recorded into the audit row
|
|
1316
|
+
* callerOpts: object, // forwarded to audit actor resolution
|
|
1317
|
+
*
|
|
1318
|
+
* @example
|
|
1319
|
+
* await b.breakGlass.revoke("bg-abc123", { reason: "operator finished read; releasing" });
|
|
1320
|
+
* // → { revoked: true, grantId: "bg-abc123" }
|
|
1321
|
+
*/
|
|
1041
1322
|
async function revoke(grantId, opts) {
|
|
1042
1323
|
_requireInit();
|
|
1043
1324
|
if (typeof grantId !== "string" || grantId.length === 0) {
|
|
@@ -1063,6 +1344,27 @@ async function revoke(grantId, opts) {
|
|
|
1063
1344
|
|
|
1064
1345
|
// ---- listActive ----
|
|
1065
1346
|
|
|
1347
|
+
/**
|
|
1348
|
+
* @primitive b.breakGlass.listActive
|
|
1349
|
+
* @signature b.breakGlass.listActive(opts)
|
|
1350
|
+
* @since 0.5.0
|
|
1351
|
+
* @status stable
|
|
1352
|
+
* @related b.breakGlass.listActiveAll, b.breakGlass.revoke
|
|
1353
|
+
*
|
|
1354
|
+
* Enumerate the active (not revoked, not expired, rows remaining)
|
|
1355
|
+
* grants the caller currently holds. Lookup is keyed via cryptoField's
|
|
1356
|
+
* `computeDerived` so the actor's id never appears in cleartext on the
|
|
1357
|
+
* grants table — the framework hashes via the table's namespaced
|
|
1358
|
+
* derivation. Unauthenticated callers (no actorId on `req`) get an
|
|
1359
|
+
* empty array.
|
|
1360
|
+
*
|
|
1361
|
+
* @opts
|
|
1362
|
+
* req: object, // request carrying the actor identity (req.user.id or req.apiKey.id)
|
|
1363
|
+
*
|
|
1364
|
+
* @example
|
|
1365
|
+
* var grants = await b.breakGlass.listActive({ req: req });
|
|
1366
|
+
* // → [{ id: "bg-...", scopeTable: "patients", scopeColumns: ["ssn"], expiresAt: ..., rowsRemaining: 1, factorType: "totp" }]
|
|
1367
|
+
*/
|
|
1066
1368
|
async function listActive(opts) {
|
|
1067
1369
|
_requireInit();
|
|
1068
1370
|
opts = opts || {};
|
|
@@ -1110,6 +1412,32 @@ async function listActive(opts) {
|
|
|
1110
1412
|
// audit row so post-incident review can distinguish operator-initiated
|
|
1111
1413
|
// from service-initiated reads.
|
|
1112
1414
|
|
|
1415
|
+
/**
|
|
1416
|
+
* @primitive b.breakGlass.unsealRowAsService
|
|
1417
|
+
* @signature b.breakGlass.unsealRowAsService(req, table, rowId, opts)
|
|
1418
|
+
* @since 0.5.0
|
|
1419
|
+
* @status stable
|
|
1420
|
+
* @compliance hipaa, pci-dss, soc2
|
|
1421
|
+
* @related b.breakGlass.policy.set, b.breakGlass.unsealRow
|
|
1422
|
+
*
|
|
1423
|
+
* Machine-account read of a glass-locked row. Bypass is gated by the
|
|
1424
|
+
* policy's `serviceAccountBypass` block — both the verified `req.apiKey.id`
|
|
1425
|
+
* must be on the operator-declared allowlist AND the apiKey must
|
|
1426
|
+
* carry the operator-declared role. Both checks must pass; either
|
|
1427
|
+
* failure emits a denied bypass audit row and throws
|
|
1428
|
+
* `breakglass/bypass-unauthorized`. Each successful bypass emits a
|
|
1429
|
+
* distinct `breakglass.grant.bypass` audit row so post-incident review
|
|
1430
|
+
* separates operator-initiated reads from scheduled-job reads.
|
|
1431
|
+
*
|
|
1432
|
+
* @opts
|
|
1433
|
+
* reason: string, // operator-supplied reason recorded into the audit row
|
|
1434
|
+
*
|
|
1435
|
+
* @example
|
|
1436
|
+
* var row = await b.breakGlass.unsealRowAsService(req, "patients", "patient-001", {
|
|
1437
|
+
* reason: "nightly de-identification job",
|
|
1438
|
+
* });
|
|
1439
|
+
* // → { _id: "patient-001", name: "Alice", ssn: "123-45-6789", ... }
|
|
1440
|
+
*/
|
|
1113
1441
|
async function unsealRowAsService(req, table, rowId, opts) {
|
|
1114
1442
|
_requireInit();
|
|
1115
1443
|
opts = opts || {};
|
|
@@ -1219,6 +1547,29 @@ async function unsealRowAsService(req, table, rowId, opts) {
|
|
|
1219
1547
|
// teams when an account is suspected compromised. Both require
|
|
1220
1548
|
// admin scope (operator wires via opts.requireScope or their own gate).
|
|
1221
1549
|
|
|
1550
|
+
/**
|
|
1551
|
+
* @primitive b.breakGlass.listActiveAll
|
|
1552
|
+
* @signature b.breakGlass.listActiveAll(opts)
|
|
1553
|
+
* @since 0.5.0
|
|
1554
|
+
* @status stable
|
|
1555
|
+
* @related b.breakGlass.listActive, b.breakGlass.revokeAll
|
|
1556
|
+
*
|
|
1557
|
+
* Admin variant of `listActive` — returns every active grant across
|
|
1558
|
+
* every actor. Used by security-team dashboards and offboarding
|
|
1559
|
+
* workflows; operators wire their own gate (`requireScope` or a
|
|
1560
|
+
* middleware) on the calling route so non-admins can't enumerate the
|
|
1561
|
+
* full grant pool. Each call emits a `breakglass.admin.listactiveall`
|
|
1562
|
+
* audit row.
|
|
1563
|
+
*
|
|
1564
|
+
* @opts
|
|
1565
|
+
* table: string, // optional filter — only grants scoped to this table
|
|
1566
|
+
* since: number, // optional issuedAt floor (ms epoch)
|
|
1567
|
+
* callerOpts: object, // forwarded to audit actor resolution
|
|
1568
|
+
*
|
|
1569
|
+
* @example
|
|
1570
|
+
* var all = await b.breakGlass.listActiveAll({ table: "patients" });
|
|
1571
|
+
* // → [{ id: "bg-...", issuedToActorId: "user-42", scopeTable: "patients", ... }]
|
|
1572
|
+
*/
|
|
1222
1573
|
async function listActiveAll(opts) {
|
|
1223
1574
|
_requireInit();
|
|
1224
1575
|
opts = opts || {};
|
|
@@ -1261,6 +1612,31 @@ async function listActiveAll(opts) {
|
|
|
1261
1612
|
return out;
|
|
1262
1613
|
}
|
|
1263
1614
|
|
|
1615
|
+
/**
|
|
1616
|
+
* @primitive b.breakGlass.revokeAll
|
|
1617
|
+
* @signature b.breakGlass.revokeAll(criteria, opts)
|
|
1618
|
+
* @since 0.5.0
|
|
1619
|
+
* @status stable
|
|
1620
|
+
* @related b.breakGlass.revoke, b.breakGlass.listActiveAll
|
|
1621
|
+
*
|
|
1622
|
+
* Mass-revoke grants matching a scope predicate. Refuses to run with
|
|
1623
|
+
* empty criteria — IR teams must name at least one of `actorId` or
|
|
1624
|
+
* `table` so the framework never silently revokes every grant in the
|
|
1625
|
+
* cluster. The to-be-revoked grant ids are snapshotted into the audit
|
|
1626
|
+
* row before the UPDATE, so post-incident timelines have the exact
|
|
1627
|
+
* list. Common shape: revoke every active grant held by a suspected-
|
|
1628
|
+
* compromised account.
|
|
1629
|
+
*
|
|
1630
|
+
* @opts
|
|
1631
|
+
* callerOpts: object, // forwarded to audit actor resolution
|
|
1632
|
+
*
|
|
1633
|
+
* @example
|
|
1634
|
+
* var result = await b.breakGlass.revokeAll(
|
|
1635
|
+
* { actorId: "user-42", reason: "account compromise — IR-2026-0042" },
|
|
1636
|
+
* { callerOpts: { actor: { userId: "soc-on-call" } } }
|
|
1637
|
+
* );
|
|
1638
|
+
* // → { revokedCount: 3 }
|
|
1639
|
+
*/
|
|
1264
1640
|
async function revokeAll(criteria, opts) {
|
|
1265
1641
|
_requireInit();
|
|
1266
1642
|
if (!criteria || typeof criteria !== "object") {
|