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 +179 -0
- package/index.js +419 -0
- package/lib/diff.js +32 -0
- package/lib/matcher.js +59 -0
- package/lib/redact.js +55 -0
- package/lib/schema.js +99 -0
- package/package.json +43 -0
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
|
+
}
|