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.
- package/CHANGELOG.md +212 -0
- package/LICENSE +21 -0
- package/README.md +1026 -0
- package/dist/admin/App-BLt4lqEM.js +1284 -0
- package/dist/admin/App-ou5hy99f.mjs +1266 -0
- package/dist/admin/en-B0rPiE2W.mjs +87 -0
- package/dist/admin/en-BMlJxq3g.js +87 -0
- package/dist/admin/index-BYi8OPTw.js +80 -0
- package/dist/admin/index-DdmY-p3Q.mjs +81 -0
- package/dist/admin/index.js +4 -0
- package/dist/admin/index.mjs +4 -0
- package/dist/admin/src/components/AuditLogFilters.d.ts +18 -0
- package/dist/admin/src/components/AuditLogTable.d.ts +19 -0
- package/dist/admin/src/components/ChangeViewer.d.ts +14 -0
- package/dist/admin/src/components/JsonViewer.d.ts +20 -0
- package/dist/admin/src/components/PluginIcon.d.ts +3 -0
- package/dist/admin/src/components/WidgetDiff.d.ts +19 -0
- package/dist/admin/src/hooks/useAuditLogs.d.ts +36 -0
- package/dist/admin/src/index.d.ts +3 -0
- package/dist/admin/src/pages/App.d.ts +11 -0
- package/dist/admin/src/pages/AuditLogDetails.d.ts +10 -0
- package/dist/admin/src/pages/AuditLogs.d.ts +3 -0
- package/dist/admin/src/permissions.d.ts +26 -0
- package/dist/admin/src/pluginId.d.ts +2 -0
- package/dist/admin/src/types.d.ts +91 -0
- package/dist/admin/src/utils/format.d.ts +41 -0
- package/dist/admin/src/utils/getTranslation.d.ts +2 -0
- package/dist/admin/src/utils/widgets.d.ts +85 -0
- package/dist/server/index.js +1856 -0
- package/dist/server/index.mjs +1856 -0
- package/dist/server/src/bootstrap.d.ts +14 -0
- package/dist/server/src/config/index.d.ts +52 -0
- package/dist/server/src/constants.d.ts +140 -0
- package/dist/server/src/content-types/audit-log/index.d.ts +86 -0
- package/dist/server/src/content-types/audit-log/schema.d.ts +141 -0
- package/dist/server/src/content-types/index.d.ts +88 -0
- package/dist/server/src/controllers/audit-log.d.ts +31 -0
- package/dist/server/src/controllers/index.d.ts +42 -0
- package/dist/server/src/destroy.d.ts +19 -0
- package/dist/server/src/index.d.ts +328 -0
- package/dist/server/src/register.d.ts +31 -0
- package/dist/server/src/routes/admin.d.ts +26 -0
- package/dist/server/src/routes/index.d.ts +19 -0
- package/dist/server/src/services/access.d.ts +51 -0
- package/dist/server/src/services/audit.d.ts +25 -0
- package/dist/server/src/services/config.d.ts +30 -0
- package/dist/server/src/services/context.d.ts +33 -0
- package/dist/server/src/services/diff.d.ts +48 -0
- package/dist/server/src/services/immutability.d.ts +30 -0
- package/dist/server/src/services/index.d.ts +137 -0
- package/dist/server/src/services/retention.d.ts +22 -0
- package/dist/server/src/services/security.d.ts +54 -0
- package/dist/server/src/services/snapshot.d.ts +46 -0
- package/dist/server/src/services/tracker.d.ts +39 -0
- package/dist/server/src/types/index.d.ts +214 -0
- package/dist/server/src/utils/json.d.ts +17 -0
- package/dist/server/src/utils/paths.d.ts +34 -0
- package/dist/server/src/utils/sanitize.d.ts +16 -0
- 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
|