@basaltkit/audit 1.2.0 → 1.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/index.d.ts +15 -19
- package/dist/index.js +201 -159
- package/package.json +11 -12
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://basaltkit-docs.pages.dev">
|
|
3
|
+
<img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
1
7
|
# @basaltkit/audit
|
|
2
8
|
|
|
3
9
|
Audit trail for Basalt applications: automatically records, in an immutable history, who did what and when — from lifecycle hooks, domain events, and manual records.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import * as _basaltkit_core from '@basaltkit/core';
|
|
2
|
-
|
|
3
1
|
/** One immutable line of the trail. */
|
|
4
|
-
interface AuditEntry {
|
|
2
|
+
export interface AuditEntry {
|
|
5
3
|
readonly id: string;
|
|
6
4
|
/** Where it came from: a lifecycle hook, a domain event or a manual record. */
|
|
7
5
|
readonly source: 'hook' | 'event' | 'manual';
|
|
@@ -13,7 +11,7 @@ interface AuditEntry {
|
|
|
13
11
|
readonly requestId?: string | undefined;
|
|
14
12
|
readonly at: number;
|
|
15
13
|
}
|
|
16
|
-
interface AuditQuery {
|
|
14
|
+
export interface AuditQuery {
|
|
17
15
|
/** Wildcard pattern over the event name (e.g. 'auth:**'). */
|
|
18
16
|
event?: string;
|
|
19
17
|
tenantId?: string;
|
|
@@ -22,11 +20,11 @@ interface AuditQuery {
|
|
|
22
20
|
limit?: number;
|
|
23
21
|
}
|
|
24
22
|
/** Append-only by contract: no update, no delete. */
|
|
25
|
-
interface AuditStore {
|
|
23
|
+
export interface AuditStore {
|
|
26
24
|
append(entry: AuditEntry): Promise<void>;
|
|
27
25
|
query(query: AuditQuery): Promise<AuditEntry[]>;
|
|
28
26
|
}
|
|
29
|
-
declare class MemoryAuditStore implements AuditStore {
|
|
27
|
+
export declare class MemoryAuditStore implements AuditStore {
|
|
30
28
|
private readonly entries;
|
|
31
29
|
append(entry: AuditEntry): Promise<void>;
|
|
32
30
|
query(query: AuditQuery): Promise<AuditEntry[]>;
|
|
@@ -35,24 +33,24 @@ declare class MemoryAuditStore implements AuditStore {
|
|
|
35
33
|
* Wildcard matcher over ':' and '.' segments: 'auth:**' matches 'auth:login',
|
|
36
34
|
* 'order.*' matches 'order.created', '**' matches everything.
|
|
37
35
|
*/
|
|
38
|
-
declare function patternMatches(pattern: string, name: string): boolean;
|
|
36
|
+
export declare function patternMatches(pattern: string, name: string): boolean;
|
|
39
37
|
/** Recursively masks sensitive fields so secrets/PII never reach the trail. */
|
|
40
|
-
declare function redactSensitive(value: unknown, depth?: number): unknown;
|
|
41
|
-
type AuditRedactor = (payload: unknown, event: string) => unknown;
|
|
38
|
+
export declare function redactSensitive(value: unknown, depth?: number): unknown;
|
|
39
|
+
export type AuditRedactor = (payload: unknown, event: string) => unknown;
|
|
42
40
|
/** Default payload scrubber: masks common secret keys, ignoring the event name. */
|
|
43
|
-
declare const defaultAuditRedactor: AuditRedactor;
|
|
41
|
+
export declare const defaultAuditRedactor: AuditRedactor;
|
|
44
42
|
/**
|
|
45
43
|
* Deterministically pseudonymizes a value: the same input always maps to the
|
|
46
44
|
* same opaque token, so records stay correlatable without persisting the raw PII.
|
|
47
45
|
*/
|
|
48
|
-
declare function pseudonymize(value: string): string;
|
|
46
|
+
export declare function pseudonymize(value: string): string;
|
|
49
47
|
/**
|
|
50
48
|
* Recursively masks secrets (like {@link redactSensitive}) AND replaces obvious
|
|
51
49
|
* PII — email/phone-shaped values, and values under common PII keys — with a
|
|
52
50
|
* stable pseudonym. Use it to minimize PII at rest in the trail while keeping
|
|
53
51
|
* entries correlatable.
|
|
54
52
|
*/
|
|
55
|
-
declare function redactSensitiveAndPii(value: unknown, depth?: number): unknown;
|
|
53
|
+
export declare function redactSensitiveAndPii(value: unknown, depth?: number): unknown;
|
|
56
54
|
/**
|
|
57
55
|
* Payload scrubber that also pseudonymizes obvious PII (PII F3). Opt-in — pass it
|
|
58
56
|
* to `auditPlugin({ redact: piiMinimizingRedactor })` or `new Audit(store, piiMinimizingRedactor)`.
|
|
@@ -62,8 +60,8 @@ declare function redactSensitiveAndPii(value: unknown, depth?: number): unknown;
|
|
|
62
60
|
* declare per-event field policies; kept out of the default here to avoid changing
|
|
63
61
|
* existing capture/redaction behavior.
|
|
64
62
|
*/
|
|
65
|
-
declare const piiMinimizingRedactor: AuditRedactor;
|
|
66
|
-
declare class Audit {
|
|
63
|
+
export declare const piiMinimizingRedactor: AuditRedactor;
|
|
64
|
+
export declare class Audit {
|
|
67
65
|
private readonly store;
|
|
68
66
|
/** Scrubs each payload before it is stored. Default masks common secret keys. */
|
|
69
67
|
private readonly redact;
|
|
@@ -101,8 +99,8 @@ declare class Audit {
|
|
|
101
99
|
systemTrail(query?: AuditQuery): Promise<AuditEntry[]>;
|
|
102
100
|
private build;
|
|
103
101
|
}
|
|
104
|
-
declare const AUDIT:
|
|
105
|
-
interface AuditPluginOptions {
|
|
102
|
+
export declare const AUDIT: import("@basaltkit/core").Token<Audit>;
|
|
103
|
+
export interface AuditPluginOptions {
|
|
106
104
|
store?: AuditStore;
|
|
107
105
|
/**
|
|
108
106
|
* Lifecycle hook patterns to record automatically.
|
|
@@ -121,6 +119,4 @@ interface AuditPluginOptions {
|
|
|
121
119
|
*/
|
|
122
120
|
redact?: AuditRedactor;
|
|
123
121
|
}
|
|
124
|
-
declare function auditPlugin(options?: AuditPluginOptions):
|
|
125
|
-
|
|
126
|
-
export { AUDIT, Audit, type AuditEntry, type AuditPluginOptions, type AuditQuery, type AuditRedactor, type AuditStore, MemoryAuditStore, auditPlugin, defaultAuditRedactor, patternMatches, piiMinimizingRedactor, pseudonymize, redactSensitive, redactSensitiveAndPii };
|
|
122
|
+
export declare function auditPlugin(options?: AuditPluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
|
package/dist/index.js
CHANGED
|
@@ -1,167 +1,209 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
};
|
|
18
|
-
function patternMatches(pattern, name) {
|
|
19
|
-
if (pattern === name || pattern === "**") return true;
|
|
20
|
-
const split = (value) => value.split(/[.:]/);
|
|
21
|
-
const patternSegments = split(pattern);
|
|
22
|
-
const nameSegments = split(name);
|
|
23
|
-
for (let i = 0; i < patternSegments.length; i++) {
|
|
24
|
-
const segment = patternSegments[i];
|
|
25
|
-
if (segment === "**") return i < nameSegments.length;
|
|
26
|
-
if (i >= nameSegments.length) return false;
|
|
27
|
-
if (segment !== "*" && segment !== nameSegments[i]) return false;
|
|
28
|
-
}
|
|
29
|
-
return patternSegments.length === nameSegments.length;
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createToken, definePlugin, tryCtx } from '@basaltkit/core';
|
|
3
|
+
import { EVENTS } from '@basaltkit/events';
|
|
4
|
+
export class MemoryAuditStore {
|
|
5
|
+
entries = [];
|
|
6
|
+
async append(entry) {
|
|
7
|
+
this.entries.push(Object.freeze({ ...entry }));
|
|
8
|
+
}
|
|
9
|
+
async query(query) {
|
|
10
|
+
let results = this.entries.filter((entry) => (query.event === undefined || patternMatches(query.event, entry.event)) &&
|
|
11
|
+
(query.tenantId === undefined || entry.tenantId === query.tenantId) &&
|
|
12
|
+
(query.actorId === undefined || entry.actorId === query.actorId) &&
|
|
13
|
+
(query.since === undefined || entry.at >= query.since));
|
|
14
|
+
results = [...results].reverse(); // newest first
|
|
15
|
+
return query.limit !== undefined ? results.slice(0, query.limit) : results;
|
|
16
|
+
}
|
|
30
17
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Wildcard matcher over ':' and '.' segments: 'auth:**' matches 'auth:login',
|
|
20
|
+
* 'order.*' matches 'order.created', '**' matches everything.
|
|
21
|
+
*/
|
|
22
|
+
export function patternMatches(pattern, name) {
|
|
23
|
+
if (pattern === name || pattern === '**')
|
|
24
|
+
return true;
|
|
25
|
+
const split = (value) => value.split(/[.:]/);
|
|
26
|
+
const patternSegments = split(pattern);
|
|
27
|
+
const nameSegments = split(name);
|
|
28
|
+
for (let i = 0; i < patternSegments.length; i++) {
|
|
29
|
+
const segment = patternSegments[i];
|
|
30
|
+
if (segment === '**')
|
|
31
|
+
return i < nameSegments.length;
|
|
32
|
+
if (i >= nameSegments.length)
|
|
33
|
+
return false;
|
|
34
|
+
if (segment !== '*' && segment !== nameSegments[i])
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return patternSegments.length === nameSegments.length;
|
|
40
38
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
function
|
|
45
|
-
|
|
39
|
+
/** Object keys whose values are masked before an entry is persisted. */
|
|
40
|
+
const SENSITIVE_KEY = /pass(word|wd)?|secret|token|authorization|api[-_]?key|credential|cookie|session|otp|mfa/i;
|
|
41
|
+
/** Recursively masks sensitive fields so secrets/PII never reach the trail. */
|
|
42
|
+
export function redactSensitive(value, depth = 0) {
|
|
43
|
+
if (depth > 6 || value === null || typeof value !== 'object')
|
|
44
|
+
return value;
|
|
45
|
+
if (Array.isArray(value))
|
|
46
|
+
return value.map((v) => redactSensitive(v, depth + 1));
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [k, v] of Object.entries(value)) {
|
|
49
|
+
out[k] = SENSITIVE_KEY.test(k) ? '[redacted]' : redactSensitive(v, depth + 1);
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
46
52
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
/** Default payload scrubber: masks common secret keys, ignoring the event name. */
|
|
54
|
+
export const defaultAuditRedactor = (payload) => redactSensitive(payload);
|
|
55
|
+
/** Object keys that commonly carry direct PII and can be pseudonymized on request. */
|
|
56
|
+
const PII_KEY = /e[-_]?mail|phone|msisdn|ssn|nif|taxid|passport/i;
|
|
57
|
+
/** A value that looks like an email address. */
|
|
58
|
+
const EMAIL_VALUE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
59
|
+
/**
|
|
60
|
+
* Deterministically pseudonymizes a value: the same input always maps to the
|
|
61
|
+
* same opaque token, so records stay correlatable without persisting the raw PII.
|
|
62
|
+
*/
|
|
63
|
+
export function pseudonymize(value) {
|
|
64
|
+
return `pii_${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
|
|
59
65
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
*/
|
|
92
|
-
async trail(query = {}) {
|
|
93
|
-
const ctxTenantId = tryCtx()?.["tenant"]?.id;
|
|
94
|
-
if (ctxTenantId !== void 0) {
|
|
95
|
-
return this.store.query({ ...query, tenantId: ctxTenantId });
|
|
66
|
+
/**
|
|
67
|
+
* Recursively masks secrets (like {@link redactSensitive}) AND replaces obvious
|
|
68
|
+
* PII — email/phone-shaped values, and values under common PII keys — with a
|
|
69
|
+
* stable pseudonym. Use it to minimize PII at rest in the trail while keeping
|
|
70
|
+
* entries correlatable.
|
|
71
|
+
*/
|
|
72
|
+
export function redactSensitiveAndPii(value, depth = 0) {
|
|
73
|
+
if (depth > 6 || value === null)
|
|
74
|
+
return value;
|
|
75
|
+
// Bound the length before the regex: a real email is <= 254 chars (RFC 5321),
|
|
76
|
+
// so only test plausibly-email-length strings — arbitrary logged values never
|
|
77
|
+
// reach the regex, avoiding ReDoS on attacker-influenceable input.
|
|
78
|
+
if (typeof value === 'string')
|
|
79
|
+
return value.length <= 320 && EMAIL_VALUE.test(value) ? pseudonymize(value) : value;
|
|
80
|
+
if (typeof value !== 'object')
|
|
81
|
+
return value;
|
|
82
|
+
if (Array.isArray(value))
|
|
83
|
+
return value.map((v) => redactSensitiveAndPii(v, depth + 1));
|
|
84
|
+
const out = {};
|
|
85
|
+
for (const [k, v] of Object.entries(value)) {
|
|
86
|
+
if (SENSITIVE_KEY.test(k))
|
|
87
|
+
out[k] = '[redacted]';
|
|
88
|
+
else if (PII_KEY.test(k) && typeof v === 'string')
|
|
89
|
+
out[k] = pseudonymize(v);
|
|
90
|
+
else
|
|
91
|
+
out[k] = redactSensitiveAndPii(v, depth + 1);
|
|
96
92
|
}
|
|
97
|
-
|
|
98
|
-
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Payload scrubber that also pseudonymizes obvious PII (PII F3). Opt-in — pass it
|
|
97
|
+
* to `auditPlugin({ redact: piiMinimizingRedactor })` or `new Audit(store, piiMinimizingRedactor)`.
|
|
98
|
+
*
|
|
99
|
+
* TODO(PII F3 follow-up): the default capture set still persists whatever the
|
|
100
|
+
* emitting code puts in the payload. A fuller minimization pass would let callers
|
|
101
|
+
* declare per-event field policies; kept out of the default here to avoid changing
|
|
102
|
+
* existing capture/redaction behavior.
|
|
103
|
+
*/
|
|
104
|
+
export const piiMinimizingRedactor = (payload) => redactSensitiveAndPii(payload);
|
|
105
|
+
export class Audit {
|
|
106
|
+
store;
|
|
107
|
+
redact;
|
|
108
|
+
constructor(store,
|
|
109
|
+
/** Scrubs each payload before it is stored. Default masks common secret keys. */
|
|
110
|
+
redact = defaultAuditRedactor) {
|
|
111
|
+
this.store = store;
|
|
112
|
+
this.redact = redact;
|
|
99
113
|
}
|
|
100
|
-
|
|
101
|
-
|
|
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);
|
|
115
|
-
}
|
|
116
|
-
build(source, event, payload) {
|
|
117
|
-
const context = tryCtx();
|
|
118
|
-
const user = context?.["user"];
|
|
119
|
-
const tenant = context?.["tenant"];
|
|
120
|
-
return Object.freeze({
|
|
121
|
-
id: randomUUID(),
|
|
122
|
-
source,
|
|
123
|
-
event,
|
|
124
|
-
payload: this.redact(payload, event),
|
|
125
|
-
actorId: user?.id,
|
|
126
|
-
tenantId: tenant?.id,
|
|
127
|
-
requestId: context?.requestId,
|
|
128
|
-
at: Date.now()
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
var AUDIT = createToken("audit");
|
|
133
|
-
var DEFAULT_HOOK_PATTERNS = ["auth:**", "billing:**", "tenancy:**", "permission:**"];
|
|
134
|
-
function auditPlugin(options = {}) {
|
|
135
|
-
const hookPatterns = options.hooks ?? DEFAULT_HOOK_PATTERNS;
|
|
136
|
-
const eventPatterns = options.events ?? ["**"];
|
|
137
|
-
return definePlugin({
|
|
138
|
-
name: "basalt:audit",
|
|
139
|
-
register({ container, hooks }) {
|
|
140
|
-
container.singleton(AUDIT, () => new Audit(options.store ?? new MemoryAuditStore(), options.redact ?? defaultAuditRedactor));
|
|
141
|
-
hooks.onAny(async (hook, payload) => {
|
|
142
|
-
if (!hookPatterns.some((pattern) => patternMatches(pattern, hook))) return;
|
|
143
|
-
await container.get(AUDIT).capture("hook", hook, payload);
|
|
144
|
-
});
|
|
145
|
-
},
|
|
146
|
-
boot({ container }) {
|
|
147
|
-
if (eventPatterns.length === 0 || !container.has(EVENTS)) return;
|
|
148
|
-
const bus = container.get(EVENTS);
|
|
149
|
-
bus.on("**", async (payload, meta) => {
|
|
150
|
-
if (!eventPatterns.some((pattern) => patternMatches(pattern, meta.name))) return;
|
|
151
|
-
await container.get(AUDIT).capture("event", meta.name, payload);
|
|
152
|
-
});
|
|
114
|
+
/** Manual entry — for actions no hook covers. */
|
|
115
|
+
async record(event, payload) {
|
|
116
|
+
const entry = this.build('manual', event, payload);
|
|
117
|
+
await this.store.append(entry);
|
|
118
|
+
return entry;
|
|
153
119
|
}
|
|
154
|
-
|
|
120
|
+
/** @internal used by the plugin's hook/event taps. */
|
|
121
|
+
async capture(source, event, payload) {
|
|
122
|
+
await this.store.append(this.build(source, event, payload));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Reads the audit trail, **always scoped to the current tenant**.
|
|
126
|
+
*
|
|
127
|
+
* Security model (PII F2):
|
|
128
|
+
* - When a tenant is present in the ambient context, the read is FORCED to
|
|
129
|
+
* that tenant. Any caller-supplied `query.tenantId` is ignored/overridden
|
|
130
|
+
* (the context tenant is spread LAST so it always wins), so a tenant-facing
|
|
131
|
+
* handler that forwards client input — e.g. `trail({ tenantId: req.query.tenantId })`
|
|
132
|
+
* — can never widen the scope and read another tenant's trail.
|
|
133
|
+
* - When there is NO tenant in context, an explicit single-tenant read
|
|
134
|
+
* (`trail({ tenantId })`) is honoured, but a broad/unscoped read is refused:
|
|
135
|
+
* returning every tenant's records must be a deliberate, system-only act via
|
|
136
|
+
* {@link systemTrail}, never the silent default.
|
|
137
|
+
*/
|
|
138
|
+
async trail(query = {}) {
|
|
139
|
+
const ctxTenantId = tryCtx()?.['tenant']?.id;
|
|
140
|
+
if (ctxTenantId !== undefined) {
|
|
141
|
+
// Force the scope: spread the context tenant LAST so a differing
|
|
142
|
+
// caller-supplied `tenantId` cannot override it.
|
|
143
|
+
return this.store.query({ ...query, tenantId: ctxTenantId });
|
|
144
|
+
}
|
|
145
|
+
if (query.tenantId !== undefined) {
|
|
146
|
+
// No context, but the caller explicitly pinned a single tenant.
|
|
147
|
+
return this.store.query(query);
|
|
148
|
+
}
|
|
149
|
+
// No tenant to scope to and no explicit tenant pinned: refuse to silently
|
|
150
|
+
// return every tenant's records. Cross-tenant/system reads go through
|
|
151
|
+
// systemTrail() so broad access is always deliberate.
|
|
152
|
+
throw new Error('Audit.trail() requires a tenant in context or an explicit `tenantId`. ' +
|
|
153
|
+
'For a deliberate system-wide, cross-tenant read use Audit.systemTrail().');
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* SYSTEM-ONLY escape hatch: reads across ALL tenants (or whatever
|
|
157
|
+
* `query.tenantId` explicitly pins), bypassing the tenant auto-scoping that
|
|
158
|
+
* {@link trail} enforces.
|
|
159
|
+
*
|
|
160
|
+
* This exists for trusted platform/admin tooling only. NEVER call it with, or
|
|
161
|
+
* forward into it, client-controlled input — doing so re-opens the
|
|
162
|
+
* cross-tenant data-exposure that {@link trail} closes.
|
|
163
|
+
*/
|
|
164
|
+
async systemTrail(query = {}) {
|
|
165
|
+
return this.store.query(query);
|
|
166
|
+
}
|
|
167
|
+
build(source, event, payload) {
|
|
168
|
+
const context = tryCtx();
|
|
169
|
+
const user = context?.['user'];
|
|
170
|
+
const tenant = context?.['tenant'];
|
|
171
|
+
return Object.freeze({
|
|
172
|
+
id: randomUUID(),
|
|
173
|
+
source,
|
|
174
|
+
event,
|
|
175
|
+
payload: this.redact(payload, event),
|
|
176
|
+
actorId: user?.id,
|
|
177
|
+
tenantId: tenant?.id,
|
|
178
|
+
requestId: context?.requestId,
|
|
179
|
+
at: Date.now(),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export const AUDIT = createToken('audit');
|
|
184
|
+
const DEFAULT_HOOK_PATTERNS = ['auth:**', 'billing:**', 'tenancy:**', 'permission:**'];
|
|
185
|
+
export function auditPlugin(options = {}) {
|
|
186
|
+
const hookPatterns = options.hooks ?? DEFAULT_HOOK_PATTERNS;
|
|
187
|
+
const eventPatterns = options.events ?? ['**'];
|
|
188
|
+
return definePlugin({
|
|
189
|
+
name: 'basalt:audit',
|
|
190
|
+
register({ container, hooks }) {
|
|
191
|
+
container.singleton(AUDIT, () => new Audit(options.store ?? new MemoryAuditStore(), options.redact ?? defaultAuditRedactor));
|
|
192
|
+
hooks.onAny(async (hook, payload) => {
|
|
193
|
+
if (!hookPatterns.some((pattern) => patternMatches(pattern, hook)))
|
|
194
|
+
return;
|
|
195
|
+
await container.get(AUDIT).capture('hook', hook, payload);
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
boot({ container }) {
|
|
199
|
+
if (eventPatterns.length === 0 || !container.has(EVENTS))
|
|
200
|
+
return;
|
|
201
|
+
const bus = container.get(EVENTS);
|
|
202
|
+
bus.on('**', async (payload, meta) => {
|
|
203
|
+
if (!eventPatterns.some((pattern) => patternMatches(pattern, meta.name)))
|
|
204
|
+
return;
|
|
205
|
+
await container.get(AUDIT).capture('event', meta.name, payload);
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
});
|
|
155
209
|
}
|
|
156
|
-
export {
|
|
157
|
-
AUDIT,
|
|
158
|
-
Audit,
|
|
159
|
-
MemoryAuditStore,
|
|
160
|
-
auditPlugin,
|
|
161
|
-
defaultAuditRedactor,
|
|
162
|
-
patternMatches,
|
|
163
|
-
piiMinimizingRedactor,
|
|
164
|
-
pseudonymize,
|
|
165
|
-
redactSensitive,
|
|
166
|
-
redactSensitiveAndPii
|
|
167
|
-
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/audit",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
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",
|
|
@@ -14,15 +14,14 @@
|
|
|
14
14
|
"dist"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@basaltkit/
|
|
18
|
-
"@basaltkit/
|
|
17
|
+
"@basaltkit/events": "^1.0.1",
|
|
18
|
+
"@basaltkit/core": "^1.1.2"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
|
-
"@types/node": "^
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"zod": "^3.24.0",
|
|
21
|
+
"@types/node": "^26.3.0",
|
|
22
|
+
"typescript": "^7.0.2",
|
|
23
|
+
"vitest": "^4.1.11",
|
|
24
|
+
"zod": "^3.24.0 || ^4.0.0",
|
|
26
25
|
"@basaltkit/tsconfig": "^0.24.0"
|
|
27
26
|
},
|
|
28
27
|
"publishConfig": {
|
|
@@ -30,11 +29,11 @@
|
|
|
30
29
|
},
|
|
31
30
|
"repository": {
|
|
32
31
|
"type": "git",
|
|
33
|
-
"url": "git+https://github.com/
|
|
32
|
+
"url": "git+https://github.com/basaltkit/basalt.git",
|
|
34
33
|
"directory": "packages/audit"
|
|
35
34
|
},
|
|
36
|
-
"homepage": "https://github.com/
|
|
37
|
-
"bugs": "https://github.com/
|
|
35
|
+
"homepage": "https://github.com/basaltkit/basalt/tree/main/packages/audit#readme",
|
|
36
|
+
"bugs": "https://github.com/basaltkit/basalt/issues",
|
|
38
37
|
"keywords": [
|
|
39
38
|
"basalt",
|
|
40
39
|
"typescript",
|
|
@@ -43,7 +42,7 @@
|
|
|
43
42
|
"audit-log"
|
|
44
43
|
],
|
|
45
44
|
"scripts": {
|
|
46
|
-
"build": "
|
|
45
|
+
"build": "tsc -p tsconfig.build.json",
|
|
47
46
|
"test": "vitest run",
|
|
48
47
|
"typecheck": "tsc --noEmit"
|
|
49
48
|
}
|