@basaltkit/audit 1.1.0 → 1.2.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/dist/index.d.ts +47 -1
- package/dist/index.js +60 -4
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -41,6 +41,28 @@ declare function redactSensitive(value: unknown, depth?: number): unknown;
|
|
|
41
41
|
type AuditRedactor = (payload: unknown, event: string) => unknown;
|
|
42
42
|
/** Default payload scrubber: masks common secret keys, ignoring the event name. */
|
|
43
43
|
declare const defaultAuditRedactor: AuditRedactor;
|
|
44
|
+
/**
|
|
45
|
+
* Deterministically pseudonymizes a value: the same input always maps to the
|
|
46
|
+
* same opaque token, so records stay correlatable without persisting the raw PII.
|
|
47
|
+
*/
|
|
48
|
+
declare function pseudonymize(value: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Recursively masks secrets (like {@link redactSensitive}) AND replaces obvious
|
|
51
|
+
* PII — email/phone-shaped values, and values under common PII keys — with a
|
|
52
|
+
* stable pseudonym. Use it to minimize PII at rest in the trail while keeping
|
|
53
|
+
* entries correlatable.
|
|
54
|
+
*/
|
|
55
|
+
declare function redactSensitiveAndPii(value: unknown, depth?: number): unknown;
|
|
56
|
+
/**
|
|
57
|
+
* Payload scrubber that also pseudonymizes obvious PII (PII F3). Opt-in — pass it
|
|
58
|
+
* to `auditPlugin({ redact: piiMinimizingRedactor })` or `new Audit(store, piiMinimizingRedactor)`.
|
|
59
|
+
*
|
|
60
|
+
* TODO(PII F3 follow-up): the default capture set still persists whatever the
|
|
61
|
+
* emitting code puts in the payload. A fuller minimization pass would let callers
|
|
62
|
+
* declare per-event field policies; kept out of the default here to avoid changing
|
|
63
|
+
* existing capture/redaction behavior.
|
|
64
|
+
*/
|
|
65
|
+
declare const piiMinimizingRedactor: AuditRedactor;
|
|
44
66
|
declare class Audit {
|
|
45
67
|
private readonly store;
|
|
46
68
|
/** Scrubs each payload before it is stored. Default masks common secret keys. */
|
|
@@ -52,7 +74,31 @@ declare class Audit {
|
|
|
52
74
|
record(event: string, payload?: unknown): Promise<AuditEntry>;
|
|
53
75
|
/** @internal used by the plugin's hook/event taps. */
|
|
54
76
|
capture(source: 'hook' | 'event', event: string, payload: unknown): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* Reads the audit trail, **always scoped to the current tenant**.
|
|
79
|
+
*
|
|
80
|
+
* Security model (PII F2):
|
|
81
|
+
* - When a tenant is present in the ambient context, the read is FORCED to
|
|
82
|
+
* that tenant. Any caller-supplied `query.tenantId` is ignored/overridden
|
|
83
|
+
* (the context tenant is spread LAST so it always wins), so a tenant-facing
|
|
84
|
+
* handler that forwards client input — e.g. `trail({ tenantId: req.query.tenantId })`
|
|
85
|
+
* — can never widen the scope and read another tenant's trail.
|
|
86
|
+
* - When there is NO tenant in context, an explicit single-tenant read
|
|
87
|
+
* (`trail({ tenantId })`) is honoured, but a broad/unscoped read is refused:
|
|
88
|
+
* returning every tenant's records must be a deliberate, system-only act via
|
|
89
|
+
* {@link systemTrail}, never the silent default.
|
|
90
|
+
*/
|
|
55
91
|
trail(query?: AuditQuery): Promise<AuditEntry[]>;
|
|
92
|
+
/**
|
|
93
|
+
* SYSTEM-ONLY escape hatch: reads across ALL tenants (or whatever
|
|
94
|
+
* `query.tenantId` explicitly pins), bypassing the tenant auto-scoping that
|
|
95
|
+
* {@link trail} enforces.
|
|
96
|
+
*
|
|
97
|
+
* This exists for trusted platform/admin tooling only. NEVER call it with, or
|
|
98
|
+
* forward into it, client-controlled input — doing so re-opens the
|
|
99
|
+
* cross-tenant data-exposure that {@link trail} closes.
|
|
100
|
+
*/
|
|
101
|
+
systemTrail(query?: AuditQuery): Promise<AuditEntry[]>;
|
|
56
102
|
private build;
|
|
57
103
|
}
|
|
58
104
|
declare const AUDIT: _basaltkit_core.Token<Audit>;
|
|
@@ -77,4 +123,4 @@ interface AuditPluginOptions {
|
|
|
77
123
|
}
|
|
78
124
|
declare function auditPlugin(options?: AuditPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
|
|
79
125
|
|
|
80
|
-
export { AUDIT, Audit, type AuditEntry, type AuditPluginOptions, type AuditQuery, type AuditRedactor, type AuditStore, MemoryAuditStore, auditPlugin, defaultAuditRedactor, patternMatches, redactSensitive };
|
|
126
|
+
export { AUDIT, Audit, type AuditEntry, type AuditPluginOptions, type AuditQuery, type AuditRedactor, type AuditStore, MemoryAuditStore, auditPlugin, defaultAuditRedactor, patternMatches, piiMinimizingRedactor, pseudonymize, redactSensitive, redactSensitiveAndPii };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { randomUUID } from "crypto";
|
|
2
|
+
import { createHash, randomUUID } from "crypto";
|
|
3
3
|
import { createToken, definePlugin, tryCtx } from "@basaltkit/core";
|
|
4
4
|
import { EVENTS } from "@basaltkit/events";
|
|
5
5
|
var MemoryAuditStore = class {
|
|
@@ -39,6 +39,25 @@ function redactSensitive(value, depth = 0) {
|
|
|
39
39
|
return out;
|
|
40
40
|
}
|
|
41
41
|
var defaultAuditRedactor = (payload) => redactSensitive(payload);
|
|
42
|
+
var PII_KEY = /e[-_]?mail|phone|msisdn|ssn|nif|taxid|passport/i;
|
|
43
|
+
var EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
44
|
+
function pseudonymize(value) {
|
|
45
|
+
return `pii_${createHash("sha256").update(value).digest("hex").slice(0, 16)}`;
|
|
46
|
+
}
|
|
47
|
+
function redactSensitiveAndPii(value, depth = 0) {
|
|
48
|
+
if (depth > 6 || value === null) return value;
|
|
49
|
+
if (typeof value === "string") return EMAIL_VALUE.test(value) ? pseudonymize(value) : value;
|
|
50
|
+
if (typeof value !== "object") return value;
|
|
51
|
+
if (Array.isArray(value)) return value.map((v) => redactSensitiveAndPii(v, depth + 1));
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [k, v] of Object.entries(value)) {
|
|
54
|
+
if (SENSITIVE_KEY.test(k)) out[k] = "[redacted]";
|
|
55
|
+
else if (PII_KEY.test(k) && typeof v === "string") out[k] = pseudonymize(v);
|
|
56
|
+
else out[k] = redactSensitiveAndPii(v, depth + 1);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
var piiMinimizingRedactor = (payload) => redactSensitiveAndPii(payload);
|
|
42
61
|
var Audit = class {
|
|
43
62
|
constructor(store, redact = defaultAuditRedactor) {
|
|
44
63
|
this.store = store;
|
|
@@ -56,9 +75,43 @@ var Audit = class {
|
|
|
56
75
|
async capture(source, event, payload) {
|
|
57
76
|
await this.store.append(this.build(source, event, payload));
|
|
58
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Reads the audit trail, **always scoped to the current tenant**.
|
|
80
|
+
*
|
|
81
|
+
* Security model (PII F2):
|
|
82
|
+
* - When a tenant is present in the ambient context, the read is FORCED to
|
|
83
|
+
* that tenant. Any caller-supplied `query.tenantId` is ignored/overridden
|
|
84
|
+
* (the context tenant is spread LAST so it always wins), so a tenant-facing
|
|
85
|
+
* handler that forwards client input — e.g. `trail({ tenantId: req.query.tenantId })`
|
|
86
|
+
* — can never widen the scope and read another tenant's trail.
|
|
87
|
+
* - When there is NO tenant in context, an explicit single-tenant read
|
|
88
|
+
* (`trail({ tenantId })`) is honoured, but a broad/unscoped read is refused:
|
|
89
|
+
* returning every tenant's records must be a deliberate, system-only act via
|
|
90
|
+
* {@link systemTrail}, never the silent default.
|
|
91
|
+
*/
|
|
59
92
|
async trail(query = {}) {
|
|
60
|
-
const
|
|
61
|
-
|
|
93
|
+
const ctxTenantId = tryCtx()?.["tenant"]?.id;
|
|
94
|
+
if (ctxTenantId !== void 0) {
|
|
95
|
+
return this.store.query({ ...query, tenantId: ctxTenantId });
|
|
96
|
+
}
|
|
97
|
+
if (query.tenantId !== void 0) {
|
|
98
|
+
return this.store.query(query);
|
|
99
|
+
}
|
|
100
|
+
throw new Error(
|
|
101
|
+
"Audit.trail() requires a tenant in context or an explicit `tenantId`. For a deliberate system-wide, cross-tenant read use Audit.systemTrail()."
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* SYSTEM-ONLY escape hatch: reads across ALL tenants (or whatever
|
|
106
|
+
* `query.tenantId` explicitly pins), bypassing the tenant auto-scoping that
|
|
107
|
+
* {@link trail} enforces.
|
|
108
|
+
*
|
|
109
|
+
* This exists for trusted platform/admin tooling only. NEVER call it with, or
|
|
110
|
+
* forward into it, client-controlled input — doing so re-opens the
|
|
111
|
+
* cross-tenant data-exposure that {@link trail} closes.
|
|
112
|
+
*/
|
|
113
|
+
async systemTrail(query = {}) {
|
|
114
|
+
return this.store.query(query);
|
|
62
115
|
}
|
|
63
116
|
build(source, event, payload) {
|
|
64
117
|
const context = tryCtx();
|
|
@@ -107,5 +160,8 @@ export {
|
|
|
107
160
|
auditPlugin,
|
|
108
161
|
defaultAuditRedactor,
|
|
109
162
|
patternMatches,
|
|
110
|
-
|
|
163
|
+
piiMinimizingRedactor,
|
|
164
|
+
pseudonymize,
|
|
165
|
+
redactSensitive,
|
|
166
|
+
redactSensitiveAndPii
|
|
111
167
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Append-only audit trail for Basalt: automatically records lifecycle hooks and domain events, enriched with actor/tenant/request from the context.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|