@basaltkit/audit 1.3.0 → 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 +29 -0
- package/dist/index.d.ts +34 -7
- package/dist/index.js +43 -12
- package/package.json +1 -1
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)
|
package/dist/index.d.ts
CHANGED
|
@@ -77,26 +77,53 @@ export declare class Audit {
|
|
|
77
77
|
private readonly store;
|
|
78
78
|
/** Scrubs each payload before it is stored. Default masks common secret keys. */
|
|
79
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;
|
|
80
90
|
constructor(store: AuditStore,
|
|
81
91
|
/** Scrubs each payload before it is stored. Default masks common secret keys. */
|
|
82
|
-
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);
|
|
83
103
|
/** Manual entry — for actions no hook covers. */
|
|
84
104
|
record(event: string, payload?: unknown): Promise<AuditEntry>;
|
|
85
105
|
/** @internal used by the plugin's hook/event taps. */
|
|
86
106
|
capture(source: 'hook' | 'event', event: string, payload: unknown): Promise<void>;
|
|
87
107
|
/**
|
|
88
|
-
* Reads the audit trail
|
|
108
|
+
* Reads the audit trail — the everyday read.
|
|
89
109
|
*
|
|
90
|
-
*
|
|
110
|
+
* Tenant scoping (PII F2), applied only where a tenant dimension exists:
|
|
91
111
|
* - When a tenant is present in the ambient context, the read is FORCED to
|
|
92
112
|
* that tenant. Any caller-supplied `query.tenantId` is ignored/overridden
|
|
93
113
|
* (the context tenant is spread LAST so it always wins), so a tenant-facing
|
|
94
114
|
* handler that forwards client input — e.g. `trail({ tenantId: req.query.tenantId })`
|
|
95
115
|
* — can never widen the scope and read another tenant's trail.
|
|
96
|
-
* -
|
|
97
|
-
* (`trail({ tenantId })`) is honoured
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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.
|
|
100
127
|
*/
|
|
101
128
|
trail(query?: AuditQuery): Promise<AuditEntry[]>;
|
|
102
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 = [];
|
|
@@ -132,11 +132,23 @@ export const piiMinimizingRedactor = (payload) => redactSensitiveAndPii(payload)
|
|
|
132
132
|
export class Audit {
|
|
133
133
|
store;
|
|
134
134
|
redact;
|
|
135
|
+
tenancyActive;
|
|
135
136
|
constructor(store,
|
|
136
137
|
/** Scrubs each payload before it is stored. Default masks common secret keys. */
|
|
137
|
-
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) {
|
|
138
149
|
this.store = store;
|
|
139
150
|
this.redact = redact;
|
|
151
|
+
this.tenancyActive = tenancyActive;
|
|
140
152
|
}
|
|
141
153
|
/** Manual entry — for actions no hook covers. */
|
|
142
154
|
async record(event, payload) {
|
|
@@ -149,18 +161,25 @@ export class Audit {
|
|
|
149
161
|
await this.store.append(this.build(source, event, payload));
|
|
150
162
|
}
|
|
151
163
|
/**
|
|
152
|
-
* Reads the audit trail
|
|
164
|
+
* Reads the audit trail — the everyday read.
|
|
153
165
|
*
|
|
154
|
-
*
|
|
166
|
+
* Tenant scoping (PII F2), applied only where a tenant dimension exists:
|
|
155
167
|
* - When a tenant is present in the ambient context, the read is FORCED to
|
|
156
168
|
* that tenant. Any caller-supplied `query.tenantId` is ignored/overridden
|
|
157
169
|
* (the context tenant is spread LAST so it always wins), so a tenant-facing
|
|
158
170
|
* handler that forwards client input — e.g. `trail({ tenantId: req.query.tenantId })`
|
|
159
171
|
* — can never widen the scope and read another tenant's trail.
|
|
160
|
-
* -
|
|
161
|
-
* (`trail({ tenantId })`) is honoured
|
|
162
|
-
*
|
|
163
|
-
*
|
|
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.
|
|
164
183
|
*/
|
|
165
184
|
async trail(query = {}) {
|
|
166
185
|
const ctxTenantId = tryCtx()?.['tenant']?.id;
|
|
@@ -173,9 +192,14 @@ export class Audit {
|
|
|
173
192
|
// No context, but the caller explicitly pinned a single tenant.
|
|
174
193
|
return this.store.query(query);
|
|
175
194
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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.
|
|
179
203
|
throw new Error('Audit.trail() requires a tenant in context or an explicit `tenantId`. ' +
|
|
180
204
|
'For a deliberate system-wide, cross-tenant read use Audit.systemTrail().');
|
|
181
205
|
}
|
|
@@ -215,7 +239,14 @@ export function auditPlugin(options = {}) {
|
|
|
215
239
|
return definePlugin({
|
|
216
240
|
name: 'basalt:audit',
|
|
217
241
|
register({ container, hooks }) {
|
|
218
|
-
|
|
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));
|
|
219
250
|
hooks.onAny(async (hook, payload) => {
|
|
220
251
|
if (!hookPatterns.some((pattern) => patternMatches(pattern, hook)))
|
|
221
252
|
return;
|