@basaltkit/audit 1.0.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Machize Contributors
3
+ Copyright (c) 2026 Basalt Contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/dist/index.d.ts CHANGED
@@ -36,14 +36,69 @@ declare class MemoryAuditStore implements AuditStore {
36
36
  * 'order.*' matches 'order.created', '**' matches everything.
37
37
  */
38
38
  declare function patternMatches(pattern: string, name: string): boolean;
39
+ /** 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;
42
+ /** Default payload scrubber: masks common secret keys, ignoring the event name. */
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;
39
66
  declare class Audit {
40
67
  private readonly store;
41
- constructor(store: AuditStore);
68
+ /** Scrubs each payload before it is stored. Default masks common secret keys. */
69
+ private readonly redact;
70
+ constructor(store: AuditStore,
71
+ /** Scrubs each payload before it is stored. Default masks common secret keys. */
72
+ redact?: AuditRedactor);
42
73
  /** Manual entry — for actions no hook covers. */
43
74
  record(event: string, payload?: unknown): Promise<AuditEntry>;
44
75
  /** @internal used by the plugin's hook/event taps. */
45
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
+ */
46
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[]>;
47
102
  private build;
48
103
  }
49
104
  declare const AUDIT: _basaltkit_core.Token<Audit>;
@@ -59,7 +114,13 @@ interface AuditPluginOptions {
59
114
  * Default: everything. Pass [] to disable.
60
115
  */
61
116
  events?: string[];
117
+ /**
118
+ * Scrubs each payload before it is stored. Defaults to masking common secret
119
+ * keys (password, token, secret, authorization, api-key, …). Pass a custom
120
+ * function to change the policy, or `(p) => p` to store payloads verbatim.
121
+ */
122
+ redact?: AuditRedactor;
62
123
  }
63
124
  declare function auditPlugin(options?: AuditPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
64
125
 
65
- export { AUDIT, Audit, type AuditEntry, type AuditPluginOptions, type AuditQuery, type AuditStore, MemoryAuditStore, auditPlugin, patternMatches };
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 {
@@ -28,11 +28,43 @@ function patternMatches(pattern, name) {
28
28
  }
29
29
  return patternSegments.length === nameSegments.length;
30
30
  }
31
+ var SENSITIVE_KEY = /pass(word|wd)?|secret|token|authorization|api[-_]?key|credential|cookie|session|otp|mfa/i;
32
+ function redactSensitive(value, depth = 0) {
33
+ if (depth > 6 || value === null || typeof value !== "object") return value;
34
+ if (Array.isArray(value)) return value.map((v) => redactSensitive(v, depth + 1));
35
+ const out = {};
36
+ for (const [k, v] of Object.entries(value)) {
37
+ out[k] = SENSITIVE_KEY.test(k) ? "[redacted]" : redactSensitive(v, depth + 1);
38
+ }
39
+ return out;
40
+ }
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);
31
61
  var Audit = class {
32
- constructor(store) {
62
+ constructor(store, redact = defaultAuditRedactor) {
33
63
  this.store = store;
64
+ this.redact = redact;
34
65
  }
35
66
  store;
67
+ redact;
36
68
  /** Manual entry — for actions no hook covers. */
37
69
  async record(event, payload) {
38
70
  const entry = this.build("manual", event, payload);
@@ -43,7 +75,42 @@ var Audit = class {
43
75
  async capture(source, event, payload) {
44
76
  await this.store.append(this.build(source, event, payload));
45
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
+ */
46
92
  async trail(query = {}) {
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 = {}) {
47
114
  return this.store.query(query);
48
115
  }
49
116
  build(source, event, payload) {
@@ -54,7 +121,7 @@ var Audit = class {
54
121
  id: randomUUID(),
55
122
  source,
56
123
  event,
57
- payload,
124
+ payload: this.redact(payload, event),
58
125
  actorId: user?.id,
59
126
  tenantId: tenant?.id,
60
127
  requestId: context?.requestId,
@@ -70,7 +137,7 @@ function auditPlugin(options = {}) {
70
137
  return definePlugin({
71
138
  name: "basalt:audit",
72
139
  register({ container, hooks }) {
73
- container.singleton(AUDIT, () => new Audit(options.store ?? new MemoryAuditStore()));
140
+ container.singleton(AUDIT, () => new Audit(options.store ?? new MemoryAuditStore(), options.redact ?? defaultAuditRedactor));
74
141
  hooks.onAny(async (hook, payload) => {
75
142
  if (!hookPatterns.some((pattern) => patternMatches(pattern, hook))) return;
76
143
  await container.get(AUDIT).capture("hook", hook, payload);
@@ -91,5 +158,10 @@ export {
91
158
  Audit,
92
159
  MemoryAuditStore,
93
160
  auditPlugin,
94
- patternMatches
161
+ defaultAuditRedactor,
162
+ patternMatches,
163
+ piiMinimizingRedactor,
164
+ pseudonymize,
165
+ redactSensitive,
166
+ redactSensitiveAndPii
95
167
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/audit",
3
- "version": "1.0.0",
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",