@blamejs/core 0.5.1 → 0.5.2
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 +1 -0
- package/lib/break-glass.js +295 -11
- package/lib/db.js +1 -0
- package/lib/framework-schema.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/lib/break-glass.js
CHANGED
|
@@ -54,6 +54,7 @@ var { defineClass } = require("./framework-error");
|
|
|
54
54
|
var vault = lazyRequire(function () { return require("./vault"); });
|
|
55
55
|
|
|
56
56
|
var lockout = lazyRequire(function () { return require("./auth/lockout"); });
|
|
57
|
+
var passkey = lazyRequire(function () { return require("./auth/passkey"); });
|
|
57
58
|
|
|
58
59
|
// Errors — all 14 codes documented in the spec. `permanent: true`
|
|
59
60
|
// means caller's input is bad (Tier-A); `permanent: false` means
|
|
@@ -67,7 +68,7 @@ var DEFAULT_MAX_ROWS = 1; // operator-locked: row-by-row auth
|
|
|
67
68
|
var DEFAULT_REASON_MIN_LEN = 12;
|
|
68
69
|
var DEFAULT_LOCKED_BEHAVIOR = "throw"; // or "redact"
|
|
69
70
|
var DEFAULT_AUDIT_REASON = "cleartext";
|
|
70
|
-
var ALLOWED_FACTORS = ["totp"];
|
|
71
|
+
var ALLOWED_FACTORS = ["totp", "passkey"];
|
|
71
72
|
var ALLOWED_REASON_STORAGE = ["cleartext", "hmac", "both"];
|
|
72
73
|
|
|
73
74
|
// In-memory policy cache. Cluster-shared via the policies table; the
|
|
@@ -135,19 +136,18 @@ function _kCell(dek, table, rowId, column) {
|
|
|
135
136
|
|
|
136
137
|
async function _ensureDek(table) {
|
|
137
138
|
if (dekCache.has(table)) return dekCache.get(table);
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
// DEK and store it there. Pre-v1 schema: clean and simple.
|
|
139
|
+
// DEK is vault-sealed and stored in the policy row's `dekSealed`
|
|
140
|
+
// column. Generated lazily on first use of cryptographic-mode for
|
|
141
|
+
// the table. Cached in-memory after first read.
|
|
142
142
|
var rows = await clusterStorage.executeAll(
|
|
143
|
-
"SELECT
|
|
143
|
+
"SELECT dekSealed FROM _blamejs_break_glass_policies WHERE tableName = ?",
|
|
144
144
|
[table]
|
|
145
145
|
);
|
|
146
146
|
if (!rows || rows.length === 0) {
|
|
147
147
|
throw new BreakGlassError("breakglass/policy-not-set",
|
|
148
148
|
"_ensureDek: no policy for table '" + table + "'", true);
|
|
149
149
|
}
|
|
150
|
-
var sealed = rows[0].
|
|
150
|
+
var sealed = rows[0].dekSealed;
|
|
151
151
|
var dek;
|
|
152
152
|
if (sealed) {
|
|
153
153
|
dek = Buffer.from(vault().unseal(sealed), "base64");
|
|
@@ -155,7 +155,7 @@ async function _ensureDek(table) {
|
|
|
155
155
|
dek = generateBytes(32);
|
|
156
156
|
var sealedDek = vault().seal(dek.toString("base64"));
|
|
157
157
|
await clusterStorage.execute(
|
|
158
|
-
"UPDATE _blamejs_break_glass_policies SET
|
|
158
|
+
"UPDATE _blamejs_break_glass_policies SET dekSealed = ? WHERE tableName = ?",
|
|
159
159
|
[sealedDek, table]
|
|
160
160
|
);
|
|
161
161
|
}
|
|
@@ -399,9 +399,35 @@ function _validatePolicySet(table, opts) {
|
|
|
399
399
|
throw new BreakGlassError("breakglass/bad-policy",
|
|
400
400
|
"policy.set: auditReasonStorage must be one of " + ALLOWED_REASON_STORAGE.join("/"));
|
|
401
401
|
}
|
|
402
|
+
// Service-account bypass: explicit opt-in per table. Operators
|
|
403
|
+
// declare the apiKey ids that may bypass + a required role; the
|
|
404
|
+
// framework requires BOTH to grant the bypass. Without this opt set,
|
|
405
|
+
// there is NO bypass path — every read of a glass-locked column
|
|
406
|
+
// requires a fresh grant.
|
|
407
|
+
var serviceAccountBypass = null;
|
|
402
408
|
if (opts.serviceAccountBypass != null && opts.serviceAccountBypass !== false) {
|
|
403
|
-
|
|
404
|
-
|
|
409
|
+
var sab = opts.serviceAccountBypass;
|
|
410
|
+
if (!sab || typeof sab !== "object") {
|
|
411
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
412
|
+
"policy.set: serviceAccountBypass must be an object { enabled, apiKeyIds, requireRole }");
|
|
413
|
+
}
|
|
414
|
+
if (sab.enabled !== true) {
|
|
415
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
416
|
+
"policy.set: serviceAccountBypass.enabled must be true (set serviceAccountBypass: false to disable)");
|
|
417
|
+
}
|
|
418
|
+
if (!Array.isArray(sab.apiKeyIds) || sab.apiKeyIds.length === 0) {
|
|
419
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
420
|
+
"policy.set: serviceAccountBypass.apiKeyIds must be a non-empty array of apiKey ids");
|
|
421
|
+
}
|
|
422
|
+
if (typeof sab.requireRole !== "string" || sab.requireRole.length === 0) {
|
|
423
|
+
throw new BreakGlassError("breakglass/bad-policy",
|
|
424
|
+
"policy.set: serviceAccountBypass.requireRole must be a non-empty role / scope string");
|
|
425
|
+
}
|
|
426
|
+
serviceAccountBypass = {
|
|
427
|
+
enabled: true,
|
|
428
|
+
apiKeyIds: sab.apiKeyIds.slice(),
|
|
429
|
+
requireRole: sab.requireRole,
|
|
430
|
+
};
|
|
405
431
|
}
|
|
406
432
|
return {
|
|
407
433
|
cryptographic: opts.cryptographic === true,
|
|
@@ -414,6 +440,7 @@ function _validatePolicySet(table, opts) {
|
|
|
414
440
|
onLockedAccess: opts.onLockedAccess || DEFAULT_LOCKED_BEHAVIOR,
|
|
415
441
|
requireScope: opts.requireScope != null ? opts.requireScope : null,
|
|
416
442
|
auditReasonStorage: opts.auditReasonStorage || DEFAULT_AUDIT_REASON,
|
|
443
|
+
serviceAccountBypass: serviceAccountBypass,
|
|
417
444
|
};
|
|
418
445
|
}
|
|
419
446
|
|
|
@@ -433,7 +460,9 @@ async function policySet(table, opts, callerOpts) {
|
|
|
433
460
|
sessionPin: validated.sessionPin ? 1 : 0,
|
|
434
461
|
onLockedAccess: validated.onLockedAccess,
|
|
435
462
|
requireScope: validated.requireScope,
|
|
436
|
-
serviceAccountBypassJson:
|
|
463
|
+
serviceAccountBypassJson: validated.serviceAccountBypass
|
|
464
|
+
? JSON.stringify(validated.serviceAccountBypass)
|
|
465
|
+
: null,
|
|
437
466
|
auditReasonStorage: validated.auditReasonStorage,
|
|
438
467
|
updatedAt: Date.now(),
|
|
439
468
|
};
|
|
@@ -492,6 +521,9 @@ async function policyGet(table) {
|
|
|
492
521
|
sessionPin: unsealed.sessionPin === 1,
|
|
493
522
|
onLockedAccess: unsealed.onLockedAccess,
|
|
494
523
|
requireScope: unsealed.requireScope,
|
|
524
|
+
serviceAccountBypass: unsealed.serviceAccountBypassJson
|
|
525
|
+
? safeJson.parse(unsealed.serviceAccountBypassJson, { maxBytes: C.BYTES.kib(8) })
|
|
526
|
+
: null,
|
|
495
527
|
auditReasonStorage: unsealed.auditReasonStorage,
|
|
496
528
|
updatedAt: Number(unsealed.updatedAt),
|
|
497
529
|
};
|
|
@@ -542,6 +574,34 @@ function _verifyTotpFactor(factor) {
|
|
|
542
574
|
return { ok: verified !== false, step: verified };
|
|
543
575
|
}
|
|
544
576
|
|
|
577
|
+
// Passkey factor — operator presents a WebAuthn assertion plus the
|
|
578
|
+
// challenge/origin/RPID + the previously-enrolled credential record.
|
|
579
|
+
// Phishing-resistant; the private key lives on the YubiKey, not in
|
|
580
|
+
// the framework's vault. v0.5.2 uses passkey for identity verification
|
|
581
|
+
// to gate grant issuance; PRF-derived per-policy DEK material (which
|
|
582
|
+
// would give true vault-key-alone-doesn't-decrypt defense) is a
|
|
583
|
+
// follow-up.
|
|
584
|
+
async function _verifyPasskeyFactor(factor) {
|
|
585
|
+
if (!factor || typeof factor !== "object") return { ok: false };
|
|
586
|
+
if (!factor.response || !factor.expectedChallenge ||
|
|
587
|
+
!factor.expectedOrigin || !factor.expectedRPID || !factor.credential) {
|
|
588
|
+
return { ok: false };
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
var result = await passkey().verifyAuthentication({
|
|
592
|
+
response: factor.response,
|
|
593
|
+
expectedChallenge: factor.expectedChallenge,
|
|
594
|
+
expectedOrigin: factor.expectedOrigin,
|
|
595
|
+
expectedRPID: factor.expectedRPID,
|
|
596
|
+
credential: factor.credential,
|
|
597
|
+
requireUserVerification: factor.requireUserVerification !== false,
|
|
598
|
+
});
|
|
599
|
+
return { ok: result && result.verified === true };
|
|
600
|
+
} catch (_e) {
|
|
601
|
+
return { ok: false };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
545
605
|
async function grant(opts) {
|
|
546
606
|
_requireInit();
|
|
547
607
|
if (!opts || typeof opts !== "object") {
|
|
@@ -613,6 +673,8 @@ async function grant(opts) {
|
|
|
613
673
|
var factorOk = false;
|
|
614
674
|
if (factorType === "totp") {
|
|
615
675
|
factorOk = _verifyTotpFactor(opts.factor).ok;
|
|
676
|
+
} else if (factorType === "passkey") {
|
|
677
|
+
factorOk = (await _verifyPasskeyFactor(opts.factor)).ok;
|
|
616
678
|
}
|
|
617
679
|
|
|
618
680
|
if (!factorOk) {
|
|
@@ -937,6 +999,220 @@ async function listActive(opts) {
|
|
|
937
999
|
return out;
|
|
938
1000
|
}
|
|
939
1001
|
|
|
1002
|
+
// ---- Service-account bypass ----
|
|
1003
|
+
//
|
|
1004
|
+
// Some legitimate workloads (nightly de-identification, scheduled
|
|
1005
|
+
// compliance reports) need PHI access without a human at the
|
|
1006
|
+
// keyboard. The framework refuses to silently bypass — operators
|
|
1007
|
+
// declare explicit service-account bypasses per-table with an apiKey
|
|
1008
|
+
// allowlist + required role. Both must match (verified apiKey id is
|
|
1009
|
+
// in the allowlist AND the apiKey carries the required role/scope)
|
|
1010
|
+
// before the bypass is granted. Each bypass emits its own distinct
|
|
1011
|
+
// audit row so post-incident review can distinguish operator-initiated
|
|
1012
|
+
// from service-initiated reads.
|
|
1013
|
+
|
|
1014
|
+
async function unsealRowAsService(req, table, rowId, opts) {
|
|
1015
|
+
_requireInit();
|
|
1016
|
+
opts = opts || {};
|
|
1017
|
+
if (!req || typeof req !== "object") {
|
|
1018
|
+
throw new BreakGlassError("breakglass/bad-grant-opts",
|
|
1019
|
+
"unsealRowAsService: req is required (with verified req.apiKey)");
|
|
1020
|
+
}
|
|
1021
|
+
if (typeof table !== "string" || table.length === 0) {
|
|
1022
|
+
throw new BreakGlassError("breakglass/bad-grant-opts",
|
|
1023
|
+
"unsealRowAsService: table must be a non-empty string");
|
|
1024
|
+
}
|
|
1025
|
+
var policy = await policyGet(table);
|
|
1026
|
+
if (!policy) {
|
|
1027
|
+
throw new BreakGlassError("breakglass/policy-not-set",
|
|
1028
|
+
"unsealRowAsService: no break-glass policy for table '" + table + "'", true);
|
|
1029
|
+
}
|
|
1030
|
+
if (!policy.serviceAccountBypass) {
|
|
1031
|
+
throw new BreakGlassError("breakglass/bypass-not-configured",
|
|
1032
|
+
"unsealRowAsService: serviceAccountBypass is not configured for '" + table + "'", true);
|
|
1033
|
+
}
|
|
1034
|
+
var apiKeyOnReq = req.apiKey;
|
|
1035
|
+
if (!apiKeyOnReq || typeof apiKeyOnReq.id !== "string") {
|
|
1036
|
+
throw new BreakGlassError("breakglass/bypass-no-apikey",
|
|
1037
|
+
"unsealRowAsService: req.apiKey.id is required (operator must run b.middleware.requireApiKey before this path)", true);
|
|
1038
|
+
}
|
|
1039
|
+
if (policy.serviceAccountBypass.apiKeyIds.indexOf(apiKeyOnReq.id) === -1) {
|
|
1040
|
+
audit.safeEmit({
|
|
1041
|
+
action: "breakglass.grant.bypass",
|
|
1042
|
+
outcome: "denied",
|
|
1043
|
+
actor: requestHelpers.extractActorContext(req),
|
|
1044
|
+
reason: "apikey-not-in-allowlist",
|
|
1045
|
+
metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id },
|
|
1046
|
+
});
|
|
1047
|
+
throw new BreakGlassError("breakglass/bypass-unauthorized",
|
|
1048
|
+
"unsealRowAsService: apiKey '" + apiKeyOnReq.id +
|
|
1049
|
+
"' is not in the bypass allowlist for '" + table + "'", true);
|
|
1050
|
+
}
|
|
1051
|
+
// Role check — actor must carry policy.serviceAccountBypass.requireRole
|
|
1052
|
+
// either as a direct scope or via b.permissions.check resolution.
|
|
1053
|
+
var actorScopes = Array.isArray(apiKeyOnReq.scopes) ? apiKeyOnReq.scopes :
|
|
1054
|
+
Array.isArray(apiKeyOnReq.roles) ? apiKeyOnReq.roles :
|
|
1055
|
+
[];
|
|
1056
|
+
var requiredRole = policy.serviceAccountBypass.requireRole;
|
|
1057
|
+
var hasRole = actorScopes.indexOf(requiredRole) !== -1;
|
|
1058
|
+
if (!hasRole) {
|
|
1059
|
+
audit.safeEmit({
|
|
1060
|
+
action: "breakglass.grant.bypass",
|
|
1061
|
+
outcome: "denied",
|
|
1062
|
+
actor: requestHelpers.extractActorContext(req),
|
|
1063
|
+
reason: "missing-role",
|
|
1064
|
+
metadata: { table: table, rowId: String(rowId), apiKeyId: apiKeyOnReq.id, requiredRole: requiredRole },
|
|
1065
|
+
});
|
|
1066
|
+
throw new BreakGlassError("breakglass/bypass-unauthorized",
|
|
1067
|
+
"unsealRowAsService: apiKey '" + apiKeyOnReq.id +
|
|
1068
|
+
"' lacks required role '" + requiredRole + "'", true);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Fetch + unseal the row (Model A or Model B path, same as
|
|
1072
|
+
// operator-initiated unsealRow).
|
|
1073
|
+
var rows = await clusterStorage.executeAll(
|
|
1074
|
+
"SELECT * FROM " + table + " WHERE _id = ?",
|
|
1075
|
+
[String(rowId)]
|
|
1076
|
+
);
|
|
1077
|
+
if (!rows || rows.length === 0) {
|
|
1078
|
+
throw new BreakGlassError("breakglass/row-not-found",
|
|
1079
|
+
"unsealRowAsService: " + table + "[" + rowId + "] not found", true);
|
|
1080
|
+
}
|
|
1081
|
+
var unsealedRow;
|
|
1082
|
+
if (policy.cryptographic) {
|
|
1083
|
+
var rawCipher = {};
|
|
1084
|
+
for (var c = 0; c < policy.columns.length; c++) rawCipher[policy.columns[c]] = rows[0][policy.columns[c]];
|
|
1085
|
+
var rowMinusLocked = Object.assign({}, rows[0]);
|
|
1086
|
+
for (var c2 = 0; c2 < policy.columns.length; c2++) delete rowMinusLocked[policy.columns[c2]];
|
|
1087
|
+
unsealedRow = cryptoField.unsealRow(table, rowMinusLocked);
|
|
1088
|
+
for (var c3 = 0; c3 < policy.columns.length; c3++) {
|
|
1089
|
+
var col = policy.columns[c3];
|
|
1090
|
+
if (rawCipher[col] == null) continue;
|
|
1091
|
+
unsealedRow[col] = await decryptCell(rawCipher[col],
|
|
1092
|
+
{ table: table, rowId: String(rowId), column: col });
|
|
1093
|
+
}
|
|
1094
|
+
} else {
|
|
1095
|
+
unsealedRow = cryptoField.unsealRow(table, rows[0]);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
audit.safeEmit({
|
|
1099
|
+
action: "breakglass.grant.bypass",
|
|
1100
|
+
outcome: "success",
|
|
1101
|
+
actor: requestHelpers.extractActorContext(req),
|
|
1102
|
+
reason: typeof opts.reason === "string" ? opts.reason : null,
|
|
1103
|
+
metadata: {
|
|
1104
|
+
table: table,
|
|
1105
|
+
rowId: String(rowId),
|
|
1106
|
+
apiKeyId: apiKeyOnReq.id,
|
|
1107
|
+
requiredRole: requiredRole,
|
|
1108
|
+
columns: policy.columns.slice(),
|
|
1109
|
+
},
|
|
1110
|
+
});
|
|
1111
|
+
observability.event("breakglass.grant.bypass", { table: table });
|
|
1112
|
+
return unsealedRow;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ---- Admin tools ----
|
|
1116
|
+
//
|
|
1117
|
+
// `listActiveAll` returns every active grant (across all actors) —
|
|
1118
|
+
// for security-team dashboards and offboarding workflows.
|
|
1119
|
+
// `revokeAll` mass-revokes grants matching criteria — used by IR
|
|
1120
|
+
// teams when an account is suspected compromised. Both require
|
|
1121
|
+
// admin scope (operator wires via opts.requireScope or their own gate).
|
|
1122
|
+
|
|
1123
|
+
async function listActiveAll(opts) {
|
|
1124
|
+
_requireInit();
|
|
1125
|
+
opts = opts || {};
|
|
1126
|
+
var nowMs = Date.now();
|
|
1127
|
+
var clauses = ["(revokedAt IS NULL)", "expiresAt > ?", "rowsConsumed < maxRowsPerGrant"];
|
|
1128
|
+
var params = [nowMs];
|
|
1129
|
+
if (opts.table) {
|
|
1130
|
+
clauses.push("scopeTable = ?");
|
|
1131
|
+
params.push(opts.table);
|
|
1132
|
+
}
|
|
1133
|
+
if (opts.since) {
|
|
1134
|
+
clauses.push("issuedAt >= ?");
|
|
1135
|
+
params.push(opts.since);
|
|
1136
|
+
}
|
|
1137
|
+
var rows = await clusterStorage.executeAll(
|
|
1138
|
+
"SELECT * FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND ") +
|
|
1139
|
+
" ORDER BY issuedAt DESC",
|
|
1140
|
+
params
|
|
1141
|
+
);
|
|
1142
|
+
var out = [];
|
|
1143
|
+
for (var i = 0; i < (rows || []).length; i++) {
|
|
1144
|
+
var u = cryptoField.unsealRow("_blamejs_break_glass_grants", rows[i]);
|
|
1145
|
+
out.push({
|
|
1146
|
+
id: u._id,
|
|
1147
|
+
issuedToActorId: u.issuedToActorId,
|
|
1148
|
+
scopeTable: u.scopeTable,
|
|
1149
|
+
scopeColumns: safeJson.parse(u.scopeColumnsJson || "[]", { maxBytes: C.BYTES.kib(64) }),
|
|
1150
|
+
factorType: u.factorType,
|
|
1151
|
+
issuedAt: Number(u.issuedAt),
|
|
1152
|
+
expiresAt: Number(u.expiresAt),
|
|
1153
|
+
rowsRemaining: Number(u.maxRowsPerGrant) - Number(u.rowsConsumed),
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
audit.safeEmit({
|
|
1157
|
+
action: "breakglass.admin.listactiveall",
|
|
1158
|
+
outcome: "success",
|
|
1159
|
+
actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
|
|
1160
|
+
metadata: { resultCount: out.length, filterTable: opts.table || null },
|
|
1161
|
+
});
|
|
1162
|
+
return out;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
async function revokeAll(criteria, opts) {
|
|
1166
|
+
_requireInit();
|
|
1167
|
+
if (!criteria || typeof criteria !== "object") {
|
|
1168
|
+
throw new BreakGlassError("breakglass/bad-revoke-criteria",
|
|
1169
|
+
"revokeAll: criteria is required ({ actorId?, table? })");
|
|
1170
|
+
}
|
|
1171
|
+
if (!criteria.actorId && !criteria.table) {
|
|
1172
|
+
throw new BreakGlassError("breakglass/bad-revoke-criteria",
|
|
1173
|
+
"revokeAll: at least one of { actorId, table } is required (refusing to mass-revoke without scope)");
|
|
1174
|
+
}
|
|
1175
|
+
opts = opts || {};
|
|
1176
|
+
var clauses = ["revokedAt IS NULL"];
|
|
1177
|
+
var params = [];
|
|
1178
|
+
if (criteria.actorId) {
|
|
1179
|
+
var derived = cryptoField.computeDerived(
|
|
1180
|
+
"_blamejs_break_glass_grants", "issuedToActorId", criteria.actorId
|
|
1181
|
+
);
|
|
1182
|
+
if (derived) {
|
|
1183
|
+
clauses.push("issuedToActorHash = ?");
|
|
1184
|
+
params.push(derived.value);
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (criteria.table) {
|
|
1188
|
+
clauses.push("scopeTable = ?");
|
|
1189
|
+
params.push(criteria.table);
|
|
1190
|
+
}
|
|
1191
|
+
// Snapshot the to-be-revoked grant ids first so audit captures specifics.
|
|
1192
|
+
var ids = await clusterStorage.executeAll(
|
|
1193
|
+
"SELECT _id FROM _blamejs_break_glass_grants WHERE " + clauses.join(" AND "),
|
|
1194
|
+
params
|
|
1195
|
+
);
|
|
1196
|
+
var nowMs = Date.now();
|
|
1197
|
+
await clusterStorage.execute(
|
|
1198
|
+
"UPDATE _blamejs_break_glass_grants SET revokedAt = ? WHERE " + clauses.join(" AND "),
|
|
1199
|
+
[nowMs].concat(params)
|
|
1200
|
+
);
|
|
1201
|
+
audit.safeEmit({
|
|
1202
|
+
action: "breakglass.admin.revokeall",
|
|
1203
|
+
outcome: "success",
|
|
1204
|
+
actor: requestHelpers.resolveActorWithOverride(opts.callerOpts),
|
|
1205
|
+
reason: typeof criteria.reason === "string" ? criteria.reason : null,
|
|
1206
|
+
metadata: {
|
|
1207
|
+
filterActorId: criteria.actorId || null,
|
|
1208
|
+
filterTable: criteria.table || null,
|
|
1209
|
+
revokedCount: (ids || []).length,
|
|
1210
|
+
revokedIds: (ids || []).map(function (r) { return r._id; }),
|
|
1211
|
+
},
|
|
1212
|
+
});
|
|
1213
|
+
return { revokedCount: (ids || []).length };
|
|
1214
|
+
}
|
|
1215
|
+
|
|
940
1216
|
// ---- Sweep (best-effort cleanup of expired grants) ----
|
|
941
1217
|
|
|
942
1218
|
async function _sweepExpired(opts) {
|
|
@@ -984,6 +1260,14 @@ module.exports = {
|
|
|
984
1260
|
encryptCell: encryptCell,
|
|
985
1261
|
decryptCell: decryptCell,
|
|
986
1262
|
migrate: migrate,
|
|
1263
|
+
// Service-account bypass — operator-declared per-table, gated by
|
|
1264
|
+
// (apiKey id in allowlist) AND (apiKey carries required role).
|
|
1265
|
+
unsealRowAsService: unsealRowAsService,
|
|
1266
|
+
// Admin tools — for security-team dashboards (listActiveAll) and
|
|
1267
|
+
// incident-response offboarding (revokeAll). Operators wire their
|
|
1268
|
+
// own gate (requireScope or middleware) on the calling routes.
|
|
1269
|
+
listActiveAll: listActiveAll,
|
|
1270
|
+
revokeAll: revokeAll,
|
|
987
1271
|
BreakGlassError: BreakGlassError,
|
|
988
1272
|
|
|
989
1273
|
// Test-only / sweep — operators with active grant volume wire this
|
package/lib/db.js
CHANGED
|
@@ -446,6 +446,7 @@ var FRAMEWORK_SCHEMA = [
|
|
|
446
446
|
onLockedAccess: "TEXT NOT NULL DEFAULT 'throw'",
|
|
447
447
|
requireScope: "TEXT",
|
|
448
448
|
serviceAccountBypassJson: "TEXT",
|
|
449
|
+
dekSealed: "TEXT",
|
|
449
450
|
auditReasonStorage: "TEXT NOT NULL DEFAULT 'cleartext'",
|
|
450
451
|
updatedAt: "INTEGER NOT NULL",
|
|
451
452
|
},
|
package/lib/framework-schema.js
CHANGED
|
@@ -584,6 +584,7 @@ function _breakGlassPoliciesDDL(dialect) {
|
|
|
584
584
|
" onLockedAccess TEXT NOT NULL DEFAULT 'throw'," +
|
|
585
585
|
" requireScope TEXT," +
|
|
586
586
|
" serviceAccountBypassJson TEXT," +
|
|
587
|
+
" dekSealed TEXT," +
|
|
587
588
|
" auditReasonStorage TEXT NOT NULL DEFAULT 'cleartext'," +
|
|
588
589
|
" updatedAt " + t.INT + " NOT NULL" +
|
|
589
590
|
")",
|