@voltro/plugin-audit 0.85.0 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5987 -0
- package/THIRD-PARTY-NOTICES.md +1122 -31
- package/dist/chain.js +205 -0
- package/dist/index.d.ts +74 -13
- package/dist/index.js +299 -404
- package/dist/invocationIdentity.js +11 -0
- package/dist/invocationQuery.js +14 -0
- package/dist/redactionShape.js +22 -0
- package/dist/store.js +183 -0
- package/dist/tableName.js +4 -0
- package/dist/transactionalCapture.js +101 -0
- package/package.json +4 -4
package/dist/chain.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { AUDIT_LOG_TABLE as e } from "./tableName.js";
|
|
2
|
+
import { auditInvocationIdOf as t, hasConsistentAuditInvocationId as n } from "./invocationIdentity.js";
|
|
3
|
+
import { and as r, column as i, eq as a, generateId as o, gt as s, isNotNull as c, isNull as l, lte as u, or as d } from "@voltro/database";
|
|
4
|
+
import { createHash as f, createHmac as p, randomBytes as m } from "node:crypto";
|
|
5
|
+
//#region src/chain.ts
|
|
6
|
+
var h = [
|
|
7
|
+
"tag",
|
|
8
|
+
"at",
|
|
9
|
+
"subjectId",
|
|
10
|
+
"tenantId",
|
|
11
|
+
"traceId",
|
|
12
|
+
"status",
|
|
13
|
+
"durationMs",
|
|
14
|
+
"subject",
|
|
15
|
+
"actor",
|
|
16
|
+
"scope",
|
|
17
|
+
"metadata",
|
|
18
|
+
"input",
|
|
19
|
+
"outcome",
|
|
20
|
+
"errorTag"
|
|
21
|
+
], g = [
|
|
22
|
+
...h,
|
|
23
|
+
"hashVersion",
|
|
24
|
+
"id",
|
|
25
|
+
"impersonation",
|
|
26
|
+
"scopeKey"
|
|
27
|
+
], _ = (e) => {
|
|
28
|
+
if (e == null) return "null";
|
|
29
|
+
if (e instanceof Date) return JSON.stringify(e.toISOString());
|
|
30
|
+
let t = typeof e;
|
|
31
|
+
return t === "number" || t === "boolean" || t === "string" ? JSON.stringify(e) : t === "bigint" ? JSON.stringify(String(e)) : Array.isArray(e) ? `[${e.map(_).join(",")}]` : t === "object" ? `{${Object.entries(e).filter(([, e]) => e !== void 0).sort(([e], [t]) => e < t ? -1 : +(e > t)).map(([e, t]) => `${JSON.stringify(e)}:${_(t)}`).join(",")}}` : JSON.stringify(String(e));
|
|
32
|
+
}, v = (e) => e instanceof Date ? e.toISOString() : e, y = (e, t) => _(Object.fromEntries((t === 2 ? g : h).map((t) => [t, v(e[t] ?? null)]))), b = (e) => {
|
|
33
|
+
let t = process.env.VOLTRO_AUDIT_CHAIN_SECRET;
|
|
34
|
+
return t ? p("sha256", t).update(e, "utf8").digest("hex") : f("sha256").update(e, "utf8").digest("hex");
|
|
35
|
+
}, x = (e, t) => {
|
|
36
|
+
let n = e.hashVersion ?? 1;
|
|
37
|
+
if (n !== 1 && n !== 2) throw TypeError("Unsupported audit hash version");
|
|
38
|
+
return b(JSON.stringify([
|
|
39
|
+
t.chainId,
|
|
40
|
+
t.seq,
|
|
41
|
+
t.prevHash,
|
|
42
|
+
y(e, n)
|
|
43
|
+
]));
|
|
44
|
+
}, S = (e = m(12).toString("base64url")) => {
|
|
45
|
+
let n = 0, r = null;
|
|
46
|
+
return {
|
|
47
|
+
chainId: e,
|
|
48
|
+
link: (i) => {
|
|
49
|
+
n += 1;
|
|
50
|
+
let a = {
|
|
51
|
+
...i,
|
|
52
|
+
invocationId: t(i.outcome),
|
|
53
|
+
id: i.id ?? o({
|
|
54
|
+
kind: "typeid",
|
|
55
|
+
prefix: "audit"
|
|
56
|
+
}, "_voltro_audit_log"),
|
|
57
|
+
hashVersion: 2
|
|
58
|
+
}, s = x(a, {
|
|
59
|
+
chainId: e,
|
|
60
|
+
seq: n,
|
|
61
|
+
prevHash: r
|
|
62
|
+
}), c = {
|
|
63
|
+
...a,
|
|
64
|
+
chainId: e,
|
|
65
|
+
seq: n,
|
|
66
|
+
prevHash: r,
|
|
67
|
+
hash: s
|
|
68
|
+
};
|
|
69
|
+
return r = s, c;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
async function* C(t, n) {
|
|
74
|
+
let o = n.chainId === void 0 ? void 0 : a("chainId", n.chainId), f = (t, n, r) => ({
|
|
75
|
+
table: e,
|
|
76
|
+
predicate: t,
|
|
77
|
+
order: r,
|
|
78
|
+
take: n,
|
|
79
|
+
skip: void 0,
|
|
80
|
+
projection: void 0
|
|
81
|
+
});
|
|
82
|
+
if (n.limit !== void 0) {
|
|
83
|
+
yield* await t.query(f(o, n.limit, [{
|
|
84
|
+
column: "chainId",
|
|
85
|
+
direction: "asc"
|
|
86
|
+
}, {
|
|
87
|
+
column: "seq",
|
|
88
|
+
direction: "asc"
|
|
89
|
+
}]));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
let p = [...await t.query({
|
|
93
|
+
...f(o, void 0, [{
|
|
94
|
+
column: "chainId",
|
|
95
|
+
direction: "asc"
|
|
96
|
+
}]),
|
|
97
|
+
groupBy: ["chainId"],
|
|
98
|
+
aggregations: [
|
|
99
|
+
i("chainId"),
|
|
100
|
+
{
|
|
101
|
+
op: "max",
|
|
102
|
+
column: "seq",
|
|
103
|
+
alias: "lastSeq"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
op: "max",
|
|
107
|
+
column: "id",
|
|
108
|
+
alias: "lastId"
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
})].sort((e, t) => String(e.chainId) < String(t.chainId) ? -1 : +(String(e.chainId) > String(t.chainId)));
|
|
112
|
+
for (let e of p) {
|
|
113
|
+
let n = e.chainId, i = n == null ? l("chainId") : a("chainId", n), o = n == null ? [!1] : [!1, !0];
|
|
114
|
+
for (let p of o) {
|
|
115
|
+
if (p && e.lastSeq == null) continue;
|
|
116
|
+
let o = [i, u("id", e.lastId)];
|
|
117
|
+
n != null && o.push(p ? r(c("seq"), u("seq", e.lastSeq)) : l("seq"));
|
|
118
|
+
let m;
|
|
119
|
+
for (;;) {
|
|
120
|
+
let e = m === void 0 ? void 0 : p ? d(s("seq", m.seq), r(a("seq", m.seq), s("id", m.id))) : s("id", m.id), n = await t.query(f(r(...o, ...e ? [e] : []), 1e3, [...p ? [{
|
|
121
|
+
column: "seq",
|
|
122
|
+
direction: "asc"
|
|
123
|
+
}] : [], {
|
|
124
|
+
column: "id",
|
|
125
|
+
direction: "asc"
|
|
126
|
+
}]));
|
|
127
|
+
if (yield* n, n.length < 1e3) break;
|
|
128
|
+
let i = n[n.length - 1];
|
|
129
|
+
if (i.id === void 0 || i.id === m?.id) throw Error("audit verification cursor did not advance");
|
|
130
|
+
m = i;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
var w = async (e, t = {}) => {
|
|
136
|
+
let r = [], i = [], a = 0, o = 0, s = 0, c = null, l = () => {
|
|
137
|
+
c &&= (i.push({
|
|
138
|
+
chainId: c.chainId,
|
|
139
|
+
from: c.from,
|
|
140
|
+
to: c.last,
|
|
141
|
+
tip: c.tip,
|
|
142
|
+
prunedPrefix: c.from > 1
|
|
143
|
+
}), null);
|
|
144
|
+
};
|
|
145
|
+
for await (let i of C(e, t)) {
|
|
146
|
+
let e = i.chainId, t = i.hash, u = i.seq;
|
|
147
|
+
if (typeof e != "string" || typeof t != "string" || u == null) {
|
|
148
|
+
a += 1;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
let d = Number(u), f = typeof i.prevHash == "string" ? i.prevHash : null, p = typeof i.id == "string" ? i.id : void 0;
|
|
152
|
+
s += 1, !c || c.chainId !== e ? (l(), c = {
|
|
153
|
+
chainId: e,
|
|
154
|
+
from: d,
|
|
155
|
+
last: d,
|
|
156
|
+
tip: t
|
|
157
|
+
}) : (d === c.last + 1 ? f !== c.tip && r.push({
|
|
158
|
+
kind: "broken-link",
|
|
159
|
+
chainId: e,
|
|
160
|
+
seq: d,
|
|
161
|
+
...p ? { rowId: p } : {},
|
|
162
|
+
detail: `prevHash does not match the hash of seq ${c.last}`
|
|
163
|
+
}) : r.push({
|
|
164
|
+
kind: "gap",
|
|
165
|
+
chainId: e,
|
|
166
|
+
seq: c.last + 1,
|
|
167
|
+
detail: `chain jumps from seq ${c.last} to ${d} — ${d - c.last - 1} row(s) missing`
|
|
168
|
+
}), c.last = d, c.tip = t);
|
|
169
|
+
let m = i.hashVersion ?? 1;
|
|
170
|
+
m !== 1 && m !== 2 ? r.push({
|
|
171
|
+
kind: "tampered",
|
|
172
|
+
chainId: e,
|
|
173
|
+
seq: d,
|
|
174
|
+
...p ? { rowId: p } : {},
|
|
175
|
+
detail: "unsupported audit hash version; the row cannot be verified"
|
|
176
|
+
}) : (m === 1 && (o += 1), n(i) ? x(i, {
|
|
177
|
+
chainId: e,
|
|
178
|
+
seq: d,
|
|
179
|
+
prevHash: f
|
|
180
|
+
}) !== t && r.push({
|
|
181
|
+
kind: "tampered",
|
|
182
|
+
chainId: e,
|
|
183
|
+
seq: d,
|
|
184
|
+
...p ? { rowId: p } : {},
|
|
185
|
+
detail: "the row content does not hash to its stored hash — a column was altered"
|
|
186
|
+
}) : r.push({
|
|
187
|
+
kind: "tampered",
|
|
188
|
+
chainId: e,
|
|
189
|
+
seq: d,
|
|
190
|
+
...p ? { rowId: p } : {},
|
|
191
|
+
detail: "the invocation lookup does not match its protected outcome"
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
return l(), {
|
|
195
|
+
ok: r.length === 0,
|
|
196
|
+
rowsChecked: s,
|
|
197
|
+
unchainedRows: a,
|
|
198
|
+
legacyHashRows: o,
|
|
199
|
+
chains: i,
|
|
200
|
+
issues: r,
|
|
201
|
+
keyed: !!process.env.VOLTRO_AUDIT_CHAIN_SECRET
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
//#endregion
|
|
205
|
+
export { x as auditRowHash, S as makeAuditChain, w as verifyAuditChain };
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,11 @@ import { AuditActor } from '@voltro/database';
|
|
|
2
2
|
import { ColumnDefinition } from '@voltro/database';
|
|
3
3
|
import { Effect } from 'effect';
|
|
4
4
|
import { MixinDefinition } from '@voltro/database';
|
|
5
|
+
import { Query } from '@voltro/database';
|
|
6
|
+
import { Row } from '@voltro/database';
|
|
7
|
+
import { SchemaTable } from '@voltro/database';
|
|
5
8
|
import { Subject } from '@voltro/protocol';
|
|
6
9
|
import { TableIndex } from '@voltro/database';
|
|
7
|
-
import { TableLike } from '@voltro/database';
|
|
8
10
|
import { VoltroPlugin } from '@voltro/protocol';
|
|
9
11
|
|
|
10
12
|
export declare const audit: () => MixinDefinition<{
|
|
@@ -72,6 +74,8 @@ export declare type AuditChainIssueKind = 'tampered' | 'broken-link' | 'gap';
|
|
|
72
74
|
|
|
73
75
|
/** The columns the chain adds to `_voltro_audit_log`. */
|
|
74
76
|
export declare interface AuditChainLink {
|
|
77
|
+
/** NULL/1 is historical coverage; new writers use format 2. */
|
|
78
|
+
readonly hashVersion?: number | null;
|
|
75
79
|
/** Which writer's chain this row belongs to. Null for an unchained row. */
|
|
76
80
|
readonly chainId: string;
|
|
77
81
|
/** 1-based position within `chainId`. */
|
|
@@ -102,6 +106,9 @@ export declare interface AuditChainVerification {
|
|
|
102
106
|
* Not an `issue` (a pre-chain deployment is not tampering) but never
|
|
103
107
|
* silently dropped either. */
|
|
104
108
|
readonly unchainedRows: number;
|
|
109
|
+
/** Rows using the historical digest, which did not cover impersonation or
|
|
110
|
+
* the derived scope key. Optional for compatibility with older result DTOs. */
|
|
111
|
+
readonly legacyHashRows?: number;
|
|
105
112
|
readonly chains: ReadonlyArray<AuditChainSummary>;
|
|
106
113
|
readonly issues: ReadonlyArray<AuditChainIssue>;
|
|
107
114
|
/** Whether the digest was keyed. An `ok: true` from an UNKEYED run is a
|
|
@@ -125,7 +132,7 @@ export declare interface AuditEvent {
|
|
|
125
132
|
* (anonymous, system). Absent is honest; a fabricated placeholder would be
|
|
126
133
|
* the thing this field exists to prevent.
|
|
127
134
|
*/
|
|
128
|
-
readonly actor?: AuditActor | undefined;
|
|
135
|
+
readonly actor?: AuditActor | null | undefined;
|
|
129
136
|
/** The app's own scoping dimension — opaque, stored verbatim, filterable.
|
|
130
137
|
* `.with(tenant())` is one level too coarse for a per-team trail. */
|
|
131
138
|
readonly scope?: unknown;
|
|
@@ -158,22 +165,38 @@ export declare interface AuditEvent {
|
|
|
158
165
|
readonly kind: 'ok';
|
|
159
166
|
readonly value: unknown;
|
|
160
167
|
readonly durationMs: number;
|
|
168
|
+
readonly invocationId?: string;
|
|
161
169
|
} | {
|
|
162
170
|
readonly kind: 'error';
|
|
163
171
|
readonly error: unknown;
|
|
164
172
|
readonly durationMs: number;
|
|
173
|
+
readonly invocationId?: string;
|
|
174
|
+
} | {
|
|
175
|
+
readonly kind: 'unknown';
|
|
176
|
+
readonly invocationId: string;
|
|
177
|
+
readonly durationMs: number;
|
|
165
178
|
};
|
|
166
179
|
}
|
|
167
180
|
|
|
168
181
|
/** Map an {@link AuditEvent} to an audit-log row. */
|
|
169
182
|
export declare const auditEventToRow: (event: AuditEvent) => Record<string, unknown>;
|
|
170
183
|
|
|
184
|
+
/** Stable display identity when an unknown admission acquires a terminal row.
|
|
185
|
+
* A pagination cursor still uses the selected raw row's actual ordering key. */
|
|
186
|
+
export declare const auditInvocationIdentity: (row: Readonly<Record<string, unknown>>) => string;
|
|
187
|
+
|
|
188
|
+
/** One logical invocation per result, before filtering/order/pagination. Raw
|
|
189
|
+
* admissions remain immutable. Qualify caller predicates/order with audit. */
|
|
190
|
+
export declare const auditInvocationQuery: () => Query<Row, string, {
|
|
191
|
+
completed: Row;
|
|
192
|
+
}>;
|
|
193
|
+
|
|
171
194
|
/**
|
|
172
195
|
* The slice of a built `Table` this package exposes — annotated explicitly so
|
|
173
196
|
* @voltro/database's private column-builder class doesn't leak across the
|
|
174
197
|
* package boundary (TS4094). Only name + columns + indexes are needed.
|
|
175
198
|
*/
|
|
176
|
-
export declare interface AuditLogTable extends
|
|
199
|
+
export declare interface AuditLogTable extends SchemaTable {
|
|
177
200
|
readonly fields: Record<string, ColumnDefinition<unknown>>;
|
|
178
201
|
readonly appliedIndexes: ReadonlyArray<TableIndex>;
|
|
179
202
|
}
|
|
@@ -200,6 +223,32 @@ export declare const auditLogTables: ReadonlyArray<AuditLogTable>;
|
|
|
200
223
|
export declare const auditPlugin: (options?: AuditPluginOptions) => VoltroPlugin;
|
|
201
224
|
|
|
202
225
|
export declare interface AuditPluginOptions {
|
|
226
|
+
/** Successful registered mutations may capture a required redacted intent on
|
|
227
|
+
* the current transaction outbox. Requires the literal datastore sink and the
|
|
228
|
+
* supported Runtime/CLI hook. Default best-effort recording is unchanged.
|
|
229
|
+
* Required scope/metadata/redaction/encoding failures roll back the mutation.
|
|
230
|
+
* Callbacks must remain free of external side effects: replay may repeat them.
|
|
231
|
+
* Refusals, actions, workflows and HTTP remain best effort in this mode. */
|
|
232
|
+
readonly mutationCapture?: 'best-effort' | 'transactional-outbox';
|
|
233
|
+
/** Registered mutation/action/workflow failures enqueue a redacted outcome
|
|
234
|
+
* after the business transaction has rolled back. Requires datastore sink
|
|
235
|
+
* and the current host's root outcome capability. Capture failures preserve
|
|
236
|
+
* the original operation error and report only bounded diagnostics. Query,
|
|
237
|
+
* HTTP and successful non-mutation capture are unchanged. */
|
|
238
|
+
readonly failureCapture?: 'best-effort' | 'outbox';
|
|
239
|
+
/** Successful registered actions and workflow-start calls enqueue a frozen
|
|
240
|
+
* redacted outcome through the host's root outbox. Requires datastore sink.
|
|
241
|
+
* This is durable delivery after completion, not a transaction around external
|
|
242
|
+
* side effects: admission failures preserve the successful business result and
|
|
243
|
+
* emit a bounded diagnostic. Queries, HTTP and mutation capture are unchanged. */
|
|
244
|
+
readonly successCapture?: 'best-effort' | 'outbox';
|
|
245
|
+
/** Required admission for selected action/workflow-start calls. Persist an
|
|
246
|
+
* immutable privacy-safe unknown outcome before executing their effect, then
|
|
247
|
+
* correlate the terminal outcome by invocationId. Requires datastore sink,
|
|
248
|
+
* success/failure outbox capture and record: all. Admission failure prevents
|
|
249
|
+
* execution; interruption leaves unknown evidence without claiming rollback.
|
|
250
|
+
* Mutations, queries and HTTP are unchanged. */
|
|
251
|
+
readonly invocationCapture?: 'best-effort' | 'required';
|
|
203
252
|
/**
|
|
204
253
|
* Namespace for this plugin's inspect surface + any rpc tags it contributes. Default `audit`.
|
|
205
254
|
*
|
|
@@ -306,7 +355,7 @@ export declare interface AuditPluginOptions {
|
|
|
306
355
|
* between an indexed read path and stuffing `teamId` into `metadata` and
|
|
307
356
|
* scanning — a column documented as a free-form note is not a read path.
|
|
308
357
|
*
|
|
309
|
-
*
|
|
358
|
+
* In default best-effort mode, throwing never fails the mutation: a scope that cannot
|
|
310
359
|
* be derived is null, the same answer as not configuring one.
|
|
311
360
|
*/
|
|
312
361
|
readonly resolveScope?: (ctx: {
|
|
@@ -323,10 +372,10 @@ export declare interface AuditPluginOptions {
|
|
|
323
372
|
*
|
|
324
373
|
* Resolved AFTER the call, so it sees the result (`outcome.kind === 'ok'`
|
|
325
374
|
* with `value`, or `'error'` with the error) — the note for a failed removal
|
|
326
|
-
* is a different note. May be async.
|
|
327
|
-
* `undefined` records no note
|
|
328
|
-
*
|
|
329
|
-
*
|
|
375
|
+
* is a different note. May be async. In default best-effort mode, throwing, rejecting, or returning
|
|
376
|
+
* `undefined` records no note. Resolution is awaited on the interceptor's
|
|
377
|
+
* recording path, but failure does not replace the call's original outcome.
|
|
378
|
+
* Transactional capture requires successful callback resolution.
|
|
330
379
|
*
|
|
331
380
|
* ```ts
|
|
332
381
|
* auditPlugin({
|
|
@@ -470,6 +519,13 @@ export declare interface AuditPluginOptions {
|
|
|
470
519
|
* "who really did this" is not app-controlled metadata, it is the event.
|
|
471
520
|
*/
|
|
472
521
|
readonly redactSubject?: 'metadata' | 'metadata-shape' | 'none' | ((subject: Subject) => unknown);
|
|
522
|
+
/** Project resolved actor displayName/email before capture or sink delivery.
|
|
523
|
+
* The framework retains id/type even if JavaScript returns extra fields.
|
|
524
|
+
* Return both labels as strings or null. The synchronous callback must be
|
|
525
|
+
* pure: required capture failure aborts its transaction; refusal admission
|
|
526
|
+
* keeps the original error. Decisions are frozen before enqueue, not rerun
|
|
527
|
+
* during delivery. Absent preserves the existing policy; no actor stays null. */
|
|
528
|
+
readonly redactActor?: (actor: Readonly<AuditActor>) => Pick<AuditActor, 'displayName' | 'email'>;
|
|
473
529
|
/**
|
|
474
530
|
* What happens to `AuditEvent.outcome`'s payload before it is handed to the
|
|
475
531
|
* sink.
|
|
@@ -555,12 +611,17 @@ export declare type AuditSink = (event: AuditEvent) => void | Promise<void> | Ef
|
|
|
555
611
|
export declare const clearAuditBuffer: () => void;
|
|
556
612
|
|
|
557
613
|
/**
|
|
558
|
-
* A DataStore-backed
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
614
|
+
* A DataStore-backed sink. Optional receiptId identifies one logical append
|
|
615
|
+
* across delivery retries and sink/process reconstruction. Stable receipts
|
|
616
|
+
* require a query-capable store and are refused if their content differs.
|
|
617
|
+
*
|
|
618
|
+
* A per-writer semaphore serializes append attempts. Read-capable stores derive
|
|
619
|
+
* the next link from persisted state, so rejected inserts and lost write
|
|
620
|
+
* acknowledgements cannot advance or fork the writer's chain. Insert-only
|
|
621
|
+
* substitutes retain the old surface, with in-memory state advanced on success.
|
|
622
|
+
* This is append recovery, not durable event capture before business commit.
|
|
562
623
|
*/
|
|
563
|
-
export declare const dataStoreAuditSink: (store: AuditDataStore) => (event: AuditEvent) => Promise<void>;
|
|
624
|
+
export declare const dataStoreAuditSink: (store: AuditDataStore) => (event: AuditEvent, receiptId?: string) => Promise<void>;
|
|
564
625
|
|
|
565
626
|
/** Mint a fresh chain. `chainId` is 96 bits of CSPRNG — collisions across
|
|
566
627
|
* replicas would merge two chains into one apparent fork. */
|