@blamejs/core 0.4.28 → 0.5.0
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 +2 -0
- package/index.js +2 -0
- package/lib/api-key.js +4 -10
- package/lib/audit-tools.js +9 -7
- package/lib/audit.js +1 -0
- package/lib/auth/oauth.js +11 -4
- package/lib/backup/manifest.js +4 -1
- package/lib/break-glass.js +741 -0
- package/lib/cache.js +1 -9
- package/lib/db.js +64 -0
- package/lib/framework-schema.js +71 -0
- package/lib/mail.js +13 -2
- package/lib/middleware/api-encrypt.js +24 -7
- package/lib/middleware/attach-user.js +3 -2
- package/lib/middleware/cors.js +11 -1
- package/lib/notify.js +1 -9
- package/lib/request-helpers.js +30 -4
- package/lib/restore-rollback.js +3 -1
- package/lib/seeders.js +1 -9
- package/lib/vault/rotate.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,741 @@
|
|
|
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 { generateToken, sha3Hash } = 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 lockout = lazyRequire(function () { return require("./auth/lockout"); });
|
|
55
|
+
|
|
56
|
+
// Errors — all 14 codes documented in the spec. `permanent: true`
|
|
57
|
+
// means caller's input is bad (Tier-A); `permanent: false` means
|
|
58
|
+
// transient (factor failed, rate-limited) — caller may retry.
|
|
59
|
+
var BreakGlassError = defineClass("BreakGlassError", { alwaysPermanent: false });
|
|
60
|
+
|
|
61
|
+
// ---- Defaults (matched to operator-locked decisions) ----
|
|
62
|
+
|
|
63
|
+
var DEFAULT_GRANT_TTL_MS = C.TIME.minutes(15);
|
|
64
|
+
var DEFAULT_MAX_ROWS = 1; // operator-locked: row-by-row auth
|
|
65
|
+
var DEFAULT_REASON_MIN_LEN = 12;
|
|
66
|
+
var DEFAULT_LOCKED_BEHAVIOR = "throw"; // or "redact"
|
|
67
|
+
var DEFAULT_AUDIT_REASON = "cleartext";
|
|
68
|
+
var ALLOWED_FACTORS = ["totp"]; // passkey added in v0.5.2
|
|
69
|
+
var ALLOWED_REASON_STORAGE = ["cleartext", "hmac", "both"];
|
|
70
|
+
|
|
71
|
+
// In-memory policy cache. Cluster-shared via the policies table; the
|
|
72
|
+
// cache short-circuits the DB roundtrip on the unsealRow hot path.
|
|
73
|
+
// Populated on first access per-table; invalidated on policy.set/delete.
|
|
74
|
+
var policyCache = new Map(); // table -> policy
|
|
75
|
+
var initialized = false;
|
|
76
|
+
|
|
77
|
+
// Factor lockout — wrap auth.lockout so a hostile actor brute-forcing
|
|
78
|
+
// TOTP codes against break-glass gets shut out after a few failures.
|
|
79
|
+
// Lazy-init on first grant attempt so init() doesn't require the
|
|
80
|
+
// cache primitive to be wired before break-glass loads.
|
|
81
|
+
var _factorLockout = null;
|
|
82
|
+
var _factorLockoutCache = null;
|
|
83
|
+
function _ensureFactorLockout() {
|
|
84
|
+
if (_factorLockout) return _factorLockout;
|
|
85
|
+
var cache = require("./cache");
|
|
86
|
+
_factorLockoutCache = cache.create({
|
|
87
|
+
namespace: "breakglass.factor",
|
|
88
|
+
backend: "memory",
|
|
89
|
+
});
|
|
90
|
+
_factorLockout = lockout().create({
|
|
91
|
+
namespace: "breakglass.factor",
|
|
92
|
+
cache: _factorLockoutCache,
|
|
93
|
+
maxAttempts: 5,
|
|
94
|
+
windowMs: C.TIME.minutes(15),
|
|
95
|
+
audit: audit,
|
|
96
|
+
});
|
|
97
|
+
return _factorLockout;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- init ----
|
|
101
|
+
|
|
102
|
+
function init(opts) {
|
|
103
|
+
opts = opts || {};
|
|
104
|
+
validateOpts(opts, ["now"], "breakGlass.init");
|
|
105
|
+
initialized = true;
|
|
106
|
+
policyCache.clear();
|
|
107
|
+
_factorLockout = null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _resetForTest() {
|
|
111
|
+
initialized = false;
|
|
112
|
+
policyCache.clear();
|
|
113
|
+
if (_factorLockoutCache && typeof _factorLockoutCache.close === "function") {
|
|
114
|
+
try { _factorLockoutCache.close(); } catch (_e) { /* best-effort */ }
|
|
115
|
+
}
|
|
116
|
+
_factorLockout = null;
|
|
117
|
+
_factorLockoutCache = null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _requireInit() {
|
|
121
|
+
if (!initialized) {
|
|
122
|
+
throw new BreakGlassError("breakglass/not-initialized",
|
|
123
|
+
"b.breakGlass.init() must be called before use");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- Policy CRUD ----
|
|
128
|
+
|
|
129
|
+
function _validatePolicySet(table, opts) {
|
|
130
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
131
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
132
|
+
"policy.set: table must be a non-empty string");
|
|
133
|
+
}
|
|
134
|
+
if (!opts || typeof opts !== "object") {
|
|
135
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
136
|
+
"policy.set: opts is required");
|
|
137
|
+
}
|
|
138
|
+
validateOpts(opts, [
|
|
139
|
+
"columns", "factors", "cryptographic", "grantTtl", "maxRowsPerGrant",
|
|
140
|
+
"reasonRequired", "reasonMinLength", "pinIp", "sessionPin",
|
|
141
|
+
"onLockedAccess", "requireScope", "serviceAccountBypass",
|
|
142
|
+
"auditReasonStorage",
|
|
143
|
+
], "breakglass.policy.set");
|
|
144
|
+
if (!Array.isArray(opts.columns) || opts.columns.length === 0) {
|
|
145
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
146
|
+
"policy.set: columns must be a non-empty array");
|
|
147
|
+
}
|
|
148
|
+
for (var i = 0; i < opts.columns.length; i++) {
|
|
149
|
+
if (typeof opts.columns[i] !== "string" || opts.columns[i].length === 0) {
|
|
150
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
151
|
+
"policy.set: columns[" + i + "] must be a non-empty string");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!Array.isArray(opts.factors) || opts.factors.length === 0) {
|
|
155
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
156
|
+
"policy.set: factors must be a non-empty array");
|
|
157
|
+
}
|
|
158
|
+
for (var j = 0; j < opts.factors.length; j++) {
|
|
159
|
+
if (ALLOWED_FACTORS.indexOf(opts.factors[j]) === -1) {
|
|
160
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
161
|
+
"policy.set: factors[" + j + "] '" + opts.factors[j] +
|
|
162
|
+
"' not in v0.5.0 allowed factors [" + ALLOWED_FACTORS.join(",") + "]" +
|
|
163
|
+
" (passkey lands in v0.5.2)");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (opts.cryptographic === true) {
|
|
167
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
168
|
+
"policy.set: cryptographic mode (Model B) ships in v0.5.1 — " +
|
|
169
|
+
"set cryptographic: false (default) or omit for v0.5.0");
|
|
170
|
+
}
|
|
171
|
+
var grantTtl = opts.grantTtl != null ? opts.grantTtl : DEFAULT_GRANT_TTL_MS;
|
|
172
|
+
if (typeof grantTtl !== "number" || !isFinite(grantTtl) || grantTtl <= 0) {
|
|
173
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
174
|
+
"policy.set: grantTtl must be a positive number of milliseconds");
|
|
175
|
+
}
|
|
176
|
+
var maxRows = opts.maxRowsPerGrant != null ? opts.maxRowsPerGrant : DEFAULT_MAX_ROWS;
|
|
177
|
+
if (!Number.isInteger(maxRows) || maxRows < 1) {
|
|
178
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
179
|
+
"policy.set: maxRowsPerGrant must be a positive integer (default 1 — row-by-row auth)");
|
|
180
|
+
}
|
|
181
|
+
if (opts.onLockedAccess != null &&
|
|
182
|
+
opts.onLockedAccess !== "throw" && opts.onLockedAccess !== "redact") {
|
|
183
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
184
|
+
"policy.set: onLockedAccess must be 'throw' or 'redact'");
|
|
185
|
+
}
|
|
186
|
+
if (opts.auditReasonStorage != null &&
|
|
187
|
+
ALLOWED_REASON_STORAGE.indexOf(opts.auditReasonStorage) === -1) {
|
|
188
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
189
|
+
"policy.set: auditReasonStorage must be one of " + ALLOWED_REASON_STORAGE.join("/"));
|
|
190
|
+
}
|
|
191
|
+
if (opts.serviceAccountBypass != null && opts.serviceAccountBypass !== false) {
|
|
192
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
193
|
+
"policy.set: serviceAccountBypass ships in v0.5.2 — leave unset for v0.5.0");
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
grantTtl: grantTtl,
|
|
197
|
+
maxRowsPerGrant: maxRows,
|
|
198
|
+
reasonRequired: opts.reasonRequired !== false,
|
|
199
|
+
reasonMinLength: opts.reasonMinLength != null ? opts.reasonMinLength : DEFAULT_REASON_MIN_LEN,
|
|
200
|
+
pinIp: opts.pinIp !== false,
|
|
201
|
+
sessionPin: opts.sessionPin !== false,
|
|
202
|
+
onLockedAccess: opts.onLockedAccess || DEFAULT_LOCKED_BEHAVIOR,
|
|
203
|
+
requireScope: opts.requireScope != null ? opts.requireScope : null,
|
|
204
|
+
auditReasonStorage: opts.auditReasonStorage || DEFAULT_AUDIT_REASON,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function policySet(table, opts, callerOpts) {
|
|
209
|
+
_requireInit();
|
|
210
|
+
var validated = _validatePolicySet(table, opts);
|
|
211
|
+
var policyRow = {
|
|
212
|
+
tableName: table,
|
|
213
|
+
columnsJson: JSON.stringify(opts.columns),
|
|
214
|
+
factorsJson: JSON.stringify(opts.factors),
|
|
215
|
+
cryptographic: 0,
|
|
216
|
+
grantTtlMs: validated.grantTtl,
|
|
217
|
+
maxRowsPerGrant: validated.maxRowsPerGrant,
|
|
218
|
+
reasonRequired: validated.reasonRequired ? 1 : 0,
|
|
219
|
+
reasonMinLength: validated.reasonMinLength,
|
|
220
|
+
pinIp: validated.pinIp ? 1 : 0,
|
|
221
|
+
sessionPin: validated.sessionPin ? 1 : 0,
|
|
222
|
+
onLockedAccess: validated.onLockedAccess,
|
|
223
|
+
requireScope: validated.requireScope,
|
|
224
|
+
serviceAccountBypassJson: null,
|
|
225
|
+
auditReasonStorage: validated.auditReasonStorage,
|
|
226
|
+
updatedAt: Date.now(),
|
|
227
|
+
};
|
|
228
|
+
var sealed = cryptoField.sealRow("_blamejs_break_glass_policies", policyRow);
|
|
229
|
+
// UPSERT — both Postgres and SQLite support ON CONFLICT.
|
|
230
|
+
var keys = Object.keys(sealed);
|
|
231
|
+
var cols = keys.join(", ");
|
|
232
|
+
var qs = keys.map(function () { return "?"; }).join(", ");
|
|
233
|
+
var setSql = keys.filter(function (k) { return k !== "tableName"; })
|
|
234
|
+
.map(function (k) { return k + " = excluded." + k; }).join(", ");
|
|
235
|
+
var sql = "INSERT INTO _blamejs_break_glass_policies (" + cols + ") " +
|
|
236
|
+
"VALUES (" + qs + ") " +
|
|
237
|
+
"ON CONFLICT (tableName) DO UPDATE SET " + setSql;
|
|
238
|
+
await clusterStorage.execute(sql, keys.map(function (k) { return sealed[k]; }));
|
|
239
|
+
policyCache.delete(table);
|
|
240
|
+
|
|
241
|
+
audit.safeEmit({
|
|
242
|
+
action: "breakglass.policy.set",
|
|
243
|
+
outcome: "success",
|
|
244
|
+
actor: requestHelpers.resolveActorWithOverride(callerOpts),
|
|
245
|
+
metadata: {
|
|
246
|
+
table: table,
|
|
247
|
+
columnCount: opts.columns.length,
|
|
248
|
+
factors: opts.factors,
|
|
249
|
+
grantTtlMs: validated.grantTtl,
|
|
250
|
+
maxRowsPerGrant: validated.maxRowsPerGrant,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
observability.event("breakglass.policy.set", { table: table });
|
|
254
|
+
return { applied: true, table: table };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function policyGet(table) {
|
|
258
|
+
_requireInit();
|
|
259
|
+
if (typeof table !== "string" || table.length === 0) return null;
|
|
260
|
+
if (policyCache.has(table)) return policyCache.get(table);
|
|
261
|
+
var rows = await clusterStorage.executeAll(
|
|
262
|
+
"SELECT * FROM _blamejs_break_glass_policies WHERE tableName = ?",
|
|
263
|
+
[table]
|
|
264
|
+
);
|
|
265
|
+
if (!rows || rows.length === 0) {
|
|
266
|
+
policyCache.set(table, null);
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
var unsealed = cryptoField.unsealRow("_blamejs_break_glass_policies", rows[0]);
|
|
270
|
+
var policy = {
|
|
271
|
+
table: unsealed.tableName,
|
|
272
|
+
columns: safeJson.parse(unsealed.columnsJson, { maxBytes: C.BYTES.kib(64) }),
|
|
273
|
+
factors: safeJson.parse(unsealed.factorsJson, { maxBytes: C.BYTES.kib(8) }),
|
|
274
|
+
cryptographic: unsealed.cryptographic === 1,
|
|
275
|
+
grantTtl: Number(unsealed.grantTtlMs),
|
|
276
|
+
maxRowsPerGrant: Number(unsealed.maxRowsPerGrant),
|
|
277
|
+
reasonRequired: unsealed.reasonRequired === 1,
|
|
278
|
+
reasonMinLength: Number(unsealed.reasonMinLength),
|
|
279
|
+
pinIp: unsealed.pinIp === 1,
|
|
280
|
+
sessionPin: unsealed.sessionPin === 1,
|
|
281
|
+
onLockedAccess: unsealed.onLockedAccess,
|
|
282
|
+
requireScope: unsealed.requireScope,
|
|
283
|
+
auditReasonStorage: unsealed.auditReasonStorage,
|
|
284
|
+
updatedAt: Number(unsealed.updatedAt),
|
|
285
|
+
};
|
|
286
|
+
policyCache.set(table, policy);
|
|
287
|
+
return policy;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function policyList() {
|
|
291
|
+
_requireInit();
|
|
292
|
+
var rows = await clusterStorage.executeAll(
|
|
293
|
+
"SELECT tableName FROM _blamejs_break_glass_policies ORDER BY tableName"
|
|
294
|
+
);
|
|
295
|
+
var out = [];
|
|
296
|
+
for (var i = 0; i < (rows || []).length; i++) {
|
|
297
|
+
var p = await policyGet(rows[i].tableName);
|
|
298
|
+
if (p) out.push(p);
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function policyDelete(table, callerOpts) {
|
|
304
|
+
_requireInit();
|
|
305
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
306
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
307
|
+
"policy.delete: table must be a non-empty string");
|
|
308
|
+
}
|
|
309
|
+
await clusterStorage.execute(
|
|
310
|
+
"DELETE FROM _blamejs_break_glass_policies WHERE tableName = ?",
|
|
311
|
+
[table]
|
|
312
|
+
);
|
|
313
|
+
policyCache.delete(table);
|
|
314
|
+
audit.safeEmit({
|
|
315
|
+
action: "breakglass.policy.delete",
|
|
316
|
+
outcome: "success",
|
|
317
|
+
actor: requestHelpers.resolveActorWithOverride(callerOpts),
|
|
318
|
+
metadata: { table: table },
|
|
319
|
+
});
|
|
320
|
+
return { deleted: true, table: table };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---- Grant issuance ----
|
|
324
|
+
|
|
325
|
+
function _verifyTotpFactor(factor) {
|
|
326
|
+
if (!factor || typeof factor !== "object") return { ok: false };
|
|
327
|
+
if (typeof factor.secret !== "string" || factor.secret.length === 0) return { ok: false };
|
|
328
|
+
if (typeof factor.code !== "string" || factor.code.length === 0) return { ok: false };
|
|
329
|
+
var verified = totp.verify(factor.secret, factor.code);
|
|
330
|
+
return { ok: verified !== false, step: verified };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function grant(opts) {
|
|
334
|
+
_requireInit();
|
|
335
|
+
if (!opts || typeof opts !== "object") {
|
|
336
|
+
throw new BreakGlassError("breakglass/bad-grant-opts",
|
|
337
|
+
"grant: opts is required");
|
|
338
|
+
}
|
|
339
|
+
validateOpts(opts, ["req", "table", "columns", "reason", "factor"], "breakGlass.grant");
|
|
340
|
+
|
|
341
|
+
var table = opts.table;
|
|
342
|
+
var policy = await policyGet(table);
|
|
343
|
+
if (!policy) {
|
|
344
|
+
throw new BreakGlassError("breakglass/policy-not-set",
|
|
345
|
+
"no break-glass policy is configured for table '" + table + "'", true);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Reason validation
|
|
349
|
+
var reason = typeof opts.reason === "string" ? opts.reason : "";
|
|
350
|
+
if (policy.reasonRequired && reason.length === 0) {
|
|
351
|
+
throw new BreakGlassError("breakglass/missing-reason",
|
|
352
|
+
"grant: reason is required for table '" + table + "'", true);
|
|
353
|
+
}
|
|
354
|
+
if (policy.reasonRequired && reason.length < policy.reasonMinLength) {
|
|
355
|
+
throw new BreakGlassError("breakglass/short-reason",
|
|
356
|
+
"grant: reason must be at least " + policy.reasonMinLength + " characters", true);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Column scoping
|
|
360
|
+
var requestedColumns = Array.isArray(opts.columns) && opts.columns.length > 0
|
|
361
|
+
? opts.columns.slice()
|
|
362
|
+
: policy.columns.slice();
|
|
363
|
+
for (var i = 0; i < requestedColumns.length; i++) {
|
|
364
|
+
if (policy.columns.indexOf(requestedColumns[i]) === -1) {
|
|
365
|
+
throw new BreakGlassError("breakglass/grant-column-mismatch",
|
|
366
|
+
"grant: requested column '" + requestedColumns[i] +
|
|
367
|
+
"' is not glass-locked on table '" + table + "'", true);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Actor identity
|
|
372
|
+
var actor = requestHelpers.extractActorContext(opts.req);
|
|
373
|
+
var actorId = actor.userId || (opts.req && opts.req.apiKey && opts.req.apiKey.id) || null;
|
|
374
|
+
if (!actorId) {
|
|
375
|
+
throw new BreakGlassError("breakglass/unauthorized",
|
|
376
|
+
"grant: no authenticated actor on request (req.user.id / req.apiKey.id required)", true);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Factor verification + lockout
|
|
380
|
+
var factorType = opts.factor && opts.factor.type;
|
|
381
|
+
if (!factorType || policy.factors.indexOf(factorType) === -1) {
|
|
382
|
+
throw new BreakGlassError("breakglass/bad-factor",
|
|
383
|
+
"grant: factor.type must be one of [" + policy.factors.join(",") + "]");
|
|
384
|
+
}
|
|
385
|
+
var fl = _ensureFactorLockout();
|
|
386
|
+
var lockKey = actorId;
|
|
387
|
+
var locked = await fl.check(lockKey);
|
|
388
|
+
if (locked && locked.locked) {
|
|
389
|
+
audit.safeEmit({
|
|
390
|
+
action: "breakglass.grant.requested",
|
|
391
|
+
outcome: "denied",
|
|
392
|
+
actor: actor,
|
|
393
|
+
reason: "factor-rate-limited",
|
|
394
|
+
metadata: { table: table, factorType: factorType, lockUntil: locked.lockedUntil },
|
|
395
|
+
});
|
|
396
|
+
throw new BreakGlassError("breakglass/factor-rate-limited",
|
|
397
|
+
"grant: too many recent factor failures; locked until " +
|
|
398
|
+
new Date(locked.lockedUntil).toISOString());
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
var factorOk = false;
|
|
402
|
+
if (factorType === "totp") {
|
|
403
|
+
factorOk = _verifyTotpFactor(opts.factor).ok;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!factorOk) {
|
|
407
|
+
await fl.recordFailure(lockKey, { reason: factorType + "-bad" });
|
|
408
|
+
audit.safeEmit({
|
|
409
|
+
action: "breakglass.grant.requested",
|
|
410
|
+
outcome: "denied",
|
|
411
|
+
actor: actor,
|
|
412
|
+
reason: "bad-factor",
|
|
413
|
+
metadata: { table: table, factorType: factorType, columns: requestedColumns },
|
|
414
|
+
});
|
|
415
|
+
throw new BreakGlassError("breakglass/bad-factor",
|
|
416
|
+
"grant: " + factorType + " factor verification failed");
|
|
417
|
+
}
|
|
418
|
+
await fl.recordSuccess(lockKey);
|
|
419
|
+
|
|
420
|
+
// Build + persist the grant row
|
|
421
|
+
var nowMs = Date.now();
|
|
422
|
+
var grantId = "bg-" + generateToken(16);
|
|
423
|
+
var sessionId = (opts.req && opts.req.session && opts.req.session.id) || null;
|
|
424
|
+
var ipFromReq = (opts.req && opts.req.socket && opts.req.socket.remoteAddress) || null;
|
|
425
|
+
|
|
426
|
+
var grantRow = {
|
|
427
|
+
_id: grantId,
|
|
428
|
+
issuedToActorId: actorId,
|
|
429
|
+
factorType: factorType,
|
|
430
|
+
reasonSealed: reason,
|
|
431
|
+
scopeTable: table,
|
|
432
|
+
scopeColumnsJson: JSON.stringify(requestedColumns),
|
|
433
|
+
issuedAt: nowMs,
|
|
434
|
+
expiresAt: nowMs + policy.grantTtl,
|
|
435
|
+
maxRowsPerGrant: policy.maxRowsPerGrant,
|
|
436
|
+
rowsConsumed: 0,
|
|
437
|
+
revokedAt: null,
|
|
438
|
+
sessionId: sessionId,
|
|
439
|
+
ip: ipFromReq,
|
|
440
|
+
kwGrantHalf: null,
|
|
441
|
+
};
|
|
442
|
+
var sealed = cryptoField.sealRow("_blamejs_break_glass_grants", grantRow);
|
|
443
|
+
var keys = Object.keys(sealed);
|
|
444
|
+
var cols = keys.join(", ");
|
|
445
|
+
var qs = keys.map(function () { return "?"; }).join(", ");
|
|
446
|
+
await clusterStorage.execute(
|
|
447
|
+
"INSERT INTO _blamejs_break_glass_grants (" + cols + ") VALUES (" + qs + ")",
|
|
448
|
+
keys.map(function (k) { return sealed[k]; })
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
// Audit
|
|
452
|
+
var reasonForAudit = _reasonForAudit(reason, policy.auditReasonStorage);
|
|
453
|
+
audit.safeEmit({
|
|
454
|
+
action: "breakglass.grant.requested",
|
|
455
|
+
outcome: "success",
|
|
456
|
+
actor: actor,
|
|
457
|
+
reason: reasonForAudit.cleartext,
|
|
458
|
+
metadata: {
|
|
459
|
+
grantId: grantId,
|
|
460
|
+
table: table,
|
|
461
|
+
columns: requestedColumns,
|
|
462
|
+
factorType: factorType,
|
|
463
|
+
ttlMs: policy.grantTtl,
|
|
464
|
+
maxRowsPerGrant: policy.maxRowsPerGrant,
|
|
465
|
+
reasonHmac: reasonForAudit.hmac,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
observability.event("breakGlass.grant", { table: table });
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
id: grantId,
|
|
472
|
+
expiresAt: grantRow.expiresAt,
|
|
473
|
+
rowsRemaining: policy.maxRowsPerGrant,
|
|
474
|
+
scopeTable: table,
|
|
475
|
+
scopeColumns: requestedColumns,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function _reasonForAudit(reason, mode) {
|
|
480
|
+
// HMAC variant uses SHA3-512 keyed by a stable framework-wide tag —
|
|
481
|
+
// operators with multiple deployments can correlate via the hash
|
|
482
|
+
// without re-deriving from the same secret. Cleartext is the default
|
|
483
|
+
// (compliance reviewers WANT to read the reason).
|
|
484
|
+
var out = { cleartext: null, hmac: null };
|
|
485
|
+
if (mode === "cleartext" || mode === "both") out.cleartext = reason;
|
|
486
|
+
if (mode === "hmac" || mode === "both") {
|
|
487
|
+
out.hmac = sha3Hash("breakGlass.reason:" + reason);
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ---- Use a grant ----
|
|
493
|
+
|
|
494
|
+
async function unsealRow(grantHandle, table, rowId) {
|
|
495
|
+
_requireInit();
|
|
496
|
+
if (!grantHandle || typeof grantHandle !== "object" || typeof grantHandle.id !== "string") {
|
|
497
|
+
throw new BreakGlassError("breakglass/bad-grant-opts",
|
|
498
|
+
"unsealRow: grant handle is required (returned from b.breakGlass.grant())");
|
|
499
|
+
}
|
|
500
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
501
|
+
throw new BreakGlassError("breakglass/bad-grant-opts",
|
|
502
|
+
"unsealRow: table must be a non-empty string");
|
|
503
|
+
}
|
|
504
|
+
if (rowId === undefined || rowId === null || rowId === "") {
|
|
505
|
+
throw new BreakGlassError("breakglass/bad-grant-opts",
|
|
506
|
+
"unsealRow: rowId is required");
|
|
507
|
+
}
|
|
508
|
+
var grantRows = await clusterStorage.executeAll(
|
|
509
|
+
"SELECT * FROM _blamejs_break_glass_grants WHERE _id = ?",
|
|
510
|
+
[grantHandle.id]
|
|
511
|
+
);
|
|
512
|
+
if (!grantRows || grantRows.length === 0) {
|
|
513
|
+
throw new BreakGlassError("breakglass/grant-revoked",
|
|
514
|
+
"unsealRow: grant " + grantHandle.id + " not found (deleted or never issued)", true);
|
|
515
|
+
}
|
|
516
|
+
var sealedGrant = grantRows[0];
|
|
517
|
+
var grantRow = cryptoField.unsealRow("_blamejs_break_glass_grants", sealedGrant);
|
|
518
|
+
|
|
519
|
+
// Table mismatch
|
|
520
|
+
if (grantRow.scopeTable !== table) {
|
|
521
|
+
audit.safeEmit({
|
|
522
|
+
action: "breakglass.unsealrow",
|
|
523
|
+
outcome: "denied",
|
|
524
|
+
actor: { userId: grantRow.issuedToActorId },
|
|
525
|
+
reason: "grant-table-mismatch",
|
|
526
|
+
metadata: { grantId: grantRow._id, expectedTable: grantRow.scopeTable, gotTable: table, rowId: String(rowId) },
|
|
527
|
+
});
|
|
528
|
+
throw new BreakGlassError("breakglass/grant-table-mismatch",
|
|
529
|
+
"unsealRow: grant " + grantHandle.id + " is scoped to '" +
|
|
530
|
+
grantRow.scopeTable + "', not '" + table + "'", true);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Revoked
|
|
534
|
+
if (grantRow.revokedAt) {
|
|
535
|
+
throw new BreakGlassError("breakglass/grant-revoked",
|
|
536
|
+
"unsealRow: grant " + grantHandle.id + " was revoked at " +
|
|
537
|
+
new Date(Number(grantRow.revokedAt)).toISOString(), true);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Expired
|
|
541
|
+
if (Number(grantRow.expiresAt) <= Date.now()) {
|
|
542
|
+
audit.safeEmit({
|
|
543
|
+
action: "breakglass.grant.expired",
|
|
544
|
+
outcome: "success",
|
|
545
|
+
actor: { userId: grantRow.issuedToActorId },
|
|
546
|
+
metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
|
|
547
|
+
});
|
|
548
|
+
throw new BreakGlassError("breakglass/grant-expired",
|
|
549
|
+
"unsealRow: grant " + grantHandle.id + " expired at " +
|
|
550
|
+
new Date(Number(grantRow.expiresAt)).toISOString(), true);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Exhausted
|
|
554
|
+
if (Number(grantRow.rowsConsumed) >= Number(grantRow.maxRowsPerGrant)) {
|
|
555
|
+
audit.safeEmit({
|
|
556
|
+
action: "breakglass.grant.exhausted",
|
|
557
|
+
outcome: "success",
|
|
558
|
+
actor: { userId: grantRow.issuedToActorId },
|
|
559
|
+
metadata: { grantId: grantRow._id, table: table, rowsConsumed: Number(grantRow.rowsConsumed) },
|
|
560
|
+
});
|
|
561
|
+
throw new BreakGlassError("breakglass/grant-exhausted",
|
|
562
|
+
"unsealRow: grant " + grantHandle.id + " has consumed all " +
|
|
563
|
+
grantRow.maxRowsPerGrant + " allowed rows", true);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Increment rowsConsumed (atomic UPDATE with WHERE rowsConsumed < cap
|
|
567
|
+
// so concurrent unseals can't both pass the runtime check above).
|
|
568
|
+
var updateRes = await clusterStorage.execute(
|
|
569
|
+
"UPDATE _blamejs_break_glass_grants " +
|
|
570
|
+
"SET rowsConsumed = rowsConsumed + 1 " +
|
|
571
|
+
"WHERE _id = ? AND rowsConsumed < maxRowsPerGrant AND " +
|
|
572
|
+
"(revokedAt IS NULL) AND expiresAt > ?",
|
|
573
|
+
[grantHandle.id, Date.now()]
|
|
574
|
+
);
|
|
575
|
+
// executeAll-style result; some backends return rowsAffected, others a count.
|
|
576
|
+
// Re-query to confirm the increment landed and get the post-increment counter.
|
|
577
|
+
var postRows = await clusterStorage.executeAll(
|
|
578
|
+
"SELECT rowsConsumed, revokedAt, expiresAt FROM _blamejs_break_glass_grants WHERE _id = ?",
|
|
579
|
+
[grantHandle.id]
|
|
580
|
+
);
|
|
581
|
+
if (!postRows || postRows.length === 0) {
|
|
582
|
+
throw new BreakGlassError("breakglass/grant-revoked",
|
|
583
|
+
"unsealRow: grant " + grantHandle.id + " disappeared during unseal", true);
|
|
584
|
+
}
|
|
585
|
+
var postRowsConsumed = Number(postRows[0].rowsConsumed);
|
|
586
|
+
// If the UPDATE didn't actually increment (race lost — another unseal
|
|
587
|
+
// exhausted the grant or it was revoked / expired between our check
|
|
588
|
+
// and the UPDATE), refuse this read.
|
|
589
|
+
if (postRowsConsumed === Number(grantRow.rowsConsumed)) {
|
|
590
|
+
throw new BreakGlassError("breakglass/grant-exhausted",
|
|
591
|
+
"unsealRow: grant " + grantHandle.id + " was exhausted by a concurrent read", true);
|
|
592
|
+
}
|
|
593
|
+
void updateRes;
|
|
594
|
+
|
|
595
|
+
// Fetch + unseal the target row through cryptoField
|
|
596
|
+
var rows = await clusterStorage.executeAll(
|
|
597
|
+
"SELECT * FROM " + table + " WHERE _id = ?",
|
|
598
|
+
[String(rowId)]
|
|
599
|
+
);
|
|
600
|
+
if (!rows || rows.length === 0) {
|
|
601
|
+
throw new BreakGlassError("breakglass/row-not-found",
|
|
602
|
+
"unsealRow: " + table + "[" + rowId + "] not found", true);
|
|
603
|
+
}
|
|
604
|
+
var unsealedRow = cryptoField.unsealRow(table, rows[0]);
|
|
605
|
+
|
|
606
|
+
// Per-row audit. The grant's reasonSealed is already cleartext after
|
|
607
|
+
// unsealRow on the grant; pass it into the audit row honoring the
|
|
608
|
+
// policy's auditReasonStorage mode.
|
|
609
|
+
var policy = await policyGet(table);
|
|
610
|
+
var reasonForAudit = _reasonForAudit(grantRow.reasonSealed || "",
|
|
611
|
+
policy ? policy.auditReasonStorage : DEFAULT_AUDIT_REASON);
|
|
612
|
+
audit.safeEmit({
|
|
613
|
+
action: "breakglass.unsealrow",
|
|
614
|
+
outcome: "success",
|
|
615
|
+
actor: { userId: grantRow.issuedToActorId },
|
|
616
|
+
reason: reasonForAudit.cleartext,
|
|
617
|
+
metadata: {
|
|
618
|
+
grantId: grantRow._id,
|
|
619
|
+
table: table,
|
|
620
|
+
rowId: String(rowId),
|
|
621
|
+
columns: safeJson.parse(grantRow.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
|
|
622
|
+
rowsRemaining: Number(grantRow.maxRowsPerGrant) - postRowsConsumed,
|
|
623
|
+
reasonHmac: reasonForAudit.hmac,
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
observability.event("breakglass.unsealrow", { table: table });
|
|
627
|
+
|
|
628
|
+
return unsealedRow;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ---- Revoke ----
|
|
632
|
+
|
|
633
|
+
async function revoke(grantId, opts) {
|
|
634
|
+
_requireInit();
|
|
635
|
+
if (typeof grantId !== "string" || grantId.length === 0) {
|
|
636
|
+
throw new BreakGlassError("breakglass/bad-grant-opts",
|
|
637
|
+
"revoke: grantId is required");
|
|
638
|
+
}
|
|
639
|
+
opts = opts || {};
|
|
640
|
+
var nowMs = Date.now();
|
|
641
|
+
await clusterStorage.execute(
|
|
642
|
+
"UPDATE _blamejs_break_glass_grants SET revokedAt = ? " +
|
|
643
|
+
"WHERE _id = ? AND revokedAt IS NULL",
|
|
644
|
+
[nowMs, grantId]
|
|
645
|
+
);
|
|
646
|
+
audit.safeEmit({
|
|
647
|
+
action: "breakglass.grant.revoked",
|
|
648
|
+
outcome: "success",
|
|
649
|
+
actor: requestHelpers.resolveActorWithOverride(opts),
|
|
650
|
+
reason: typeof opts.reason === "string" ? opts.reason : null,
|
|
651
|
+
metadata: { grantId: grantId },
|
|
652
|
+
});
|
|
653
|
+
return { revoked: true, grantId: grantId };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ---- listActive ----
|
|
657
|
+
|
|
658
|
+
async function listActive(opts) {
|
|
659
|
+
_requireInit();
|
|
660
|
+
opts = opts || {};
|
|
661
|
+
var actor = requestHelpers.extractActorContext(opts.req);
|
|
662
|
+
var actorId = actor.userId || (opts.req && opts.req.apiKey && opts.req.apiKey.id) || null;
|
|
663
|
+
if (!actorId) return [];
|
|
664
|
+
// Use cryptoField's computeDerived so the hash matches the table's
|
|
665
|
+
// hashNamespace prefix — raw sha3Hash would produce a different value.
|
|
666
|
+
var derived = cryptoField.computeDerived(
|
|
667
|
+
"_blamejs_break_glass_grants", "issuedToActorId", actorId
|
|
668
|
+
);
|
|
669
|
+
if (!derived) return [];
|
|
670
|
+
var nowMs = Date.now();
|
|
671
|
+
var rows = await clusterStorage.executeAll(
|
|
672
|
+
"SELECT * FROM _blamejs_break_glass_grants " +
|
|
673
|
+
"WHERE issuedToActorHash = ? AND (revokedAt IS NULL) AND expiresAt > ? AND rowsConsumed < maxRowsPerGrant " +
|
|
674
|
+
"ORDER BY issuedAt DESC",
|
|
675
|
+
[derived.value, nowMs]
|
|
676
|
+
);
|
|
677
|
+
var out = [];
|
|
678
|
+
for (var i = 0; i < (rows || []).length; i++) {
|
|
679
|
+
var u = cryptoField.unsealRow("_blamejs_break_glass_grants", rows[i]);
|
|
680
|
+
out.push({
|
|
681
|
+
id: u._id,
|
|
682
|
+
scopeTable: u.scopeTable,
|
|
683
|
+
scopeColumns: safeJson.parse(u.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
|
|
684
|
+
issuedAt: Number(u.issuedAt),
|
|
685
|
+
expiresAt: Number(u.expiresAt),
|
|
686
|
+
rowsRemaining: Number(u.maxRowsPerGrant) - Number(u.rowsConsumed),
|
|
687
|
+
factorType: u.factorType,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
return out;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ---- Sweep (best-effort cleanup of expired grants) ----
|
|
694
|
+
|
|
695
|
+
async function _sweepExpired(opts) {
|
|
696
|
+
opts = opts || {};
|
|
697
|
+
var nowMs = Date.now();
|
|
698
|
+
var expired = await clusterStorage.executeAll(
|
|
699
|
+
"SELECT _id, issuedToActorId, scopeTable, rowsConsumed FROM _blamejs_break_glass_grants " +
|
|
700
|
+
"WHERE revokedAt IS NULL AND expiresAt <= ?",
|
|
701
|
+
[nowMs]
|
|
702
|
+
);
|
|
703
|
+
for (var i = 0; i < (expired || []).length; i++) {
|
|
704
|
+
var row = expired[i];
|
|
705
|
+
audit.safeEmit({
|
|
706
|
+
action: "breakglass.grant.expired",
|
|
707
|
+
outcome: "success",
|
|
708
|
+
actor: { userId: row.issuedToActorId },
|
|
709
|
+
metadata: { grantId: row._id, table: row.scopeTable, rowsConsumed: Number(row.rowsConsumed) },
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
await clusterStorage.execute(
|
|
713
|
+
"UPDATE _blamejs_break_glass_grants SET revokedAt = ? " +
|
|
714
|
+
"WHERE revokedAt IS NULL AND expiresAt <= ?",
|
|
715
|
+
[nowMs, nowMs]
|
|
716
|
+
);
|
|
717
|
+
return { expired: (expired || []).length };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
void safeAsync; // kept import for future grant-async ops in v0.5.1+
|
|
721
|
+
|
|
722
|
+
module.exports = {
|
|
723
|
+
init: init,
|
|
724
|
+
policy: {
|
|
725
|
+
set: policySet,
|
|
726
|
+
get: policyGet,
|
|
727
|
+
list: policyList,
|
|
728
|
+
delete: policyDelete,
|
|
729
|
+
},
|
|
730
|
+
grant: grant,
|
|
731
|
+
unsealRow: unsealRow,
|
|
732
|
+
revoke: revoke,
|
|
733
|
+
listActive: listActive,
|
|
734
|
+
BreakGlassError: BreakGlassError,
|
|
735
|
+
|
|
736
|
+
// Test-only / sweep — operators with active grant volume wire this
|
|
737
|
+
// into a scheduler; the framework doesn't auto-start the timer so
|
|
738
|
+
// boot doesn't depend on anything firing in the background.
|
|
739
|
+
_sweepExpiredForTest: _sweepExpired,
|
|
740
|
+
_resetForTest: _resetForTest,
|
|
741
|
+
};
|