strapi-cms-audit-log 1.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1026 -0
  4. package/dist/admin/App-BLt4lqEM.js +1284 -0
  5. package/dist/admin/App-ou5hy99f.mjs +1266 -0
  6. package/dist/admin/en-B0rPiE2W.mjs +87 -0
  7. package/dist/admin/en-BMlJxq3g.js +87 -0
  8. package/dist/admin/index-BYi8OPTw.js +80 -0
  9. package/dist/admin/index-DdmY-p3Q.mjs +81 -0
  10. package/dist/admin/index.js +4 -0
  11. package/dist/admin/index.mjs +4 -0
  12. package/dist/admin/src/components/AuditLogFilters.d.ts +18 -0
  13. package/dist/admin/src/components/AuditLogTable.d.ts +19 -0
  14. package/dist/admin/src/components/ChangeViewer.d.ts +14 -0
  15. package/dist/admin/src/components/JsonViewer.d.ts +20 -0
  16. package/dist/admin/src/components/PluginIcon.d.ts +3 -0
  17. package/dist/admin/src/components/WidgetDiff.d.ts +19 -0
  18. package/dist/admin/src/hooks/useAuditLogs.d.ts +36 -0
  19. package/dist/admin/src/index.d.ts +3 -0
  20. package/dist/admin/src/pages/App.d.ts +11 -0
  21. package/dist/admin/src/pages/AuditLogDetails.d.ts +10 -0
  22. package/dist/admin/src/pages/AuditLogs.d.ts +3 -0
  23. package/dist/admin/src/permissions.d.ts +26 -0
  24. package/dist/admin/src/pluginId.d.ts +2 -0
  25. package/dist/admin/src/types.d.ts +91 -0
  26. package/dist/admin/src/utils/format.d.ts +41 -0
  27. package/dist/admin/src/utils/getTranslation.d.ts +2 -0
  28. package/dist/admin/src/utils/widgets.d.ts +85 -0
  29. package/dist/server/index.js +1856 -0
  30. package/dist/server/index.mjs +1856 -0
  31. package/dist/server/src/bootstrap.d.ts +14 -0
  32. package/dist/server/src/config/index.d.ts +52 -0
  33. package/dist/server/src/constants.d.ts +140 -0
  34. package/dist/server/src/content-types/audit-log/index.d.ts +86 -0
  35. package/dist/server/src/content-types/audit-log/schema.d.ts +141 -0
  36. package/dist/server/src/content-types/index.d.ts +88 -0
  37. package/dist/server/src/controllers/audit-log.d.ts +31 -0
  38. package/dist/server/src/controllers/index.d.ts +42 -0
  39. package/dist/server/src/destroy.d.ts +19 -0
  40. package/dist/server/src/index.d.ts +328 -0
  41. package/dist/server/src/register.d.ts +31 -0
  42. package/dist/server/src/routes/admin.d.ts +26 -0
  43. package/dist/server/src/routes/index.d.ts +19 -0
  44. package/dist/server/src/services/access.d.ts +51 -0
  45. package/dist/server/src/services/audit.d.ts +25 -0
  46. package/dist/server/src/services/config.d.ts +30 -0
  47. package/dist/server/src/services/context.d.ts +33 -0
  48. package/dist/server/src/services/diff.d.ts +48 -0
  49. package/dist/server/src/services/immutability.d.ts +30 -0
  50. package/dist/server/src/services/index.d.ts +137 -0
  51. package/dist/server/src/services/retention.d.ts +22 -0
  52. package/dist/server/src/services/security.d.ts +54 -0
  53. package/dist/server/src/services/snapshot.d.ts +46 -0
  54. package/dist/server/src/services/tracker.d.ts +39 -0
  55. package/dist/server/src/types/index.d.ts +214 -0
  56. package/dist/server/src/utils/json.d.ts +17 -0
  57. package/dist/server/src/utils/paths.d.ts +34 -0
  58. package/dist/server/src/utils/sanitize.d.ts +16 -0
  59. package/package.json +112 -0
package/README.md ADDED
@@ -0,0 +1,1026 @@
1
+ # strapi-plugin-audit-log
2
+
3
+ Automatic audit logging for **Strapi v5**, in two halves.
4
+
5
+ **Content**: every create, update, delete, publish and unpublish across every
6
+ content type, with field-level diffs and a per-widget before/after view of
7
+ dynamic zones.
8
+
9
+ **Security**: every login, failed login, logout and refused request, and every
10
+ change to an admin user, role, permission or media file — all from Strapi's own
11
+ events, all in **Community Edition**.
12
+
13
+ Both land in one table, behind one RBAC-protected admin page, with
14
+ sensitive-field redaction, retention and optional structured-log forwarding.
15
+
16
+ You do not add a single line of code to your content types.
17
+
18
+ ---
19
+
20
+ ## Contents
21
+
22
+ - [Why](#why)
23
+ - [What is recorded](#what-is-recorded)
24
+ - [Installation](#installation)
25
+ - [Configuration](#configuration)
26
+ - [Features](#features)
27
+ - [Security events](#security-events)
28
+ - [Centralized logging / SIEM](#centralized-logging--siem)
29
+ - [Installing from a local path](#installing-from-a-local-path)
30
+ - [Permissions](#permissions)
31
+ - [Plugin API](#plugin-api)
32
+ - [Architecture](#architecture)
33
+ - [Performance](#performance)
34
+ - [Error handling](#error-handling)
35
+ - [Limitations](#limitations)
36
+ - [Public types](#public-types)
37
+ - [Development](#development)
38
+ - [Upgrade guide](#upgrade-guide)
39
+ - [License](#license)
40
+
41
+ ---
42
+
43
+ ## Why
44
+
45
+ The usual way to audit a Strapi project is a `lifecycles.ts` per content type.
46
+ That means the same file copied into `page`, `vehicle`, `webinar`, `event` and
47
+ everything added afterwards; a content type someone forgets is a content type
48
+ with no audit trail, and nothing tells you which one it was.
49
+
50
+ It also cannot see what actually happened. To `db.lifecycles`, publishing a
51
+ document is an ordinary row insert and unpublishing is a delete — the semantics
52
+ that make an audit log worth reading are gone by the time the event fires.
53
+
54
+ This plugin registers a single **Document Service middleware**, Strapi v5's
55
+ replacement for that pattern. One registration covers every content type that
56
+ exists now, every one added later, and every route that writes content: the
57
+ admin panel, the REST and GraphQL content APIs, and your own server code.
58
+
59
+ The other half of an audit trail is not about content at all. "Who logged in",
60
+ "who failed to", "who was refused", "who gave that account Super Admin" and "who
61
+ deleted that file" are the questions a security review actually opens with, and
62
+ none of them is a document operation — Strapi writes admin users and media
63
+ through `strapi.db.query`, below the Document Service, and authentication is not
64
+ a write at all. Strapi does emit a named event for each, on the same
65
+ `strapi.eventHub` its webhooks run on, so this plugin listens there. Those
66
+ emissions are in Community Edition; nothing here needs an Enterprise licence.
67
+
68
+ ---
69
+
70
+ ## What is recorded
71
+
72
+ | | Action | Where it comes from |
73
+ |---|---|---|
74
+ | **Content** | `create` `update` `delete` `publish` `unpublish` | Document Service middleware |
75
+ | **Authentication** | `login.success` `login.failed` `logout` | `strapi.eventHub` |
76
+ | **Authorisation** | `access.denied` (401 and 403) | Koa middleware |
77
+ | **Admin domain** | `admin.user.*` `admin.role.*` `admin.permission.*` | `strapi.eventHub` |
78
+ | **Media library** | `media.*` `media-folder.*` | `strapi.eventHub` |
79
+
80
+ Every record carries the date and time, the actor (id, email and name,
81
+ snapshotted so the row survives the user being renamed or deleted), the IP
82
+ address, the user agent, the request id, the source, and — for content — the
83
+ before state, the after state and the field-level diff.
84
+
85
+ Mapped against the usual checklist:
86
+
87
+ | Requirement | Covered by |
88
+ |---|---|
89
+ | Successful and failed login attempts | `login.success`, `login.failed` (with the attempted email) |
90
+ | Logout events | `logout` |
91
+ | Who created / updated / deleted content | `create`, `update`, `delete` |
92
+ | Who published / unpublished content | `publish`, `unpublish` |
93
+ | Date and time of the action | `createdAt` |
94
+ | User ID / username | `userId`, `userEmail`, `userName` |
95
+ | Resource type and record ID | `contentType`, `contentDocumentId`, `contentId` |
96
+ | Before/after changes | `before`, `after`, `changes`, plus the [widget view](#per-widget-beforeafter) |
97
+ | Unauthorized access / permission failures | `access.denied` |
98
+ | Protection against modification or deletion | [Immutability](#immutability) |
99
+ | Centralized logging / SIEM | [`forwardToLogger`](#centralized-logging--siem) |
100
+
101
+ ---
102
+
103
+ ## Installation
104
+
105
+ ```bash
106
+ npm install strapi-plugin-audit-log
107
+ ```
108
+
109
+ ```bash
110
+ yarn add strapi-plugin-audit-log
111
+ ```
112
+
113
+ Then enable it in `config/plugins.ts`:
114
+
115
+ ```ts
116
+ export default () => ({
117
+ 'audit-log': { enabled: true },
118
+ });
119
+ ```
120
+
121
+ Restart Strapi. The `audit_logs` table, its indexes, the permissions and the
122
+ sidebar entry are all created on boot.
123
+
124
+ **Requirements**
125
+
126
+ | | |
127
+ |---|---|
128
+ | Strapi | `^5.0.0` (developed and tested against 5.52) |
129
+ | Node | `>=18 <=22` |
130
+ | Database | Any Strapi v5 supports — PostgreSQL, MySQL/MariaDB, SQLite |
131
+
132
+ There is one manual step after installing: **grant the permission**. See
133
+ [Permissions](#permissions).
134
+
135
+ ---
136
+
137
+ ## Configuration
138
+
139
+ Every option is optional. A complete, annotated example lives in
140
+ [`example/plugins.ts`](./example/plugins.ts).
141
+
142
+ ```ts
143
+ export default () => ({
144
+ 'audit-log': {
145
+ enabled: true,
146
+ config: {
147
+ actions: ['create', 'update', 'delete', 'publish', 'unpublish'],
148
+ securityEvents: '*',
149
+ contentTypes: '*',
150
+ ignoredContentTypes: [],
151
+ additionalIgnoredFields: [],
152
+ storeBefore: true,
153
+ storeAfter: true,
154
+ storeChanges: true,
155
+ retentionDays: 365,
156
+ forwardToLogger: false,
157
+ },
158
+ },
159
+ });
160
+ ```
161
+
162
+ ### Reference
163
+
164
+ | Option | Type | Default | Meaning |
165
+ |---|---|---|---|
166
+ | `actions` | `AuditAction[]` | all five | Which **content** operations to record. |
167
+ | `securityEvents` | `'*' | AuditSecurityAction[]` | `'*'` | Which **security** events to record. `[]` registers no listeners and no middleware. |
168
+ | `contentTypes` | `'*' \| string[]` | `'*'` | Which content types to audit. |
169
+ | `ignoredContentTypes` | `string[]` | `[]` | Always wins over `contentTypes`. |
170
+ | `ignoredFields` | `string[]` | see below | **Replaces** the built-in redaction list. |
171
+ | `additionalIgnoredFields` | `string[]` | `[]` | **Extends** the built-in list. |
172
+ | `ignoredChangeFields` | `string[]` | `['updatedAt', 'updatedBy', 'createdBy']` | Excluded from `changes` only. |
173
+ | `storeBefore` | `boolean` | `true` | Persist the pre-operation snapshot. |
174
+ | `storeAfter` | `boolean` | `true` | Persist the post-operation snapshot. |
175
+ | `storeChanges` | `boolean` | `true` | Persist the field-level diff. |
176
+ | `maxSnapshotBytes` | `number` | `524288` | Snapshots above this are replaced with a marker. `0` disables. |
177
+ | `maxPopulateDepth` | `0–5` | `2` | How far to descend into components. See [Performance](#performance). |
178
+ | `retentionDays` | `number` | `365` | Age at which records are deleted. `0` keeps everything. |
179
+ | `retentionCron` | `string` | `'0 3 * * *'` | When the cleanup job runs. |
180
+ | `failOnAuditError` | `boolean` | `false` | Whether an audit failure fails the content operation. |
181
+ | `auditSystemOperations` | `boolean` | `true` | Record operations that happen outside a request. See [System operations](#system-operations). |
182
+ | `writeMode` | `'sync' \| 'async'` | `'sync'` | See [Is audit creation synchronous?](#is-audit-creation-synchronous). |
183
+ | `forwardToLogger` | `boolean` | `false` | Mirror each record to `strapi.log` as structured JSON. See [SIEM](#centralized-logging--siem). |
184
+ | `forwardLogLevel` | `'debug' | 'info' | 'warn' | 'error'` | `'info'` | Level the mirrored line is written at. |
185
+
186
+ Invalid values are rejected **at boot** with a message naming the option. A typo
187
+ in `actions` fails startup rather than silently auditing nothing.
188
+
189
+ ### Sensitive fields
190
+
191
+ Patterns are matched **case-insensitively** against the leaf field name at any
192
+ depth, so `password` covers `user.password` and `blocks[3].auth.password`.
193
+ Wildcards are supported:
194
+
195
+ | Pattern | Matches |
196
+ |---|---|
197
+ | `password` | any field named exactly `password`, at any depth |
198
+ | `*token*` | `token`, `accessToken`, `seo.internalToken`, `refreshTokenHash` |
199
+ | `seo.metaTitle` | that exact path, anchored at the document root |
200
+ | `settings.*.apiKey` | exactly one segment between them |
201
+ | `blocks.**.secret` | zero or more segments between them |
202
+
203
+ The built-in list is:
204
+
205
+ ```
206
+ *password* *passwd* *token* *secret* *apikey* *api_key*
207
+ *privatekey* *private_key* *credential* *accesskey* salt otp *totp*
208
+ ```
209
+
210
+ These are substring patterns rather than an exhaustive list of exact names
211
+ because the exact names are unknowable: `internalToken` and `stripeSecretKey`
212
+ are what turn up in real projects, and an exact-match list stores both. The
213
+ trade is deliberate — `*token*` also redacts a field named `tokenCount`, which
214
+ costs one uninteresting value; missing a credential writes it, in clear, into a
215
+ table designed to be kept for a year.
216
+
217
+ Redacted fields are **removed**, not masked. A placeholder still tells a reader
218
+ that a secret exists and changed. They are stripped before the diff runs, so they
219
+ cannot appear in `before`, `after` or `changes`.
220
+
221
+ > **Note on `ignoredFields`** — setting it replaces the built-in list entirely.
222
+ > Strapi merges plugin config with lodash's `defaultsDeep`, which merges arrays
223
+ > element-wise, so a literal array default would splice your `['x']` into
224
+ > `['x', ...ourDefaults.slice(1)]`. Every collection-valued option therefore
225
+ > defaults to `null` internally and the real defaults are applied afterwards.
226
+ > Use `additionalIgnoredFields` to extend rather than replace.
227
+
228
+ ---
229
+
230
+ ## Features
231
+
232
+ ### Automatic tracking
233
+
234
+ | Action | Recorded when |
235
+ |---|---|
236
+ | `create` | a document is created |
237
+ | `update` | a document is updated |
238
+ | `delete` | a document is deleted |
239
+ | `publish` | a document is published |
240
+ | `unpublish` | a document is unpublished |
241
+
242
+ Security events are listed under [Security events](#security-events).
243
+
244
+ A create or update that also publishes (`status: 'published'`) produces **two**
245
+ records — the write and the publish. Strapi's repository publishes internally by
246
+ calling its own `publish()` rather than the middleware-wrapped facade, so no
247
+ `publish` action reaches any middleware; recording it explicitly keeps "when was
248
+ this published" answerable regardless of which route created the document.
249
+
250
+ A bulk publish across six locales produces six records, one per locale, because
251
+ six documents changed.
252
+
253
+ ### Diffs
254
+
255
+ Nested structures are flattened into dotted paths, with array members addressed
256
+ by index:
257
+
258
+ ```json
259
+ {
260
+ "changes": {
261
+ "title": { "from": "Old Homepage", "to": "New Homepage" },
262
+ "seo.metaTitle": { "from": "Old title", "to": "New title" },
263
+ "blocks[2].heading": { "from": "Features", "to": "What you get" },
264
+ "cover.url": { "from": "/uploads/old.png", "to": "/uploads/new.png" }
265
+ }
266
+ }
267
+ ```
268
+
269
+ The engine is entirely generic — it knows nothing about content types — and
270
+ handles primitives, nested objects, components, repeatable components, dynamic
271
+ zones, relations, media, arrays and localized fields alike.
272
+
273
+ Two details worth knowing:
274
+
275
+ - A `Date` from the database and its ISO string from a request body compare
276
+ **equal**, so a re-save does not report every datetime as changed.
277
+ - `null` and `undefined` compare equal, so an absent key is not a change.
278
+
279
+ ### Per-widget before/after
280
+
281
+ Flat paths answer "which fields moved". They do not answer the question an editor
282
+ actually arrives with, which is *what did that widget look like before, and what
283
+ does it look like now* — reading `widgets[3].heading` out of a list means
284
+ reconstructing a component from an array index in your head.
285
+
286
+ So the detail page also reassembles dynamic zones. Every zone in the snapshots is
287
+ found **structurally** — an array whose members carry `__component`, which is how
288
+ Strapi marks one in a populated result — so this works on `widgets`, on `blocks`,
289
+ and on any zone added to any content type later, with nothing to register.
290
+
291
+ Each changed slot is rendered **twice**: the whole widget as it was, then the
292
+ whole widget as it now is, with the fields that differ highlighted in both
293
+ copies.
294
+
295
+ ```
296
+ Widgets — 1 changed of 5
297
+
298
+ +-- #3 . Hero . home.hero --------------- CHANGED --+
299
+ | * BEFORE |
300
+ | heading Welcome to YCS |
301
+ | subheading Cloud for hotels |
302
+ | cta.label Book a demo |
303
+ +----------------------------------------------------+
304
+ | * AFTER |
305
+ | heading Run your hotel better <-- |
306
+ | subheading Cloud for hotels |
307
+ | cta.label Get started <-- |
308
+ +----------------------------------------------------+
309
+ ```
310
+
311
+ Slots are paired by **position**, not by component id. Strapi does not preserve
312
+ component row ids across an update — the Content Manager sends the whole zone
313
+ back and the repository replaces its rows — so pairing by id would report every
314
+ widget as removed-and-re-added on every save. Position is also what an editor
315
+ sees and reasons about ("the third block"), and it makes a reorder show up as the
316
+ change it is.
317
+
318
+ A slot that gained a widget shows only the *after* card, one that lost a widget
319
+ only the *before* card, and a slot whose widget was swapped for a different
320
+ component says so outright. Unchanged widgets are collapsed behind a toggle, so a
321
+ two-field edit on a 170-widget page opens showing exactly the widget that
322
+ changed.
323
+
324
+ Computed in the browser from `before` and `after`, which are already on the
325
+ record — so it needs no new column, costs no extra query, and works
326
+ retroactively on every row already in your table.
327
+
328
+ ### Localization
329
+
330
+ Each record carries the locale it applies to. An update to the `fr` translation
331
+ records `locale: "fr"` and diffs against the `fr` draft, never against `en`.
332
+
333
+ ### Draft & publish
334
+
335
+ `before` for a publish is the version being replaced; `after` is the new
336
+ published version. An unpublish records the state that went away. Updates always
337
+ work against the draft.
338
+
339
+ ### Admin UI
340
+
341
+ **Audit Logs** in the sidebar opens `/admin/audit-logs`:
342
+
343
+ - server-side pagination, search, sorting and filtering — the browser never
344
+ receives more than one page;
345
+ - filters for action, content type, user, locale, source, outcome, exact
346
+ document id and a date range, all reflected in the URL so a filtered view is
347
+ shareable. Each dropdown offers **everything filterable**, not just the values
348
+ already in the table — content types from the registry, users from
349
+ `admin::user`, locales from i18n — because a filter is most useful when the
350
+ answer is "nothing yet". Values that exist only in stored rows, such as a
351
+ deleted content type or a removed administrator, are merged in too. Content
352
+ type and user are searchable, since both lists are long;
353
+ - a detail page with full metadata, a readable before/after diff, and
354
+ collapsible JSON viewers for the raw snapshots;
355
+ - delete, shown only to roles that hold `plugin::audit-log.delete`.
356
+
357
+ ### Retention
358
+
359
+ A daily cron job (03:00 server time by default) deletes records past
360
+ `retentionDays`. It runs off the request path entirely — see
361
+ [Performance](#performance). `retentionDays: 0` registers no job at all.
362
+
363
+ Run it by hand from `strapi console`:
364
+
365
+ ```js
366
+ await strapi.plugin('audit-log').service('retention').cleanup();
367
+ ```
368
+
369
+ ---
370
+
371
+ ## Security events
372
+
373
+ The half of the trail that is not about content. Every event below is emitted by
374
+ **Strapi Community Edition** itself — this plugin subscribes to them, it does not
375
+ patch Strapi and it does not need an Enterprise licence.
376
+
377
+ | Action | Recorded when | Strapi event |
378
+ |---|---|---|
379
+ | `login.success` | an administrator signs in | `admin.auth.success` |
380
+ | `login.failed` | a sign-in is refused | `admin.auth.error` |
381
+ | `logout` | an administrator signs out | `admin.logout` |
382
+ | `access.denied` | a request is answered 401 or 403 | Koa middleware |
383
+ | `admin.user.*` | an admin user is created, changed or removed | `user.*` |
384
+ | `admin.role.*` | a role is created, changed or removed | `role.*` |
385
+ | `admin.permission.*` | a permission is created, changed or removed | `permission.*` |
386
+ | `media.*` | a file is uploaded, replaced or deleted | `media.*` |
387
+ | `media-folder.*` | a media folder changes | `media-folder.*` |
388
+
389
+ Select a subset with `securityEvents: ['login.failed', 'access.denied']`, or turn
390
+ the whole half off with `securityEvents: []` — which registers no listeners and
391
+ adds no middleware at all.
392
+
393
+ Security rows carry two columns content rows barely use: `outcome`
394
+ (`success` / `failure`, indexed, so "show me every failure" is one query) and
395
+ `metadata`, a small JSON bag of the detail specific to each action.
396
+
397
+ ### Failed logins name the account
398
+
399
+ `admin.auth.error` carries only `{ error, provider }`. Strapi puts no identity on
400
+ it, which on its own makes "which account is being brute-forced" unanswerable, so
401
+ the plugin reads the attempted email off the live request body — the event is
402
+ emitted synchronously inside that request. **Only `email` is read**; the body's
403
+ other field is the password, and it is never touched.
404
+
405
+ ```json
406
+ {
407
+ "action": "login.failed",
408
+ "outcome": "failure",
409
+ "userEmail": "victim@example.com",
410
+ "ipAddress": "203.0.113.7",
411
+ "metadata": {
412
+ "attemptedEmail": "victim@example.com",
413
+ "reason": "Invalid credentials",
414
+ "provider": "local"
415
+ }
416
+ }
417
+ ```
418
+
419
+ ### Denied requests
420
+
421
+ A 401 is "you are not authenticated"; a 403 is "you are, and you still may not".
422
+ Both are recorded, with the method, the path, the status and the reason Strapi
423
+ gave. The login and token-refresh routes are excluded: a wrong password is a 401
424
+ that `login.failed` has already recorded *with the account name*, and recording
425
+ the status too would double every attempt.
426
+
427
+ 404s are not recorded. Strapi answers an unauthorised content-API read with a 404
428
+ rather than a 403 so as not to confirm that a document exists, and logging every
429
+ 404 to catch those would bury the trail in typos and favicon requests.
430
+
431
+ The middleware is registered from the plugin's `register` lifecycle, not
432
+ `bootstrap`. `strapi.server.use()` appends to the Koa stack, and Strapi applies
433
+ `config/middlewares.ts` and mounts the router *inside* `bootstrap()`, before the
434
+ bootstrap lifecycles run — so registering there would put this middleware behind
435
+ the router, where it would see nothing and fail silently.
436
+
437
+ ### Admin-domain records never carry credentials
438
+
439
+ A `user.update` payload is the entire admin user row — password hash, reset
440
+ token, registration token and all. Only an **allow-list** of identifying fields is
441
+ kept (id, email, name, active flag, and the role *names*, which are the part of an
442
+ admin-user change anyone reviews). Everything else is dropped before the record is
443
+ built, rather than redacted afterwards.
444
+
445
+ ### System operations
446
+
447
+ Not every event comes from a person. Strapi reconciles its permission table
448
+ **during boot**, which emits `permission.create` and `permission.delete` with no
449
+ actor and no request in flight. Those records are labelled `source: "system"`
450
+ rather than `admin`, because calling them `admin` would put rows in the log that
451
+ read as though an administrator had edited permissions — the exact row a security
452
+ review is meant to stop on.
453
+
454
+ Set `auditSystemOperations: false` to drop them entirely. Worth doing on a
455
+ project that restarts often: a handful of `admin.permission.*` rows per restart
456
+ buries the one time somebody really did change a permission. Request-borne events
457
+ are unaffected.
458
+
459
+ ### A listener can never break the operation
460
+
461
+ `eventHub.emit` awaits its subscribers *inside* the operation that emitted them.
462
+ A listener that threw on `admin.auth.success` would turn a correct password into a
463
+ failed login; one that threw on `media.create` would fail the upload. Every
464
+ handler is wrapped, and `failOnAuditError` is deliberately **not** honoured on this
465
+ side — that option exists so a project can refuse to serve content it cannot
466
+ audit, which is a very different proposition from locking every administrator out
467
+ of the panel because the audit table is unreachable.
468
+
469
+ ---
470
+
471
+ ## Centralized logging / SIEM
472
+
473
+ ```ts
474
+ 'audit-log': { config: { forwardToLogger: true, forwardLogLevel: 'info' } }
475
+ ```
476
+
477
+ Every record is then also written to `strapi.log` as one line of structured JSON:
478
+
479
+ ```json
480
+ {"type":"audit-log","action":"login.failed","outcome":"failure","contentType":"admin::auth","userEmail":"victim@example.com","source":"admin","ipAddress":"203.0.113.7","changedPaths":[],"at":"2026-09-03T14:22:16.666Z"}
481
+ ```
482
+
483
+ That is the whole integration, and deliberately so. Every deployment target — ECS,
484
+ Kubernetes, Heroku, a bare systemd unit — already ships process stdout somewhere,
485
+ and every log pipeline worth the name already parses JSON off it. A line costs
486
+ nothing, needs no credentials, cannot block a request, and cannot fail in a way
487
+ that loses the database row. An in-process HTTP forwarder holding a retry queue
488
+ can do all four.
489
+
490
+ The **snapshots are not included**. They are unbounded — a page with a large
491
+ dynamic zone runs to megabytes — and a log pipeline is the wrong place to store
492
+ them. Only the changed field *paths* are emitted, which is what an alert rule
493
+ needs; the row in `audit_logs` remains the record of truth.
494
+
495
+ Worth alerting on: repeated `login.failed` from one `ipAddress`, any
496
+ `admin.role.update`, any `admin.user.create`, a burst of `access.denied` from one
497
+ `userId`, or a `delete` outside working hours.
498
+
499
+ ---
500
+
501
+ ## Installing from a local path
502
+
503
+ Developing the plugin alongside a Strapi app usually means a path dependency:
504
+
505
+ ```json
506
+ { "dependencies": { "strapi-plugin-audit-log": "file:../strapi-plugin-audit-log" } }
507
+ ```
508
+
509
+ That works, with **one thing you must clean up afterwards**.
510
+
511
+ Yarn 1's `file:` protocol copies the directory wholesale. It does not honour the
512
+ `files` field and it does not skip `node_modules`, so the plugin's *development*
513
+ dependencies are installed into your app at
514
+ `node_modules/strapi-plugin-audit-log/node_modules/` — `@strapi/strapi`,
515
+ `@strapi/admin`, `react`, `react-dom`, `react-router-dom`, `react-intl` and
516
+ `styled-components` among them.
517
+
518
+ Every one of those is a **peer** dependency that has to resolve to your app's
519
+ copy. Node and Vite resolve from the importing file upward, so a nested copy
520
+ wins, and the plugin's admin code ends up bound to a second instance of the admin
521
+ runtime with its own React context. Your app populates the context on *its*
522
+ instance; the plugin reads the *other* one and finds nothing there.
523
+
524
+ The failure is silent and points nowhere near the cause:
525
+
526
+ - the **sidebar entry appears** — `addMenuLink` hands a plain object to the app's
527
+ own router, so that check runs in the app's context and passes;
528
+ - **clicking it** renders *"You don't have the permissions to access that
529
+ content"* — `Page.Protect` calls `useAuth('Protect', s => s.permissions)`
530
+ without Strapi's `shouldThrowOnMissingContext` flag, so a missing context
531
+ returns `undefined` rather than throwing, `(userPermissions || [])` turns that
532
+ into `[]`, and the guard concludes the user has nothing.
533
+
534
+ A Super Admin sees this while the database, the RBAC registration and the
535
+ `/admin/users/me/permissions` response are all completely correct — there is
536
+ nothing wrong to find in any of them.
537
+
538
+ **The fix** is to delete the nested tree, which is what a published install from
539
+ npm would have given you anyway:
540
+
541
+ ```bash
542
+ rm -rf node_modules/strapi-plugin-audit-log/node_modules
543
+ ```
544
+
545
+ Make it survive the next install with a `postinstall` script:
546
+
547
+ ```json
548
+ { "scripts": { "postinstall": "rm -rf node_modules/strapi-plugin-audit-log/node_modules" } }
549
+ ```
550
+
551
+ Then clear Vite's dependency cache (`rm -rf node_modules/.strapi/vite`), restart
552
+ Strapi, and hard-reload the browser.
553
+
554
+ Two more things about a path install, unrelated to the above:
555
+
556
+ - Yarn **copies** rather than links, so changes to the plugin need
557
+ `npm run build` in the plugin followed by re-copying `dist/` into
558
+ `node_modules/strapi-plugin-audit-log/dist`. A bare `yarn install` will not
559
+ refresh it — the resolution is unchanged, so yarn skips the copy.
560
+ - Strapi loads plugins at boot, so a restart is always required.
561
+
562
+ ---
563
+
564
+ ## Permissions
565
+
566
+ Three RBAC actions are registered in the **Plugins** section of the role editor:
567
+
568
+ | Permission | Grants |
569
+ |---|---|
570
+ | `plugin::audit-log.read` | See the sidebar entry, list and read records |
571
+ | `plugin::audit-log.delete` | Delete a record |
572
+ | `plugin::audit-log.settings` | Reserved for a future settings page |
573
+
574
+ Read and delete are separate on purpose: being allowed to investigate an incident
575
+ must not imply being allowed to erase the evidence.
576
+
577
+ **Grant them at Settings → Administration Panel → Roles → _role_ → Plugins →
578
+ Audit Logs.** New permissions are not granted automatically; Super Admins have
579
+ them implicitly.
580
+
581
+ Three independent checks enforce `read`:
582
+
583
+ 1. the sidebar link is filtered out of the menu;
584
+ 2. `Page.Protect` renders a "no access" page for a bookmarked URL;
585
+ 3. every server route runs `admin::isAuthenticatedAdmin` then
586
+ `admin::hasPermissions`.
587
+
588
+ Only the third is a security boundary. The first two exist so the panel behaves
589
+ sensibly rather than showing a raw 403.
590
+
591
+ ### Immutability
592
+
593
+ Audit records cannot be edited. This is enforced structurally rather than by a
594
+ check that could be forgotten: the collection type is declared
595
+ `'content-manager': { visible: false }`, and `@strapi/content-manager` only
596
+ registers create/update/delete/publish RBAC actions for content types it
597
+ *displays*. There is no permission an administrator could grant that would let a
598
+ role write these rows through the content APIs.
599
+
600
+ A Document Service guard adds a second line for server code — a stray
601
+ `strapi.documents('plugin::audit-log.audit-log').update(...)` in a bootstrap
602
+ throws rather than succeeding quietly. Deletion is deliberately *not* blocked
603
+ there: it is legitimate (retention needs it) and is gated where it belongs, on
604
+ the one route that performs it.
605
+
606
+ There is no create or update route. The plugin's HTTP surface is read plus a
607
+ single guarded delete.
608
+
609
+ ---
610
+
611
+ ## Plugin API
612
+
613
+ All routes are `type: 'admin'`, mounted under the plugin id, and require an
614
+ authenticated admin session plus an explicit permission. **None of them is
615
+ reachable from the public content API**, with or without an API token, because
616
+ no content-api route file exists.
617
+
618
+ | Method | Path | Permission |
619
+ |---|---|---|
620
+ | `GET` | `/audit-log/logs` | `read` |
621
+ | `GET` | `/audit-log/logs/:id` | `read` |
622
+ | `GET` | `/audit-log/filters` | `read` |
623
+ | `GET` | `/audit-log/config` | `read` |
624
+ | `DELETE` | `/audit-log/logs/:id` | `delete` |
625
+
626
+ ### `GET /audit-log/logs`
627
+
628
+ | Parameter | Notes |
629
+ |---|---|
630
+ | `page`, `pageSize` | `pageSize` is capped at 100 |
631
+ | `sort` | `field:asc\|desc`; only whitelisted columns, anything else falls back to `createdAt:desc` |
632
+ | `action`, `contentType`, `userId`, `locale`, `source`, `outcome` | string or array (array becomes `$in`) |
633
+ | `contentDocumentId` | exact match |
634
+ | `dateFrom`, `dateTo` | ISO dates, applied independently |
635
+ | `_q` | case-insensitive search across the identifying columns |
636
+
637
+ Unrecognised query keys are discarded. Object-valued parameters — what
638
+ `?action[$ne]=create` parses to — are rejected rather than forwarded, so a client
639
+ cannot author its own query operators.
640
+
641
+ ```json
642
+ {
643
+ "results": [ /* AuditLog[] */ ],
644
+ "pagination": { "page": 1, "pageSize": 20, "pageCount": 12, "total": 231 }
645
+ }
646
+ ```
647
+
648
+ ### Server-side services
649
+
650
+ ```ts
651
+ const plugin = strapi.plugin('audit-log');
652
+
653
+ plugin.service('audit').find(query);
654
+ plugin.service('audit').deleteOlderThan(new Date('2026-01-01'));
655
+ plugin.service('retention').cleanup();
656
+ plugin.service('config').getPublicConfig();
657
+
658
+ // Label writes that happen outside a request.
659
+ plugin.service('context').runAs({ source: 'migration' }, () => importEverything());
660
+ ```
661
+
662
+ ---
663
+
664
+ ## Architecture
665
+
666
+ ### Data model
667
+
668
+ `plugin::audit-log.audit-log`, stored in `audit_logs`:
669
+
670
+ | Column | Notes |
671
+ |---|---|
672
+ | `id` | |
673
+ | `documentId` | the audit record's own Strapi v5 document id |
674
+ | `action` | `create` \| `update` \| `delete` \| `publish` \| `unpublish` |
675
+ | `contentType` | uid of the audited type, e.g. `api::page.page` |
676
+ | `contentTypeDisplayName` | snapshotted, so the log survives a rename |
677
+ | `contentDocumentId` | **the audited document's** v5 document id |
678
+ | `contentId` | the audited entry's numeric database id |
679
+ | `locale` | |
680
+ | `userId`, `userEmail`, `userName` | snapshotted, so the record survives the user being renamed or deleted |
681
+ | `changes`, `before`, `after` | JSON |
682
+ | `outcome` | `success` or `failure`. Always `success` for content — the tracker runs after the write succeeded. |
683
+ | `metadata` | JSON. Action-specific detail: why a login was refused, the method and path of a denied request. |
684
+ | `ipAddress`, `userAgent`, `requestId` | |
685
+ | `source` | `admin` \| `api` \| `system` \| `cron` \| `migration` \| `unknown` |
686
+ | `createdAt`, `updatedAt` | |
687
+
688
+ > **Why `contentDocumentId` and not `documentId`** — Strapi v5 reserves
689
+ > `documentId` as an attribute name and throws at boot if a schema declares one
690
+ > (`transformContentTypesToModels` raises *"The attribute 'documentId' is
691
+ > reserved"*), because it injects its own `documentId` column into every
692
+ > collection type. Each audit row therefore has both: `documentId` is the audit
693
+ > record's identity, `contentDocumentId` is the id of the document it is about.
694
+
695
+ The schema declares six indexes, created by Strapi's own schema sync on every
696
+ supported database with no hand-written DDL:
697
+
698
+ ```
699
+ audit_logs_created_idx (created_at)
700
+ audit_logs_ct_created_idx (content_type, created_at) -- the admin list's default query
701
+ audit_logs_doc_idx (content_document_id)
702
+ audit_logs_action_idx (action)
703
+ audit_logs_user_idx (user_id)
704
+ audit_logs_locale_idx (locale)
705
+ audit_logs_outcome_idx (outcome)
706
+ ```
707
+
708
+ ### Interception
709
+
710
+ ```
711
+ strapi.documents('api::page.page').update(...)
712
+
713
+ ├─ immutability guard ← rejects writes to the audit table itself
714
+
715
+ ├─ audit tracker
716
+ │ ├─ before next(): one narrow query for the pre-state
717
+ │ │
718
+ │ ├─ next() ──────────────► repository ← the transaction lives HERE
719
+ │ │ (wrapInTransaction)
720
+ │ │
721
+ │ └─ after next(): one narrow query for the post-state,
722
+ │ diff, redact, INSERT
723
+
724
+ └─ result returned unchanged
725
+ ```
726
+
727
+ Three properties make the Document Service the right layer:
728
+
729
+ 1. **It sees the semantic action.** `publish` and `unpublish` are indistinguishable
730
+ from ordinary inserts and deletes at the database layer.
731
+ 2. **It runs outside the transaction.** Strapi's middleware manager wraps the
732
+ already-transaction-wrapped repository methods, so an audit failure cannot
733
+ roll back an editor's save and an audit insert never holds a row lock open.
734
+ 3. **It sees `documentId` and `locale` as parameters** rather than having to
735
+ reconstruct them from a row.
736
+
737
+ ### Context
738
+
739
+ Actor and request metadata come from `strapi.requestContext` — the Koa context
740
+ Strapi keeps in AsyncLocalStorage — and never from a request body. `source` is
741
+ derived from the route type and auth strategy, honouring `state.auditSource` when
742
+ present so this log agrees with Strapi's own EE audit log about where an
743
+ operation came from. `ipAddress` is `ctx.request.ip`, which respects Koa's
744
+ `proxy` setting: behind a correctly configured load balancer it is the client
745
+ address, and with `proxy` off it ignores `X-Forwarded-For` entirely rather than
746
+ trusting a spoofable header.
747
+
748
+ ### Why admin and server types are separate
749
+
750
+ The canonical types live in `server/src/types` and are re-exported from
751
+ `strapi-plugin-audit-log/strapi-server`. The admin panel keeps its own copies of
752
+ the few shared unions in `admin/src/types.ts`.
753
+
754
+ That is a build constraint, not an oversight. `@strapi/sdk-plugin` compiles the
755
+ two halves in separate Vite passes whose declaration emit is rooted at
756
+ `admin/src` and `server/src` respectively, so a module imported across that
757
+ boundary is emitted outside its own root. The admin types are also genuinely
758
+ different — they describe JSON on the wire, where dates are strings.
759
+
760
+ ---
761
+
762
+ ## Performance
763
+
764
+ ### The dynamic-zone problem
765
+
766
+ The motivating case: a `page` content type whose dynamic zone declares **173**
767
+ possible widgets, of which a given page uses **five**. Strapi's own
768
+ `getDeepPopulate` walks the *schema* and requests every relation, component and
769
+ media field the type could ever hold — hundreds of joins to audit a two-field
770
+ edit.
771
+
772
+ This plugin never does that. Instead:
773
+
774
+ **1. The key set comes from the write, not the schema.** An update's snapshot
775
+ covers exactly the attributes present in `params.data`. Editing `title` reads
776
+ `title` — no populate at all.
777
+
778
+ **2. Relations and media are reduced to identifying fields.** A relation
779
+ populates `id` and `documentId`; media adds name, url, mime and size. The audit
780
+ trail records *which* author was linked, not a copy of the author record.
781
+
782
+ **3. Dynamic zones are populated with `true`.** This makes Strapi read the join
783
+ table first and then issue one query per component type **actually present in the
784
+ rows**. Five widgets used out of 173 declared means five queries; the 168 unused
785
+ schemas are never touched. This is verified by a test that asserts the populate
786
+ spec never names more than the five widgets in use.
787
+
788
+ **4. Nested components are resolved from their own schemas.** Component
789
+ definitions are small and static, so descending them costs no extra round trips —
790
+ it only makes the query Strapi already issues return more columns.
791
+
792
+ **5. Dynamic-zone refinement is data-driven.** With `maxPopulateDepth >= 2` (the
793
+ default), a second query resolves the nested components and media *inside* the
794
+ widgets — using an `on` map built from the widget types the first pass found. One
795
+ extra query for documents that have dynamic zones, none for those that do not.
796
+ Set `maxPopulateDepth: 1` to skip it.
797
+
798
+ ### Query budget
799
+
800
+ Per audited write:
801
+
802
+ | Operation | Queries |
803
+ |---|---|
804
+ | `create` | 1 (after) + 1 INSERT |
805
+ | `update` | 1 (before) + 1 (after) + 1 INSERT |
806
+ | `delete`, `unpublish` | 1 (before) + 1 INSERT |
807
+ | `publish` | 1 (before) + 1 (after) + 1 INSERT |
808
+ | *(+1 per snapshot with dynamic zones, when `maxPopulateDepth >= 2`)* | |
809
+
810
+ All snapshot queries are `findMany` across every affected locale at once, so a
811
+ publish over six locales is one round trip rather than six. There are no N+1
812
+ loops: nothing is fetched per row.
813
+
814
+ `before` **must** be read before the write. `after` is re-read with the *same*
815
+ query shape rather than reusing the operation's own result — the Content Manager
816
+ hands back a deeply populated document while our `before` is deliberately
817
+ shallow, and diffing one against the other would report every un-populated nested
818
+ field as a deletion. One narrow query buys a diff that is actually correct.
819
+
820
+ To reduce the budget further: `storeBefore: false` drops the before query,
821
+ `storeAfter: false` and `storeChanges: false` together drop the after query.
822
+
823
+ ### Is audit creation synchronous?
824
+
825
+ **Yes, by default, and deliberately.**
826
+
827
+ The audit row is written *after* the content operation resolves, so it never
828
+ extends the operation's transaction. What `writeMode: 'sync'` adds is that the
829
+ single-row INSERT is awaited before the HTTP response is produced.
830
+
831
+ That costs sub-millisecond time on every supported database and buys the
832
+ guarantee an audit trail exists for: **once the editor sees "saved", the record
833
+ is durably on disk.** A crash, a redeploy or a scaled-down container cannot lose
834
+ it. An audit log that silently drops records is worse than no audit log, because
835
+ you cannot tell which records are missing.
836
+
837
+ `writeMode: 'async'` trades that guarantee for the millisecond. Writes are
838
+ tracked and drained on `destroy()`, so an orderly shutdown loses nothing — but a
839
+ hard kill between the response and the flush will. Choose it only if that is
840
+ acceptable for your compliance story.
841
+
842
+ ### Retention
843
+
844
+ Cleanup runs on `strapi.cron`, off the request path entirely. Doing it inline —
845
+ the obvious shortcut — would put an unbounded `DELETE` over the largest table in
846
+ the project directly in front of an editor pressing Save, once per write.
847
+
848
+ ### Admin listing
849
+
850
+ Filtering, sorting and pagination all happen in SQL, `pageSize` is capped at 100,
851
+ and the filter dropdowns come from three indexed `DISTINCT` queries rather than
852
+ from the rows on screen. The browser never holds more than one page.
853
+
854
+ ---
855
+
856
+ ## Error handling
857
+
858
+ With the default `failOnAuditError: false`, an audit failure is logged and the
859
+ content operation succeeds:
860
+
861
+ ```
862
+ [audit-log] audit write failed for update on api::page.page: connection terminated
863
+ ```
864
+
865
+ Both the pre-write snapshot and the post-write bookkeeping are individually
866
+ guarded, and `next()` is always reached.
867
+
868
+ **The trade-off.** `false` means editors are never blocked by an unreachable
869
+ audit table — but it also means a period of unavailability produces content
870
+ changes with no record, and nothing in the audit log itself says so. Your only
871
+ signal is the application log.
872
+
873
+ `failOnAuditError: true` inverts this: an audit failure fails the content
874
+ operation, so the log can never have a silent gap. Content editing stops when
875
+ audit storage is unavailable. If you are auditing for compliance rather than for
876
+ convenience, that is usually the correct setting; monitor the error log either
877
+ way.
878
+
879
+ ---
880
+
881
+ ## Limitations
882
+
883
+ Things this plugin genuinely cannot see. Each is a consequence of the
884
+ interception point, not an omission.
885
+
886
+ **Content writes that bypass the Document Service.** Anything calling
887
+ `strapi.db.query(uid).update(...)` or `strapi.db.connection` directly is invisible
888
+ to the *content* tracker — the middleware is registered on `strapi.documents`. The
889
+ Content Manager, the REST and GraphQL content APIs, and `strapi.documents(...)` in
890
+ your own code are all covered. Raw SQL and direct database access are not.
891
+
892
+ **API tokens and webhooks.** These are `admin::` entities that Strapi emits no
893
+ event for, so there is nothing to subscribe to. Admin users, roles, permissions
894
+ and media files *are* covered — see [Security events](#security-events).
895
+
896
+ **Failed operations.** Only completed writes produce a record: the tracker runs
897
+ after the Document Service resolves, so a save rejected by validation leaves no
898
+ row. Refused *requests* are covered separately by `access.denied`.
899
+
900
+ **Field-level diffs for admin-domain changes.** Strapi's `user.*` and `role.*`
901
+ events carry one side of the change only, so those records show the resulting
902
+ state rather than a before/after diff. Inventing a diff from a single payload
903
+ would mean guessing. Content records have real diffs.
904
+
905
+ **Content-Type Builder changes.** Schema edits are file writes, not document
906
+ operations.
907
+
908
+ **Nesting past `maxPopulateDepth`.** Components inside dynamic-zone widgets are
909
+ resolved one level by default. Deeper structures appear as ids rather than full
910
+ values. Raise `maxPopulateDepth` (max 5) at the cost of query depth.
911
+
912
+ **Reorders read as content changes.** Arrays are diffed by index, so moving a
913
+ dynamic-zone widget reports a change to every element that moved. This is
914
+ honest — stored order *is* content — but it is noisier than a move-aware diff.
915
+
916
+ **Oversized snapshots are dropped.** Above `maxSnapshotBytes` the snapshot is
917
+ replaced with a marker. The record, the action and the metadata still exist; only
918
+ the payload is gone.
919
+
920
+ **Diffs are capped at 500 changes**, after which a `__truncated__` marker is
921
+ added. The full state remains in `before`/`after`.
922
+
923
+ ---
924
+
925
+ ## Public types
926
+
927
+ ```ts
928
+ import type {
929
+ AuditAction,
930
+ AuditActor,
931
+ AuditChange,
932
+ AuditChangeSet,
933
+ AuditConfig,
934
+ AuditContext,
935
+ AuditEntryInput,
936
+ AuditLog,
937
+ AuditLogListResult,
938
+ AuditLogQuery,
939
+ AuditRequestContext,
940
+ AuditSource,
941
+ AuditUserConfig,
942
+ AuditWriteMode,
943
+ ContentTypeSelector,
944
+ } from 'strapi-plugin-audit-log/strapi-server';
945
+ ```
946
+
947
+ `PLUGIN_ID`, `AUDIT_LOG_UID`, `PERMISSIONS` and `DEFAULT_IGNORED_FIELDS` are
948
+ exported as values from the same entry point.
949
+
950
+ ---
951
+
952
+ ## Development
953
+
954
+ ```bash
955
+ npm install
956
+ npm run build # vite build via @strapi/sdk-plugin
957
+ npm run typecheck # tsc over server/ and admin/ separately
958
+ npm run test # jest
959
+ npm run lint # eslint
960
+ npm run verify # validate the package for publishing
961
+ ```
962
+
963
+ ### Testing approach
964
+
965
+ The suite runs the real services against an in-memory Strapi harness
966
+ (`tests/helpers/strapi.ts`) that implements the boundary Strapi owns — the model
967
+ registry, `db.query` with working `select`/`populate` narrowing, the Document
968
+ Service middleware chain, the request context, cron. That means the tests
969
+ exercise the whole path from middleware through snapshot, diff, redaction and
970
+ write, rather than asserting against mocks, and they need no database.
971
+
972
+ Every query is recorded, which is what lets the performance tests assert that a
973
+ two-field edit on a 173-widget page does not populate 173 widgets.
974
+
975
+ ### Working against a local Strapi project
976
+
977
+ ```bash
978
+ # in the plugin
979
+ npm run watch:link
980
+
981
+ # in your Strapi project
982
+ npx yalc add --link strapi-plugin-audit-log && npm install
983
+ npm run develop
984
+ ```
985
+
986
+ ### Publishing
987
+
988
+ ```bash
989
+ npm run lint && npm run typecheck && npm run test
990
+ npm run build
991
+ npm run verify
992
+ npm publish
993
+ ```
994
+
995
+ `prepublishOnly` runs the build, and `files: ["dist"]` keeps sources, tests and
996
+ config out of the published tarball.
997
+
998
+ ---
999
+
1000
+ ## Upgrade guide
1001
+
1002
+ The plugin follows semantic versioning.
1003
+
1004
+ **Patch and minor releases** need no action beyond `npm update`. New
1005
+ configuration options always ship with a default matching the previous
1006
+ behaviour. New columns are added to the schema and created by Strapi's schema
1007
+ sync on the next boot; existing rows keep `null` for them.
1008
+
1009
+ **Major releases** will document any breaking change and its migration in
1010
+ [`CHANGELOG.md`](./CHANGELOG.md). Existing audit records are never rewritten by
1011
+ an upgrade — they are historical evidence, and a migration that edited them would
1012
+ defeat the point. A change to how records are *produced* affects new records
1013
+ only, so a table can legitimately contain records written by several versions.
1014
+
1015
+ Before a major upgrade:
1016
+
1017
+ 1. Read the changelog entry.
1018
+ 2. Back up the `audit_logs` table if you rely on it for compliance.
1019
+ 3. Re-check your `ignoredFields` after any change to the built-in redaction list
1020
+ — if you override it, you do not inherit new defaults.
1021
+
1022
+ ---
1023
+
1024
+ ## License
1025
+
1026
+ MIT © Latheefwac