davepi-plugin-audit 0.1.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 ADDED
@@ -0,0 +1,179 @@
1
+ # davepi-plugin-audit
2
+
3
+ Immutable, append-only audit log for [dAvePi][davepi]. Subscribes to the in-process record event bus and writes one document per CRUD mutation into an auto-registered `audit` collection — with `before` / `after` snapshots, an RFC 6902 [JSON-Patch][rfc6902] `diff`, the actor's `userId`, request `ip` / `userAgent` / `reqId`, and the resource + action. Queryable through the standard REST + GraphQL surface, admin-only cross-tenant list, no API-level writes or deletes.
4
+
5
+ [davepi]: https://docs.davepi.dev
6
+ [rfc6902]: https://www.rfc-editor.org/rfc/rfc6902
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install davepi-plugin-audit
12
+ ```
13
+
14
+ Add it to your project's `package.json` under `davepi.plugins`:
15
+
16
+ ```json
17
+ {
18
+ "davepi": {
19
+ "plugins": ["davepi-plugin-audit"]
20
+ }
21
+ }
22
+ ```
23
+
24
+ That's it — on boot, the plugin auto-registers the `audit` schema, attaches a `bus.on('record', ...)` listener, and creates the TTL index on `at`. Your existing schemas need **no changes**: every mutation through REST or GraphQL becomes one audit row.
25
+
26
+ ## Configure
27
+
28
+ All config is env-driven:
29
+
30
+ | Variable | Required | Default | Description |
31
+ |----------|----------|---------|-------------|
32
+ | `AUDIT_ENABLED` | no | `true` | Master switch. Setting `false` leaves the plugin dormant — no schema registered, no events captured. |
33
+ | `AUDIT_RETENTION_DAYS` | no | `365` | TTL index on the `at` field. `0` disables retention (audit rows are kept forever) and drops any existing TTL index. |
34
+ | `AUDIT_BULK_BYPASS` | no | `false` | When `true`, bulk events (`PUT /api/{v}/{path}`, GraphQL `updateMany` / `removeMany`) are NOT audited. See *Storage and bulk events* below. |
35
+ | `AUDIT_INCLUDE` | no | *(all)* | Comma-separated allowlist of resource names. Empty / unset means "all resources". |
36
+ | `AUDIT_EXCLUDE` | no | — | Comma-separated denylist. Wins over `AUDIT_INCLUDE` on conflict. |
37
+ | `AUDIT_REDACT` | no | `password,token,secret` | Comma-separated field names whose values are replaced with `[REDACTED]` in `before` and `after`, recursively. Independent of the `pino` redaction set in the framework's logger. |
38
+
39
+ Setting `AUDIT_INCLUDE=order,invoice` audits only those two resources. Setting `AUDIT_EXCLUDE=otp` skips the `otp` resource even if it's in the allowlist (denylist wins). Setting `AUDIT_REDACT=ssn,taxId` *replaces* the default redaction list — if you also want `password` redacted, add it back: `AUDIT_REDACT=password,token,secret,ssn,taxId`.
40
+
41
+ ## What gets written
42
+
43
+ Each row carries these fields (schema declared at boot):
44
+
45
+ | Field | Description |
46
+ |-------|-------------|
47
+ | `userId` | The actor's user_id from the mutation's JWT. The tenant the row "belongs to" for read scoping. |
48
+ | `accountId` | The actor's accountId, when the mutated record had one. |
49
+ | `action` | One of `created`, `updated`, `deleted`, `transitioned`, or any custom string supplied to `plugin.record({...})`. |
50
+ | `resource` | The schema `path` (e.g. `order`, `invoice`). |
51
+ | `resourceId` | The single-record `_id`, or `null` for bulk events. |
52
+ | `before` | The pre-mutation snapshot (post-redaction). `null` for `created`, populated for `updated` / `deleted`, also populated for `transitioned`. May be `null` on GraphQL paths where the framework doesn't fetch a `before`. |
53
+ | `after` | The post-mutation snapshot (post-redaction). Populated for `created` / `updated` / `transitioned`, `null` for `deleted` on the hard-delete path. |
54
+ | `diff` | An RFC 6902 JSON-Patch from `before` to `after`. Stable shape regardless of which side is `null`. |
55
+ | `filter` | Mongo filter for bulk events (`updateMany`). |
56
+ | `numAffected` | Number of records changed by a bulk event. |
57
+ | `ip`, `userAgent`, `reqId` | Request metadata captured at the producing handler. May be `null` for non-HTTP producers (the MCP tools, internal jobs). |
58
+ | `at` | Timestamp the row was written (also drives the TTL index). |
59
+
60
+ The standard `createdAt` / `updatedAt` are also there from the framework's mongoose-timestamp plugin, but `at` is the canonical time-of-event field — it's what the TTL is keyed on, and it's what you sort by when reconstructing a history.
61
+
62
+ ## Reading the audit log
63
+
64
+ The plugin's `audit` schema is registered like any other dAvePi schema, so every standard surface works:
65
+
66
+ ### REST
67
+
68
+ ```bash
69
+ # All events for one record
70
+ GET /api/v1/audit?resource=order&resourceId=<oid>&__sort=at:desc
71
+
72
+ # All deletes for the last 30 days
73
+ GET /api/v1/audit?action=deleted&at__gte=2026-04-25T00:00:00Z
74
+
75
+ # Per-resource view
76
+ GET /api/v1/audit?resource=invoice&__sort=at:desc
77
+ ```
78
+
79
+ ### GraphQL
80
+
81
+ ```graphql
82
+ query {
83
+ auditMany(
84
+ filter: { resource: "order" }
85
+ sort: AT_DESC
86
+ limit: 50
87
+ ) {
88
+ _id
89
+ action
90
+ resourceId
91
+ userId
92
+ before
93
+ after
94
+ diff
95
+ at
96
+ }
97
+ }
98
+ ```
99
+
100
+ ### Tenant scope
101
+
102
+ A regular caller sees only audit rows whose `userId` equals their own — the standard dAvePi owner-scope rule, applied to the audit collection like every other resource. The `audit` schema declares `acl.list = ['admin']`, so callers carrying the `admin` role bypass the owner predicate and see cross-tenant rows. Promote a compliance reviewer's user with `db.users.updateOne({_id}, {$set: {roles: ['admin', 'user']}})` (or your own admin management UI) to grant them the bypass.
103
+
104
+ ## Append-only enforcement (and its limits)
105
+
106
+ The plugin enforces append-only at the **API layer**:
107
+
108
+ - Every field declares an ACL whose only allowed role is a sentinel value no real user holds, so `filterWritable` (the framework's pre-persist strip pass) drops every key from `POST` / `PUT` / bulk-PUT request bodies — the resulting `$set` is empty, the write is a no-op.
109
+ - The schema declares `beforeCreate` / `beforeUpdate` / `beforeDelete` hooks that throw `ForbiddenError`, so the REST single-record `POST` / `PUT /:id` / `DELETE /:id` paths (and their GraphQL `createOne` / `updateById` / `removeById` counterparts) return HTTP **403** with code `FORBIDDEN`.
110
+ - `acl.delete` is intentionally absent — admins don't get a tenant-bypass on delete either, and even an admin's owner-scoped delete is rejected by the hook above.
111
+
112
+ What this **doesn't** stop:
113
+
114
+ - A direct `db.audit.updateOne(...)` / `db.audit.deleteMany(...)` from someone who has Mongo shell access. Database-level immutability is the consumer's call: replica-set + RBAC, periodic archival to S3 with object-lock, or both. The plugin is the wire-side guarantee; the DBA owns the file-system side.
115
+ - GraphQL `auditRemoveMany` will go through `wrapFilter` for tenant scoping but does not currently invoke the `beforeDelete` hook (which is REST-only). A regular user can still delete their **own** audit rows via that mutation. If this matters in your deployment, either rebuild your admin UI to fence the mutation off, or run with `AUDIT_RETENTION_DAYS=0` and a separate replicated copy.
116
+
117
+ ## Storage and bulk events
118
+
119
+ Audit rows carry full `before` + `after` snapshots, which means **the audit collection grows in proportion to your mutation rate × your record size**. A schema whose typical record is 4 KB and that sees 100 mutations/sec produces roughly 8 KB × 100 = 800 KB/s of audit data, or about 70 GB/day before redaction overhead. The defaults are tuned for typical CRUD apps (a few mutations per second per tenant); high-throughput workloads should:
120
+
121
+ 1. Set `AUDIT_RETENTION_DAYS` to the regulatory minimum you can defend (e.g. 90 instead of 365).
122
+ 2. Set `AUDIT_BULK_BYPASS=true` so a `updateMany({status: 'pending'}, ...)` doesn't explode into N audit rows — bulk events without bypass already write **one** row carrying `filter` + `numAffected`, but on a hot bulk path even that one row per call adds up.
123
+ 3. Use `AUDIT_INCLUDE` to narrow the surface to compliance-relevant resources only. Most apps don't need `cache.*` or `session.*` events audited.
124
+
125
+ ## Calling `record()` from a hook
126
+
127
+ The plugin also exports `record(entry)` for ad-hoc audit writes — handy when a non-CRUD event happens that you still want trailed:
128
+
129
+ ```js
130
+ // schema/versions/v1/contract.js
131
+ const audit = require('davepi-plugin-audit');
132
+
133
+ module.exports = {
134
+ path: 'contract',
135
+ collection: 'contract',
136
+ fields: [/* ... */],
137
+ hooks: {
138
+ afterUpdate: async ({ record, previous, user, req }) => {
139
+ // A signature event isn't a normal CRUD verb — record it
140
+ // under a custom action so it shows up alongside the
141
+ // automatic updated/deleted/created rows.
142
+ if (previous && !previous.signedAt && record.signedAt) {
143
+ await audit.record({
144
+ userId: user.user_id,
145
+ action: 'contract_signed',
146
+ resource: 'contract',
147
+ resourceId: record._id,
148
+ before: previous,
149
+ after: record,
150
+ ip: req && req.ip,
151
+ userAgent: req && req.get && req.get('user-agent'),
152
+ reqId: req && req.id,
153
+ });
154
+ }
155
+ },
156
+ },
157
+ };
158
+ ```
159
+
160
+ `record()` is best-effort like the bus subscriber — a thrown Mongo error logs and is swallowed. The function returns `true` when the row was written and `false` otherwise (dormant plugin, failed write).
161
+
162
+ ## Differences from the framework's in-tree audit
163
+
164
+ dAvePi already writes to a separate `audit_log` collection via `utils/audit.js`. That trail captures the same per-mutation before/after as this plugin's `audit` collection but isn't exposed through the schema-driven surface — there are no REST routes, no GraphQL types, no admin UI integration. For v1, both coexist:
165
+
166
+ - `audit_log` (in-tree): existing behaviour, no API surface, written by the persist sites in `utils/schemaLoader.js`.
167
+ - `audit` (this plugin): new collection, full REST + GraphQL + admin SPA + MCP surface, written by the bus listener.
168
+
169
+ Future versions may deprecate the in-tree path once the plugin is the canonical answer; for now, leave both running and query whichever one your tooling expects.
170
+
171
+ ## Failure handling
172
+
173
+ - **Bus subscriber**: every audit write is wrapped in `try/catch`. A Mongo outage logs an `error` row via the framework's pino instance and is otherwise silent — the request loop is never blocked, and the user-facing response is committed even if the audit row is lost. Same posture as every other plugin bus subscriber.
174
+ - **TTL index management**: at boot the plugin tries to align the TTL on `at` with `AUDIT_RETENTION_DAYS`. A failure (Mongo not yet connected, missing permissions) logs a warning and continues — the index can be created manually later, or on the next process restart.
175
+ - **Boot**: a missing dependency (`mongoose`, `davepi/utils/errors`) logs an error and leaves the plugin dormant rather than failing boot. The framework continues to serve traffic without an audit trail; this is intentional for CI / staging without the package fully wired.
176
+
177
+ ## License
178
+
179
+ ISC
package/index.js ADDED
@@ -0,0 +1,419 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * davepi-plugin-audit
5
+ *
6
+ * Immutable, append-only audit log for dAvePi. Loaded by listing the
7
+ * package under the consumer project's `package.json -> davepi.plugins`:
8
+ *
9
+ * {
10
+ * "davepi": { "plugins": ["davepi-plugin-audit"] }
11
+ * }
12
+ *
13
+ * Behaviour:
14
+ * - On boot, auto-registers an `audit` schema (one per process) and
15
+ * subscribes to the in-process `record` event bus. Every CRUD
16
+ * event lands as one document in the `audit` collection with
17
+ * `userId`, `accountId?`, `action`, `resource`, `resourceId`,
18
+ * `before`, `after`, `diff` (a JSON-Patch from before to after),
19
+ * `filter` / `numAffected` for bulk events, plus request metadata
20
+ * (`ip`, `userAgent`, `reqId`) when the producing event carried a
21
+ * `req` snapshot.
22
+ * - The `audit` collection is read-only via the standard API:
23
+ * every field declares an ACL that no role overlaps, and the
24
+ * schema declares `beforeCreate` / `beforeUpdate` / `beforeDelete`
25
+ * hooks that throw `ForbiddenError`. The plugin itself writes
26
+ * directly through Mongoose, which doesn't go through hooks.
27
+ * - `acl.list = ['admin']` gives admins a cross-tenant view; regular
28
+ * users see only audit rows for actions they performed (the
29
+ * standard tenant invariant).
30
+ * - Retention via TTL index on `at`, controlled by
31
+ * `AUDIT_RETENTION_DAYS` (default 365; `0` keeps forever and drops
32
+ * any existing TTL index).
33
+ *
34
+ * Failure isolation: the bus subscriber wraps every Mongo write in
35
+ * try/catch and logs through the framework's pino instance handed in
36
+ * at setup. A misbehaving Mongo or a transient connection blip
37
+ * never blocks the request loop or surfaces as an unhandled
38
+ * rejection — same posture as the slack / postmark plugins.
39
+ *
40
+ * Storage growth: every mutation lands one row carrying `before` +
41
+ * `after` snapshots. Schemas with many large fields and many writes
42
+ * fill Mongo fast — the TTL index is the safety valve. The README
43
+ * carries the sizing math.
44
+ */
45
+
46
+ const { compare } = require('./lib/diff');
47
+ const { redact } = require('./lib/redact');
48
+ const { buildAuditSchema } = require('./lib/schema');
49
+ const {
50
+ shouldAuditResource,
51
+ parseEventType,
52
+ parseList,
53
+ } = require('./lib/matcher');
54
+
55
+ const ENV_KEYS = {
56
+ enabled: 'AUDIT_ENABLED',
57
+ retentionDays: 'AUDIT_RETENTION_DAYS',
58
+ bulkBypass: 'AUDIT_BULK_BYPASS',
59
+ include: 'AUDIT_INCLUDE',
60
+ exclude: 'AUDIT_EXCLUDE',
61
+ redact: 'AUDIT_REDACT',
62
+ };
63
+
64
+ const DEFAULT_REDACT_FIELDS = ['password', 'token', 'secret'];
65
+ const DEFAULT_RETENTION_DAYS = 365;
66
+ const TTL_INDEX_NAME = 'audit_at_ttl';
67
+ // The plugin's own collection. Events whose resource matches this are
68
+ // short-circuited so an audit-of-the-audit-log isn't possible — that
69
+ // would only fire if the plugin's own writes somehow re-entered the
70
+ // bus (they don't today, but the guard makes the contract explicit).
71
+ const SELF_RESOURCE = 'audit';
72
+
73
+ function parseBool(raw, fallback) {
74
+ if (raw === undefined || raw === null || raw === '') return fallback;
75
+ const v = String(raw).trim().toLowerCase();
76
+ if (['1', 'true', 'yes', 'on'].includes(v)) return true;
77
+ if (['0', 'false', 'no', 'off'].includes(v)) return false;
78
+ return fallback;
79
+ }
80
+
81
+ function parseRetentionDays(raw) {
82
+ if (raw === undefined || raw === null || raw === '') return DEFAULT_RETENTION_DAYS;
83
+ const n = parseInt(raw, 10);
84
+ if (Number.isNaN(n) || n < 0) return DEFAULT_RETENTION_DAYS;
85
+ return n;
86
+ }
87
+
88
+ function readConfigFromEnv(env) {
89
+ return {
90
+ enabled: parseBool(env[ENV_KEYS.enabled], true),
91
+ retentionDays: parseRetentionDays(env[ENV_KEYS.retentionDays]),
92
+ bulkBypass: parseBool(env[ENV_KEYS.bulkBypass], false),
93
+ include: parseList(env[ENV_KEYS.include]),
94
+ exclude: parseList(env[ENV_KEYS.exclude]),
95
+ redact: env[ENV_KEYS.redact] === undefined
96
+ ? DEFAULT_REDACT_FIELDS
97
+ : parseList(env[ENV_KEYS.redact]),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Build a fresh plugin instance. Most consumers don't call this
103
+ * directly — `require('davepi-plugin-audit')` returns a default
104
+ * instance configured from `process.env`. Use this factory in tests
105
+ * (so you can inject `env`, `mongoose`, and `errors` stubs without a
106
+ * live framework install) or to override the schema version.
107
+ */
108
+ function createPlugin(opts = {}) {
109
+ const env = opts.env || process.env;
110
+ const injectedMongoose = opts.mongoose || null;
111
+ const injectedErrors = opts.errors || null;
112
+ const schemaVersion = opts.schemaVersion || 'v1';
113
+ const config = readConfigFromEnv(env);
114
+
115
+ const state = {
116
+ enabled: false,
117
+ AuditModel: null,
118
+ log: null,
119
+ };
120
+
121
+ /**
122
+ * Hand-fire a write into the audit collection. Exposed so consumer
123
+ * code (or a hook) can record a non-CRUD event — "user manually
124
+ * approved waiver", "background job ran" — through the same surface.
125
+ * Best-effort: errors are logged and swallowed, mirroring the bus
126
+ * subscriber's posture.
127
+ */
128
+ async function record(entry) {
129
+ if (!state.enabled || !state.AuditModel) {
130
+ // No-throw on the public API — callers from `after*` hooks
131
+ // shouldn't have to gate on whether the plugin happened to be
132
+ // configured. The slack/postmark plugins DO throw on dormant
133
+ // `postMessage` / `sendEmail` because those are explicit
134
+ // outbound calls; here, "audit a thing" is meant to be
135
+ // ergonomic. A dormant plugin just no-ops.
136
+ return false;
137
+ }
138
+ try {
139
+ await state.AuditModel.create(buildRow(entry));
140
+ return true;
141
+ } catch (err) {
142
+ state.log.error(
143
+ { err, plugin: 'audit' },
144
+ 'davepi-plugin-audit: manual record() failed'
145
+ );
146
+ return false;
147
+ }
148
+ }
149
+
150
+ function buildRow(input) {
151
+ const now = input && input.at ? new Date(input.at) : new Date();
152
+ const before = redact(input.before == null ? null : input.before, config.redact);
153
+ const after = redact(input.after == null ? null : input.after, config.redact);
154
+ return {
155
+ userId: input.userId ? String(input.userId) : null,
156
+ accountId: input.accountId ? String(input.accountId) : null,
157
+ action: input.action || null,
158
+ resource: input.resource || null,
159
+ resourceId: input.resourceId ? String(input.resourceId) : null,
160
+ before,
161
+ after,
162
+ diff: compare(before, after),
163
+ filter: input.filter || null,
164
+ numAffected: typeof input.numAffected === 'number' ? input.numAffected : null,
165
+ ip: input.ip || null,
166
+ userAgent: input.userAgent || null,
167
+ reqId: input.reqId || null,
168
+ at: now,
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Translate one bus event into the audit row arguments. Returns
174
+ * `null` to indicate the event should be skipped (resource is the
175
+ * plugin's own collection, type is malformed, or the resource fell
176
+ * out of the include/exclude policy).
177
+ */
178
+ function buildRowFromEvent(event) {
179
+ if (!event || !event.type) return null;
180
+ const parsed = parseEventType(event.type);
181
+ if (!parsed) return null;
182
+ const { resource, action } = parsed;
183
+ if (resource === SELF_RESOURCE) return null;
184
+ if (!shouldAuditResource(resource, config)) return null;
185
+ const isBulk = typeof event.numAffected === 'number' && !event.recordId;
186
+ if (isBulk && config.bulkBypass) return null;
187
+
188
+ // For bulk events we lose `before` / `after` per the framework's
189
+ // event contract — emit a single row with the filter + count.
190
+ // Single-record events carry `record` (after), and the framework's
191
+ // REST layer also carries `before` for updates/deletes when
192
+ // available. Some producers (GraphQL, MCP) don't carry `before`
193
+ // yet — those rows show `before: null`, and the diff is the
194
+ // equivalent of "every field added at this snapshot".
195
+ const reqMeta = event.req && typeof event.req === 'object' ? event.req : null;
196
+ if (isBulk) {
197
+ return {
198
+ userId: event.userId,
199
+ accountId: event.accountId,
200
+ action,
201
+ resource,
202
+ resourceId: null,
203
+ before: null,
204
+ after: null,
205
+ filter: event.filter || null,
206
+ numAffected: event.numAffected,
207
+ ip: reqMeta && reqMeta.ip,
208
+ userAgent: reqMeta && reqMeta.userAgent,
209
+ reqId: reqMeta && reqMeta.reqId,
210
+ };
211
+ }
212
+ return {
213
+ userId: event.userId,
214
+ accountId: event.accountId || (event.record && event.record.accountId),
215
+ action,
216
+ resource,
217
+ resourceId: event.recordId,
218
+ before: event.before === undefined ? null : event.before,
219
+ // `record` is the standard payload key from the framework's
220
+ // single-record events; `after` is the (newer) explicit name.
221
+ // Prefer `after` when both are set so a producer can opt into
222
+ // the explicit shape without breaking older consumers.
223
+ after: event.after !== undefined
224
+ ? event.after
225
+ : (event.record !== undefined ? event.record : null),
226
+ ip: reqMeta && reqMeta.ip,
227
+ userAgent: reqMeta && reqMeta.userAgent,
228
+ reqId: reqMeta && reqMeta.reqId,
229
+ };
230
+ }
231
+
232
+ async function ensureTtlIndex(collection) {
233
+ // `0` (or any non-positive) disables retention. We try to drop an
234
+ // existing TTL index if present so flipping the env from "365 →
235
+ // 0" actually frees the index. dropIndex throws when the index
236
+ // doesn't exist; that's fine — we want the no-op path to be
237
+ // silent.
238
+ if (config.retentionDays <= 0) {
239
+ try {
240
+ await collection.dropIndex(TTL_INDEX_NAME);
241
+ } catch (_e) {
242
+ // not present — nothing to drop
243
+ }
244
+ return;
245
+ }
246
+ const seconds = config.retentionDays * 86400;
247
+ try {
248
+ const existing = await collection.indexes();
249
+ const ttl = existing.find((idx) => idx.name === TTL_INDEX_NAME);
250
+ if (ttl && ttl.expireAfterSeconds !== seconds) {
251
+ // Mongo allows modifying TTL via collMod, but dropping and
252
+ // recreating is simpler and the plugin's setup runs once per
253
+ // process anyway. The brief window without a TTL index can't
254
+ // grow the collection meaningfully.
255
+ await collection.dropIndex(TTL_INDEX_NAME);
256
+ }
257
+ if (!ttl || ttl.expireAfterSeconds !== seconds) {
258
+ await collection.createIndex(
259
+ { at: 1 },
260
+ { name: TTL_INDEX_NAME, expireAfterSeconds: seconds }
261
+ );
262
+ }
263
+ } catch (err) {
264
+ state.log.warn(
265
+ { err, plugin: 'audit' },
266
+ 'davepi-plugin-audit: TTL index management failed; continuing without TTL'
267
+ );
268
+ }
269
+ }
270
+
271
+ async function setup({ app, schemaLoader, bus, log, appName }) {
272
+ state.log = log;
273
+
274
+ if (!config.enabled) {
275
+ log.warn(
276
+ { plugin: 'audit' },
277
+ 'AUDIT_ENABLED=false; davepi-plugin-audit is dormant'
278
+ );
279
+ return;
280
+ }
281
+ if (!schemaLoader || typeof schemaLoader.loadSchema !== 'function') {
282
+ log.error(
283
+ { plugin: 'audit' },
284
+ 'davepi-plugin-audit setup({ schemaLoader }) is required; staying dormant'
285
+ );
286
+ return;
287
+ }
288
+ if (!bus || typeof bus.on !== 'function') {
289
+ log.error(
290
+ { plugin: 'audit' },
291
+ 'davepi-plugin-audit setup({ bus }) is required; staying dormant'
292
+ );
293
+ return;
294
+ }
295
+
296
+ // Resolve framework dependencies lazily so the package's own unit
297
+ // tests (which don't install `davepi` or any of its deps) can run
298
+ // standalone. Production callers will have these on the require
299
+ // path because the framework itself uses them.
300
+ let mongoose = injectedMongoose;
301
+ if (!mongoose) {
302
+ try {
303
+ mongoose = require('mongoose');
304
+ } catch (err) {
305
+ log.error(
306
+ { err, plugin: 'audit' },
307
+ "could not require 'mongoose' to register audit schema; staying dormant"
308
+ );
309
+ return;
310
+ }
311
+ }
312
+ let errors = injectedErrors;
313
+ if (!errors) {
314
+ try {
315
+ errors = require('davepi/utils/errors');
316
+ } catch (err) {
317
+ log.error(
318
+ { err, plugin: 'audit' },
319
+ "could not require 'davepi/utils/errors' to define audit schema hooks; staying dormant"
320
+ );
321
+ return;
322
+ }
323
+ }
324
+
325
+ // Register the audit schema. The loader hot-mounts it onto the
326
+ // existing Express app and rebuilds the GraphQL surface — exactly
327
+ // what the consumer would have done by dropping a file under
328
+ // schema/versions/v1/, just without requiring them to.
329
+ const schema = buildAuditSchema({ mongoose, version: schemaVersion, errors });
330
+ try {
331
+ await schemaLoader.loadSchema(schema);
332
+ } catch (err) {
333
+ log.error(
334
+ { err, plugin: 'audit' },
335
+ 'davepi-plugin-audit: failed to register audit schema; staying dormant'
336
+ );
337
+ return;
338
+ }
339
+ const entry = schemaLoader.getEntry(`${schemaVersion}/audit`);
340
+ if (!entry || !entry.model) {
341
+ log.error(
342
+ { plugin: 'audit' },
343
+ 'davepi-plugin-audit: audit schema registered but model is missing; staying dormant'
344
+ );
345
+ return;
346
+ }
347
+ state.AuditModel = entry.model;
348
+
349
+ // TTL index. Best-effort: if Mongo isn't reachable yet we log and
350
+ // continue — the next setup-cycle (or operator's manual
351
+ // `collection.createIndex(...)`) will catch up.
352
+ if (
353
+ state.AuditModel.collection &&
354
+ typeof state.AuditModel.collection.createIndex === 'function'
355
+ ) {
356
+ await ensureTtlIndex(state.AuditModel.collection);
357
+ }
358
+
359
+ state.enabled = true;
360
+
361
+ bus.on('record', async (event) => {
362
+ if (!state.enabled || !state.AuditModel) return;
363
+ let row;
364
+ try {
365
+ row = buildRowFromEvent(event);
366
+ } catch (err) {
367
+ // Defensive: a malformed event shouldn't take down the bus
368
+ // listener. Log and move on.
369
+ log.error(
370
+ { err, plugin: 'audit', eventType: event && event.type },
371
+ 'davepi-plugin-audit: row build failed'
372
+ );
373
+ return;
374
+ }
375
+ if (!row) return;
376
+ try {
377
+ await state.AuditModel.create(buildRow(row));
378
+ } catch (err) {
379
+ log.error(
380
+ { err, plugin: 'audit', eventType: event && event.type },
381
+ 'davepi-plugin-audit: write failed (audit row lost)'
382
+ );
383
+ }
384
+ });
385
+
386
+ log.info(
387
+ {
388
+ plugin: 'audit',
389
+ retentionDays: config.retentionDays,
390
+ include: config.include,
391
+ exclude: config.exclude,
392
+ bulkBypass: config.bulkBypass,
393
+ },
394
+ 'davepi-plugin-audit ready'
395
+ );
396
+ // Reference `appName` so a future formatter can use it (parity
397
+ // with the slack/postmark plugin signatures); kept here so the
398
+ // contract documented in pluginLoader stays exercised.
399
+ void appName;
400
+ void app;
401
+ }
402
+
403
+ return {
404
+ name: 'audit',
405
+ setup,
406
+ record,
407
+ // Exposed for tests + ad-hoc debugging — not part of the
408
+ // documented plugin API but harmless to leave on the object.
409
+ _buildRowFromEvent: buildRowFromEvent,
410
+ _buildRow: buildRow,
411
+ _config: config,
412
+ };
413
+ }
414
+
415
+ const defaultPlugin = createPlugin();
416
+ module.exports = defaultPlugin;
417
+ module.exports.createPlugin = createPlugin;
418
+ module.exports.compare = compare;
419
+ module.exports.redact = redact;
package/lib/diff.js ADDED
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * RFC 6902 JSON-Patch `compare(before, after)`. Returns an array of
5
+ * `{ op, path, value? }` ops describing how `before` would be
6
+ * transformed into `after`.
7
+ *
8
+ * Delegates to `fast-json-patch.compare` for the actual diff — it's
9
+ * the reference implementation of RFC 6902, has zero runtime deps of
10
+ * its own, and gives us round-trip applicability for free (a consumer
11
+ * can `applyPatch({}, diff)` to reconstruct `after` from `before`).
12
+ * Issue #116 explicitly named `fast-json-patch` as the implementation
13
+ * choice; a homegrown compare would diverge over time on edge cases
14
+ * (array LCS, escape-sequence corners, no-op detection).
15
+ *
16
+ * Inputs may be `null` / `undefined`: the framework's bus emits
17
+ * `before: null` on create and `after: null` on hard-delete, and
18
+ * we coerce both sides to `{}` so the resulting patch is a per-key
19
+ * `add` / `remove` series at the top level rather than a root-replace
20
+ * — easier to render in an audit UI and easier to apply back through
21
+ * `applyPatch({}, diff)`.
22
+ */
23
+
24
+ const jsonpatch = require('fast-json-patch');
25
+
26
+ function compare(before, after) {
27
+ const b = before === null || before === undefined ? {} : before;
28
+ const a = after === null || after === undefined ? {} : after;
29
+ return jsonpatch.compare(b, a);
30
+ }
31
+
32
+ module.exports = { compare };
package/lib/matcher.js ADDED
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Resource allow/deny matcher.
5
+ *
6
+ * `include` is an allowlist of resource names ('order', 'invoice'); an
7
+ * empty / unset list means "all resources are eligible". `exclude` is
8
+ * a denylist that wins on a conflict — if `audit` is in `exclude`, the
9
+ * plugin never writes a row for it, even when also in `include`. That
10
+ * ordering is documented in the issue and lets an operator turn off
11
+ * audit for a specific high-cardinality resource without rebuilding
12
+ * their allowlist.
13
+ *
14
+ * The plugin's own `audit` resource is filtered out by the caller
15
+ * before this matcher runs (any row produced from an `audit.*` event
16
+ * would create a feedback loop), so we don't special-case it here.
17
+ */
18
+ function shouldAuditResource(resource, { include, exclude }) {
19
+ if (!resource) return false;
20
+ if (Array.isArray(exclude) && exclude.length && exclude.includes(resource)) {
21
+ return false;
22
+ }
23
+ if (Array.isArray(include) && include.length) {
24
+ return include.includes(resource);
25
+ }
26
+ return true;
27
+ }
28
+
29
+ /**
30
+ * Parse a record-event `type` into `{ resource, action }`. Events are
31
+ * shaped as `<resource>.<verb>` where verb is one of
32
+ * `created` / `updated` / `deleted` / `transitioned`. We split on the
33
+ * LAST `.` so a resource name with a `.` in it (none today, but the
34
+ * framework doesn't forbid it) still routes correctly.
35
+ *
36
+ * `action` is normalised to the past-tense form the audit row stores
37
+ * (matching the spec). Unknown verbs fall through as-is so the row
38
+ * isn't silently dropped — operators can investigate via the audit
39
+ * row's stored action.
40
+ */
41
+ function parseEventType(type) {
42
+ if (typeof type !== 'string' || !type.length) return null;
43
+ const lastDot = type.lastIndexOf('.');
44
+ if (lastDot <= 0 || lastDot === type.length - 1) return null;
45
+ return {
46
+ resource: type.slice(0, lastDot),
47
+ action: type.slice(lastDot + 1),
48
+ };
49
+ }
50
+
51
+ function parseList(raw) {
52
+ if (!raw || typeof raw !== 'string') return [];
53
+ return raw
54
+ .split(',')
55
+ .map((s) => s.trim())
56
+ .filter(Boolean);
57
+ }
58
+
59
+ module.exports = { shouldAuditResource, parseEventType, parseList };
package/lib/redact.js ADDED
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Recursively scrub fields whose name appears in `fields`, replacing
5
+ * the value with the literal string `[REDACTED]`. Operates on a fresh
6
+ * copy — the input is never mutated, so the same snapshot can also be
7
+ * passed to non-redacting consumers (the framework's in-tree audit,
8
+ * future analytics consumers) without surprise.
9
+ *
10
+ * The match is case-insensitive on the *field name*. Values are
11
+ * untouched apart from the redaction marker; we deliberately do NOT
12
+ * try to redact things like "looks-like-a-credit-card" — that's pino
13
+ * redaction's job and a different posture entirely.
14
+ *
15
+ * Arrays are walked element-by-element. Dates, Buffers, ObjectIds,
16
+ * and other non-plain-object values pass through unchanged so the
17
+ * audit row still serialises faithfully.
18
+ */
19
+ function redact(value, fields) {
20
+ if (!Array.isArray(fields) || fields.length === 0) return value;
21
+ const set = new Set(fields.map((f) => String(f).toLowerCase()));
22
+ return walk(value, set);
23
+ }
24
+
25
+ function walk(value, set) {
26
+ if (value === null || value === undefined) return value;
27
+ if (Array.isArray(value)) {
28
+ return value.map((item) => walk(item, set));
29
+ }
30
+ if (typeof value !== 'object') return value;
31
+ // Preserve non-plain-object types — Date, Buffer, BSON ObjectId, etc.
32
+ // Walking their properties would either produce nonsense or mutate
33
+ // semantics (e.g., replacing Date.prototype.toISOString output with
34
+ // a redacted string).
35
+ if (
36
+ value instanceof Date ||
37
+ (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(value)) ||
38
+ Object.getPrototypeOf(value) !== Object.prototype
39
+ ) {
40
+ return value;
41
+ }
42
+ const out = {};
43
+ for (const [k, v] of Object.entries(value)) {
44
+ if (set.has(k.toLowerCase())) {
45
+ out[k] = '[REDACTED]';
46
+ } else if (v && typeof v === 'object') {
47
+ out[k] = walk(v, set);
48
+ } else {
49
+ out[k] = v;
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ module.exports = { redact };
package/lib/schema.js ADDED
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Build the audit-collection schema the plugin registers with the
5
+ * framework's schemaLoader. The shape matches dAvePi's schema-driven
6
+ * vocabulary (see AGENTS.md) so every standard surface — REST,
7
+ * GraphQL, swagger, the `_describe` manifest — picks it up
8
+ * automatically.
9
+ *
10
+ * Read-only-via-API is enforced at two layers:
11
+ *
12
+ * 1. Every field declares `acl.create` / `acl.update` whose value is
13
+ * a sentinel role no real user holds. `filterWritable` strips
14
+ * every key from inbound payloads, so an authenticated `POST` /
15
+ * `PUT` / bulk `PUT` lands an empty `$set` and writes nothing.
16
+ * 2. `beforeCreate` / `beforeUpdate` / `beforeDelete` hooks throw
17
+ * `ForbiddenError`, surfacing a 403 on the REST single-record
18
+ * paths (and the equivalent GraphQL mutations, which route
19
+ * through the same hook runner).
20
+ *
21
+ * `acl.list = ['admin']` gives admins a cross-tenant bypass on read.
22
+ * `acl.delete` is intentionally absent — combined with the
23
+ * `beforeDelete` hook above, no caller (including admins, since the
24
+ * bypass would only let them widen the owner predicate) can remove a
25
+ * row through the standard CRUD surface.
26
+ *
27
+ * `audit: false` opts this schema OUT of the framework's in-tree
28
+ * audit pipeline (`utils/audit.js`). The plugin's own bus subscriber
29
+ * is what writes rows here; the in-tree pipeline writes to a separate
30
+ * `audit_log` collection. Leaving the framework audit on would let
31
+ * the (blocked) hook throws still leak audit_log rows.
32
+ *
33
+ * `softDelete: false` keeps this collection literal — no `deletedAt`
34
+ * tombstone, no `/restore` route. The acceptance contract is
35
+ * "append-only at the API layer", and a soft-deletable row still
36
+ * presents as deletable to clients even when the underlying document
37
+ * sticks around.
38
+ */
39
+ function buildAuditSchema({ mongoose, version = 'v1', errors }) {
40
+ const Mixed = mongoose.Schema.Types.Mixed;
41
+ // A sentinel role no real user holds. `filterWritable` keeps fields
42
+ // whose acl-allowed roles overlap the caller's roles; an opaque
43
+ // marker guarantees the overlap check fails for every persona,
44
+ // including admin.
45
+ const NO_ONE = ['__davepi_audit_plugin_only__'];
46
+ const withWriteLock = (field) => ({
47
+ ...field,
48
+ acl: { ...(field.acl || {}), create: NO_ONE, update: NO_ONE },
49
+ });
50
+
51
+ const { ForbiddenError } = errors;
52
+ const blockWrite = (op) => async () => {
53
+ throw new ForbiddenError(
54
+ `audit log is append-only; ${op} not permitted via API ` +
55
+ '(entries are written by davepi-plugin-audit only)'
56
+ );
57
+ };
58
+
59
+ return {
60
+ path: 'audit',
61
+ collection: 'audit',
62
+ version,
63
+ softDelete: false,
64
+ audit: false,
65
+ fields: [
66
+ withWriteLock({ name: 'userId', type: String, required: true }),
67
+ withWriteLock({ name: 'accountId', type: String }),
68
+ withWriteLock({ name: 'action', type: String, required: true }),
69
+ withWriteLock({ name: 'resource', type: String, required: true }),
70
+ withWriteLock({ name: 'resourceId', type: String }),
71
+ withWriteLock({ name: 'before', type: Mixed }),
72
+ withWriteLock({ name: 'after', type: Mixed }),
73
+ withWriteLock({ name: 'diff', type: Mixed }),
74
+ withWriteLock({ name: 'filter', type: Mixed }),
75
+ withWriteLock({ name: 'numAffected', type: Number }),
76
+ withWriteLock({ name: 'ip', type: String }),
77
+ withWriteLock({ name: 'userAgent', type: String }),
78
+ withWriteLock({ name: 'reqId', type: String }),
79
+ withWriteLock({ name: 'at', type: Date, required: true }),
80
+ ],
81
+ acl: {
82
+ // Admin role bypasses the owner-scoped read filter so compliance
83
+ // reviewers see cross-tenant rows. Regular users see only the
84
+ // rows whose `userId` matches their own — the standard tenant
85
+ // invariant.
86
+ list: ['admin'],
87
+ // `delete` is deliberately omitted (no bypass). The hook below
88
+ // is the actual block; the missing entry just keeps the bypass
89
+ // story unambiguous.
90
+ },
91
+ hooks: {
92
+ beforeCreate: blockWrite('create'),
93
+ beforeUpdate: blockWrite('update'),
94
+ beforeDelete: blockWrite('delete'),
95
+ },
96
+ };
97
+ }
98
+
99
+ module.exports = { buildAuditSchema };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "davepi-plugin-audit",
3
+ "version": "0.1.0",
4
+ "description": "Immutable append-only audit log for dAvePi. Subscribes to the in-process record event bus and writes one row per CRUD mutation (with before/after, JSON-patch diff, actor, IP, user-agent, and request ID) into an auto-registered `audit` collection that's queryable through the standard REST + GraphQL surface. Admin-only list bypass, no API-level writes, optional TTL retention, redaction, and per-resource allow/deny lists.",
5
+ "license": "ISC",
6
+ "homepage": "https://docs.davepi.dev/features/plugins/",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/projik/davepi.git",
10
+ "directory": "packages/davepi-plugin-audit"
11
+ },
12
+ "keywords": [
13
+ "davepi",
14
+ "davepi-plugin",
15
+ "audit",
16
+ "audit-log",
17
+ "compliance",
18
+ "events"
19
+ ],
20
+ "main": "index.js",
21
+ "files": [
22
+ "index.js",
23
+ "lib",
24
+ "README.md"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "dependencies": {
30
+ "fast-json-patch": "^3.1.1"
31
+ },
32
+ "peerDependencies": {
33
+ "davepi": ">=1.0.5"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "davepi": {
37
+ "optional": false
38
+ }
39
+ },
40
+ "scripts": {
41
+ "test": "node --test test/*.test.js"
42
+ }
43
+ }