@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 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, **always scoped to the current tenant**.
108
+ * Reads the audit trail the everyday read.
89
109
  *
90
- * Security model (PII F2):
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
- * - When there is NO tenant in context, an explicit single-tenant read
97
- * (`trail({ tenantId })`) is honoured, but a broad/unscoped read is refused:
98
- * returning every tenant's records must be a deliberate, system-only act via
99
- * {@link systemTrail}, never the silent default.
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, **always scoped to the current tenant**.
164
+ * Reads the audit trail the everyday read.
153
165
  *
154
- * Security model (PII F2):
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
- * - When there is NO tenant in context, an explicit single-tenant read
161
- * (`trail({ tenantId })`) is honoured, but a broad/unscoped read is refused:
162
- * returning every tenant's records must be a deliberate, system-only act via
163
- * {@link systemTrail}, never the silent default.
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
- // No tenant to scope to and no explicit tenant pinned: refuse to silently
177
- // return every tenant's records. Cross-tenant/system reads go through
178
- // systemTrail() so broad access is always deliberate.
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
- container.singleton(AUDIT, () => new Audit(options.store ?? new MemoryAuditStore(), options.redact ?? defaultAuditRedactor));
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/audit",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "engines": {
5
5
  "node": ">=22.5.0"
6
6
  },