@blamejs/core 0.7.4 → 0.7.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/audit.js
CHANGED
|
@@ -1,766 +1,766 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* Audit log — tamper-evident, append-only record of every privileged action.
|
|
4
|
-
*
|
|
5
|
-
* audit_log table is baked into db.js's schema runner — apps cannot opt out.
|
|
6
|
-
* Every row is hash-chained (lib/audit-chain.js); the chain is verified at
|
|
7
|
-
* boot in db.init(); a chain break refuses-to-boot per the compliance stance.
|
|
8
|
-
*
|
|
9
|
-
* Action namespaces:
|
|
10
|
-
* - Framework owns: 'auth.*', 'system.*', 'audit.*', 'consent.*', 'subject.*'
|
|
11
|
-
* - Apps register their own via audit.registerNamespace('orders'), then
|
|
12
|
-
* can record 'orders.created', 'orders.shipped', etc.
|
|
13
|
-
* - Unregistered namespaces are rejected — prevents typos becoming silent
|
|
14
|
-
* unobservable events.
|
|
15
|
-
*
|
|
16
|
-
* Hash chain:
|
|
17
|
-
* - rowHash is computed over the *sealed* form of the row + the nonce.
|
|
18
|
-
* The sealed form is what's stored on disk; verification recomputes
|
|
19
|
-
* directly from disk without unsealing anything (faster + lets auditors
|
|
20
|
-
* verify integrity even without the vault key).
|
|
21
|
-
*
|
|
22
|
-
* Public API:
|
|
23
|
-
* audit.registerNamespace(name)
|
|
24
|
-
* audit.record({ actor, action, resource, outcome, reason, metadata, requestId }) → row
|
|
25
|
-
* audit.query(criteria) → rows [auto-self-logs an 'audit.read' event before returning]
|
|
26
|
-
* audit.verify(opts?) → { ok, rowsVerified, breakAt? }
|
|
27
|
-
* audit.beginTrace() → traceId (32 hex chars)
|
|
28
|
-
*
|
|
29
|
-
* Conventions for `metadata` (apps SHOULD follow these keys for cross-app
|
|
30
|
-
* tooling and RoPA correlation; framework's own subject.* events do):
|
|
31
|
-
* traceId — cross-request correlation; same value across linked events
|
|
32
|
-
* parentEventId — immediate parent event in the causation chain
|
|
33
|
-
* before — state before a change (object), for change events
|
|
34
|
-
* after — state after the change
|
|
35
|
-
* evidenceRef — pointer to evidence (signed PDF hash, ticket URL, etc.)
|
|
36
|
-
* App-defined keys are also welcome; don't shadow these reserved ones.
|
|
37
|
-
*/
|
|
38
|
-
var auditChain = require("./audit-chain");
|
|
39
|
-
var auditSign = require("./audit-sign");
|
|
40
|
-
var chainWriter = require("./chain-writer");
|
|
41
|
-
var cluster = require("./cluster");
|
|
42
|
-
var clusterStorage = require("./cluster-storage");
|
|
43
|
-
var { generateToken } = require("./crypto");
|
|
44
|
-
var cryptoField = require("./crypto-field");
|
|
45
|
-
var handlers = require("./handlers");
|
|
46
|
-
var { boot } = require("./log");
|
|
47
|
-
var safeAsync = require("./safe-async");
|
|
48
|
-
var C = require("./constants");
|
|
49
|
-
var lazyRequire = require("./lazy-require");
|
|
50
|
-
var observability = require("./observability");
|
|
51
|
-
var { ClusterError } = require("./framework-error");
|
|
52
|
-
|
|
53
|
-
var log = boot("audit");
|
|
54
|
-
|
|
55
|
-
// Per-operation timeout for framework-state SQL. A misbehaving
|
|
56
|
-
// external-db driver hanging on a query shouldn't hang audit forever.
|
|
57
|
-
// 30s is generous for genuinely slow networks while still bounding
|
|
58
|
-
// the worst case.
|
|
59
|
-
var FRAMEWORK_SQL_TIMEOUT_MS = C.TIME.seconds(30);
|
|
60
|
-
|
|
61
|
-
// ---- Resilience-wrapped SQL operations (audit-specific reads) ----
|
|
62
|
-
// Chain APPEND lives in chain-writer (race-safe via mutex, retry, timeout).
|
|
63
|
-
// The wrappers below cover audit-specific reads/writes that aren't part
|
|
64
|
-
// of the chain append: checkpoint queries, verifyCheckpoints reads,
|
|
65
|
-
// audit-tip cluster-row updates.
|
|
66
|
-
|
|
67
|
-
async function _readLastCheckpointCounter() {
|
|
68
|
-
return await safeAsync.withTimeout(
|
|
69
|
-
safeAsync.asyncRetry(function () {
|
|
70
|
-
return clusterStorage.executeOne(
|
|
71
|
-
"SELECT atMonotonicCounter FROM audit_checkpoints " +
|
|
72
|
-
"ORDER BY atMonotonicCounter DESC LIMIT 1"
|
|
73
|
-
);
|
|
74
|
-
}),
|
|
75
|
-
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
76
|
-
{ name: "audit.readLastCheckpoint" }
|
|
77
|
-
);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function _readAllAuditRowsAsc() {
|
|
81
|
-
return await safeAsync.withTimeout(
|
|
82
|
-
safeAsync.asyncRetry(function () {
|
|
83
|
-
return clusterStorage.executeAll(
|
|
84
|
-
'SELECT * FROM "audit_log" ORDER BY monotonicCounter ASC'
|
|
85
|
-
);
|
|
86
|
-
}),
|
|
87
|
-
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
88
|
-
{ name: "audit.readAllRowsAsc" }
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
async function _readAllCheckpointsAsc() {
|
|
93
|
-
return await safeAsync.withTimeout(
|
|
94
|
-
safeAsync.asyncRetry(function () {
|
|
95
|
-
return clusterStorage.executeAll(
|
|
96
|
-
"SELECT * FROM audit_checkpoints ORDER BY atMonotonicCounter ASC"
|
|
97
|
-
);
|
|
98
|
-
}),
|
|
99
|
-
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
100
|
-
{ name: "audit.readAllCheckpoints" }
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async function _readAuditRowHashAtCounter(counter) {
|
|
105
|
-
return await safeAsync.withTimeout(
|
|
106
|
-
safeAsync.asyncRetry(function () {
|
|
107
|
-
return clusterStorage.executeOne(
|
|
108
|
-
"SELECT rowHash FROM audit_log WHERE monotonicCounter = ?",
|
|
109
|
-
[counter]
|
|
110
|
-
);
|
|
111
|
-
}),
|
|
112
|
-
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
113
|
-
{ name: "audit.readRowHashAtCounter" }
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function _insertAuditRow(allCols, values) {
|
|
118
|
-
// No retry — non-idempotent. Timeout only.
|
|
119
|
-
var placeholders = allCols.map(function () { return "?"; }).join(", ");
|
|
120
|
-
var quoted = allCols.map(function (c) { return '"' + c + '"'; }).join(", ");
|
|
121
|
-
return await safeAsync.withTimeout(
|
|
122
|
-
clusterStorage.execute(
|
|
123
|
-
"INSERT INTO audit_log (" + quoted + ") VALUES (" + placeholders + ")",
|
|
124
|
-
values
|
|
125
|
-
),
|
|
126
|
-
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
127
|
-
{ name: "audit.insertRow" }
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function _insertCheckpoint(values) {
|
|
132
|
-
return await safeAsync.withTimeout(
|
|
133
|
-
clusterStorage.execute(
|
|
134
|
-
"INSERT INTO audit_checkpoints (_id, createdAt, atMonotonicCounter, atRowHash, signature, publicKeyFingerprint, fencingToken) " +
|
|
135
|
-
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
136
|
-
values
|
|
137
|
-
),
|
|
138
|
-
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
139
|
-
{ name: "audit.insertCheckpoint" }
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function _upsertAuditTip(counter, rowHash, signedAt, fencingToken) {
|
|
144
|
-
// Cluster-mode only. Single atomic INSERT … ON CONFLICT … DO UPDATE
|
|
145
|
-
// … WHERE … RETURNING. The WHERE clause is the canonical
|
|
146
|
-
// fencing-token guard from blamejs-cluster-spec.md — it enforces
|
|
147
|
-
// monotonic-non-decreasing fencingToken at the database level so a
|
|
148
|
-
// partitioned old leader cannot overwrite the tip even if its
|
|
149
|
-
// application-layer cluster.requireLeader() gate somehow allowed
|
|
150
|
-
// the call through.
|
|
151
|
-
//
|
|
152
|
-
// Update accepted iff the row's stored fencingToken <= incoming one
|
|
153
|
-
// (same-token re-write is fine; a strictly-lower token is fenced
|
|
154
|
-
// out). On rejection RETURNING produces 0 rows — we surface that
|
|
155
|
-
// as ClusterError(code='FENCED_OUT', permanent=true) so the
|
|
156
|
-
// dispatching node knows it's been superseded and should step down
|
|
157
|
-
// rather than retry.
|
|
158
|
-
var result = await safeAsync.withTimeout(
|
|
159
|
-
clusterStorage.execute(
|
|
160
|
-
"INSERT INTO _blamejs_audit_tip " +
|
|
161
|
-
" (scope, atMonotonicCounter, rowHash, signedAt, fencingToken) " +
|
|
162
|
-
"VALUES ('audit', ?, ?, ?, ?) " +
|
|
163
|
-
"ON CONFLICT (scope) DO UPDATE SET " +
|
|
164
|
-
" atMonotonicCounter = EXCLUDED.atMonotonicCounter, " +
|
|
165
|
-
" rowHash = EXCLUDED.rowHash, " +
|
|
166
|
-
" signedAt = EXCLUDED.signedAt, " +
|
|
167
|
-
" fencingToken = EXCLUDED.fencingToken " +
|
|
168
|
-
"WHERE _blamejs_audit_tip.fencingToken <= EXCLUDED.fencingToken " +
|
|
169
|
-
"RETURNING fencingToken",
|
|
170
|
-
[counter, rowHash, signedAt, fencingToken]
|
|
171
|
-
),
|
|
172
|
-
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
173
|
-
{ name: "audit.upsertAuditTip" }
|
|
174
|
-
);
|
|
175
|
-
if (!result.rows || result.rows.length === 0) {
|
|
176
|
-
throw new ClusterError(
|
|
177
|
-
"FENCED_OUT",
|
|
178
|
-
"audit-tip update rejected: incoming fencingToken=" + fencingToken +
|
|
179
|
-
" is below the stored token (this leader has been fenced out " +
|
|
180
|
-
"by a higher-token successor)",
|
|
181
|
-
true
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Every namespace any framework primitive emits on must be listed here.
|
|
187
|
-
// A primitive that adds a new namespace adds it here in the same patch.
|
|
188
|
-
// Smoke walks lib/ at boot-time-equivalent (layer-0-primitives/audit-framework-namespaces.test.js)
|
|
189
|
-
// and fails if any emitted namespace is missing from this list.
|
|
190
|
-
//
|
|
191
|
-
// Why this list and not auto-registration via registerNamespace() at
|
|
192
|
-
// each primitive's create(): namespace registration must be effective
|
|
193
|
-
// the first time ANY primitive emits, regardless of which primitive
|
|
194
|
-
// the operator initialized first. Operators wiring just b.scheduler
|
|
195
|
-
// without ever calling b.apiKey.create() still trip the apikey verify
|
|
196
|
-
// path (e.g. through middleware they didn't write); the action-name
|
|
197
|
-
// validation needs to know about apikey at boot, not at first call.
|
|
198
|
-
var FRAMEWORK_NAMESPACES = [
|
|
199
|
-
// Generic buckets
|
|
200
|
-
"auth", "system", "audit", "consent", "subject",
|
|
201
|
-
// Per-primitive namespaces — keep alphabetical
|
|
202
|
-
"apikey", // b.apiKey
|
|
203
|
-
"backup", // b.backup
|
|
204
|
-
"breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
|
|
205
|
-
"cache", // b.cache
|
|
206
|
-
"config", // b.configDrift (config.baseline.captured / config.drift.detected / config.baseline.tamper / config.baseline.unreadable)
|
|
207
|
-
"db", // b.db / b.middleware.dbRoleFor / b.externalDb.runAs
|
|
208
|
-
// (role-switching, RLS-shaped events)
|
|
209
|
-
"dkim", // b.mail.dkim (DKIM-Signature generation events)
|
|
210
|
-
"dual", // b.dualControl (dual.grant.requested / approved / denied / consumed / expired / self_approval_denied)
|
|
211
|
-
"mail", // b.mail (b.mail-bounce uses "system.mail.*")
|
|
212
|
-
"network", // b.middleware.networkAllowlist (network.gate.denied)
|
|
213
|
-
"notify", // b.notify
|
|
214
|
-
"objectstore", // b.objectStore.bucketOps (objectstore.bucket.* / objectstore.object.*)
|
|
215
|
-
"permissions", // b.permissions
|
|
216
|
-
"restore", // b.restore
|
|
217
|
-
"retention", // b.retention (retention.rule.declared / sweep.started / row.processed / sweep.completed / sweep.failed)
|
|
218
|
-
"scheduler", // b.scheduler (lifecycle: scheduler.start / scheduler.stop;
|
|
219
|
-
// tick/task events use "system.scheduler.*")
|
|
220
|
-
"seeders", // b.seeders
|
|
221
|
-
"webhook", // b.webhook
|
|
222
|
-
];
|
|
223
|
-
var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
224
|
-
|
|
225
|
-
// All hashable columns of audit_log (everything in the table except the chain
|
|
226
|
-
// bookkeeping itself). This list MUST match what's actually written by INSERT
|
|
227
|
-
// and what's read back by verify; the canonicalizer needs the same key set
|
|
228
|
-
// at both ends or the hash will mismatch on missing-vs-null keys.
|
|
229
|
-
var HASHABLE_COLS = [
|
|
230
|
-
"_id", "recordedAt", "monotonicCounter",
|
|
231
|
-
"actorUserId", "actorUserIdHash",
|
|
232
|
-
"actorIp",
|
|
233
|
-
"actorUserAgent", "actorSessionId",
|
|
234
|
-
"action", "resourceKind",
|
|
235
|
-
"resourceId", "resourceIdHash",
|
|
236
|
-
"outcome", "reason", "metadata", "requestId",
|
|
237
|
-
];
|
|
238
|
-
|
|
239
|
-
// Lazy db ref — avoids circular require (db -> audit -> db on init paths).
|
|
240
|
-
var db = lazyRequire(function () { return require("./db"); });
|
|
241
|
-
|
|
242
|
-
// Chain-writer instance owns the race-safe chain append: counter primer,
|
|
243
|
-
// chain mutex, prev-tip read, hash compute, INSERT. Per the framework
|
|
244
|
-
// rule that repeated tasks become primitives, the audit_log and
|
|
245
|
-
// consent_log chains both consume chain-writer.
|
|
246
|
-
var _chainWriter = chainWriter.create({
|
|
247
|
-
table: "audit_log",
|
|
248
|
-
hashableColumns: HASHABLE_COLS,
|
|
249
|
-
columnsForInsert: [
|
|
250
|
-
"_id", "recordedAt", "monotonicCounter",
|
|
251
|
-
"actorUserId", "actorUserIdHash",
|
|
252
|
-
"actorIp",
|
|
253
|
-
"actorUserAgent", "actorSessionId",
|
|
254
|
-
"action", "resourceKind",
|
|
255
|
-
"resourceId", "resourceIdHash",
|
|
256
|
-
"outcome", "reason", "metadata", "requestId",
|
|
257
|
-
"prevHash", "rowHash", "nonce", "fencingToken",
|
|
258
|
-
],
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
// ---- Public API ----
|
|
262
|
-
|
|
263
|
-
function registerNamespace(name) {
|
|
264
|
-
if (typeof name !== "string" || !/^[a-z][a-z0-9_]*$/.test(name)) {
|
|
265
|
-
throw new Error("audit namespace must match [a-z][a-z0-9_]* — got: " + name);
|
|
266
|
-
}
|
|
267
|
-
if (FRAMEWORK_NAMESPACES.indexOf(name) !== -1) return;
|
|
268
|
-
registeredNamespaces.add(name);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function _validateAction(action) {
|
|
272
|
-
if (typeof action !== "string" || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(action)) {
|
|
273
|
-
throw new Error(
|
|
274
|
-
"audit action must be 'namespace.verb[.qualifier...]' (lowercase, dot-separated) — got: " + action
|
|
275
|
-
);
|
|
276
|
-
}
|
|
277
|
-
var ns = action.split(".")[0];
|
|
278
|
-
if (!registeredNamespaces.has(ns)) {
|
|
279
|
-
throw new Error(
|
|
280
|
-
"audit namespace '" + ns + "' is not registered. " +
|
|
281
|
-
"Call audit.registerNamespace('" + ns + "') at app bootstrap before recording '" + action + "'."
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
async function record(event) {
|
|
287
|
-
if (!event || typeof event !== "object") {
|
|
288
|
-
throw new Error("audit.record requires an event object");
|
|
289
|
-
}
|
|
290
|
-
_validateAction(event.action);
|
|
291
|
-
if (!event.outcome || ["success", "failure", "denied"].indexOf(event.outcome) === -1) {
|
|
292
|
-
throw new Error("audit.record outcome must be 'success', 'failure', or 'denied'");
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
return observability.tap("audit.record",
|
|
296
|
-
{ action: event.action, outcome: event.outcome },
|
|
297
|
-
async function () {
|
|
298
|
-
// Build the audit-specific logical row; chain-writer handles _id /
|
|
299
|
-
// recordedAt / monotonicCounter / sealing / null-fill / hashing /
|
|
300
|
-
// insert / fencing-token / chain mutex / counter primer.
|
|
301
|
-
var actor = event.actor || {};
|
|
302
|
-
var resource = event.resource || {};
|
|
303
|
-
var logical = {
|
|
304
|
-
actorUserId: actor.userId || null,
|
|
305
|
-
actorIp: actor.ip || null,
|
|
306
|
-
actorUserAgent: actor.userAgent || null,
|
|
307
|
-
actorSessionId: actor.sessionId || null,
|
|
308
|
-
action: event.action,
|
|
309
|
-
resourceKind: resource.kind || null,
|
|
310
|
-
resourceId: resource.id || null,
|
|
311
|
-
outcome: event.outcome,
|
|
312
|
-
reason: event.reason || null,
|
|
313
|
-
metadata: event.metadata ? JSON.stringify(event.metadata) : null,
|
|
314
|
-
requestId: event.requestId || null,
|
|
315
|
-
};
|
|
316
|
-
return _chainWriter.append(logical);
|
|
317
|
-
}
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// ---- Query ----
|
|
322
|
-
//
|
|
323
|
-
// Plain-field criteria translate into derived-hash equality where the column
|
|
324
|
-
// is sealed. Returns unsealed rows for the auditor's view.
|
|
325
|
-
//
|
|
326
|
-
// Self-logging (PCI DSS 10.2.3): every read of audit_log is itself recorded
|
|
327
|
-
// as an 'audit.read' event before the query runs, so an exfiltration attempt
|
|
328
|
-
// is forensically visible. The recursion guard (_selfLogging flag) prevents
|
|
329
|
-
// the audit.read recording from triggering its own self-log; queries
|
|
330
|
-
// SPECIFICALLY filtering for action='audit.read' don't auto-log either
|
|
331
|
-
// (otherwise legitimate audit auditing produces a Russell-set spiral).
|
|
332
|
-
var _selfLogging = false;
|
|
333
|
-
|
|
334
|
-
async function query(criteria) {
|
|
335
|
-
criteria = criteria || {};
|
|
336
|
-
if (!_selfLogging && criteria.action !== "audit.read") {
|
|
337
|
-
_selfLogging = true;
|
|
338
|
-
try {
|
|
339
|
-
await record({
|
|
340
|
-
actor: criteria.actor || {},
|
|
341
|
-
action: "audit.read",
|
|
342
|
-
outcome: "success",
|
|
343
|
-
metadata: {
|
|
344
|
-
criteria: _redactCriteria(criteria),
|
|
345
|
-
traceId: criteria.traceId || null,
|
|
346
|
-
},
|
|
347
|
-
});
|
|
348
|
-
} finally {
|
|
349
|
-
_selfLogging = false;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// In single-node mode the query builder gives us field-crypto unsealing
|
|
354
|
-
// for free. In cluster mode we read raw rows from external-db and
|
|
355
|
-
// unseal manually.
|
|
356
|
-
if (cluster.isClusterMode()) {
|
|
357
|
-
return await _queryCluster(criteria);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
var q = db().from("audit_log");
|
|
361
|
-
|
|
362
|
-
if (criteria.from) q = q.where("recordedAt", ">=", _toMs(criteria.from));
|
|
363
|
-
if (criteria.to) q = q.where("recordedAt", "<=", _toMs(criteria.to));
|
|
364
|
-
if (criteria.actorUserId) q = q.where({ actorUserId: criteria.actorUserId });
|
|
365
|
-
if (criteria.resourceId) q = q.where({ resourceId: criteria.resourceId });
|
|
366
|
-
if (criteria.action) q = q.where({ action: criteria.action });
|
|
367
|
-
if (criteria.resourceKind) q = q.where({ resourceKind: criteria.resourceKind });
|
|
368
|
-
if (criteria.outcome) q = q.where({ outcome: criteria.outcome });
|
|
369
|
-
|
|
370
|
-
q.orderBy("monotonicCounter", "asc");
|
|
371
|
-
if (criteria.limit != null) q.limit(criteria.limit);
|
|
372
|
-
if (criteria.offset != null) q.offset(criteria.offset);
|
|
373
|
-
|
|
374
|
-
return q.all();
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
async function _queryCluster(criteria) {
|
|
378
|
-
var conds = [];
|
|
379
|
-
var params = [];
|
|
380
|
-
if (criteria.from) {
|
|
381
|
-
conds.push("recordedAt >= ?");
|
|
382
|
-
params.push(_toMs(criteria.from));
|
|
383
|
-
}
|
|
384
|
-
if (criteria.to) {
|
|
385
|
-
conds.push("recordedAt <= ?");
|
|
386
|
-
params.push(_toMs(criteria.to));
|
|
387
|
-
}
|
|
388
|
-
if (criteria.actorUserId) {
|
|
389
|
-
var auh = cryptoField.lookupHash("audit_log", "actorUserId", criteria.actorUserId);
|
|
390
|
-
if (auh) { conds.push(auh.field + " = ?"); params.push(auh.value); }
|
|
391
|
-
}
|
|
392
|
-
if (criteria.resourceId) {
|
|
393
|
-
var rh = cryptoField.lookupHash("audit_log", "resourceId", criteria.resourceId);
|
|
394
|
-
if (rh) { conds.push(rh.field + " = ?"); params.push(rh.value); }
|
|
395
|
-
}
|
|
396
|
-
if (criteria.action) { conds.push("action = ?"); params.push(criteria.action); }
|
|
397
|
-
if (criteria.resourceKind) { conds.push("resourceKind = ?"); params.push(criteria.resourceKind); }
|
|
398
|
-
if (criteria.outcome) { conds.push("outcome = ?"); params.push(criteria.outcome); }
|
|
399
|
-
|
|
400
|
-
var sql = "SELECT * FROM audit_log";
|
|
401
|
-
if (conds.length > 0) sql += " WHERE " + conds.join(" AND ");
|
|
402
|
-
sql += " ORDER BY monotonicCounter ASC";
|
|
403
|
-
if (criteria.limit != null) { sql += " LIMIT ?"; params.push(criteria.limit); }
|
|
404
|
-
if (criteria.offset != null) { sql += " OFFSET ?"; params.push(criteria.offset); }
|
|
405
|
-
|
|
406
|
-
var rows = await clusterStorage.executeAll(sql, params);
|
|
407
|
-
return rows.map(function (row) { return cryptoField.unsealRow("audit_log", row); });
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Audit-readable summary of the criteria without storing raw subject IDs
|
|
411
|
-
// in plaintext anywhere outside the sealed columns of audit_log itself.
|
|
412
|
-
function _redactCriteria(c) {
|
|
413
|
-
return {
|
|
414
|
-
from: c.from || null,
|
|
415
|
-
to: c.to || null,
|
|
416
|
-
action: c.action || null,
|
|
417
|
-
resourceKind: c.resourceKind || null,
|
|
418
|
-
outcome: c.outcome || null,
|
|
419
|
-
hasUserFilter: !!c.actorUserId,
|
|
420
|
-
hasResourceFilter: !!c.resourceId,
|
|
421
|
-
limit: c.limit != null ? c.limit : null,
|
|
422
|
-
offset: c.offset != null ? c.offset : null,
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Generate a fresh trace id apps can thread through their request handlers
|
|
427
|
-
// and pass into audit.record() / consent.grant() / etc. via the metadata
|
|
428
|
-
// field. Width matches the W3C traceparent trace-id format (16 random
|
|
429
|
-
// bytes hex-encoded → 32 chars). Routed through C.BYTES so the byte
|
|
430
|
-
// count has a single source of truth.
|
|
431
|
-
var TRACE_ID_BYTES = C.BYTES.bytes(16);
|
|
432
|
-
function beginTrace() {
|
|
433
|
-
return generateToken(TRACE_ID_BYTES);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// ---- Checkpoints (tamper-proof external anchor) ----
|
|
437
|
-
|
|
438
|
-
// Build the canonical bytes that get signed for a checkpoint at a given
|
|
439
|
-
// chain tip. Keep this format stable across the framework's lifetime —
|
|
440
|
-
// changing it invalidates every prior checkpoint signature.
|
|
441
|
-
var CHECKPOINT_FORMAT = "blamejs-audit-checkpoint-v1";
|
|
442
|
-
function _checkpointPayload(atMonotonicCounter, atRowHash, createdAt) {
|
|
443
|
-
// Use a fixed multi-line layout. Avoids JSON serializer quirks; portable
|
|
444
|
-
// to any verifier reading the same column triple from the DB.
|
|
445
|
-
return Buffer.from(
|
|
446
|
-
CHECKPOINT_FORMAT + "\n" +
|
|
447
|
-
String(atMonotonicCounter) + "\n" +
|
|
448
|
-
atRowHash + "\n" +
|
|
449
|
-
String(createdAt),
|
|
450
|
-
"utf8"
|
|
451
|
-
);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
// Anchor the current chain tip with a fresh ML-DSA-87 signature. Inserts
|
|
455
|
-
// a row into audit_checkpoints. Updates <dataDir>/audit.tip for boot-time
|
|
456
|
-
// rollback detection.
|
|
457
|
-
//
|
|
458
|
-
// opts:
|
|
459
|
-
// skipIfUnchanged: bool — return null without inserting if the chain tip
|
|
460
|
-
// hasn't advanced since the most recent checkpoint
|
|
461
|
-
async function checkpoint(opts) {
|
|
462
|
-
cluster.requireLeader();
|
|
463
|
-
opts = opts || {};
|
|
464
|
-
|
|
465
|
-
var tip = await safeAsync.withTimeout(
|
|
466
|
-
safeAsync.asyncRetry(function () {
|
|
467
|
-
return clusterStorage.executeOne(
|
|
468
|
-
"SELECT _id, monotonicCounter, rowHash FROM audit_log " +
|
|
469
|
-
"ORDER BY monotonicCounter DESC LIMIT 1"
|
|
470
|
-
);
|
|
471
|
-
}),
|
|
472
|
-
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
473
|
-
{ name: "audit.checkpoint.readTip" }
|
|
474
|
-
);
|
|
475
|
-
|
|
476
|
-
if (!tip) return null; // empty audit log; nothing to anchor
|
|
477
|
-
|
|
478
|
-
if (opts.skipIfUnchanged) {
|
|
479
|
-
var lastCkpt = await _readLastCheckpointCounter();
|
|
480
|
-
if (lastCkpt && Number(lastCkpt.atMonotonicCounter) >= Number(tip.monotonicCounter)) {
|
|
481
|
-
return null; // already anchored at this tip
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
var createdAt = Date.now();
|
|
486
|
-
var counter = Number(tip.monotonicCounter);
|
|
487
|
-
var payload = _checkpointPayload(counter, tip.rowHash, createdAt);
|
|
488
|
-
var signature = auditSign.sign(payload);
|
|
489
|
-
var pubFp = auditSign.getPublicKeyFingerprint();
|
|
490
|
-
|
|
491
|
-
var ckptId = generateToken(TRACE_ID_BYTES);
|
|
492
|
-
var fencingToken = cluster.fencingToken();
|
|
493
|
-
await _insertCheckpoint(
|
|
494
|
-
[ckptId, createdAt, counter, tip.rowHash, signature, pubFp, fencingToken]
|
|
495
|
-
);
|
|
496
|
-
|
|
497
|
-
// Update rollback-detection sidecar (single-node) or audit-tip row
|
|
498
|
-
// (cluster mode).
|
|
499
|
-
//
|
|
500
|
-
// Single-node sidecar is best-effort — a sidecar write failure must
|
|
501
|
-
// not block checkpointing because the chain itself is already
|
|
502
|
-
// committed.
|
|
503
|
-
//
|
|
504
|
-
// Cluster-mode audit-tip is NOT best-effort: the upsert's WHERE
|
|
505
|
-
// clause is the fencing-token guard, and a FENCED_OUT response
|
|
506
|
-
// means the local node has been superseded by a newer leader. The
|
|
507
|
-
// checkpoint row was already inserted at this point but propagating
|
|
508
|
-
// the error up makes the leadership-loss visible to the caller — it
|
|
509
|
-
// also means the caller can audit the leader-lost transition and
|
|
510
|
-
// step down. Other audit-tip errors (network blip, transient DB)
|
|
511
|
-
// also surface so the operator can react.
|
|
512
|
-
if (cluster.isClusterMode()) {
|
|
513
|
-
await _upsertAuditTip(counter, tip.rowHash, String(createdAt), fencingToken);
|
|
514
|
-
} else {
|
|
515
|
-
try {
|
|
516
|
-
db()._writeAuditTip({
|
|
517
|
-
atMonotonicCounter: counter,
|
|
518
|
-
atRowHash: tip.rowHash,
|
|
519
|
-
anchoredAt: createdAt,
|
|
520
|
-
checkpointId: ckptId,
|
|
521
|
-
publicKeyFingerprint: pubFp,
|
|
522
|
-
version: 1,
|
|
523
|
-
});
|
|
524
|
-
} catch (_e) { /* best effort */ }
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
return {
|
|
528
|
-
_id: ckptId,
|
|
529
|
-
createdAt: createdAt,
|
|
530
|
-
atMonotonicCounter: counter,
|
|
531
|
-
atRowHash: tip.rowHash,
|
|
532
|
-
publicKeyFingerprint: pubFp,
|
|
533
|
-
};
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// Walk every checkpoint, verify its signature against the current public
|
|
537
|
-
// key (or one matching the row's stored fingerprint). Also confirms the
|
|
538
|
-
// audit_log row at atMonotonicCounter still has the recorded rowHash.
|
|
539
|
-
//
|
|
540
|
-
// Returns { ok, checkpointsVerified, breakAt? }.
|
|
541
|
-
async function verifyCheckpoints() {
|
|
542
|
-
var rows = await _readAllCheckpointsAsc();
|
|
543
|
-
|
|
544
|
-
if (rows.length === 0) return { ok: true, checkpointsVerified: 0 };
|
|
545
|
-
|
|
546
|
-
var currentFp = auditSign.getPublicKeyFingerprint();
|
|
547
|
-
var currentPub = auditSign.getPublicKey();
|
|
548
|
-
|
|
549
|
-
for (var i = 0; i < rows.length; i++) {
|
|
550
|
-
var c = rows[i];
|
|
551
|
-
// Public key check: only the current key is accepted — there is no
|
|
552
|
-
// key-history table, so any rotation requires re-signing existing
|
|
553
|
-
// checkpoints. A fingerprint mismatch fails verification.
|
|
554
|
-
if (c.publicKeyFingerprint !== currentFp) {
|
|
555
|
-
return {
|
|
556
|
-
ok: false,
|
|
557
|
-
checkpointsVerified: i,
|
|
558
|
-
breakAt: i,
|
|
559
|
-
checkpointId: c._id,
|
|
560
|
-
reason: "public key fingerprint mismatch (key rotated without history?)",
|
|
561
|
-
expected: currentFp,
|
|
562
|
-
actual: c.publicKeyFingerprint,
|
|
563
|
-
};
|
|
564
|
-
}
|
|
565
|
-
var payload = _checkpointPayload(Number(c.atMonotonicCounter), c.atRowHash, Number(c.createdAt));
|
|
566
|
-
var sigBuf = Buffer.isBuffer(c.signature) ? c.signature : Buffer.from(c.signature);
|
|
567
|
-
if (!auditSign.verify(payload, sigBuf, currentPub)) {
|
|
568
|
-
return {
|
|
569
|
-
ok: false,
|
|
570
|
-
checkpointsVerified: i,
|
|
571
|
-
breakAt: i,
|
|
572
|
-
checkpointId: c._id,
|
|
573
|
-
reason: "ML-DSA-87 signature failed",
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
// Also confirm the audit row at atMonotonicCounter still matches the
|
|
577
|
-
// anchored rowHash. If someone tampered with audit_log AND recomputed
|
|
578
|
-
// hashes (requiring vault key), this catches them via the off-chain
|
|
579
|
-
// signature anchor.
|
|
580
|
-
var anchored = await _readAuditRowHashAtCounter(c.atMonotonicCounter);
|
|
581
|
-
if (!anchored) {
|
|
582
|
-
return {
|
|
583
|
-
ok: false,
|
|
584
|
-
checkpointsVerified: i,
|
|
585
|
-
breakAt: i,
|
|
586
|
-
checkpointId: c._id,
|
|
587
|
-
reason: "anchored audit_log row missing (counter=" + c.atMonotonicCounter + ")",
|
|
588
|
-
};
|
|
589
|
-
}
|
|
590
|
-
if (anchored.rowHash !== c.atRowHash) {
|
|
591
|
-
return {
|
|
592
|
-
ok: false,
|
|
593
|
-
checkpointsVerified: i,
|
|
594
|
-
breakAt: i,
|
|
595
|
-
checkpointId: c._id,
|
|
596
|
-
reason: "anchored rowHash mismatch — audit_log was tampered with",
|
|
597
|
-
expected: c.atRowHash,
|
|
598
|
-
actual: anchored.rowHash,
|
|
599
|
-
};
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
return { ok: true, checkpointsVerified: rows.length };
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function _toMs(value) {
|
|
606
|
-
if (typeof value === "number") return value;
|
|
607
|
-
if (value instanceof Date) return value.getTime();
|
|
608
|
-
if (typeof value === "string") {
|
|
609
|
-
var ms = Date.parse(value);
|
|
610
|
-
if (isNaN(ms)) throw new Error("invalid date: " + value);
|
|
611
|
-
return ms;
|
|
612
|
-
}
|
|
613
|
-
throw new Error("invalid date value");
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// ---- Verify ----
|
|
617
|
-
|
|
618
|
-
async function verify(opts) {
|
|
619
|
-
// verifyChain just needs an executeAll; route through the same
|
|
620
|
-
// resilience-wrapped reader the rest of audit uses.
|
|
621
|
-
return await auditChain.verifyChain(
|
|
622
|
-
function (sql, params) {
|
|
623
|
-
return safeAsync.withTimeout(
|
|
624
|
-
safeAsync.asyncRetry(function () {
|
|
625
|
-
return clusterStorage.executeAll(sql, params || []);
|
|
626
|
-
}),
|
|
627
|
-
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
628
|
-
{ name: "audit.verifyChain" }
|
|
629
|
-
);
|
|
630
|
-
},
|
|
631
|
-
"audit_log",
|
|
632
|
-
opts
|
|
633
|
-
);
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
// ---- Test helpers ----
|
|
637
|
-
|
|
638
|
-
function _resetForTest() {
|
|
639
|
-
registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
640
|
-
db.reset();
|
|
641
|
-
_chainWriter._resetForTest();
|
|
642
|
-
// Drop pending buffered emits and cancel the age-flush timer on the
|
|
643
|
-
// old handler before dereferencing it. Without this, the old
|
|
644
|
-
// handler's setTimeout (scheduled when emits buffer below maxBatch)
|
|
645
|
-
// fires AFTER the next test's db.init has opened a fresh database —
|
|
646
|
-
// the buffered items then drain through chain-writer into the wrong
|
|
647
|
-
// tmpDir's audit_log, breaking chain verify on the next-next test.
|
|
648
|
-
// shutdownSync is the explicit "drop, don't drain" path because
|
|
649
|
-
// draining to a stale or changing backing store is exactly the bug.
|
|
650
|
-
// The handler's flush also checks ctx.isShutdown() between items, so
|
|
651
|
-
// an in-flight drain that's mid-batch when reset fires bails out
|
|
652
|
-
// instead of writing the rest of the batch to the new database.
|
|
653
|
-
if (_auditHandler) {
|
|
654
|
-
try { _auditHandler.shutdownSync("audit._resetForTest"); }
|
|
655
|
-
catch (e) { log.debug("reset-handler-shutdown-failed: " + (e && e.message || e)); }
|
|
656
|
-
_auditHandler = null;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// ---- Handler-backed emit + flush ----
|
|
661
|
-
//
|
|
662
|
-
// emit() is the call-site API for fire-and-forget audit emission from
|
|
663
|
-
// middleware / log-stream / external-db hooks / queue / storage / subject.
|
|
664
|
-
// It is SYNCHRONOUS, NEVER throws, and NEVER returns a Promise — request-
|
|
665
|
-
// path code can call it without await and without try/catch.
|
|
666
|
-
//
|
|
667
|
-
// Internally events queue in an AsyncHandler. flush() drains the queue
|
|
668
|
-
// to the audit chain (single writer in-process, serialized via the
|
|
669
|
-
// chain mutex). Tests, shutdown, and any code that needs audit-row
|
|
670
|
-
// durability before reading audit_log calls await audit.flush().
|
|
671
|
-
//
|
|
672
|
-
// Why this beats fire-and-forget Promises:
|
|
673
|
-
// - No leaked Promises across test/shutdown boundaries
|
|
674
|
-
// - Errors go through a single onError hook (visible to operators)
|
|
675
|
-
// - Recursive emits during flush land in the buffer for the next
|
|
676
|
-
// drain cycle — no infinite loop in cluster-mode dispatchers
|
|
677
|
-
// - Tests have a deterministic "audit is durable now" point
|
|
678
|
-
var _auditHandler = null;
|
|
679
|
-
|
|
680
|
-
function _ensureHandler() {
|
|
681
|
-
if (_auditHandler) return _auditHandler;
|
|
682
|
-
_auditHandler = handlers.create({
|
|
683
|
-
name: "audit",
|
|
684
|
-
flush: async function (batch, ctx) {
|
|
685
|
-
// Drain by serially writing each event through record(). The chain
|
|
686
|
-
// mutex inside record() further serializes vs concurrent direct
|
|
687
|
-
// record() callers.
|
|
688
|
-
//
|
|
689
|
-
// Between items, check the handler's shutdown probe — if a test
|
|
690
|
-
// (or operator) reset audit while this batch was in flight, the
|
|
691
|
-
// remaining items would otherwise drain through the chain-writer
|
|
692
|
-
// into a database that no longer represents the chain those
|
|
693
|
-
// items were emitted against. Early-exit drops them; the
|
|
694
|
-
// alternative is silent corruption of the next chain.
|
|
695
|
-
for (var i = 0; i < batch.length; i++) {
|
|
696
|
-
if (ctx && ctx.isShutdown && ctx.isShutdown()) return;
|
|
697
|
-
try { await record(batch[i]); }
|
|
698
|
-
catch (e) {
|
|
699
|
-
// Per-item failure shouldn't drop the whole batch; log and
|
|
700
|
-
// continue. The handler's onError gets called for batch-
|
|
701
|
-
// wide failures only.
|
|
702
|
-
log.error("flush dropped event: " +
|
|
703
|
-
(e && e.message ? e.message : String(e)) +
|
|
704
|
-
" (action=" + (batch[i] && batch[i].action) + ")");
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
},
|
|
708
|
-
});
|
|
709
|
-
return _auditHandler;
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
function emit(event) {
|
|
713
|
-
_ensureHandler().emit(event);
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// safeEmit — fire-and-forget audit emit with safe defaults + try/catch.
|
|
717
|
-
//
|
|
718
|
-
// Most modules wrap emit() in their own _emit helper that fills in
|
|
719
|
-
// `actor: {}`, `outcome: "success"`, etc. and catches the throw so an
|
|
720
|
-
// audit outage doesn't crash the request handler. This is that helper,
|
|
721
|
-
// hoisted out so each module can stop redefining it.
|
|
722
|
-
//
|
|
723
|
-
// Drop-silent on malformed input by design. safeEmit is called from
|
|
724
|
-
// request hot paths where throwing on a
|
|
725
|
-
// missing `action` would mean a malformed audit attempt crashes the
|
|
726
|
-
// request that triggered it — strictly worse than the missing audit
|
|
727
|
-
// row. Operators who need a hard guarantee the event landed should call
|
|
728
|
-
// record() and await it with their own error handling. The audit chain
|
|
729
|
-
// itself is verified at boot, so a silently dropped row shows up in the
|
|
730
|
-
// next chain integrity sweep.
|
|
731
|
-
function safeEmit(event) {
|
|
732
|
-
if (!event || typeof event !== "object") return;
|
|
733
|
-
if (typeof event.action !== "string") return; // can't emit without an action
|
|
734
|
-
try {
|
|
735
|
-
_ensureHandler().emit({
|
|
736
|
-
actor: event.actor || {},
|
|
737
|
-
action: event.action,
|
|
738
|
-
resource: event.resource || null,
|
|
739
|
-
outcome: event.outcome || "success",
|
|
740
|
-
reason: event.reason || null,
|
|
741
|
-
metadata: event.metadata || null,
|
|
742
|
-
requestId: event.requestId || null,
|
|
743
|
-
});
|
|
744
|
-
} catch (_e) { /* audit best-effort — never break the caller */ }
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
async function flush() {
|
|
748
|
-
if (!_auditHandler) return;
|
|
749
|
-
await _auditHandler.drain();
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
module.exports = {
|
|
753
|
-
registerNamespace: registerNamespace,
|
|
754
|
-
record: record,
|
|
755
|
-
emit: emit,
|
|
756
|
-
safeEmit: safeEmit,
|
|
757
|
-
flush: flush,
|
|
758
|
-
query: query,
|
|
759
|
-
verify: verify,
|
|
760
|
-
beginTrace: beginTrace,
|
|
761
|
-
checkpoint: checkpoint,
|
|
762
|
-
verifyCheckpoints: verifyCheckpoints,
|
|
763
|
-
CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
|
|
764
|
-
FRAMEWORK_NAMESPACES: FRAMEWORK_NAMESPACES,
|
|
765
|
-
_resetForTest: _resetForTest,
|
|
766
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Audit log — tamper-evident, append-only record of every privileged action.
|
|
4
|
+
*
|
|
5
|
+
* audit_log table is baked into db.js's schema runner — apps cannot opt out.
|
|
6
|
+
* Every row is hash-chained (lib/audit-chain.js); the chain is verified at
|
|
7
|
+
* boot in db.init(); a chain break refuses-to-boot per the compliance stance.
|
|
8
|
+
*
|
|
9
|
+
* Action namespaces:
|
|
10
|
+
* - Framework owns: 'auth.*', 'system.*', 'audit.*', 'consent.*', 'subject.*'
|
|
11
|
+
* - Apps register their own via audit.registerNamespace('orders'), then
|
|
12
|
+
* can record 'orders.created', 'orders.shipped', etc.
|
|
13
|
+
* - Unregistered namespaces are rejected — prevents typos becoming silent
|
|
14
|
+
* unobservable events.
|
|
15
|
+
*
|
|
16
|
+
* Hash chain:
|
|
17
|
+
* - rowHash is computed over the *sealed* form of the row + the nonce.
|
|
18
|
+
* The sealed form is what's stored on disk; verification recomputes
|
|
19
|
+
* directly from disk without unsealing anything (faster + lets auditors
|
|
20
|
+
* verify integrity even without the vault key).
|
|
21
|
+
*
|
|
22
|
+
* Public API:
|
|
23
|
+
* audit.registerNamespace(name)
|
|
24
|
+
* audit.record({ actor, action, resource, outcome, reason, metadata, requestId }) → row
|
|
25
|
+
* audit.query(criteria) → rows [auto-self-logs an 'audit.read' event before returning]
|
|
26
|
+
* audit.verify(opts?) → { ok, rowsVerified, breakAt? }
|
|
27
|
+
* audit.beginTrace() → traceId (32 hex chars)
|
|
28
|
+
*
|
|
29
|
+
* Conventions for `metadata` (apps SHOULD follow these keys for cross-app
|
|
30
|
+
* tooling and RoPA correlation; framework's own subject.* events do):
|
|
31
|
+
* traceId — cross-request correlation; same value across linked events
|
|
32
|
+
* parentEventId — immediate parent event in the causation chain
|
|
33
|
+
* before — state before a change (object), for change events
|
|
34
|
+
* after — state after the change
|
|
35
|
+
* evidenceRef — pointer to evidence (signed PDF hash, ticket URL, etc.)
|
|
36
|
+
* App-defined keys are also welcome; don't shadow these reserved ones.
|
|
37
|
+
*/
|
|
38
|
+
var auditChain = require("./audit-chain");
|
|
39
|
+
var auditSign = require("./audit-sign");
|
|
40
|
+
var chainWriter = require("./chain-writer");
|
|
41
|
+
var cluster = require("./cluster");
|
|
42
|
+
var clusterStorage = require("./cluster-storage");
|
|
43
|
+
var { generateToken } = require("./crypto");
|
|
44
|
+
var cryptoField = require("./crypto-field");
|
|
45
|
+
var handlers = require("./handlers");
|
|
46
|
+
var { boot } = require("./log");
|
|
47
|
+
var safeAsync = require("./safe-async");
|
|
48
|
+
var C = require("./constants");
|
|
49
|
+
var lazyRequire = require("./lazy-require");
|
|
50
|
+
var observability = require("./observability");
|
|
51
|
+
var { ClusterError } = require("./framework-error");
|
|
52
|
+
|
|
53
|
+
var log = boot("audit");
|
|
54
|
+
|
|
55
|
+
// Per-operation timeout for framework-state SQL. A misbehaving
|
|
56
|
+
// external-db driver hanging on a query shouldn't hang audit forever.
|
|
57
|
+
// 30s is generous for genuinely slow networks while still bounding
|
|
58
|
+
// the worst case.
|
|
59
|
+
var FRAMEWORK_SQL_TIMEOUT_MS = C.TIME.seconds(30);
|
|
60
|
+
|
|
61
|
+
// ---- Resilience-wrapped SQL operations (audit-specific reads) ----
|
|
62
|
+
// Chain APPEND lives in chain-writer (race-safe via mutex, retry, timeout).
|
|
63
|
+
// The wrappers below cover audit-specific reads/writes that aren't part
|
|
64
|
+
// of the chain append: checkpoint queries, verifyCheckpoints reads,
|
|
65
|
+
// audit-tip cluster-row updates.
|
|
66
|
+
|
|
67
|
+
async function _readLastCheckpointCounter() {
|
|
68
|
+
return await safeAsync.withTimeout(
|
|
69
|
+
safeAsync.asyncRetry(function () {
|
|
70
|
+
return clusterStorage.executeOne(
|
|
71
|
+
"SELECT atMonotonicCounter FROM audit_checkpoints " +
|
|
72
|
+
"ORDER BY atMonotonicCounter DESC LIMIT 1"
|
|
73
|
+
);
|
|
74
|
+
}),
|
|
75
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
76
|
+
{ name: "audit.readLastCheckpoint" }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function _readAllAuditRowsAsc() {
|
|
81
|
+
return await safeAsync.withTimeout(
|
|
82
|
+
safeAsync.asyncRetry(function () {
|
|
83
|
+
return clusterStorage.executeAll(
|
|
84
|
+
'SELECT * FROM "audit_log" ORDER BY monotonicCounter ASC'
|
|
85
|
+
);
|
|
86
|
+
}),
|
|
87
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
88
|
+
{ name: "audit.readAllRowsAsc" }
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function _readAllCheckpointsAsc() {
|
|
93
|
+
return await safeAsync.withTimeout(
|
|
94
|
+
safeAsync.asyncRetry(function () {
|
|
95
|
+
return clusterStorage.executeAll(
|
|
96
|
+
"SELECT * FROM audit_checkpoints ORDER BY atMonotonicCounter ASC"
|
|
97
|
+
);
|
|
98
|
+
}),
|
|
99
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
100
|
+
{ name: "audit.readAllCheckpoints" }
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function _readAuditRowHashAtCounter(counter) {
|
|
105
|
+
return await safeAsync.withTimeout(
|
|
106
|
+
safeAsync.asyncRetry(function () {
|
|
107
|
+
return clusterStorage.executeOne(
|
|
108
|
+
"SELECT rowHash FROM audit_log WHERE monotonicCounter = ?",
|
|
109
|
+
[counter]
|
|
110
|
+
);
|
|
111
|
+
}),
|
|
112
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
113
|
+
{ name: "audit.readRowHashAtCounter" }
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function _insertAuditRow(allCols, values) {
|
|
118
|
+
// No retry — non-idempotent. Timeout only.
|
|
119
|
+
var placeholders = allCols.map(function () { return "?"; }).join(", ");
|
|
120
|
+
var quoted = allCols.map(function (c) { return '"' + c + '"'; }).join(", ");
|
|
121
|
+
return await safeAsync.withTimeout(
|
|
122
|
+
clusterStorage.execute(
|
|
123
|
+
"INSERT INTO audit_log (" + quoted + ") VALUES (" + placeholders + ")",
|
|
124
|
+
values
|
|
125
|
+
),
|
|
126
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
127
|
+
{ name: "audit.insertRow" }
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function _insertCheckpoint(values) {
|
|
132
|
+
return await safeAsync.withTimeout(
|
|
133
|
+
clusterStorage.execute(
|
|
134
|
+
"INSERT INTO audit_checkpoints (_id, createdAt, atMonotonicCounter, atRowHash, signature, publicKeyFingerprint, fencingToken) " +
|
|
135
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
136
|
+
values
|
|
137
|
+
),
|
|
138
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
139
|
+
{ name: "audit.insertCheckpoint" }
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function _upsertAuditTip(counter, rowHash, signedAt, fencingToken) {
|
|
144
|
+
// Cluster-mode only. Single atomic INSERT … ON CONFLICT … DO UPDATE
|
|
145
|
+
// … WHERE … RETURNING. The WHERE clause is the canonical
|
|
146
|
+
// fencing-token guard from blamejs-cluster-spec.md — it enforces
|
|
147
|
+
// monotonic-non-decreasing fencingToken at the database level so a
|
|
148
|
+
// partitioned old leader cannot overwrite the tip even if its
|
|
149
|
+
// application-layer cluster.requireLeader() gate somehow allowed
|
|
150
|
+
// the call through.
|
|
151
|
+
//
|
|
152
|
+
// Update accepted iff the row's stored fencingToken <= incoming one
|
|
153
|
+
// (same-token re-write is fine; a strictly-lower token is fenced
|
|
154
|
+
// out). On rejection RETURNING produces 0 rows — we surface that
|
|
155
|
+
// as ClusterError(code='FENCED_OUT', permanent=true) so the
|
|
156
|
+
// dispatching node knows it's been superseded and should step down
|
|
157
|
+
// rather than retry.
|
|
158
|
+
var result = await safeAsync.withTimeout(
|
|
159
|
+
clusterStorage.execute(
|
|
160
|
+
"INSERT INTO _blamejs_audit_tip " +
|
|
161
|
+
" (scope, atMonotonicCounter, rowHash, signedAt, fencingToken) " +
|
|
162
|
+
"VALUES ('audit', ?, ?, ?, ?) " +
|
|
163
|
+
"ON CONFLICT (scope) DO UPDATE SET " +
|
|
164
|
+
" atMonotonicCounter = EXCLUDED.atMonotonicCounter, " +
|
|
165
|
+
" rowHash = EXCLUDED.rowHash, " +
|
|
166
|
+
" signedAt = EXCLUDED.signedAt, " +
|
|
167
|
+
" fencingToken = EXCLUDED.fencingToken " +
|
|
168
|
+
"WHERE _blamejs_audit_tip.fencingToken <= EXCLUDED.fencingToken " +
|
|
169
|
+
"RETURNING fencingToken",
|
|
170
|
+
[counter, rowHash, signedAt, fencingToken]
|
|
171
|
+
),
|
|
172
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
173
|
+
{ name: "audit.upsertAuditTip" }
|
|
174
|
+
);
|
|
175
|
+
if (!result.rows || result.rows.length === 0) {
|
|
176
|
+
throw new ClusterError(
|
|
177
|
+
"FENCED_OUT",
|
|
178
|
+
"audit-tip update rejected: incoming fencingToken=" + fencingToken +
|
|
179
|
+
" is below the stored token (this leader has been fenced out " +
|
|
180
|
+
"by a higher-token successor)",
|
|
181
|
+
true
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Every namespace any framework primitive emits on must be listed here.
|
|
187
|
+
// A primitive that adds a new namespace adds it here in the same patch.
|
|
188
|
+
// Smoke walks lib/ at boot-time-equivalent (layer-0-primitives/audit-framework-namespaces.test.js)
|
|
189
|
+
// and fails if any emitted namespace is missing from this list.
|
|
190
|
+
//
|
|
191
|
+
// Why this list and not auto-registration via registerNamespace() at
|
|
192
|
+
// each primitive's create(): namespace registration must be effective
|
|
193
|
+
// the first time ANY primitive emits, regardless of which primitive
|
|
194
|
+
// the operator initialized first. Operators wiring just b.scheduler
|
|
195
|
+
// without ever calling b.apiKey.create() still trip the apikey verify
|
|
196
|
+
// path (e.g. through middleware they didn't write); the action-name
|
|
197
|
+
// validation needs to know about apikey at boot, not at first call.
|
|
198
|
+
var FRAMEWORK_NAMESPACES = [
|
|
199
|
+
// Generic buckets
|
|
200
|
+
"auth", "system", "audit", "consent", "subject",
|
|
201
|
+
// Per-primitive namespaces — keep alphabetical
|
|
202
|
+
"apikey", // b.apiKey
|
|
203
|
+
"backup", // b.backup
|
|
204
|
+
"breakglass", // b.breakGlass — column-policy / row-enforcement step-up auth (audit namespace lowercased per the validator's `namespace.verb` rule, same convention as b.apiKey → apikey.*)
|
|
205
|
+
"cache", // b.cache
|
|
206
|
+
"config", // b.configDrift (config.baseline.captured / config.drift.detected / config.baseline.tamper / config.baseline.unreadable)
|
|
207
|
+
"db", // b.db / b.middleware.dbRoleFor / b.externalDb.runAs
|
|
208
|
+
// (role-switching, RLS-shaped events)
|
|
209
|
+
"dkim", // b.mail.dkim (DKIM-Signature generation events)
|
|
210
|
+
"dual", // b.dualControl (dual.grant.requested / approved / denied / consumed / expired / self_approval_denied)
|
|
211
|
+
"mail", // b.mail (b.mail-bounce uses "system.mail.*")
|
|
212
|
+
"network", // b.middleware.networkAllowlist (network.gate.denied)
|
|
213
|
+
"notify", // b.notify
|
|
214
|
+
"objectstore", // b.objectStore.bucketOps (objectstore.bucket.* / objectstore.object.*)
|
|
215
|
+
"permissions", // b.permissions
|
|
216
|
+
"restore", // b.restore
|
|
217
|
+
"retention", // b.retention (retention.rule.declared / sweep.started / row.processed / sweep.completed / sweep.failed)
|
|
218
|
+
"scheduler", // b.scheduler (lifecycle: scheduler.start / scheduler.stop;
|
|
219
|
+
// tick/task events use "system.scheduler.*")
|
|
220
|
+
"seeders", // b.seeders
|
|
221
|
+
"webhook", // b.webhook
|
|
222
|
+
];
|
|
223
|
+
var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
224
|
+
|
|
225
|
+
// All hashable columns of audit_log (everything in the table except the chain
|
|
226
|
+
// bookkeeping itself). This list MUST match what's actually written by INSERT
|
|
227
|
+
// and what's read back by verify; the canonicalizer needs the same key set
|
|
228
|
+
// at both ends or the hash will mismatch on missing-vs-null keys.
|
|
229
|
+
var HASHABLE_COLS = [
|
|
230
|
+
"_id", "recordedAt", "monotonicCounter",
|
|
231
|
+
"actorUserId", "actorUserIdHash",
|
|
232
|
+
"actorIp",
|
|
233
|
+
"actorUserAgent", "actorSessionId",
|
|
234
|
+
"action", "resourceKind",
|
|
235
|
+
"resourceId", "resourceIdHash",
|
|
236
|
+
"outcome", "reason", "metadata", "requestId",
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
// Lazy db ref — avoids circular require (db -> audit -> db on init paths).
|
|
240
|
+
var db = lazyRequire(function () { return require("./db"); });
|
|
241
|
+
|
|
242
|
+
// Chain-writer instance owns the race-safe chain append: counter primer,
|
|
243
|
+
// chain mutex, prev-tip read, hash compute, INSERT. Per the framework
|
|
244
|
+
// rule that repeated tasks become primitives, the audit_log and
|
|
245
|
+
// consent_log chains both consume chain-writer.
|
|
246
|
+
var _chainWriter = chainWriter.create({
|
|
247
|
+
table: "audit_log",
|
|
248
|
+
hashableColumns: HASHABLE_COLS,
|
|
249
|
+
columnsForInsert: [
|
|
250
|
+
"_id", "recordedAt", "monotonicCounter",
|
|
251
|
+
"actorUserId", "actorUserIdHash",
|
|
252
|
+
"actorIp",
|
|
253
|
+
"actorUserAgent", "actorSessionId",
|
|
254
|
+
"action", "resourceKind",
|
|
255
|
+
"resourceId", "resourceIdHash",
|
|
256
|
+
"outcome", "reason", "metadata", "requestId",
|
|
257
|
+
"prevHash", "rowHash", "nonce", "fencingToken",
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ---- Public API ----
|
|
262
|
+
|
|
263
|
+
function registerNamespace(name) {
|
|
264
|
+
if (typeof name !== "string" || !/^[a-z][a-z0-9_]*$/.test(name)) {
|
|
265
|
+
throw new Error("audit namespace must match [a-z][a-z0-9_]* — got: " + name);
|
|
266
|
+
}
|
|
267
|
+
if (FRAMEWORK_NAMESPACES.indexOf(name) !== -1) return;
|
|
268
|
+
registeredNamespaces.add(name);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function _validateAction(action) {
|
|
272
|
+
if (typeof action !== "string" || !/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/.test(action)) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
"audit action must be 'namespace.verb[.qualifier...]' (lowercase, dot-separated) — got: " + action
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
var ns = action.split(".")[0];
|
|
278
|
+
if (!registeredNamespaces.has(ns)) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
"audit namespace '" + ns + "' is not registered. " +
|
|
281
|
+
"Call audit.registerNamespace('" + ns + "') at app bootstrap before recording '" + action + "'."
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function record(event) {
|
|
287
|
+
if (!event || typeof event !== "object") {
|
|
288
|
+
throw new Error("audit.record requires an event object");
|
|
289
|
+
}
|
|
290
|
+
_validateAction(event.action);
|
|
291
|
+
if (!event.outcome || ["success", "failure", "denied"].indexOf(event.outcome) === -1) {
|
|
292
|
+
throw new Error("audit.record outcome must be 'success', 'failure', or 'denied'");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return observability.tap("audit.record",
|
|
296
|
+
{ action: event.action, outcome: event.outcome },
|
|
297
|
+
async function () {
|
|
298
|
+
// Build the audit-specific logical row; chain-writer handles _id /
|
|
299
|
+
// recordedAt / monotonicCounter / sealing / null-fill / hashing /
|
|
300
|
+
// insert / fencing-token / chain mutex / counter primer.
|
|
301
|
+
var actor = event.actor || {};
|
|
302
|
+
var resource = event.resource || {};
|
|
303
|
+
var logical = {
|
|
304
|
+
actorUserId: actor.userId || null,
|
|
305
|
+
actorIp: actor.ip || null,
|
|
306
|
+
actorUserAgent: actor.userAgent || null,
|
|
307
|
+
actorSessionId: actor.sessionId || null,
|
|
308
|
+
action: event.action,
|
|
309
|
+
resourceKind: resource.kind || null,
|
|
310
|
+
resourceId: resource.id || null,
|
|
311
|
+
outcome: event.outcome,
|
|
312
|
+
reason: event.reason || null,
|
|
313
|
+
metadata: event.metadata ? JSON.stringify(event.metadata) : null,
|
|
314
|
+
requestId: event.requestId || null,
|
|
315
|
+
};
|
|
316
|
+
return _chainWriter.append(logical);
|
|
317
|
+
}
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---- Query ----
|
|
322
|
+
//
|
|
323
|
+
// Plain-field criteria translate into derived-hash equality where the column
|
|
324
|
+
// is sealed. Returns unsealed rows for the auditor's view.
|
|
325
|
+
//
|
|
326
|
+
// Self-logging (PCI DSS 10.2.3): every read of audit_log is itself recorded
|
|
327
|
+
// as an 'audit.read' event before the query runs, so an exfiltration attempt
|
|
328
|
+
// is forensically visible. The recursion guard (_selfLogging flag) prevents
|
|
329
|
+
// the audit.read recording from triggering its own self-log; queries
|
|
330
|
+
// SPECIFICALLY filtering for action='audit.read' don't auto-log either
|
|
331
|
+
// (otherwise legitimate audit auditing produces a Russell-set spiral).
|
|
332
|
+
var _selfLogging = false;
|
|
333
|
+
|
|
334
|
+
async function query(criteria) {
|
|
335
|
+
criteria = criteria || {};
|
|
336
|
+
if (!_selfLogging && criteria.action !== "audit.read") {
|
|
337
|
+
_selfLogging = true;
|
|
338
|
+
try {
|
|
339
|
+
await record({
|
|
340
|
+
actor: criteria.actor || {},
|
|
341
|
+
action: "audit.read",
|
|
342
|
+
outcome: "success",
|
|
343
|
+
metadata: {
|
|
344
|
+
criteria: _redactCriteria(criteria),
|
|
345
|
+
traceId: criteria.traceId || null,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
} finally {
|
|
349
|
+
_selfLogging = false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// In single-node mode the query builder gives us field-crypto unsealing
|
|
354
|
+
// for free. In cluster mode we read raw rows from external-db and
|
|
355
|
+
// unseal manually.
|
|
356
|
+
if (cluster.isClusterMode()) {
|
|
357
|
+
return await _queryCluster(criteria);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
var q = db().from("audit_log");
|
|
361
|
+
|
|
362
|
+
if (criteria.from) q = q.where("recordedAt", ">=", _toMs(criteria.from));
|
|
363
|
+
if (criteria.to) q = q.where("recordedAt", "<=", _toMs(criteria.to));
|
|
364
|
+
if (criteria.actorUserId) q = q.where({ actorUserId: criteria.actorUserId });
|
|
365
|
+
if (criteria.resourceId) q = q.where({ resourceId: criteria.resourceId });
|
|
366
|
+
if (criteria.action) q = q.where({ action: criteria.action });
|
|
367
|
+
if (criteria.resourceKind) q = q.where({ resourceKind: criteria.resourceKind });
|
|
368
|
+
if (criteria.outcome) q = q.where({ outcome: criteria.outcome });
|
|
369
|
+
|
|
370
|
+
q.orderBy("monotonicCounter", "asc");
|
|
371
|
+
if (criteria.limit != null) q.limit(criteria.limit);
|
|
372
|
+
if (criteria.offset != null) q.offset(criteria.offset);
|
|
373
|
+
|
|
374
|
+
return q.all();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function _queryCluster(criteria) {
|
|
378
|
+
var conds = [];
|
|
379
|
+
var params = [];
|
|
380
|
+
if (criteria.from) {
|
|
381
|
+
conds.push("recordedAt >= ?");
|
|
382
|
+
params.push(_toMs(criteria.from));
|
|
383
|
+
}
|
|
384
|
+
if (criteria.to) {
|
|
385
|
+
conds.push("recordedAt <= ?");
|
|
386
|
+
params.push(_toMs(criteria.to));
|
|
387
|
+
}
|
|
388
|
+
if (criteria.actorUserId) {
|
|
389
|
+
var auh = cryptoField.lookupHash("audit_log", "actorUserId", criteria.actorUserId);
|
|
390
|
+
if (auh) { conds.push(auh.field + " = ?"); params.push(auh.value); }
|
|
391
|
+
}
|
|
392
|
+
if (criteria.resourceId) {
|
|
393
|
+
var rh = cryptoField.lookupHash("audit_log", "resourceId", criteria.resourceId);
|
|
394
|
+
if (rh) { conds.push(rh.field + " = ?"); params.push(rh.value); }
|
|
395
|
+
}
|
|
396
|
+
if (criteria.action) { conds.push("action = ?"); params.push(criteria.action); }
|
|
397
|
+
if (criteria.resourceKind) { conds.push("resourceKind = ?"); params.push(criteria.resourceKind); }
|
|
398
|
+
if (criteria.outcome) { conds.push("outcome = ?"); params.push(criteria.outcome); }
|
|
399
|
+
|
|
400
|
+
var sql = "SELECT * FROM audit_log";
|
|
401
|
+
if (conds.length > 0) sql += " WHERE " + conds.join(" AND ");
|
|
402
|
+
sql += " ORDER BY monotonicCounter ASC";
|
|
403
|
+
if (criteria.limit != null) { sql += " LIMIT ?"; params.push(criteria.limit); }
|
|
404
|
+
if (criteria.offset != null) { sql += " OFFSET ?"; params.push(criteria.offset); }
|
|
405
|
+
|
|
406
|
+
var rows = await clusterStorage.executeAll(sql, params);
|
|
407
|
+
return rows.map(function (row) { return cryptoField.unsealRow("audit_log", row); });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Audit-readable summary of the criteria without storing raw subject IDs
|
|
411
|
+
// in plaintext anywhere outside the sealed columns of audit_log itself.
|
|
412
|
+
function _redactCriteria(c) {
|
|
413
|
+
return {
|
|
414
|
+
from: c.from || null,
|
|
415
|
+
to: c.to || null,
|
|
416
|
+
action: c.action || null,
|
|
417
|
+
resourceKind: c.resourceKind || null,
|
|
418
|
+
outcome: c.outcome || null,
|
|
419
|
+
hasUserFilter: !!c.actorUserId,
|
|
420
|
+
hasResourceFilter: !!c.resourceId,
|
|
421
|
+
limit: c.limit != null ? c.limit : null,
|
|
422
|
+
offset: c.offset != null ? c.offset : null,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Generate a fresh trace id apps can thread through their request handlers
|
|
427
|
+
// and pass into audit.record() / consent.grant() / etc. via the metadata
|
|
428
|
+
// field. Width matches the W3C traceparent trace-id format (16 random
|
|
429
|
+
// bytes hex-encoded → 32 chars). Routed through C.BYTES so the byte
|
|
430
|
+
// count has a single source of truth.
|
|
431
|
+
var TRACE_ID_BYTES = C.BYTES.bytes(16);
|
|
432
|
+
function beginTrace() {
|
|
433
|
+
return generateToken(TRACE_ID_BYTES);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---- Checkpoints (tamper-proof external anchor) ----
|
|
437
|
+
|
|
438
|
+
// Build the canonical bytes that get signed for a checkpoint at a given
|
|
439
|
+
// chain tip. Keep this format stable across the framework's lifetime —
|
|
440
|
+
// changing it invalidates every prior checkpoint signature.
|
|
441
|
+
var CHECKPOINT_FORMAT = "blamejs-audit-checkpoint-v1";
|
|
442
|
+
function _checkpointPayload(atMonotonicCounter, atRowHash, createdAt) {
|
|
443
|
+
// Use a fixed multi-line layout. Avoids JSON serializer quirks; portable
|
|
444
|
+
// to any verifier reading the same column triple from the DB.
|
|
445
|
+
return Buffer.from(
|
|
446
|
+
CHECKPOINT_FORMAT + "\n" +
|
|
447
|
+
String(atMonotonicCounter) + "\n" +
|
|
448
|
+
atRowHash + "\n" +
|
|
449
|
+
String(createdAt),
|
|
450
|
+
"utf8"
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Anchor the current chain tip with a fresh ML-DSA-87 signature. Inserts
|
|
455
|
+
// a row into audit_checkpoints. Updates <dataDir>/audit.tip for boot-time
|
|
456
|
+
// rollback detection.
|
|
457
|
+
//
|
|
458
|
+
// opts:
|
|
459
|
+
// skipIfUnchanged: bool — return null without inserting if the chain tip
|
|
460
|
+
// hasn't advanced since the most recent checkpoint
|
|
461
|
+
async function checkpoint(opts) {
|
|
462
|
+
cluster.requireLeader();
|
|
463
|
+
opts = opts || {};
|
|
464
|
+
|
|
465
|
+
var tip = await safeAsync.withTimeout(
|
|
466
|
+
safeAsync.asyncRetry(function () {
|
|
467
|
+
return clusterStorage.executeOne(
|
|
468
|
+
"SELECT _id, monotonicCounter, rowHash FROM audit_log " +
|
|
469
|
+
"ORDER BY monotonicCounter DESC LIMIT 1"
|
|
470
|
+
);
|
|
471
|
+
}),
|
|
472
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
473
|
+
{ name: "audit.checkpoint.readTip" }
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
if (!tip) return null; // empty audit log; nothing to anchor
|
|
477
|
+
|
|
478
|
+
if (opts.skipIfUnchanged) {
|
|
479
|
+
var lastCkpt = await _readLastCheckpointCounter();
|
|
480
|
+
if (lastCkpt && Number(lastCkpt.atMonotonicCounter) >= Number(tip.monotonicCounter)) {
|
|
481
|
+
return null; // already anchored at this tip
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
var createdAt = Date.now();
|
|
486
|
+
var counter = Number(tip.monotonicCounter);
|
|
487
|
+
var payload = _checkpointPayload(counter, tip.rowHash, createdAt);
|
|
488
|
+
var signature = auditSign.sign(payload);
|
|
489
|
+
var pubFp = auditSign.getPublicKeyFingerprint();
|
|
490
|
+
|
|
491
|
+
var ckptId = generateToken(TRACE_ID_BYTES);
|
|
492
|
+
var fencingToken = cluster.fencingToken();
|
|
493
|
+
await _insertCheckpoint(
|
|
494
|
+
[ckptId, createdAt, counter, tip.rowHash, signature, pubFp, fencingToken]
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
// Update rollback-detection sidecar (single-node) or audit-tip row
|
|
498
|
+
// (cluster mode).
|
|
499
|
+
//
|
|
500
|
+
// Single-node sidecar is best-effort — a sidecar write failure must
|
|
501
|
+
// not block checkpointing because the chain itself is already
|
|
502
|
+
// committed.
|
|
503
|
+
//
|
|
504
|
+
// Cluster-mode audit-tip is NOT best-effort: the upsert's WHERE
|
|
505
|
+
// clause is the fencing-token guard, and a FENCED_OUT response
|
|
506
|
+
// means the local node has been superseded by a newer leader. The
|
|
507
|
+
// checkpoint row was already inserted at this point but propagating
|
|
508
|
+
// the error up makes the leadership-loss visible to the caller — it
|
|
509
|
+
// also means the caller can audit the leader-lost transition and
|
|
510
|
+
// step down. Other audit-tip errors (network blip, transient DB)
|
|
511
|
+
// also surface so the operator can react.
|
|
512
|
+
if (cluster.isClusterMode()) {
|
|
513
|
+
await _upsertAuditTip(counter, tip.rowHash, String(createdAt), fencingToken);
|
|
514
|
+
} else {
|
|
515
|
+
try {
|
|
516
|
+
db()._writeAuditTip({
|
|
517
|
+
atMonotonicCounter: counter,
|
|
518
|
+
atRowHash: tip.rowHash,
|
|
519
|
+
anchoredAt: createdAt,
|
|
520
|
+
checkpointId: ckptId,
|
|
521
|
+
publicKeyFingerprint: pubFp,
|
|
522
|
+
version: 1,
|
|
523
|
+
});
|
|
524
|
+
} catch (_e) { /* best effort */ }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return {
|
|
528
|
+
_id: ckptId,
|
|
529
|
+
createdAt: createdAt,
|
|
530
|
+
atMonotonicCounter: counter,
|
|
531
|
+
atRowHash: tip.rowHash,
|
|
532
|
+
publicKeyFingerprint: pubFp,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Walk every checkpoint, verify its signature against the current public
|
|
537
|
+
// key (or one matching the row's stored fingerprint). Also confirms the
|
|
538
|
+
// audit_log row at atMonotonicCounter still has the recorded rowHash.
|
|
539
|
+
//
|
|
540
|
+
// Returns { ok, checkpointsVerified, breakAt? }.
|
|
541
|
+
async function verifyCheckpoints() {
|
|
542
|
+
var rows = await _readAllCheckpointsAsc();
|
|
543
|
+
|
|
544
|
+
if (rows.length === 0) return { ok: true, checkpointsVerified: 0 };
|
|
545
|
+
|
|
546
|
+
var currentFp = auditSign.getPublicKeyFingerprint();
|
|
547
|
+
var currentPub = auditSign.getPublicKey();
|
|
548
|
+
|
|
549
|
+
for (var i = 0; i < rows.length; i++) {
|
|
550
|
+
var c = rows[i];
|
|
551
|
+
// Public key check: only the current key is accepted — there is no
|
|
552
|
+
// key-history table, so any rotation requires re-signing existing
|
|
553
|
+
// checkpoints. A fingerprint mismatch fails verification.
|
|
554
|
+
if (c.publicKeyFingerprint !== currentFp) {
|
|
555
|
+
return {
|
|
556
|
+
ok: false,
|
|
557
|
+
checkpointsVerified: i,
|
|
558
|
+
breakAt: i,
|
|
559
|
+
checkpointId: c._id,
|
|
560
|
+
reason: "public key fingerprint mismatch (key rotated without history?)",
|
|
561
|
+
expected: currentFp,
|
|
562
|
+
actual: c.publicKeyFingerprint,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
var payload = _checkpointPayload(Number(c.atMonotonicCounter), c.atRowHash, Number(c.createdAt));
|
|
566
|
+
var sigBuf = Buffer.isBuffer(c.signature) ? c.signature : Buffer.from(c.signature);
|
|
567
|
+
if (!auditSign.verify(payload, sigBuf, currentPub)) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
checkpointsVerified: i,
|
|
571
|
+
breakAt: i,
|
|
572
|
+
checkpointId: c._id,
|
|
573
|
+
reason: "ML-DSA-87 signature failed",
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
// Also confirm the audit row at atMonotonicCounter still matches the
|
|
577
|
+
// anchored rowHash. If someone tampered with audit_log AND recomputed
|
|
578
|
+
// hashes (requiring vault key), this catches them via the off-chain
|
|
579
|
+
// signature anchor.
|
|
580
|
+
var anchored = await _readAuditRowHashAtCounter(c.atMonotonicCounter);
|
|
581
|
+
if (!anchored) {
|
|
582
|
+
return {
|
|
583
|
+
ok: false,
|
|
584
|
+
checkpointsVerified: i,
|
|
585
|
+
breakAt: i,
|
|
586
|
+
checkpointId: c._id,
|
|
587
|
+
reason: "anchored audit_log row missing (counter=" + c.atMonotonicCounter + ")",
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
if (anchored.rowHash !== c.atRowHash) {
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
checkpointsVerified: i,
|
|
594
|
+
breakAt: i,
|
|
595
|
+
checkpointId: c._id,
|
|
596
|
+
reason: "anchored rowHash mismatch — audit_log was tampered with",
|
|
597
|
+
expected: c.atRowHash,
|
|
598
|
+
actual: anchored.rowHash,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return { ok: true, checkpointsVerified: rows.length };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function _toMs(value) {
|
|
606
|
+
if (typeof value === "number") return value;
|
|
607
|
+
if (value instanceof Date) return value.getTime();
|
|
608
|
+
if (typeof value === "string") {
|
|
609
|
+
var ms = Date.parse(value);
|
|
610
|
+
if (isNaN(ms)) throw new Error("invalid date: " + value);
|
|
611
|
+
return ms;
|
|
612
|
+
}
|
|
613
|
+
throw new Error("invalid date value");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ---- Verify ----
|
|
617
|
+
|
|
618
|
+
async function verify(opts) {
|
|
619
|
+
// verifyChain just needs an executeAll; route through the same
|
|
620
|
+
// resilience-wrapped reader the rest of audit uses.
|
|
621
|
+
return await auditChain.verifyChain(
|
|
622
|
+
function (sql, params) {
|
|
623
|
+
return safeAsync.withTimeout(
|
|
624
|
+
safeAsync.asyncRetry(function () {
|
|
625
|
+
return clusterStorage.executeAll(sql, params || []);
|
|
626
|
+
}),
|
|
627
|
+
FRAMEWORK_SQL_TIMEOUT_MS,
|
|
628
|
+
{ name: "audit.verifyChain" }
|
|
629
|
+
);
|
|
630
|
+
},
|
|
631
|
+
"audit_log",
|
|
632
|
+
opts
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ---- Test helpers ----
|
|
637
|
+
|
|
638
|
+
function _resetForTest() {
|
|
639
|
+
registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
640
|
+
db.reset();
|
|
641
|
+
_chainWriter._resetForTest();
|
|
642
|
+
// Drop pending buffered emits and cancel the age-flush timer on the
|
|
643
|
+
// old handler before dereferencing it. Without this, the old
|
|
644
|
+
// handler's setTimeout (scheduled when emits buffer below maxBatch)
|
|
645
|
+
// fires AFTER the next test's db.init has opened a fresh database —
|
|
646
|
+
// the buffered items then drain through chain-writer into the wrong
|
|
647
|
+
// tmpDir's audit_log, breaking chain verify on the next-next test.
|
|
648
|
+
// shutdownSync is the explicit "drop, don't drain" path because
|
|
649
|
+
// draining to a stale or changing backing store is exactly the bug.
|
|
650
|
+
// The handler's flush also checks ctx.isShutdown() between items, so
|
|
651
|
+
// an in-flight drain that's mid-batch when reset fires bails out
|
|
652
|
+
// instead of writing the rest of the batch to the new database.
|
|
653
|
+
if (_auditHandler) {
|
|
654
|
+
try { _auditHandler.shutdownSync("audit._resetForTest"); }
|
|
655
|
+
catch (e) { log.debug("reset-handler-shutdown-failed: " + (e && e.message || e)); }
|
|
656
|
+
_auditHandler = null;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ---- Handler-backed emit + flush ----
|
|
661
|
+
//
|
|
662
|
+
// emit() is the call-site API for fire-and-forget audit emission from
|
|
663
|
+
// middleware / log-stream / external-db hooks / queue / storage / subject.
|
|
664
|
+
// It is SYNCHRONOUS, NEVER throws, and NEVER returns a Promise — request-
|
|
665
|
+
// path code can call it without await and without try/catch.
|
|
666
|
+
//
|
|
667
|
+
// Internally events queue in an AsyncHandler. flush() drains the queue
|
|
668
|
+
// to the audit chain (single writer in-process, serialized via the
|
|
669
|
+
// chain mutex). Tests, shutdown, and any code that needs audit-row
|
|
670
|
+
// durability before reading audit_log calls await audit.flush().
|
|
671
|
+
//
|
|
672
|
+
// Why this beats fire-and-forget Promises:
|
|
673
|
+
// - No leaked Promises across test/shutdown boundaries
|
|
674
|
+
// - Errors go through a single onError hook (visible to operators)
|
|
675
|
+
// - Recursive emits during flush land in the buffer for the next
|
|
676
|
+
// drain cycle — no infinite loop in cluster-mode dispatchers
|
|
677
|
+
// - Tests have a deterministic "audit is durable now" point
|
|
678
|
+
var _auditHandler = null;
|
|
679
|
+
|
|
680
|
+
function _ensureHandler() {
|
|
681
|
+
if (_auditHandler) return _auditHandler;
|
|
682
|
+
_auditHandler = handlers.create({
|
|
683
|
+
name: "audit",
|
|
684
|
+
flush: async function (batch, ctx) {
|
|
685
|
+
// Drain by serially writing each event through record(). The chain
|
|
686
|
+
// mutex inside record() further serializes vs concurrent direct
|
|
687
|
+
// record() callers.
|
|
688
|
+
//
|
|
689
|
+
// Between items, check the handler's shutdown probe — if a test
|
|
690
|
+
// (or operator) reset audit while this batch was in flight, the
|
|
691
|
+
// remaining items would otherwise drain through the chain-writer
|
|
692
|
+
// into a database that no longer represents the chain those
|
|
693
|
+
// items were emitted against. Early-exit drops them; the
|
|
694
|
+
// alternative is silent corruption of the next chain.
|
|
695
|
+
for (var i = 0; i < batch.length; i++) {
|
|
696
|
+
if (ctx && ctx.isShutdown && ctx.isShutdown()) return;
|
|
697
|
+
try { await record(batch[i]); }
|
|
698
|
+
catch (e) {
|
|
699
|
+
// Per-item failure shouldn't drop the whole batch; log and
|
|
700
|
+
// continue. The handler's onError gets called for batch-
|
|
701
|
+
// wide failures only.
|
|
702
|
+
log.error("flush dropped event: " +
|
|
703
|
+
(e && e.message ? e.message : String(e)) +
|
|
704
|
+
" (action=" + (batch[i] && batch[i].action) + ")");
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
return _auditHandler;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function emit(event) {
|
|
713
|
+
_ensureHandler().emit(event);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// safeEmit — fire-and-forget audit emit with safe defaults + try/catch.
|
|
717
|
+
//
|
|
718
|
+
// Most modules wrap emit() in their own _emit helper that fills in
|
|
719
|
+
// `actor: {}`, `outcome: "success"`, etc. and catches the throw so an
|
|
720
|
+
// audit outage doesn't crash the request handler. This is that helper,
|
|
721
|
+
// hoisted out so each module can stop redefining it.
|
|
722
|
+
//
|
|
723
|
+
// Drop-silent on malformed input by design. safeEmit is called from
|
|
724
|
+
// request hot paths where throwing on a
|
|
725
|
+
// missing `action` would mean a malformed audit attempt crashes the
|
|
726
|
+
// request that triggered it — strictly worse than the missing audit
|
|
727
|
+
// row. Operators who need a hard guarantee the event landed should call
|
|
728
|
+
// record() and await it with their own error handling. The audit chain
|
|
729
|
+
// itself is verified at boot, so a silently dropped row shows up in the
|
|
730
|
+
// next chain integrity sweep.
|
|
731
|
+
function safeEmit(event) {
|
|
732
|
+
if (!event || typeof event !== "object") return;
|
|
733
|
+
if (typeof event.action !== "string") return; // can't emit without an action
|
|
734
|
+
try {
|
|
735
|
+
_ensureHandler().emit({
|
|
736
|
+
actor: event.actor || {},
|
|
737
|
+
action: event.action,
|
|
738
|
+
resource: event.resource || null,
|
|
739
|
+
outcome: event.outcome || "success",
|
|
740
|
+
reason: event.reason || null,
|
|
741
|
+
metadata: event.metadata || null,
|
|
742
|
+
requestId: event.requestId || null,
|
|
743
|
+
});
|
|
744
|
+
} catch (_e) { /* audit best-effort — never break the caller */ }
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function flush() {
|
|
748
|
+
if (!_auditHandler) return;
|
|
749
|
+
await _auditHandler.drain();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
module.exports = {
|
|
753
|
+
registerNamespace: registerNamespace,
|
|
754
|
+
record: record,
|
|
755
|
+
emit: emit,
|
|
756
|
+
safeEmit: safeEmit,
|
|
757
|
+
flush: flush,
|
|
758
|
+
query: query,
|
|
759
|
+
verify: verify,
|
|
760
|
+
beginTrace: beginTrace,
|
|
761
|
+
checkpoint: checkpoint,
|
|
762
|
+
verifyCheckpoints: verifyCheckpoints,
|
|
763
|
+
CHECKPOINT_FORMAT: CHECKPOINT_FORMAT,
|
|
764
|
+
FRAMEWORK_NAMESPACES: FRAMEWORK_NAMESPACES,
|
|
765
|
+
_resetForTest: _resetForTest,
|
|
766
|
+
};
|