@basaltkit/audit 1.2.2 → 1.4.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/README.md +44 -1
- package/dist/index.d.ts +46 -7
- package/dist/index.js +72 -14
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -117,6 +117,35 @@ await audit.trail({ since: Date.now() - 86_400_000 }) // last 24h
|
|
|
117
117
|
await audit.trail({ limit: 50 }) // at most 50
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
+
#### `trail()` vs `systemTrail()` — how the tenant scope is decided
|
|
121
|
+
|
|
122
|
+
`@basaltkit/audit` is a **general-purpose** package: it works with or without
|
|
123
|
+
`@basaltkit/tenancy`, and `trail()` is always the everyday read. What it does
|
|
124
|
+
when it can't resolve a tenant depends on whether the app is multi-tenant at all
|
|
125
|
+
— detected through tenancy's `tenancy:active` metadata marker, not an import.
|
|
126
|
+
|
|
127
|
+
| Situation | `trail()` |
|
|
128
|
+
|---|---|
|
|
129
|
+
| Tenant in `ctx()` | **Forced** to that tenant. A caller-supplied `tenantId` is overridden, so forwarding client input can never widen the scope. |
|
|
130
|
+
| No context tenant, explicit `trail({ tenantId })` | Honoured — a system job or CLI pinning one tenant deliberately. |
|
|
131
|
+
| No context tenant, no `tenantId`, **no `tenancyPlugin`** | Returns the trail. A single-tenant app has no tenant dimension, so there is nothing to cross. |
|
|
132
|
+
| No context tenant, no `tenantId`, **`tenancyPlugin` registered** | **Throws.** Returning every tenant's records must be deliberate — use `systemTrail()`. |
|
|
133
|
+
|
|
134
|
+
`systemTrail(query)` is the **system-only** escape hatch: it reads across all
|
|
135
|
+
tenants, bypassing the auto-scoping above. Use it from trusted platform/admin
|
|
136
|
+
tooling only, and never pass client-controlled input into it — that re-opens
|
|
137
|
+
exactly the cross-tenant exposure `trail()` closes.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
// Single-tenant app (no tenancyPlugin): this is the normal read.
|
|
141
|
+
await audit.trail()
|
|
142
|
+
|
|
143
|
+
// Multi-tenant app: scoped automatically inside a request…
|
|
144
|
+
await audit.trail() // → only ctx().tenant's entries
|
|
145
|
+
// …and a platform-wide read is spelled out.
|
|
146
|
+
await audit.systemTrail({ event: 'billing:**' })
|
|
147
|
+
```
|
|
148
|
+
|
|
120
149
|
Event patterns support segments separated by `:` (hooks) or `.` (events): `*` matches one segment, `**` matches one or more. E.g.: `auth:*` matches `auth:login`; `order.**` matches `order.created` and `order.item.added`; `**` matches everything.
|
|
121
150
|
|
|
122
151
|
### Custom store (production)
|
|
@@ -182,7 +211,7 @@ Registers an `Audit` (singleton, token `AUDIT`), hooks into **all** hooks (`hook
|
|
|
182
211
|
| `tenantId` | `string` | No | all | Filters by tenant. |
|
|
183
212
|
| `actorId` | `string` | No | all | Filters by actor. |
|
|
184
213
|
| `since` | `number` | No | since forever | Only entries with `at >= since`. |
|
|
185
|
-
| `limit` | `number` | No | no limit | Maximum number of results. |
|
|
214
|
+
| `limit` | `number` | No | no limit | Maximum number of results. The SQL-backed stores push it into the database, so a limited query never loads the whole trail. |
|
|
186
215
|
|
|
187
216
|
### `interface AuditStore`
|
|
188
217
|
|
|
@@ -191,10 +220,21 @@ Storage contract, **append-only by contract** (no update/delete):
|
|
|
191
220
|
- `append(entry: AuditEntry): Promise<void>`
|
|
192
221
|
- `query(query: AuditQuery): Promise<AuditEntry[]>` — must return most recent first and apply filters/limit.
|
|
193
222
|
|
|
223
|
+
Two helpers exist so a driver can push the limit down safely:
|
|
224
|
+
|
|
225
|
+
- `exactEventMatch(pattern?: string): string | undefined` — the event filter that may be pushed into SQL as an equality. Returns `undefined` for a pattern containing `*` (a wildcard) **or** `.` (because `patternMatches` treats `.` and `:` as interchangeable, so an equality would miss `a:b` for the pattern `a.b`); those must still be matched in code.
|
|
226
|
+
- `AUDIT_SCAN_PAGE: number` — rows a driver should read per round-trip when a wildcard forces a scan (500). Bounds peak memory.
|
|
227
|
+
|
|
194
228
|
### `class MemoryAuditStore`
|
|
195
229
|
|
|
196
230
|
In-memory implementation of `AuditStore` (freezes each entry; filters and reverses on query). Ideal for dev and tests; does not persist.
|
|
197
231
|
|
|
232
|
+
### Redaction
|
|
233
|
+
|
|
234
|
+
Payloads are scrubbed before they are persisted. `redactSensitive` masks values under secret-looking keys (`password`, `token`, `api_key`, `authorization`, …) as `'[redacted]'`; the opt-in `redactSensitiveAndPii` / `piiMinimizingRedactor` additionally replaces email/phone-shaped values with a stable `pii_<hash>` pseudonym.
|
|
235
|
+
|
|
236
|
+
Both walk **6 levels deep**. Anything deeper is replaced with `'[truncated]'` — not passed through. Payloads are arbitrary and the default subscription is `events: ['**']`, so returning the raw subtree meant a secret nested seven levels down reached the trail in cleartext. If your payloads are deeply nested, flatten them before recording rather than relying on depth.
|
|
237
|
+
|
|
198
238
|
### `patternMatches(pattern: string, name: string): boolean`
|
|
199
239
|
|
|
200
240
|
Wildcard matcher over `:` and `.` segments — exported for reuse. `*` = one segment; `**` = one or more; `'**'` matches everything.
|
|
@@ -225,6 +265,9 @@ The defaults only cover `auth/billing/tenancy/permission`. Pass `hooks: [...]` w
|
|
|
225
265
|
**I lost the history after restarting.**
|
|
226
266
|
`MemoryAuditStore` is volatile. In production, implement `AuditStore` over a database.
|
|
227
267
|
|
|
268
|
+
**A deeply-nested field comes back as `'[truncated]'`.**
|
|
269
|
+
The redactors stop at 6 levels and drop everything below, so a secret can never slip past the depth bound. Flatten the payload (or record the interesting fields explicitly) if you need that data in the trail.
|
|
270
|
+
|
|
228
271
|
**Can I edit or delete an entry?**
|
|
229
272
|
No — the contract is append-only and entries are frozen. This is a feature, not a limitation: it's what gives the trail evidentiary value.
|
|
230
273
|
|
package/dist/index.d.ts
CHANGED
|
@@ -34,6 +34,18 @@ export declare class MemoryAuditStore implements AuditStore {
|
|
|
34
34
|
* 'order.*' matches 'order.created', '**' matches everything.
|
|
35
35
|
*/
|
|
36
36
|
export declare function patternMatches(pattern: string, name: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* The event filter a driver may push into SQL as an equality. A pattern with a
|
|
39
|
+
* wildcard must still be matched in code; so must one containing `.`, because
|
|
40
|
+
* {@link patternMatches} treats `.` and `:` as interchangeable separators and an
|
|
41
|
+
* equality would miss `a:b` for the pattern `a.b`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function exactEventMatch(pattern: string | undefined): string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Rows a driver reads per round-trip when a wildcard pattern forces a scan.
|
|
46
|
+
* Bounds peak memory: a limited query no longer materialises the whole trail.
|
|
47
|
+
*/
|
|
48
|
+
export declare const AUDIT_SCAN_PAGE = 500;
|
|
37
49
|
/** Recursively masks sensitive fields so secrets/PII never reach the trail. */
|
|
38
50
|
export declare function redactSensitive(value: unknown, depth?: number): unknown;
|
|
39
51
|
export type AuditRedactor = (payload: unknown, event: string) => unknown;
|
|
@@ -65,26 +77,53 @@ export declare class Audit {
|
|
|
65
77
|
private readonly store;
|
|
66
78
|
/** Scrubs each payload before it is stored. Default masks common secret keys. */
|
|
67
79
|
private readonly redact;
|
|
80
|
+
/**
|
|
81
|
+
* Whether the host app is multi-tenant, i.e. whether `@basaltkit/tenancy`
|
|
82
|
+
* is registered. `auditPlugin` wires this to the container's
|
|
83
|
+
* `'tenancy:active'` metadata marker; it is a *signal*, never an import —
|
|
84
|
+
* `@basaltkit/audit` is a generic package and must not depend on tenancy.
|
|
85
|
+
*
|
|
86
|
+
* Defaults to `false`: a hand-built `new Audit(store)` behaves like a
|
|
87
|
+
* single-tenant app, which is the only thing it can safely assume.
|
|
88
|
+
*/
|
|
89
|
+
private readonly tenancyActive;
|
|
68
90
|
constructor(store: AuditStore,
|
|
69
91
|
/** Scrubs each payload before it is stored. Default masks common secret keys. */
|
|
70
|
-
redact?: AuditRedactor
|
|
92
|
+
redact?: AuditRedactor,
|
|
93
|
+
/**
|
|
94
|
+
* Whether the host app is multi-tenant, i.e. whether `@basaltkit/tenancy`
|
|
95
|
+
* is registered. `auditPlugin` wires this to the container's
|
|
96
|
+
* `'tenancy:active'` metadata marker; it is a *signal*, never an import —
|
|
97
|
+
* `@basaltkit/audit` is a generic package and must not depend on tenancy.
|
|
98
|
+
*
|
|
99
|
+
* Defaults to `false`: a hand-built `new Audit(store)` behaves like a
|
|
100
|
+
* single-tenant app, which is the only thing it can safely assume.
|
|
101
|
+
*/
|
|
102
|
+
tenancyActive?: () => boolean);
|
|
71
103
|
/** Manual entry — for actions no hook covers. */
|
|
72
104
|
record(event: string, payload?: unknown): Promise<AuditEntry>;
|
|
73
105
|
/** @internal used by the plugin's hook/event taps. */
|
|
74
106
|
capture(source: 'hook' | 'event', event: string, payload: unknown): Promise<void>;
|
|
75
107
|
/**
|
|
76
|
-
* Reads the audit trail
|
|
108
|
+
* Reads the audit trail — the everyday read.
|
|
77
109
|
*
|
|
78
|
-
*
|
|
110
|
+
* Tenant scoping (PII F2), applied only where a tenant dimension exists:
|
|
79
111
|
* - When a tenant is present in the ambient context, the read is FORCED to
|
|
80
112
|
* that tenant. Any caller-supplied `query.tenantId` is ignored/overridden
|
|
81
113
|
* (the context tenant is spread LAST so it always wins), so a tenant-facing
|
|
82
114
|
* handler that forwards client input — e.g. `trail({ tenantId: req.query.tenantId })`
|
|
83
115
|
* — can never widen the scope and read another tenant's trail.
|
|
84
|
-
* -
|
|
85
|
-
* (`trail({ tenantId })`) is honoured
|
|
86
|
-
*
|
|
87
|
-
*
|
|
116
|
+
* - With no tenant in context, an explicit single-tenant read
|
|
117
|
+
* (`trail({ tenantId })`) is honoured.
|
|
118
|
+
* - With no tenant in context and no explicit `tenantId`, the behavior depends
|
|
119
|
+
* on whether the app is multi-tenant at all:
|
|
120
|
+
* - **Tenancy registered** (`@basaltkit/tenancy` present): the read is
|
|
121
|
+
* REFUSED. Returning every tenant's records must be a deliberate,
|
|
122
|
+
* system-only act via {@link systemTrail}, never the silent default.
|
|
123
|
+
* - **No tenancy** (single-tenant/non-SaaS app): there is no tenant
|
|
124
|
+
* dimension to scope to, so this is simply "read the trail" and returns
|
|
125
|
+
* the rows. `@basaltkit/audit` is a general-purpose package; it must work
|
|
126
|
+
* without the opt-in SaaS layer.
|
|
88
127
|
*/
|
|
89
128
|
trail(query?: AuditQuery): Promise<AuditEntry[]>;
|
|
90
129
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { createToken, definePlugin, tryCtx } from '@basaltkit/core';
|
|
2
|
+
import { createToken, definePlugin, ensureMetadata, tryCtx } from '@basaltkit/core';
|
|
3
3
|
import { EVENTS } from '@basaltkit/events';
|
|
4
4
|
export class MemoryAuditStore {
|
|
5
5
|
entries = [];
|
|
@@ -36,12 +36,37 @@ export function patternMatches(pattern, name) {
|
|
|
36
36
|
}
|
|
37
37
|
return patternSegments.length === nameSegments.length;
|
|
38
38
|
}
|
|
39
|
+
/** How deep the redactors walk a payload before dropping the rest. */
|
|
40
|
+
const MAX_REDACT_DEPTH = 6;
|
|
41
|
+
/** Stand-in for a subtree deeper than {@link MAX_REDACT_DEPTH}. */
|
|
42
|
+
const TRUNCATED = '[truncated]';
|
|
43
|
+
/**
|
|
44
|
+
* The event filter a driver may push into SQL as an equality. A pattern with a
|
|
45
|
+
* wildcard must still be matched in code; so must one containing `.`, because
|
|
46
|
+
* {@link patternMatches} treats `.` and `:` as interchangeable separators and an
|
|
47
|
+
* equality would miss `a:b` for the pattern `a.b`.
|
|
48
|
+
*/
|
|
49
|
+
export function exactEventMatch(pattern) {
|
|
50
|
+
if (pattern === undefined)
|
|
51
|
+
return undefined;
|
|
52
|
+
return /[*.]/.test(pattern) ? undefined : pattern;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Rows a driver reads per round-trip when a wildcard pattern forces a scan.
|
|
56
|
+
* Bounds peak memory: a limited query no longer materialises the whole trail.
|
|
57
|
+
*/
|
|
58
|
+
export const AUDIT_SCAN_PAGE = 500;
|
|
39
59
|
/** Object keys whose values are masked before an entry is persisted. */
|
|
40
60
|
const SENSITIVE_KEY = /pass(word|wd)?|secret|token|authorization|api[-_]?key|credential|cookie|session|otp|mfa/i;
|
|
41
61
|
/** Recursively masks sensitive fields so secrets/PII never reach the trail. */
|
|
42
62
|
export function redactSensitive(value, depth = 0) {
|
|
43
|
-
|
|
63
|
+
// Past the depth bound the subtree is dropped, NOT passed through: event
|
|
64
|
+
// payloads are arbitrary, and returning the raw value here let a secret nested
|
|
65
|
+
// deeper than the limit reach the trail in cleartext.
|
|
66
|
+
if (value === null || typeof value !== 'object')
|
|
44
67
|
return value;
|
|
68
|
+
if (depth > MAX_REDACT_DEPTH)
|
|
69
|
+
return TRUNCATED;
|
|
45
70
|
if (Array.isArray(value))
|
|
46
71
|
return value.map((v) => redactSensitive(v, depth + 1));
|
|
47
72
|
const out = {};
|
|
@@ -70,7 +95,7 @@ export function pseudonymize(value) {
|
|
|
70
95
|
* entries correlatable.
|
|
71
96
|
*/
|
|
72
97
|
export function redactSensitiveAndPii(value, depth = 0) {
|
|
73
|
-
if (
|
|
98
|
+
if (value === null)
|
|
74
99
|
return value;
|
|
75
100
|
// Bound the length before the regex: a real email is <= 254 chars (RFC 5321),
|
|
76
101
|
// so only test plausibly-email-length strings — arbitrary logged values never
|
|
@@ -79,6 +104,8 @@ export function redactSensitiveAndPii(value, depth = 0) {
|
|
|
79
104
|
return value.length <= 320 && EMAIL_VALUE.test(value) ? pseudonymize(value) : value;
|
|
80
105
|
if (typeof value !== 'object')
|
|
81
106
|
return value;
|
|
107
|
+
if (depth > MAX_REDACT_DEPTH)
|
|
108
|
+
return TRUNCATED;
|
|
82
109
|
if (Array.isArray(value))
|
|
83
110
|
return value.map((v) => redactSensitiveAndPii(v, depth + 1));
|
|
84
111
|
const out = {};
|
|
@@ -105,11 +132,23 @@ export const piiMinimizingRedactor = (payload) => redactSensitiveAndPii(payload)
|
|
|
105
132
|
export class Audit {
|
|
106
133
|
store;
|
|
107
134
|
redact;
|
|
135
|
+
tenancyActive;
|
|
108
136
|
constructor(store,
|
|
109
137
|
/** Scrubs each payload before it is stored. Default masks common secret keys. */
|
|
110
|
-
redact = defaultAuditRedactor
|
|
138
|
+
redact = defaultAuditRedactor,
|
|
139
|
+
/**
|
|
140
|
+
* Whether the host app is multi-tenant, i.e. whether `@basaltkit/tenancy`
|
|
141
|
+
* is registered. `auditPlugin` wires this to the container's
|
|
142
|
+
* `'tenancy:active'` metadata marker; it is a *signal*, never an import —
|
|
143
|
+
* `@basaltkit/audit` is a generic package and must not depend on tenancy.
|
|
144
|
+
*
|
|
145
|
+
* Defaults to `false`: a hand-built `new Audit(store)` behaves like a
|
|
146
|
+
* single-tenant app, which is the only thing it can safely assume.
|
|
147
|
+
*/
|
|
148
|
+
tenancyActive = () => false) {
|
|
111
149
|
this.store = store;
|
|
112
150
|
this.redact = redact;
|
|
151
|
+
this.tenancyActive = tenancyActive;
|
|
113
152
|
}
|
|
114
153
|
/** Manual entry — for actions no hook covers. */
|
|
115
154
|
async record(event, payload) {
|
|
@@ -122,18 +161,25 @@ export class Audit {
|
|
|
122
161
|
await this.store.append(this.build(source, event, payload));
|
|
123
162
|
}
|
|
124
163
|
/**
|
|
125
|
-
* Reads the audit trail
|
|
164
|
+
* Reads the audit trail — the everyday read.
|
|
126
165
|
*
|
|
127
|
-
*
|
|
166
|
+
* Tenant scoping (PII F2), applied only where a tenant dimension exists:
|
|
128
167
|
* - When a tenant is present in the ambient context, the read is FORCED to
|
|
129
168
|
* that tenant. Any caller-supplied `query.tenantId` is ignored/overridden
|
|
130
169
|
* (the context tenant is spread LAST so it always wins), so a tenant-facing
|
|
131
170
|
* handler that forwards client input — e.g. `trail({ tenantId: req.query.tenantId })`
|
|
132
171
|
* — can never widen the scope and read another tenant's trail.
|
|
133
|
-
* -
|
|
134
|
-
* (`trail({ tenantId })`) is honoured
|
|
135
|
-
*
|
|
136
|
-
*
|
|
172
|
+
* - With no tenant in context, an explicit single-tenant read
|
|
173
|
+
* (`trail({ tenantId })`) is honoured.
|
|
174
|
+
* - With no tenant in context and no explicit `tenantId`, the behavior depends
|
|
175
|
+
* on whether the app is multi-tenant at all:
|
|
176
|
+
* - **Tenancy registered** (`@basaltkit/tenancy` present): the read is
|
|
177
|
+
* REFUSED. Returning every tenant's records must be a deliberate,
|
|
178
|
+
* system-only act via {@link systemTrail}, never the silent default.
|
|
179
|
+
* - **No tenancy** (single-tenant/non-SaaS app): there is no tenant
|
|
180
|
+
* dimension to scope to, so this is simply "read the trail" and returns
|
|
181
|
+
* the rows. `@basaltkit/audit` is a general-purpose package; it must work
|
|
182
|
+
* without the opt-in SaaS layer.
|
|
137
183
|
*/
|
|
138
184
|
async trail(query = {}) {
|
|
139
185
|
const ctxTenantId = tryCtx()?.['tenant']?.id;
|
|
@@ -146,9 +192,14 @@ export class Audit {
|
|
|
146
192
|
// No context, but the caller explicitly pinned a single tenant.
|
|
147
193
|
return this.store.query(query);
|
|
148
194
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
195
|
+
if (!this.tenancyActive()) {
|
|
196
|
+
// Single-tenant app: no tenant dimension, so an unscoped read is correct
|
|
197
|
+
// and is the everyday call. Nothing to widen — every entry is "ours".
|
|
198
|
+
return this.store.query(query);
|
|
199
|
+
}
|
|
200
|
+
// Multi-tenant app with no tenant to scope to and no explicit tenant
|
|
201
|
+
// pinned: refuse to silently return every tenant's records. Cross-tenant /
|
|
202
|
+
// system reads go through systemTrail() so broad access is deliberate.
|
|
152
203
|
throw new Error('Audit.trail() requires a tenant in context or an explicit `tenantId`. ' +
|
|
153
204
|
'For a deliberate system-wide, cross-tenant read use Audit.systemTrail().');
|
|
154
205
|
}
|
|
@@ -188,7 +239,14 @@ export function auditPlugin(options = {}) {
|
|
|
188
239
|
return definePlugin({
|
|
189
240
|
name: 'basalt:audit',
|
|
190
241
|
register({ container, hooks }) {
|
|
191
|
-
|
|
242
|
+
// The 'tenancy:active' marker is set by tenancyPlugin. Reading it here
|
|
243
|
+
// (a string-keyed metadata bucket, not an import) is how a generic
|
|
244
|
+
// package learns the app is multi-tenant without depending on
|
|
245
|
+
// @basaltkit/tenancy — the same signal @basaltkit/cache uses. It is
|
|
246
|
+
// resolved per call, so plugin registration order does not matter.
|
|
247
|
+
const metadata = ensureMetadata(container);
|
|
248
|
+
const tenancyActive = () => metadata.get('tenancy:active').length > 0;
|
|
249
|
+
container.singleton(AUDIT, () => new Audit(options.store ?? new MemoryAuditStore(), options.redact ?? defaultAuditRedactor, tenancyActive));
|
|
192
250
|
hooks.onAny(async (hook, payload) => {
|
|
193
251
|
if (!hookPatterns.some((pattern) => patternMatches(pattern, hook)))
|
|
194
252
|
return;
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": ">=22.5.0"
|
|
6
|
+
},
|
|
4
7
|
"description": "Append-only audit trail for Basalt: automatically records lifecycle hooks and domain events, enriched with actor/tenant/request from the context.",
|
|
5
8
|
"license": "MIT",
|
|
6
9
|
"type": "module",
|
|
10
|
+
"sideEffects": false,
|
|
7
11
|
"exports": {
|
|
8
12
|
".": {
|
|
9
13
|
"types": "./dist/index.d.ts",
|
|
@@ -14,8 +18,8 @@
|
|
|
14
18
|
"dist"
|
|
15
19
|
],
|
|
16
20
|
"dependencies": {
|
|
17
|
-
"@basaltkit/
|
|
18
|
-
"@basaltkit/
|
|
21
|
+
"@basaltkit/core": "^1.3.1",
|
|
22
|
+
"@basaltkit/events": "^1.1.1"
|
|
19
23
|
},
|
|
20
24
|
"devDependencies": {
|
|
21
25
|
"@types/node": "^26.3.0",
|