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
@@ -0,0 +1,1856 @@
1
+ "use strict";
2
+ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
+ const node_async_hooks = require("node:async_hooks");
4
+ const PLUGIN_ID = "audit-log";
5
+ const AUDIT_LOG_UID = "plugin::audit-log.audit-log";
6
+ const ALL_ACTIONS = ["create", "update", "delete", "publish", "unpublish"];
7
+ const PERMISSIONS = {
8
+ read: `plugin::${PLUGIN_ID}.read`,
9
+ delete: `plugin::${PLUGIN_ID}.delete`,
10
+ settings: `plugin::${PLUGIN_ID}.settings`
11
+ };
12
+ const DEFAULT_IGNORED_FIELDS = [
13
+ "*password*",
14
+ "*passwd*",
15
+ "*token*",
16
+ "*secret*",
17
+ "*apikey*",
18
+ "*api_key*",
19
+ "*privatekey*",
20
+ "*private_key*",
21
+ "*credential*",
22
+ "*accesskey*",
23
+ "salt",
24
+ "otp",
25
+ "*totp*"
26
+ ];
27
+ const DEFAULT_IGNORED_CHANGE_FIELDS = ["updatedAt", "updatedBy", "createdBy"];
28
+ const SKIPPED_ATTRIBUTES = /* @__PURE__ */ new Set([
29
+ "id",
30
+ "documentId",
31
+ "locale",
32
+ "publishedAt",
33
+ "createdBy",
34
+ "updatedBy"
35
+ ]);
36
+ const REQUEST_ID_HEADERS = ["x-request-id", "request-id", "x-correlation-id", "x-amzn-trace-id"];
37
+ const RETENTION_JOB_NAME = "auditLogRetention";
38
+ const CONTENT_ACTIONS = ALL_ACTIONS;
39
+ const ALL_SECURITY_ACTIONS = [
40
+ "login.success",
41
+ "login.failed",
42
+ "logout",
43
+ "access.denied",
44
+ "admin.user.create",
45
+ "admin.user.update",
46
+ "admin.user.delete",
47
+ "admin.role.create",
48
+ "admin.role.update",
49
+ "admin.role.delete",
50
+ "admin.permission.create",
51
+ "admin.permission.update",
52
+ "admin.permission.delete",
53
+ "media.create",
54
+ "media.update",
55
+ "media.delete",
56
+ "media-folder.create",
57
+ "media-folder.update",
58
+ "media-folder.delete"
59
+ ];
60
+ const SUBJECTS = {
61
+ auth: "admin::auth",
62
+ access: "admin::access",
63
+ user: "admin::user",
64
+ role: "admin::role",
65
+ permission: "admin::permission",
66
+ file: "plugin::upload.file",
67
+ folder: "plugin::upload.folder"
68
+ };
69
+ const SUBJECT_DISPLAY_NAMES = {
70
+ [SUBJECTS.auth]: "Authentication",
71
+ [SUBJECTS.access]: "Access control",
72
+ [SUBJECTS.user]: "Admin user",
73
+ [SUBJECTS.role]: "Admin role",
74
+ [SUBJECTS.permission]: "Permission",
75
+ [SUBJECTS.file]: "Media file",
76
+ [SUBJECTS.folder]: "Media folder"
77
+ };
78
+ const SECURITY_EVENT_MAP = {
79
+ "admin.auth.success": { action: "login.success", subject: SUBJECTS.auth },
80
+ "admin.auth.error": { action: "login.failed", subject: SUBJECTS.auth },
81
+ "admin.logout": { action: "logout", subject: SUBJECTS.auth },
82
+ "user.create": { action: "admin.user.create", subject: SUBJECTS.user },
83
+ "user.update": { action: "admin.user.update", subject: SUBJECTS.user },
84
+ "user.delete": { action: "admin.user.delete", subject: SUBJECTS.user },
85
+ "role.create": { action: "admin.role.create", subject: SUBJECTS.role },
86
+ "role.update": { action: "admin.role.update", subject: SUBJECTS.role },
87
+ "role.delete": { action: "admin.role.delete", subject: SUBJECTS.role },
88
+ "permission.create": { action: "admin.permission.create", subject: SUBJECTS.permission },
89
+ "permission.update": { action: "admin.permission.update", subject: SUBJECTS.permission },
90
+ "permission.delete": { action: "admin.permission.delete", subject: SUBJECTS.permission },
91
+ "media.create": { action: "media.create", subject: SUBJECTS.file },
92
+ "media.update": { action: "media.update", subject: SUBJECTS.file },
93
+ "media.delete": { action: "media.delete", subject: SUBJECTS.file },
94
+ "media-folder.create": { action: "media-folder.create", subject: SUBJECTS.folder },
95
+ "media-folder.update": { action: "media-folder.update", subject: SUBJECTS.folder },
96
+ "media-folder.delete": { action: "media-folder.delete", subject: SUBJECTS.folder }
97
+ };
98
+ const ALL_SOURCES = ["admin", "api", "system", "cron", "migration", "unknown"];
99
+ const DENIED_STATUSES = /* @__PURE__ */ new Set([401, 403]);
100
+ const ACCESS_IGNORED_PATHS = [
101
+ /^\/admin\/login(\/|$)/,
102
+ /^\/admin\/renew-token(\/|$)/,
103
+ /^\/admin\/refresh-token(\/|$)/,
104
+ /^\/api\/auth\/local(\/|$)/
105
+ ];
106
+ const bootstrap = ({ strapi }) => {
107
+ const plugin = strapi.plugin(PLUGIN_ID);
108
+ const config2 = plugin.service("config").resolve();
109
+ plugin.service("immutability").register();
110
+ plugin.service("tracker").register();
111
+ plugin.service("security").register();
112
+ plugin.service("retention").register();
113
+ const scope = config2.contentTypes === "*" ? "all content types" : `${config2.contentTypes.length} content type(s)`;
114
+ strapi.log.info(
115
+ `[audit-log] tracking ${config2.actions.join(", ")} on ${scope}` + (config2.ignoredContentTypes.length > 0 ? ` (${config2.ignoredContentTypes.length} ignored)` : "") + `; writes are ${config2.writeMode}.`
116
+ );
117
+ if (config2.forwardToLogger) {
118
+ strapi.log.info(
119
+ `[audit-log] mirroring every record to the logger at level "${config2.forwardLogLevel}" for downstream collection.`
120
+ );
121
+ }
122
+ };
123
+ const config = {
124
+ default: {
125
+ enabled: true,
126
+ actions: null,
127
+ contentTypes: null,
128
+ ignoredContentTypes: null,
129
+ ignoredFields: null,
130
+ additionalIgnoredFields: null,
131
+ ignoredChangeFields: null,
132
+ storeBefore: true,
133
+ storeAfter: true,
134
+ storeChanges: true,
135
+ retentionDays: 365,
136
+ retentionCron: "0 3 * * *",
137
+ failOnAuditError: false,
138
+ writeMode: "sync",
139
+ maxPopulateDepth: 2,
140
+ maxSnapshotBytes: 512 * 1024,
141
+ auditSystemOperations: true,
142
+ // `null` for the same reason every other collection-valued option is null —
143
+ // see the note above on `defaultsDeep` splicing arrays element-wise.
144
+ securityEvents: null,
145
+ forwardToLogger: false,
146
+ forwardLogLevel: "info"
147
+ },
148
+ /**
149
+ * Runs once at boot, before anything else touches the config. Throwing here
150
+ * fails startup with a clear message, which is far kinder than a plugin that
151
+ * silently audits nothing because of a typo.
152
+ */
153
+ validator(config2) {
154
+ const fail = (message) => {
155
+ throw new Error(message);
156
+ };
157
+ if (config2.actions != null) {
158
+ if (!Array.isArray(config2.actions)) {
159
+ fail(`"actions" must be an array, received ${typeof config2.actions}`);
160
+ }
161
+ for (const action of config2.actions) {
162
+ if (!ALL_ACTIONS.includes(action)) {
163
+ fail(`"actions" contains an unknown action "${action}". Allowed: ${ALL_ACTIONS.join(", ")}`);
164
+ }
165
+ }
166
+ }
167
+ if (config2.contentTypes != null && config2.contentTypes !== "*" && !Array.isArray(config2.contentTypes)) {
168
+ fail('"contentTypes" must be "*" or an array of content-type uids');
169
+ }
170
+ for (const key of [
171
+ "ignoredContentTypes",
172
+ "ignoredFields",
173
+ "additionalIgnoredFields",
174
+ "ignoredChangeFields"
175
+ ]) {
176
+ const value = config2[key];
177
+ if (value != null && !Array.isArray(value)) {
178
+ fail(`"${key}" must be an array of strings`);
179
+ }
180
+ }
181
+ for (const key of ["storeBefore", "storeAfter", "storeChanges", "failOnAuditError", "auditSystemOperations"]) {
182
+ const value = config2[key];
183
+ if (value != null && typeof value !== "boolean") {
184
+ fail(`"${key}" must be a boolean`);
185
+ }
186
+ }
187
+ if (config2.retentionDays != null) {
188
+ if (typeof config2.retentionDays !== "number" || !Number.isFinite(config2.retentionDays) || config2.retentionDays < 0) {
189
+ fail('"retentionDays" must be a number >= 0 (0 disables automatic deletion)');
190
+ }
191
+ }
192
+ if (config2.writeMode != null && config2.writeMode !== "sync" && config2.writeMode !== "async") {
193
+ fail('"writeMode" must be either "sync" or "async"');
194
+ }
195
+ if (config2.securityEvents != null && config2.securityEvents !== "*") {
196
+ if (!Array.isArray(config2.securityEvents)) {
197
+ fail('"securityEvents" must be "*" or an array of security event names');
198
+ }
199
+ for (const event of config2.securityEvents) {
200
+ if (!ALL_SECURITY_ACTIONS.includes(event)) {
201
+ fail(
202
+ `"securityEvents" contains an unknown event "${event}". Allowed: ${ALL_SECURITY_ACTIONS.join(", ")}`
203
+ );
204
+ }
205
+ }
206
+ }
207
+ if (config2.forwardToLogger != null && typeof config2.forwardToLogger !== "boolean") {
208
+ fail('"forwardToLogger" must be a boolean');
209
+ }
210
+ if (config2.forwardLogLevel != null && !["debug", "info", "warn", "error"].includes(config2.forwardLogLevel)) {
211
+ fail('"forwardLogLevel" must be one of: debug, info, warn, error');
212
+ }
213
+ if (config2.maxPopulateDepth != null) {
214
+ if (typeof config2.maxPopulateDepth !== "number" || config2.maxPopulateDepth < 0 || config2.maxPopulateDepth > 5) {
215
+ fail('"maxPopulateDepth" must be a number between 0 and 5');
216
+ }
217
+ }
218
+ if (config2.maxSnapshotBytes != null) {
219
+ if (typeof config2.maxSnapshotBytes !== "number" || config2.maxSnapshotBytes < 0) {
220
+ fail('"maxSnapshotBytes" must be a number >= 0 (0 disables the cap)');
221
+ }
222
+ }
223
+ }
224
+ };
225
+ const schema = {
226
+ kind: "collectionType",
227
+ collectionName: "audit_logs",
228
+ info: {
229
+ singularName: "audit-log",
230
+ pluralName: "audit-logs",
231
+ displayName: "Audit Log",
232
+ description: "Immutable record of a content operation or a security event."
233
+ },
234
+ options: {
235
+ draftAndPublish: false
236
+ },
237
+ pluginOptions: {
238
+ "content-manager": { visible: false },
239
+ "content-type-builder": { visible: false }
240
+ },
241
+ attributes: {
242
+ action: { type: "string", required: true },
243
+ /** UID of the audited content type, e.g. `api::page.page`. */
244
+ contentType: { type: "string", required: true },
245
+ /** Display name at write time, so the log stays readable if the type is renamed or removed. */
246
+ contentTypeDisplayName: { type: "string" },
247
+ /**
248
+ * The audited document's Strapi v5 document id.
249
+ *
250
+ * NOT named `documentId`. Strapi reserves that attribute name on every
251
+ * content type — `transformContentTypesToModels` throws
252
+ * "The attribute "documentId" is reserved" at boot — because it injects its
253
+ * own `documentId` column into every collection type. Each audit row
254
+ * therefore still *has* a framework `documentId` (its own identity); this
255
+ * column is the id of the document the row is *about*.
256
+ */
257
+ contentDocumentId: { type: "string" },
258
+ /** The audited entry's numeric database id, stored as a string. */
259
+ contentId: { type: "string" },
260
+ locale: { type: "string" },
261
+ /** Actor identity, snapshotted so the record survives the user being renamed or deleted. */
262
+ userId: { type: "string" },
263
+ userEmail: { type: "string" },
264
+ userName: { type: "string" },
265
+ /** Field-level diff, keyed by dotted path. */
266
+ changes: { type: "json" },
267
+ before: { type: "json" },
268
+ after: { type: "json" },
269
+ /**
270
+ * Whether the recorded attempt succeeded.
271
+ *
272
+ * Always `success` for a content write — the tracker runs after the
273
+ * operation resolved, so a save that threw produces no row at all. The
274
+ * column earns its place on the security side, where `login.failed` and
275
+ * `access.denied` are exactly the rows a reviewer opens the log to find, and
276
+ * where "show me every failure" has to be an indexed query rather than a
277
+ * scan against a hard-coded list of action names.
278
+ */
279
+ outcome: { type: "string" },
280
+ /**
281
+ * Action-specific detail: the reason a login was refused, the method and
282
+ * path of a denied request, the filename of an upload.
283
+ *
284
+ * A loose JSON bag rather than a column each, because the useful fields
285
+ * differ per action and none of them is ever filtered or sorted on. Anything
286
+ * that needs to be queryable gets a real column instead.
287
+ */
288
+ metadata: { type: "json" },
289
+ ipAddress: { type: "string" },
290
+ userAgent: { type: "text" },
291
+ source: { type: "string" },
292
+ requestId: { type: "string" }
293
+ },
294
+ /**
295
+ * Secondary indexes, declared here so Strapi's schema sync owns them and they
296
+ * are created on every supported database without hand-written DDL.
297
+ *
298
+ * Columns are DB column names (snake_cased attribute names), and names are
299
+ * kept short enough for PostgreSQL's 63-character identifier limit.
300
+ *
301
+ * `audit_logs_ct_created_idx` is composite and leading-column ordered for the
302
+ * admin list's default query — filter by content type, sort by date — which is
303
+ * the only query that runs on every page load.
304
+ */
305
+ indexes: [
306
+ { name: "audit_logs_created_idx", columns: ["created_at"] },
307
+ { name: "audit_logs_ct_created_idx", columns: ["content_type", "created_at"] },
308
+ { name: "audit_logs_doc_idx", columns: ["content_document_id"] },
309
+ { name: "audit_logs_action_idx", columns: ["action"] },
310
+ { name: "audit_logs_user_idx", columns: ["user_id"] },
311
+ { name: "audit_logs_locale_idx", columns: ["locale"] },
312
+ { name: "audit_logs_outcome_idx", columns: ["outcome"] }
313
+ ]
314
+ };
315
+ const auditLog = { schema };
316
+ const contentTypes = {
317
+ "audit-log": auditLog
318
+ };
319
+ const ALLOWED_QUERY_KEYS = [
320
+ "page",
321
+ "pageSize",
322
+ "sort",
323
+ "action",
324
+ "contentType",
325
+ "userId",
326
+ "locale",
327
+ "source",
328
+ "outcome",
329
+ "contentDocumentId",
330
+ "dateFrom",
331
+ "dateTo",
332
+ "_q"
333
+ ];
334
+ const MAX_STRING_FILTER_LENGTH = 256;
335
+ const asFilterValue = (value) => {
336
+ if (value === void 0 || value === null) return void 0;
337
+ if (Array.isArray(value)) {
338
+ const items = value.filter((item) => typeof item === "string" || typeof item === "number").map((item) => String(item).slice(0, MAX_STRING_FILTER_LENGTH));
339
+ return items.length > 0 ? items : void 0;
340
+ }
341
+ if (typeof value === "object") return void 0;
342
+ const text = String(value).slice(0, MAX_STRING_FILTER_LENGTH);
343
+ return text.length > 0 ? text : void 0;
344
+ };
345
+ const parseId = (raw) => {
346
+ if (raw === void 0) return null;
347
+ const id = Number(raw);
348
+ if (!Number.isInteger(id) || id < 1 || id > Number.MAX_SAFE_INTEGER) return null;
349
+ return id;
350
+ };
351
+ const auditLogController = ({ strapi }) => {
352
+ const service = (name) => strapi.plugin("audit-log").service(name);
353
+ return {
354
+ async find(ctx) {
355
+ const query = {};
356
+ for (const key of ALLOWED_QUERY_KEYS) {
357
+ const value = asFilterValue(ctx.query[key]);
358
+ if (value === void 0) continue;
359
+ if (key === "page" || key === "pageSize") {
360
+ const parsed = Number(Array.isArray(value) ? value[0] : value);
361
+ if (Number.isFinite(parsed)) query[key] = parsed;
362
+ continue;
363
+ }
364
+ if (key === "sort" || key === "contentDocumentId" || key === "dateFrom" || key === "dateTo" || key === "_q") {
365
+ query[key] = Array.isArray(value) ? value[0] : value;
366
+ continue;
367
+ }
368
+ query[key] = value;
369
+ }
370
+ ctx.body = await service("audit").find(query);
371
+ },
372
+ async findOne(ctx) {
373
+ const id = parseId(ctx.params.id);
374
+ if (id === null) return ctx.badRequest("Invalid audit log id.");
375
+ const log = await service("audit").findOne(id);
376
+ if (!log) return ctx.notFound("Audit log not found.");
377
+ ctx.body = { data: log };
378
+ },
379
+ async delete(ctx) {
380
+ const id = parseId(ctx.params.id);
381
+ if (id === null) return ctx.badRequest("Invalid audit log id.");
382
+ const deleted = await service("audit").deleteOne(id);
383
+ if (!deleted) return ctx.notFound("Audit log not found.");
384
+ strapi.log.info(
385
+ `[audit-log] record #${id} deleted by ${service("context").resolve().userEmail ?? "an admin user"}.`
386
+ );
387
+ ctx.body = { data: deleted };
388
+ },
389
+ /** Filter dropdown options, derived from the rows that actually exist. */
390
+ async filters(ctx) {
391
+ ctx.body = { data: await service("audit").getFilterOptions() };
392
+ },
393
+ /** The effective configuration, so the UI can hide controls for disabled features. */
394
+ async config(ctx) {
395
+ ctx.body = { data: service("config").getPublicConfig() };
396
+ }
397
+ };
398
+ };
399
+ const controllers = {
400
+ "audit-log": auditLogController
401
+ };
402
+ const destroy = async ({ strapi }) => {
403
+ const plugin = strapi.plugin(PLUGIN_ID);
404
+ try {
405
+ plugin.service("security").unregister();
406
+ } catch (error) {
407
+ strapi.log.debug(`[audit-log] security listener teardown: ${error?.message ?? error}`);
408
+ }
409
+ try {
410
+ plugin.service("retention").unregister();
411
+ } catch (error) {
412
+ strapi.log.debug(`[audit-log] retention teardown: ${error?.message ?? error}`);
413
+ }
414
+ try {
415
+ await plugin.service("audit").flush();
416
+ } catch (error) {
417
+ strapi.log.error(`[audit-log] failed to flush pending writes: ${error?.message ?? error}`);
418
+ }
419
+ };
420
+ const ACTIONS = [
421
+ {
422
+ section: "plugins",
423
+ displayName: "Read audit logs",
424
+ uid: "read",
425
+ pluginName: PLUGIN_ID
426
+ },
427
+ {
428
+ section: "plugins",
429
+ displayName: "Delete audit logs",
430
+ uid: "delete",
431
+ pluginName: PLUGIN_ID
432
+ },
433
+ {
434
+ section: "plugins",
435
+ displayName: "Read audit log settings",
436
+ uid: "settings",
437
+ pluginName: PLUGIN_ID
438
+ }
439
+ ];
440
+ const register = async ({ strapi }) => {
441
+ await strapi.service("admin::permission").actionProvider.registerMany(ACTIONS);
442
+ strapi.plugin(PLUGIN_ID).service("access").register();
443
+ };
444
+ const protectedBy = (action) => ({
445
+ policies: [
446
+ "admin::isAuthenticatedAdmin",
447
+ { name: "admin::hasPermissions", config: { actions: [action] } }
448
+ ]
449
+ });
450
+ const admin = {
451
+ type: "admin",
452
+ routes: [
453
+ {
454
+ method: "GET",
455
+ path: "/logs",
456
+ handler: "audit-log.find",
457
+ config: protectedBy(PERMISSIONS.read)
458
+ },
459
+ {
460
+ method: "GET",
461
+ path: "/filters",
462
+ handler: "audit-log.filters",
463
+ config: protectedBy(PERMISSIONS.read)
464
+ },
465
+ {
466
+ method: "GET",
467
+ path: "/config",
468
+ handler: "audit-log.config",
469
+ config: protectedBy(PERMISSIONS.read)
470
+ },
471
+ {
472
+ // Declared after the two static paths above so `/logs/filters` can never
473
+ // be swallowed by `:id`.
474
+ method: "GET",
475
+ path: "/logs/:id",
476
+ handler: "audit-log.findOne",
477
+ config: protectedBy(PERMISSIONS.read)
478
+ },
479
+ {
480
+ method: "DELETE",
481
+ path: "/logs/:id",
482
+ handler: "audit-log.delete",
483
+ // A separate permission from `read`: being allowed to investigate an
484
+ // incident must not imply being allowed to erase the evidence.
485
+ config: protectedBy(PERMISSIONS.delete)
486
+ }
487
+ ]
488
+ };
489
+ const routes = {
490
+ admin
491
+ };
492
+ const MAX_PATH_LENGTH = 512;
493
+ const asString$2 = (value, maxLength = 256) => {
494
+ if (value === null || value === void 0) return null;
495
+ const text = String(value).trim();
496
+ return text.length === 0 ? null : text.slice(0, maxLength);
497
+ };
498
+ const accessService = ({ strapi }) => {
499
+ const plugin = () => strapi.plugin("audit-log");
500
+ let registered = false;
501
+ const isIgnoredPath = (path) => ACCESS_IGNORED_PATHS.some((pattern) => pattern.test(path));
502
+ const actorOf = (ctx) => {
503
+ const user = ctx.state?.user ?? null;
504
+ if (!user) return { userId: null, userEmail: null, userName: null };
505
+ const firstname = asString$2(user.firstname);
506
+ const lastname = asString$2(user.lastname);
507
+ return {
508
+ userId: asString$2(user.id),
509
+ userEmail: asString$2(user.email),
510
+ userName: firstname && lastname ? `${firstname} ${lastname}` : asString$2(user.username) ?? firstname ?? lastname ?? asString$2(user.email)
511
+ };
512
+ };
513
+ const sourceOf = (ctx) => {
514
+ const routeType = asString$2(ctx.state?.route?.info?.type);
515
+ if (routeType === "admin") return "admin";
516
+ if (routeType === "content-api") return "api";
517
+ const strategy = asString$2(ctx.state?.auth?.strategy?.name);
518
+ if (strategy === "admin") return "admin";
519
+ if (strategy === "api-token" || strategy === "users-permissions") return "api";
520
+ return "unknown";
521
+ };
522
+ const recordDenial = async (ctx) => {
523
+ const headers = ctx.request?.headers ?? {};
524
+ await plugin().service("audit").record({
525
+ action: "access.denied",
526
+ contentType: SUBJECTS.access,
527
+ contentTypeDisplayName: "Access control",
528
+ contentDocumentId: null,
529
+ contentId: null,
530
+ locale: null,
531
+ changes: null,
532
+ before: null,
533
+ after: null,
534
+ outcome: "failure",
535
+ metadata: {
536
+ method: asString$2(ctx.request?.method, 16),
537
+ path: asString$2(ctx.request?.path, MAX_PATH_LENGTH),
538
+ statusCode: ctx.status,
539
+ // Strapi's error handler puts a reason in the body. It is the difference
540
+ // between "no session" and "your role lacks this permission", which is
541
+ // the first thing anyone reading the row wants to know.
542
+ reason: asString$2(ctx.body?.error?.message) ?? null,
543
+ routeType: asString$2(ctx.state?.route?.info?.type)
544
+ },
545
+ source: sourceOf(ctx),
546
+ ...actorOf(ctx),
547
+ ipAddress: asString$2(ctx.request?.ip ?? ctx.ip, 64),
548
+ userAgent: asString$2(headers["user-agent"], 512),
549
+ requestId: asString$2(ctx.state?.requestId ?? headers["x-request-id"])
550
+ });
551
+ };
552
+ const createMiddleware = () => {
553
+ return async (ctx, next) => {
554
+ await next();
555
+ if (!DENIED_STATUSES.has(ctx.status)) return;
556
+ const path = String(ctx.request?.path ?? "");
557
+ if (isIgnoredPath(path)) return;
558
+ if (!plugin().service("config").isAuditedSecurityAction("access.denied")) return;
559
+ try {
560
+ await recordDenial(ctx);
561
+ } catch (error) {
562
+ strapi.log.error(
563
+ `[audit-log] failed to record a denied request: ${error?.message ?? error}`
564
+ );
565
+ }
566
+ };
567
+ };
568
+ const register2 = () => {
569
+ if (registered) return;
570
+ if (!plugin().service("config").isAuditedSecurityAction("access.denied")) return;
571
+ strapi.server.use(createMiddleware());
572
+ registered = true;
573
+ };
574
+ return { createMiddleware, register: register2, recordDenial, sourceOf, isIgnoredPath };
575
+ };
576
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
577
+ const isEqual = (a, b) => {
578
+ if (a === b) return true;
579
+ if (a instanceof Date || b instanceof Date) {
580
+ const left = a instanceof Date ? a.getTime() : Date.parse(String(a));
581
+ const right = b instanceof Date ? b.getTime() : Date.parse(String(b));
582
+ return Number.isFinite(left) && Number.isFinite(right) && left === right;
583
+ }
584
+ if (a == null && b == null) return true;
585
+ if (a == null || b == null) return false;
586
+ if (Array.isArray(a) || Array.isArray(b)) {
587
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
588
+ return a.every((item, index2) => isEqual(item, b[index2]));
589
+ }
590
+ if (isPlainObject(a) && isPlainObject(b)) {
591
+ const aKeys = Object.keys(a);
592
+ const bKeys = Object.keys(b);
593
+ if (aKeys.length !== bKeys.length) return false;
594
+ return aKeys.every((key) => Object.prototype.hasOwnProperty.call(b, key) && isEqual(a[key], b[key]));
595
+ }
596
+ return false;
597
+ };
598
+ const approximateJsonBytes = (value) => {
599
+ try {
600
+ return Buffer.byteLength(JSON.stringify(value) ?? "", "utf8");
601
+ } catch {
602
+ return Number.POSITIVE_INFINITY;
603
+ }
604
+ };
605
+ const SORTABLE_FIELDS = /* @__PURE__ */ new Set([
606
+ "createdAt",
607
+ "action",
608
+ "contentType",
609
+ "userEmail",
610
+ "userName",
611
+ "locale",
612
+ "source",
613
+ "outcome",
614
+ "id"
615
+ ]);
616
+ const SEARCHABLE_FIELDS = [
617
+ "contentDocumentId",
618
+ "contentId",
619
+ "contentType",
620
+ "contentTypeDisplayName",
621
+ "userEmail",
622
+ "userName",
623
+ "requestId",
624
+ "ipAddress"
625
+ ];
626
+ const MAX_PAGE_SIZE = 100;
627
+ const toArray = (value) => {
628
+ if (value === void 0 || value === null) return [];
629
+ const values = Array.isArray(value) ? value : [value];
630
+ return values.map((item) => String(item).trim()).filter((item) => item.length > 0);
631
+ };
632
+ const toPositiveInt = (value, fallback, max) => {
633
+ const parsed = Number(value);
634
+ if (!Number.isInteger(parsed) || parsed < 1) return fallback;
635
+ return max ? Math.min(parsed, max) : parsed;
636
+ };
637
+ const auditService = ({ strapi }) => {
638
+ const pending = /* @__PURE__ */ new Set();
639
+ const getConfig = () => strapi.plugin("audit-log").service("config").resolve();
640
+ const capSnapshot = (value, maxBytes, label) => {
641
+ if (!value || maxBytes <= 0) return value;
642
+ const bytes = approximateJsonBytes(value);
643
+ if (bytes <= maxBytes) return value;
644
+ strapi.log.warn(
645
+ `[audit-log] ${label} snapshot dropped: ~${bytes} bytes exceeds maxSnapshotBytes (${maxBytes}).`
646
+ );
647
+ return { __omitted__: `Snapshot omitted: ~${bytes} bytes exceeds maxSnapshotBytes (${maxBytes}).` };
648
+ };
649
+ const write = async (entry) => {
650
+ const config2 = getConfig();
651
+ await strapi.db.query(AUDIT_LOG_UID).create({
652
+ data: {
653
+ action: entry.action,
654
+ contentType: entry.contentType,
655
+ contentTypeDisplayName: entry.contentTypeDisplayName,
656
+ contentDocumentId: entry.contentDocumentId,
657
+ contentId: entry.contentId,
658
+ locale: entry.locale,
659
+ userId: entry.userId,
660
+ userEmail: entry.userEmail,
661
+ userName: entry.userName,
662
+ changes: config2.storeChanges ? entry.changes : null,
663
+ before: capSnapshot(config2.storeBefore ? entry.before : null, config2.maxSnapshotBytes, "before"),
664
+ after: capSnapshot(config2.storeAfter ? entry.after : null, config2.maxSnapshotBytes, "after"),
665
+ ipAddress: entry.ipAddress,
666
+ userAgent: entry.userAgent,
667
+ source: entry.source,
668
+ requestId: entry.requestId,
669
+ // A content write only ever reaches the tracker once it has succeeded,
670
+ // so the default is the honest value rather than a placeholder.
671
+ outcome: entry.outcome ?? "success",
672
+ metadata: entry.metadata ?? null
673
+ }
674
+ });
675
+ if (config2.forwardToLogger) forwardToLogger(entry, config2);
676
+ };
677
+ const forwardToLogger = (entry, config2) => {
678
+ try {
679
+ const line = JSON.stringify({
680
+ type: "audit-log",
681
+ action: entry.action,
682
+ outcome: entry.outcome ?? "success",
683
+ contentType: entry.contentType,
684
+ contentDocumentId: entry.contentDocumentId,
685
+ contentId: entry.contentId,
686
+ locale: entry.locale,
687
+ userId: entry.userId,
688
+ userEmail: entry.userEmail,
689
+ userName: entry.userName,
690
+ source: entry.source,
691
+ ipAddress: entry.ipAddress,
692
+ userAgent: entry.userAgent,
693
+ requestId: entry.requestId,
694
+ // Paths only. The values are in the database row; what a SIEM rule needs
695
+ // is "which fields moved", and that is what alerts can be written against.
696
+ changedPaths: entry.changes ? Object.keys(entry.changes) : [],
697
+ metadata: entry.metadata ?? null,
698
+ at: (/* @__PURE__ */ new Date()).toISOString()
699
+ });
700
+ strapi.log[config2.forwardLogLevel](
701
+ line
702
+ );
703
+ } catch (error) {
704
+ strapi.log.debug(`[audit-log] logger forwarding skipped: ${error?.message ?? error}`);
705
+ }
706
+ };
707
+ const record = async (entry) => {
708
+ const config2 = getConfig();
709
+ if (config2.writeMode === "async") {
710
+ const task = write(entry).catch((error) => {
711
+ strapi.log.error(
712
+ `[audit-log] failed to persist ${entry.action} on ${entry.contentType}: ${error?.message ?? error}`
713
+ );
714
+ }).finally(() => {
715
+ pending.delete(task);
716
+ });
717
+ pending.add(task);
718
+ return;
719
+ }
720
+ await write(entry);
721
+ };
722
+ const flush = async () => {
723
+ if (pending.size === 0) return;
724
+ await Promise.allSettled([...pending]);
725
+ };
726
+ const buildWhere = (query) => {
727
+ const where = {};
728
+ const inFilter = (values) => {
729
+ if (values.length === 0) return void 0;
730
+ return values.length === 1 ? values[0] : { $in: values };
731
+ };
732
+ const action = inFilter(toArray(query.action));
733
+ if (action !== void 0) where.action = action;
734
+ const contentType = inFilter(toArray(query.contentType));
735
+ if (contentType !== void 0) where.contentType = contentType;
736
+ const userId = inFilter(toArray(query.userId));
737
+ if (userId !== void 0) where.userId = userId;
738
+ const locale = inFilter(toArray(query.locale));
739
+ if (locale !== void 0) where.locale = locale;
740
+ const source = inFilter(toArray(query.source));
741
+ if (source !== void 0) where.source = source;
742
+ const outcome = inFilter(toArray(query.outcome));
743
+ if (outcome !== void 0) where.outcome = outcome;
744
+ if (query.contentDocumentId) {
745
+ where.contentDocumentId = String(query.contentDocumentId).trim();
746
+ }
747
+ const createdAt = {};
748
+ const from = query.dateFrom ? new Date(query.dateFrom) : null;
749
+ const to = query.dateTo ? new Date(query.dateTo) : null;
750
+ if (from && !Number.isNaN(from.getTime())) createdAt.$gte = from.toISOString();
751
+ if (to && !Number.isNaN(to.getTime())) createdAt.$lte = to.toISOString();
752
+ if (Object.keys(createdAt).length > 0) where.createdAt = createdAt;
753
+ const search = query._q ? String(query._q).trim() : "";
754
+ if (search.length > 0) {
755
+ where.$or = SEARCHABLE_FIELDS.map((field) => ({ [field]: { $containsi: search } }));
756
+ }
757
+ return where;
758
+ };
759
+ const parseSort = (sort) => {
760
+ const fallback = { createdAt: "desc" };
761
+ if (!sort) return fallback;
762
+ const [field, rawDirection] = String(sort).split(":");
763
+ if (!field || !SORTABLE_FIELDS.has(field)) return fallback;
764
+ return { [field]: rawDirection?.toLowerCase() === "asc" ? "asc" : "desc" };
765
+ };
766
+ const find = async (query = {}) => {
767
+ const page = toPositiveInt(query.page, 1);
768
+ const pageSize = toPositiveInt(query.pageSize, 20, MAX_PAGE_SIZE);
769
+ const { results, pagination } = await strapi.db.query(AUDIT_LOG_UID).findPage({
770
+ where: buildWhere(query),
771
+ orderBy: parseSort(query.sort),
772
+ page,
773
+ pageSize
774
+ });
775
+ return { results, pagination };
776
+ };
777
+ const findOne = async (id) => await strapi.db.query(AUDIT_LOG_UID).findOne({ where: { id } });
778
+ const deleteOne = async (id) => await strapi.db.query(AUDIT_LOG_UID).delete({ where: { id } });
779
+ const deleteOlderThan = async (date) => {
780
+ const { count } = await strapi.db.query(AUDIT_LOG_UID).deleteMany({
781
+ where: { createdAt: { $lt: date.toISOString() } }
782
+ });
783
+ return count;
784
+ };
785
+ const registryOptions = () => {
786
+ const configService2 = strapi.plugin(PLUGIN_ID).service("config");
787
+ const contentTypes2 = [];
788
+ try {
789
+ const registry = strapi.contentTypes ?? {};
790
+ for (const [uid, schema2] of Object.entries(registry)) {
791
+ if (!uid.startsWith("api::") && !uid.startsWith("plugin::")) continue;
792
+ if (!configService2.isAuditedContentType(uid)) continue;
793
+ contentTypes2.push({
794
+ uid,
795
+ displayName: schema2?.info?.displayName ?? schema2?.info?.singularName ?? uid
796
+ });
797
+ }
798
+ } catch (error) {
799
+ strapi.log.debug(
800
+ `[audit-log] could not read the content-type registry for filters: ${error?.message ?? error}`
801
+ );
802
+ }
803
+ if (configService2.hasSecurityEvents()) {
804
+ for (const uid of Object.values(SUBJECTS)) {
805
+ contentTypes2.push({ uid, displayName: SUBJECT_DISPLAY_NAMES[uid] ?? uid });
806
+ }
807
+ }
808
+ return { contentTypes: contentTypes2 };
809
+ };
810
+ const configuredLocales = async () => {
811
+ try {
812
+ if (!strapi.plugin("i18n")) return [];
813
+ const locales = await strapi.db.query("plugin::i18n.locale").findMany({
814
+ select: ["code"]
815
+ });
816
+ return locales.map((locale) => locale.code).filter((code) => Boolean(code));
817
+ } catch (error) {
818
+ strapi.log.debug(
819
+ `[audit-log] could not read locales for filters: ${error?.message ?? error}`
820
+ );
821
+ return [];
822
+ }
823
+ };
824
+ const adminUsers = async () => {
825
+ try {
826
+ const users = await strapi.db.query("admin::user").findMany({
827
+ select: ["id", "email", "firstname", "lastname", "username"]
828
+ });
829
+ return users.map((user) => {
830
+ const firstname = user.firstname ? String(user.firstname).trim() : "";
831
+ const lastname = user.lastname ? String(user.lastname).trim() : "";
832
+ const name = [firstname, lastname].filter(Boolean).join(" ");
833
+ return {
834
+ userId: String(user.id),
835
+ // Email first: it is the identifier an auditor is given in a ticket,
836
+ // and it is unique where a display name is not.
837
+ label: String(user.email ?? user.username ?? name ?? user.id)
838
+ };
839
+ });
840
+ } catch (error) {
841
+ strapi.log.debug(
842
+ `[audit-log] could not read admin users for filters: ${error?.message ?? error}`
843
+ );
844
+ return [];
845
+ }
846
+ };
847
+ const getFilterOptions = async () => {
848
+ const registry = registryOptions();
849
+ const fallback = {
850
+ contentTypes: registry.contentTypes,
851
+ users: await adminUsers(),
852
+ locales: await configuredLocales(),
853
+ actions: [...CONTENT_ACTIONS, ...ALL_SECURITY_ACTIONS],
854
+ sources: [...ALL_SOURCES],
855
+ outcomes: ["success", "failure"]
856
+ };
857
+ let stored = null;
858
+ try {
859
+ stored = await readStoredOptions();
860
+ } catch (error) {
861
+ strapi.log.error(
862
+ `[audit-log] could not read stored filter options: ${error?.message ?? error}`
863
+ );
864
+ }
865
+ if (!stored) return sortOptions(fallback);
866
+ return sortOptions({
867
+ contentTypes: dedupeBy([...fallback.contentTypes, ...stored.contentTypes], (item) => item.uid),
868
+ users: dedupeBy([...fallback.users, ...stored.users], (item) => item.userId),
869
+ locales: [.../* @__PURE__ */ new Set([...fallback.locales, ...stored.locales])],
870
+ actions: [.../* @__PURE__ */ new Set([...fallback.actions, ...stored.actions])],
871
+ sources: [.../* @__PURE__ */ new Set([...fallback.sources, ...stored.sources])],
872
+ outcomes: [.../* @__PURE__ */ new Set([...fallback.outcomes, ...stored.outcomes])]
873
+ });
874
+ };
875
+ const dedupeBy = (items, key) => {
876
+ const seen = /* @__PURE__ */ new Map();
877
+ for (const item of items) {
878
+ const id = key(item);
879
+ if (!seen.has(id)) seen.set(id, item);
880
+ }
881
+ return [...seen.values()];
882
+ };
883
+ const sortOptions = (options) => ({
884
+ ...options,
885
+ contentTypes: [...options.contentTypes].sort(
886
+ (a, b) => a.displayName.localeCompare(b.displayName)
887
+ ),
888
+ users: [...options.users].sort((a, b) => a.label.localeCompare(b.label)),
889
+ locales: [...options.locales].sort(),
890
+ actions: [...options.actions].sort(),
891
+ sources: [...options.sources].sort(),
892
+ outcomes: [...options.outcomes].sort()
893
+ });
894
+ const readStoredOptions = async () => {
895
+ const knex = strapi.db.connection;
896
+ const metadata = strapi.db.metadata.get(AUDIT_LOG_UID);
897
+ const table = metadata.tableName;
898
+ const column = (attribute) => {
899
+ const meta = metadata.attributes[attribute];
900
+ return meta?.columnName ?? attribute;
901
+ };
902
+ const cols = {
903
+ contentType: column("contentType"),
904
+ contentTypeDisplayName: column("contentTypeDisplayName"),
905
+ userId: column("userId"),
906
+ userEmail: column("userEmail"),
907
+ userName: column("userName"),
908
+ locale: column("locale"),
909
+ action: column("action"),
910
+ source: column("source"),
911
+ outcome: column("outcome")
912
+ };
913
+ const [contentTypeRows, userRows, localeRows, actionRows, sourceRows, outcomeRows] = await Promise.all([
914
+ knex(table).distinct(cols.contentType, cols.contentTypeDisplayName).orderBy(cols.contentType, "asc"),
915
+ knex(table).distinct(cols.userId, cols.userEmail, cols.userName).whereNotNull(cols.userId).orderBy(cols.userId, "asc"),
916
+ knex(table).distinct(cols.locale).whereNotNull(cols.locale).orderBy(cols.locale, "asc"),
917
+ knex(table).distinct(cols.action).orderBy(cols.action, "asc"),
918
+ knex(table).distinct(cols.source).whereNotNull(cols.source).orderBy(cols.source, "asc"),
919
+ knex(table).distinct(cols.outcome).whereNotNull(cols.outcome).orderBy(cols.outcome, "asc")
920
+ ]);
921
+ const rows = (input) => Array.isArray(input) ? input : [];
922
+ return {
923
+ contentTypes: rows(contentTypeRows).map((row) => ({
924
+ uid: row[cols.contentType],
925
+ displayName: row[cols.contentTypeDisplayName] || row[cols.contentType]
926
+ })),
927
+ users: rows(userRows).map((row) => ({
928
+ userId: row[cols.userId],
929
+ label: row[cols.userEmail] || row[cols.userName] || row[cols.userId]
930
+ })),
931
+ locales: rows(localeRows).map((row) => row[cols.locale]),
932
+ actions: rows(actionRows).map((row) => row[cols.action]),
933
+ sources: rows(sourceRows).map((row) => row[cols.source]),
934
+ outcomes: rows(outcomeRows).map((row) => row[cols.outcome])
935
+ };
936
+ };
937
+ return {
938
+ record,
939
+ write,
940
+ flush,
941
+ find,
942
+ findOne,
943
+ deleteOne,
944
+ deleteOlderThan,
945
+ getFilterOptions,
946
+ buildWhere,
947
+ parseSort
948
+ };
949
+ };
950
+ const toSegments = (path) => path.replace(/\[\d+\]/g, "").split(".").filter((segment) => segment.length > 0).map((segment) => segment.toLowerCase());
951
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
952
+ const compileSegment = (segment) => {
953
+ if (segment === "*") return () => true;
954
+ if (!segment.includes("*")) {
955
+ return (value) => value === segment;
956
+ }
957
+ const source = `^${segment.split("*").map(escapeRegExp).join(".*")}$`;
958
+ const regex = new RegExp(source);
959
+ return (value) => regex.test(value);
960
+ };
961
+ const matchSegments = (pattern, segments) => {
962
+ if (pattern.length === 0) return segments.length === 0;
963
+ const [head, ...restPattern] = pattern;
964
+ if (head === "**") {
965
+ for (let skip = 0; skip <= segments.length; skip += 1) {
966
+ if (matchSegments(restPattern, segments.slice(skip))) return true;
967
+ }
968
+ return false;
969
+ }
970
+ if (segments.length === 0) return false;
971
+ if (!head(segments[0])) return false;
972
+ return matchSegments(restPattern, segments.slice(1));
973
+ };
974
+ const createPathMatcher = (patterns) => {
975
+ const exactLeaves = /* @__PURE__ */ new Set();
976
+ const leafMatchers = [];
977
+ const compiled = [];
978
+ for (const raw of patterns) {
979
+ const pattern = String(raw).trim().toLowerCase();
980
+ if (pattern.length === 0) continue;
981
+ if (!pattern.includes(".")) {
982
+ if (!pattern.includes("*")) exactLeaves.add(pattern);
983
+ else leafMatchers.push(compileSegment(pattern));
984
+ continue;
985
+ }
986
+ compiled.push(
987
+ toSegments(pattern).map((segment) => segment === "**" ? "**" : compileSegment(segment))
988
+ );
989
+ }
990
+ if (exactLeaves.size === 0 && leafMatchers.length === 0 && compiled.length === 0) {
991
+ return () => false;
992
+ }
993
+ return (path) => {
994
+ const segments = toSegments(path);
995
+ if (segments.length === 0) return false;
996
+ const leaf = segments[segments.length - 1];
997
+ if (exactLeaves.has(leaf)) return true;
998
+ if (leafMatchers.some((matches) => matches(leaf))) return true;
999
+ return compiled.some((pattern) => matchSegments(pattern, segments));
1000
+ };
1001
+ };
1002
+ const joinPath = (base, key) => {
1003
+ if (typeof key === "number") return `${base}[${key}]`;
1004
+ return base.length === 0 ? key : `${base}.${key}`;
1005
+ };
1006
+ const sanitizeValue = (value, isIgnored, basePath = "") => {
1007
+ if (Array.isArray(value)) {
1008
+ return value.map((item, index2) => sanitizeValue(item, isIgnored, joinPath(basePath, index2)));
1009
+ }
1010
+ if (isPlainObject(value)) {
1011
+ const result = {};
1012
+ for (const [key, child] of Object.entries(value)) {
1013
+ const path = joinPath(basePath, key);
1014
+ if (isIgnored(path)) continue;
1015
+ result[key] = sanitizeValue(child, isIgnored, path);
1016
+ }
1017
+ return result;
1018
+ }
1019
+ return value;
1020
+ };
1021
+ const asArray = (value, fallback) => Array.isArray(value) ? value.map(String) : fallback;
1022
+ const configService = ({ strapi }) => {
1023
+ let cached = null;
1024
+ const resolve = () => {
1025
+ if (cached) return cached;
1026
+ const raw = strapi.config.get(`plugin::${PLUGIN_ID}`, {}) ?? {};
1027
+ const ignoredFields = asArray(raw.ignoredFields, DEFAULT_IGNORED_FIELDS);
1028
+ const additionalIgnoredFields = asArray(raw.additionalIgnoredFields, []);
1029
+ const ignoredChangeFields = asArray(raw.ignoredChangeFields, DEFAULT_IGNORED_CHANGE_FIELDS);
1030
+ const snapshotPatterns = [...ignoredFields, ...additionalIgnoredFields];
1031
+ const contentTypes2 = raw.contentTypes == null ? "*" : raw.contentTypes;
1032
+ const config2 = {
1033
+ actions: Array.isArray(raw.actions) ? raw.actions : ALL_ACTIONS,
1034
+ contentTypes: Array.isArray(contentTypes2) ? contentTypes2.map(String) : "*",
1035
+ ignoredContentTypes: asArray(raw.ignoredContentTypes, []),
1036
+ ignoredFields,
1037
+ additionalIgnoredFields,
1038
+ ignoredChangeFields,
1039
+ storeBefore: raw.storeBefore !== false,
1040
+ storeAfter: raw.storeAfter !== false,
1041
+ storeChanges: raw.storeChanges !== false,
1042
+ retentionDays: typeof raw.retentionDays === "number" ? raw.retentionDays : 365,
1043
+ retentionCron: typeof raw.retentionCron === "string" ? raw.retentionCron : "0 3 * * *",
1044
+ failOnAuditError: raw.failOnAuditError === true,
1045
+ writeMode: raw.writeMode === "async" ? "async" : "sync",
1046
+ maxPopulateDepth: typeof raw.maxPopulateDepth === "number" ? raw.maxPopulateDepth : 2,
1047
+ maxSnapshotBytes: typeof raw.maxSnapshotBytes === "number" ? raw.maxSnapshotBytes : 512 * 1024,
1048
+ auditSystemOperations: raw.auditSystemOperations !== false,
1049
+ securityEvents: Array.isArray(raw.securityEvents) ? raw.securityEvents.map(String) : "*",
1050
+ forwardToLogger: raw.forwardToLogger === true,
1051
+ forwardLogLevel: ["debug", "info", "warn", "error"].includes(
1052
+ raw.forwardLogLevel
1053
+ ) ? raw.forwardLogLevel : "info",
1054
+ isIgnoredForSnapshot: createPathMatcher(snapshotPatterns),
1055
+ isIgnoredForChanges: createPathMatcher([...snapshotPatterns, ...ignoredChangeFields])
1056
+ };
1057
+ cached = config2;
1058
+ return config2;
1059
+ };
1060
+ const isAuditedContentType = (uid) => {
1061
+ const { contentTypes: contentTypes2, ignoredContentTypes } = resolve();
1062
+ if (uid === `plugin::${PLUGIN_ID}.audit-log`) return false;
1063
+ if (ignoredContentTypes.includes(uid)) return false;
1064
+ if (contentTypes2 === "*") return true;
1065
+ return contentTypes2.includes(uid);
1066
+ };
1067
+ const isAuditedAction = (action) => resolve().actions.includes(action);
1068
+ const isAuditedSecurityAction = (action) => {
1069
+ const { securityEvents } = resolve();
1070
+ if (securityEvents === "*") return true;
1071
+ return securityEvents.includes(action);
1072
+ };
1073
+ const hasSecurityEvents = () => {
1074
+ const { securityEvents } = resolve();
1075
+ return securityEvents === "*" || securityEvents.length > 0;
1076
+ };
1077
+ const enabledSecurityActions = () => {
1078
+ const { securityEvents } = resolve();
1079
+ return securityEvents === "*" ? [...ALL_SECURITY_ACTIONS] : securityEvents;
1080
+ };
1081
+ const getPublicConfig = () => {
1082
+ const { isIgnoredForChanges: _c, isIgnoredForSnapshot: _s, ...rest } = resolve();
1083
+ return rest;
1084
+ };
1085
+ const clearCache = () => {
1086
+ cached = null;
1087
+ };
1088
+ return {
1089
+ resolve,
1090
+ isAuditedContentType,
1091
+ isAuditedAction,
1092
+ isAuditedSecurityAction,
1093
+ hasSecurityEvents,
1094
+ enabledSecurityActions,
1095
+ getPublicConfig,
1096
+ clearCache
1097
+ };
1098
+ };
1099
+ const overrideStorage = new node_async_hooks.AsyncLocalStorage();
1100
+ const EMPTY_CONTEXT = {
1101
+ source: "system",
1102
+ userId: null,
1103
+ userEmail: null,
1104
+ userName: null,
1105
+ ipAddress: null,
1106
+ userAgent: null,
1107
+ requestId: null
1108
+ };
1109
+ const asString$1 = (value) => {
1110
+ if (value === null || value === void 0) return null;
1111
+ const text = String(value).trim();
1112
+ return text.length === 0 ? null : text;
1113
+ };
1114
+ const resolveUserName = (user) => {
1115
+ const firstname = asString$1(user.firstname);
1116
+ const lastname = asString$1(user.lastname);
1117
+ if (firstname && lastname) return `${firstname} ${lastname}`;
1118
+ return asString$1(user.username) ?? firstname ?? lastname ?? asString$1(user.email);
1119
+ };
1120
+ const contextService = ({ strapi }) => {
1121
+ const runAs = (override, callback) => overrideStorage.run(override, callback);
1122
+ const getRequestContext = () => {
1123
+ try {
1124
+ return strapi.requestContext?.get?.();
1125
+ } catch {
1126
+ return void 0;
1127
+ }
1128
+ };
1129
+ const resolveSource = (ctx) => {
1130
+ const declared = asString$1(ctx?.state?.auditSource);
1131
+ if (declared) return declared;
1132
+ const routeType = asString$1(ctx?.state?.route?.info?.type);
1133
+ if (routeType === "admin") return "admin";
1134
+ if (routeType === "content-api") return "api";
1135
+ const strategyName = asString$1(ctx?.state?.auth?.strategy?.name);
1136
+ if (strategyName === "admin") return "admin";
1137
+ if (strategyName === "api-token" || strategyName === "users-permissions") return "api";
1138
+ return ctx ? "unknown" : "system";
1139
+ };
1140
+ const resolve = () => {
1141
+ const override = overrideStorage.getStore();
1142
+ const ctx = getRequestContext();
1143
+ if (!ctx) {
1144
+ if (!override) return EMPTY_CONTEXT;
1145
+ return {
1146
+ ...EMPTY_CONTEXT,
1147
+ source: override.source,
1148
+ userId: asString$1(override.userId),
1149
+ userEmail: asString$1(override.userEmail),
1150
+ userName: asString$1(override.userName)
1151
+ };
1152
+ }
1153
+ const user = ctx.state?.user ?? null;
1154
+ const headers = ctx.request?.headers ?? ctx.req?.headers ?? {};
1155
+ const requestId = asString$1(ctx.state?.requestId) ?? REQUEST_ID_HEADERS.map((header) => asString$1(headers[header])).find(Boolean) ?? null;
1156
+ return {
1157
+ // An explicit override wins even inside a request: a controller that
1158
+ // deliberately labels its work knows more than the route type does.
1159
+ source: override?.source ?? resolveSource(ctx),
1160
+ userId: asString$1(override?.userId ?? user?.id),
1161
+ userEmail: asString$1(override?.userEmail ?? user?.email),
1162
+ userName: override?.userName !== void 0 ? asString$1(override.userName) : user ? resolveUserName(user) : null,
1163
+ // `ctx.request.ip` honours Koa's `proxy` setting, so behind a correctly
1164
+ // configured load balancer this is the client address rather than the
1165
+ // proxy's. With `proxy` off it ignores `X-Forwarded-For` entirely, which
1166
+ // is the safe default — a spoofable header is worse than no address.
1167
+ ipAddress: asString$1(ctx.request?.ip ?? ctx.ip),
1168
+ userAgent: asString$1(headers["user-agent"]),
1169
+ requestId
1170
+ };
1171
+ };
1172
+ return { resolve, runAs };
1173
+ };
1174
+ const DEFAULT_MAX_DEPTH = 6;
1175
+ const DEFAULT_MAX_CHANGES = 500;
1176
+ const diffService = ({ strapi }) => {
1177
+ const buildDiff = (before, after, options = {}) => {
1178
+ const {
1179
+ isIgnored = () => false,
1180
+ keys = null,
1181
+ maxDepth = DEFAULT_MAX_DEPTH,
1182
+ maxChanges = DEFAULT_MAX_CHANGES
1183
+ } = options;
1184
+ const changes = {};
1185
+ let count = 0;
1186
+ let truncated = false;
1187
+ const record = (path, from, to) => {
1188
+ if (count >= maxChanges) {
1189
+ truncated = true;
1190
+ return;
1191
+ }
1192
+ changes[path] = { from: normalise(from), to: normalise(to) };
1193
+ count += 1;
1194
+ };
1195
+ const walk = (from, to, path, depth) => {
1196
+ if (count >= maxChanges) {
1197
+ truncated = true;
1198
+ return;
1199
+ }
1200
+ if (path.length > 0 && isIgnored(path)) return;
1201
+ if (isEqual(from, to)) return;
1202
+ const bothObjects = isPlainObject(from) && isPlainObject(to);
1203
+ const bothArrays = Array.isArray(from) && Array.isArray(to);
1204
+ if (depth >= maxDepth || !bothObjects && !bothArrays) {
1205
+ record(path, from, to);
1206
+ return;
1207
+ }
1208
+ if (bothArrays) {
1209
+ const length = Math.max(from.length, to.length);
1210
+ for (let index2 = 0; index2 < length; index2 += 1) {
1211
+ walk(from[index2], to[index2], joinPath(path, index2), depth + 1);
1212
+ }
1213
+ return;
1214
+ }
1215
+ const objectKeys = /* @__PURE__ */ new Set([...Object.keys(from), ...Object.keys(to)]);
1216
+ for (const key of objectKeys) {
1217
+ walk(
1218
+ from[key],
1219
+ to[key],
1220
+ joinPath(path, key),
1221
+ depth + 1
1222
+ );
1223
+ }
1224
+ };
1225
+ const beforeObject = isPlainObject(before) ? before : {};
1226
+ const afterObject = isPlainObject(after) ? after : {};
1227
+ const topLevelKeys = keys && keys.length > 0 ? keys : [.../* @__PURE__ */ new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)])];
1228
+ for (const key of topLevelKeys) {
1229
+ walk(beforeObject[key], afterObject[key], key, 1);
1230
+ }
1231
+ if (truncated) {
1232
+ changes.__truncated__ = {
1233
+ from: null,
1234
+ to: `Diff truncated at ${maxChanges} changes. See the before/after snapshots for the full state.`
1235
+ };
1236
+ }
1237
+ return changes;
1238
+ };
1239
+ const normalise = (value) => {
1240
+ if (value instanceof Date) return value.toISOString();
1241
+ if (value === void 0) return null;
1242
+ return value;
1243
+ };
1244
+ return { buildDiff };
1245
+ };
1246
+ const FORBIDDEN_ACTIONS = /* @__PURE__ */ new Set(["update", "publish", "unpublish", "discardDraft", "clone"]);
1247
+ const immutabilityService = ({ strapi }) => {
1248
+ const createMiddleware = () => {
1249
+ return async (ctx, next) => {
1250
+ if (ctx.uid === AUDIT_LOG_UID && FORBIDDEN_ACTIONS.has(ctx.action)) {
1251
+ throw new Error(
1252
+ `[audit-log] Audit records are immutable: "${ctx.action}" is not permitted on ${AUDIT_LOG_UID}. Audit logs may only be created by the plugin and deleted through the retention job or the DELETE /audit-log/logs/:id route, which requires the plugin::audit-log.delete permission.`
1253
+ );
1254
+ }
1255
+ return next();
1256
+ };
1257
+ };
1258
+ const register2 = () => {
1259
+ strapi.documents.use(createMiddleware());
1260
+ };
1261
+ return { createMiddleware, register: register2 };
1262
+ };
1263
+ const MS_PER_DAY = 24 * 60 * 60 * 1e3;
1264
+ const retentionService = ({ strapi }) => {
1265
+ const getConfig = () => strapi.plugin("audit-log").service("config").resolve();
1266
+ const cutoffDate = (retentionDays, now = /* @__PURE__ */ new Date()) => new Date(now.getTime() - retentionDays * MS_PER_DAY);
1267
+ const cleanup = async () => {
1268
+ const { retentionDays } = getConfig();
1269
+ if (retentionDays <= 0) return 0;
1270
+ const cutoff = cutoffDate(retentionDays);
1271
+ const deleted = await strapi.plugin("audit-log").service("audit").deleteOlderThan(cutoff);
1272
+ if (deleted > 0) {
1273
+ strapi.log.info(
1274
+ `[audit-log] retention removed ${deleted} record(s) created before ${cutoff.toISOString()}.`
1275
+ );
1276
+ }
1277
+ return deleted;
1278
+ };
1279
+ const register2 = () => {
1280
+ const { retentionDays, retentionCron } = getConfig();
1281
+ if (retentionDays <= 0) {
1282
+ strapi.log.debug("[audit-log] retentionDays is 0 — automatic cleanup is disabled.");
1283
+ return;
1284
+ }
1285
+ strapi.cron.add({
1286
+ [RETENTION_JOB_NAME]: {
1287
+ async task() {
1288
+ try {
1289
+ await cleanup();
1290
+ } catch (error) {
1291
+ strapi.log.error(
1292
+ `[audit-log] retention job failed: ${error?.message ?? error}`
1293
+ );
1294
+ }
1295
+ },
1296
+ options: retentionCron
1297
+ }
1298
+ });
1299
+ strapi.log.info(
1300
+ `[audit-log] retention enabled: records older than ${retentionDays} day(s) are removed on "${retentionCron}".`
1301
+ );
1302
+ };
1303
+ const unregister = () => {
1304
+ try {
1305
+ strapi.cron.remove(RETENTION_JOB_NAME);
1306
+ } catch {
1307
+ }
1308
+ };
1309
+ return { cleanup, cutoffDate, register: register2, unregister };
1310
+ };
1311
+ const FAILURE_ACTIONS = /* @__PURE__ */ new Set(["login.failed", "access.denied"]);
1312
+ const IDENTIFYING_FIELDS = [
1313
+ "id",
1314
+ "documentId",
1315
+ "name",
1316
+ "email",
1317
+ "username",
1318
+ "firstname",
1319
+ "lastname",
1320
+ "code",
1321
+ "action",
1322
+ "subject",
1323
+ "isActive",
1324
+ "blocked",
1325
+ "mime",
1326
+ "url",
1327
+ "ext",
1328
+ "size",
1329
+ "path"
1330
+ ];
1331
+ const asString = (value) => {
1332
+ if (value === null || value === void 0) return null;
1333
+ const text = String(value).trim();
1334
+ return text.length === 0 ? null : text;
1335
+ };
1336
+ const nameOf = (user) => {
1337
+ if (!user) return null;
1338
+ const firstname = asString(user.firstname);
1339
+ const lastname = asString(user.lastname);
1340
+ if (firstname && lastname) return `${firstname} ${lastname}`;
1341
+ return asString(user.username) ?? firstname ?? lastname ?? asString(user.email);
1342
+ };
1343
+ const securityService = ({ strapi }) => {
1344
+ const plugin = () => strapi.plugin("audit-log");
1345
+ let unsubscribers = [];
1346
+ const requestContext = () => {
1347
+ try {
1348
+ return strapi.requestContext?.get?.();
1349
+ } catch {
1350
+ return void 0;
1351
+ }
1352
+ };
1353
+ const actor = (explicit) => {
1354
+ const ctx = requestContext();
1355
+ const user = explicit ?? ctx?.state?.user ?? null;
1356
+ return {
1357
+ userId: asString(user?.id),
1358
+ userEmail: asString(user?.email),
1359
+ userName: nameOf(user)
1360
+ };
1361
+ };
1362
+ const requestMeta = () => {
1363
+ const ctx = requestContext();
1364
+ const headers = ctx?.request?.headers ?? {};
1365
+ return {
1366
+ ipAddress: asString(ctx?.request?.ip ?? ctx?.ip),
1367
+ userAgent: asString(headers["user-agent"]),
1368
+ requestId: asString(ctx?.state?.requestId ?? headers["x-request-id"])
1369
+ };
1370
+ };
1371
+ const sourceOf = () => requestContext() ? "admin" : "system";
1372
+ const record = async (action, subject, fields = {}) => {
1373
+ const config2 = plugin().service("config");
1374
+ if (!config2.isAuditedSecurityAction(action)) return;
1375
+ const source = sourceOf();
1376
+ if (source === "system" && !config2.resolve().auditSystemOperations) return;
1377
+ const entry = {
1378
+ action,
1379
+ contentType: subject,
1380
+ contentTypeDisplayName: SUBJECT_DISPLAY_NAMES[subject] ?? subject,
1381
+ contentDocumentId: fields.contentDocumentId ?? null,
1382
+ contentId: fields.contentId ?? null,
1383
+ locale: null,
1384
+ changes: null,
1385
+ before: fields.before ?? null,
1386
+ after: fields.after ?? null,
1387
+ outcome: fields.outcome ?? (FAILURE_ACTIONS.has(action) ? "failure" : "success"),
1388
+ metadata: fields.metadata ?? null,
1389
+ source,
1390
+ ...actor(fields.user),
1391
+ ...requestMeta()
1392
+ };
1393
+ await plugin().service("audit").record(entry);
1394
+ };
1395
+ const identify = (entity) => {
1396
+ if (!entity || typeof entity !== "object") return null;
1397
+ const summary = {};
1398
+ for (const key of IDENTIFYING_FIELDS) {
1399
+ if (entity[key] !== void 0 && entity[key] !== null) summary[key] = entity[key];
1400
+ }
1401
+ if (Array.isArray(entity.roles)) {
1402
+ summary.roles = entity.roles.map((role) => asString(role?.name) ?? asString(role?.code) ?? asString(role?.id)).filter(Boolean);
1403
+ }
1404
+ return Object.keys(summary).length > 0 ? summary : null;
1405
+ };
1406
+ const subjectOf = (payload) => payload?.user ?? payload?.role ?? payload?.permission ?? payload?.media ?? payload?.folder ?? null;
1407
+ const attemptedEmail = () => {
1408
+ const body = requestContext()?.request?.body;
1409
+ return asString(body?.email);
1410
+ };
1411
+ const handlers = {
1412
+ "admin.auth.success": async (payload) => {
1413
+ const user = payload?.user;
1414
+ await record("login.success", SECURITY_EVENT_MAP["admin.auth.success"].subject, {
1415
+ user,
1416
+ contentId: asString(user?.id),
1417
+ metadata: { provider: asString(payload?.provider) ?? "local" }
1418
+ });
1419
+ },
1420
+ "admin.auth.error": async (payload) => {
1421
+ const email = attemptedEmail();
1422
+ await record("login.failed", SECURITY_EVENT_MAP["admin.auth.error"].subject, {
1423
+ // No session exists, so `actor()` would find nobody. The attempted
1424
+ // identity is the whole value of the record: it is what turns twenty
1425
+ // rows into "twenty attempts against one account".
1426
+ user: email ? { email } : null,
1427
+ metadata: {
1428
+ provider: asString(payload?.provider) ?? "local",
1429
+ reason: asString(payload?.error?.message) ?? "Authentication failed",
1430
+ attemptedEmail: email
1431
+ }
1432
+ });
1433
+ },
1434
+ "admin.logout": async (payload) => {
1435
+ const user = payload?.user;
1436
+ await record("logout", SECURITY_EVENT_MAP["admin.logout"].subject, {
1437
+ user,
1438
+ contentId: asString(user?.id)
1439
+ });
1440
+ }
1441
+ };
1442
+ const entityHandler = (action, subject) => async (payload) => {
1443
+ const entity = subjectOf(payload);
1444
+ const summary = identify(entity);
1445
+ const isDelete = action.endsWith(".delete");
1446
+ await record(action, subject, {
1447
+ contentId: asString(entity?.id),
1448
+ contentDocumentId: asString(entity?.documentId),
1449
+ before: isDelete ? summary : null,
1450
+ after: isDelete ? null : summary
1451
+ });
1452
+ };
1453
+ const register2 = () => {
1454
+ const config2 = plugin().service("config");
1455
+ if (!config2.hasSecurityEvents()) {
1456
+ strapi.log.debug("[audit-log] securityEvents is empty — no security listeners registered.");
1457
+ return;
1458
+ }
1459
+ unregister();
1460
+ const enabled = new Set(config2.enabledSecurityActions());
1461
+ for (const [event, { action, subject }] of Object.entries(SECURITY_EVENT_MAP)) {
1462
+ if (!enabled.has(action)) continue;
1463
+ const handler = handlers[event] ?? entityHandler(action, subject);
1464
+ const listener = async (payload = {}) => {
1465
+ try {
1466
+ await handler(payload);
1467
+ } catch (error) {
1468
+ strapi.log.error(
1469
+ `[audit-log] failed to record "${event}": ${error?.message ?? error}`
1470
+ );
1471
+ }
1472
+ };
1473
+ unsubscribers.push(strapi.eventHub.on(event, listener));
1474
+ }
1475
+ strapi.log.info(
1476
+ `[audit-log] security events: listening on ${unsubscribers.length} of ${Object.keys(SECURITY_EVENT_MAP).length} Strapi events.`
1477
+ );
1478
+ };
1479
+ const unregister = () => {
1480
+ for (const off of unsubscribers) {
1481
+ try {
1482
+ off();
1483
+ } catch {
1484
+ }
1485
+ }
1486
+ unsubscribers = [];
1487
+ };
1488
+ return { register: register2, unregister, record, identify, subjectOf, sourceOf, entityHandler, handlers };
1489
+ };
1490
+ const RELATION_FIELDS = ["id", "documentId"];
1491
+ const MEDIA_FIELDS = ["id", "documentId", "name", "url", "mime", "size", "alternativeText"];
1492
+ const META_FIELDS = ["id", "documentId", "locale", "publishedAt"];
1493
+ const isRelational = (attribute) => attribute?.type === "relation" || attribute?.type === "component" || attribute?.type === "dynamiczone" || attribute?.type === "media";
1494
+ const snapshotService = ({ strapi }) => {
1495
+ const getSchema = (uid) => {
1496
+ try {
1497
+ return strapi.getModel(uid) ?? null;
1498
+ } catch {
1499
+ return null;
1500
+ }
1501
+ };
1502
+ const hasDraftAndPublish = (uid) => getSchema(uid)?.options?.draftAndPublish === true;
1503
+ const isLocalized = (uid) => getSchema(uid)?.pluginOptions?.i18n?.localized === true;
1504
+ const buildComponentPopulate = (componentUid, depth) => {
1505
+ if (depth <= 0) return true;
1506
+ const schema2 = getSchema(componentUid);
1507
+ const attributes = schema2?.attributes ?? {};
1508
+ const populate = {};
1509
+ for (const [name, attribute] of Object.entries(attributes)) {
1510
+ if (!isRelational(attribute)) continue;
1511
+ switch (attribute.type) {
1512
+ case "media":
1513
+ populate[name] = { select: MEDIA_FIELDS };
1514
+ break;
1515
+ case "relation":
1516
+ populate[name] = { select: RELATION_FIELDS };
1517
+ break;
1518
+ case "component":
1519
+ populate[name] = buildComponentPopulate(attribute.component, depth - 1);
1520
+ break;
1521
+ case "dynamiczone":
1522
+ populate[name] = true;
1523
+ break;
1524
+ }
1525
+ }
1526
+ return Object.keys(populate).length > 0 ? { populate } : true;
1527
+ };
1528
+ const buildSnapshotQuery = (uid, keys, options) => {
1529
+ const { depth, isIgnored = () => false } = options;
1530
+ const schema2 = getSchema(uid);
1531
+ const attributes = schema2?.attributes ?? {};
1532
+ const select = /* @__PURE__ */ new Set();
1533
+ const populate = {};
1534
+ const dynamicZones = [];
1535
+ for (const field of META_FIELDS) {
1536
+ if (field === "publishedAt" && !hasDraftAndPublish(uid)) continue;
1537
+ if (field === "locale" && !isLocalized(uid)) continue;
1538
+ select.add(field);
1539
+ }
1540
+ const candidates = keys ?? Object.keys(attributes);
1541
+ for (const name of candidates) {
1542
+ const attribute = attributes[name];
1543
+ if (!attribute) continue;
1544
+ if (SKIPPED_ATTRIBUTES.has(name)) continue;
1545
+ if (isIgnored(name)) continue;
1546
+ if (!isRelational(attribute)) {
1547
+ select.add(name);
1548
+ continue;
1549
+ }
1550
+ switch (attribute.type) {
1551
+ case "media":
1552
+ populate[name] = { select: MEDIA_FIELDS };
1553
+ break;
1554
+ case "relation":
1555
+ populate[name] = { select: RELATION_FIELDS };
1556
+ break;
1557
+ case "component":
1558
+ populate[name] = buildComponentPopulate(attribute.component, depth);
1559
+ break;
1560
+ case "dynamiczone":
1561
+ populate[name] = true;
1562
+ dynamicZones.push(name);
1563
+ break;
1564
+ }
1565
+ }
1566
+ return {
1567
+ select: [...select],
1568
+ populate: Object.keys(populate).length > 0 ? populate : void 0,
1569
+ dynamicZones
1570
+ };
1571
+ };
1572
+ const buildWhere = (uid, lookup) => {
1573
+ const where = { documentId: lookup.documentId };
1574
+ if (hasDraftAndPublish(uid) && lookup.status && lookup.status !== "any") {
1575
+ where.publishedAt = lookup.status === "draft" ? null : { $ne: null };
1576
+ }
1577
+ const locales = (lookup.locales ?? []).filter(
1578
+ (locale) => Boolean(locale) && locale !== "*"
1579
+ );
1580
+ if (isLocalized(uid) && locales.length > 0) {
1581
+ where.locale = locales.length === 1 ? locales[0] : { $in: locales };
1582
+ }
1583
+ return where;
1584
+ };
1585
+ const refineDynamicZones = async (uid, rows, query, depth) => {
1586
+ if (query.dynamicZones.length === 0 || depth < 2 || rows.length === 0) return rows;
1587
+ const populate = {};
1588
+ for (const zone of query.dynamicZones) {
1589
+ const usedComponents = /* @__PURE__ */ new Set();
1590
+ for (const row of rows) {
1591
+ const items = Array.isArray(row[zone]) ? row[zone] : [];
1592
+ for (const item of items) {
1593
+ const componentUid = item?.__component ?? item?.__type;
1594
+ if (typeof componentUid === "string") usedComponents.add(componentUid);
1595
+ }
1596
+ }
1597
+ if (usedComponents.size === 0) continue;
1598
+ const on = {};
1599
+ for (const componentUid of usedComponents) {
1600
+ const spec = buildComponentPopulate(componentUid, depth - 1);
1601
+ on[componentUid] = spec === true ? {} : spec;
1602
+ }
1603
+ populate[zone] = { on };
1604
+ }
1605
+ if (Object.keys(populate).length === 0) return rows;
1606
+ const ids = rows.map((row) => row.id).filter((id) => id != null);
1607
+ if (ids.length === 0) return rows;
1608
+ const refined = await strapi.db.query(uid).findMany({
1609
+ where: { id: { $in: ids } },
1610
+ select: ["id"],
1611
+ populate
1612
+ });
1613
+ const byId = new Map(refined.map((row) => [row.id, row]));
1614
+ return rows.map((row) => {
1615
+ const extra = byId.get(row.id);
1616
+ if (!extra) return row;
1617
+ const merged = { ...row };
1618
+ for (const zone of Object.keys(populate)) merged[zone] = extra[zone];
1619
+ return merged;
1620
+ });
1621
+ };
1622
+ const fetchRows = async (uid, lookup, query, depth) => {
1623
+ const rows = await strapi.db.query(uid).findMany({
1624
+ where: buildWhere(uid, lookup),
1625
+ select: query.select,
1626
+ ...query.populate ? { populate: query.populate } : {}
1627
+ });
1628
+ return refineDynamicZones(uid, rows, query, depth);
1629
+ };
1630
+ const toSnapshot = (row) => {
1631
+ if (!row) return null;
1632
+ const snapshot = {};
1633
+ for (const [key, value] of Object.entries(row)) {
1634
+ if (SKIPPED_ATTRIBUTES.has(key)) continue;
1635
+ snapshot[key] = value;
1636
+ }
1637
+ return snapshot;
1638
+ };
1639
+ return {
1640
+ buildSnapshotQuery,
1641
+ buildComponentPopulate,
1642
+ buildWhere,
1643
+ fetchRows,
1644
+ refineDynamicZones,
1645
+ toSnapshot,
1646
+ hasDraftAndPublish,
1647
+ isLocalized
1648
+ };
1649
+ };
1650
+ const TRACKED_ACTIONS = {
1651
+ create: "create",
1652
+ update: "update",
1653
+ delete: "delete",
1654
+ publish: "publish",
1655
+ unpublish: "unpublish"
1656
+ };
1657
+ const asId = (value) => value === null || value === void 0 ? null : String(value);
1658
+ const paramLocales = (params) => {
1659
+ const { locale } = params;
1660
+ if (locale === void 0 || locale === null || locale === "*") return null;
1661
+ if (Array.isArray(locale)) return locale.map(String);
1662
+ return [String(locale)];
1663
+ };
1664
+ const trackerService = ({ strapi }) => {
1665
+ const plugin = () => strapi.plugin("audit-log");
1666
+ const getConfig = () => plugin().service("config").resolve();
1667
+ const displayNameOf = (uid) => {
1668
+ try {
1669
+ const schema2 = strapi.getModel(uid);
1670
+ return schema2?.info?.displayName ?? schema2?.info?.singularName ?? null;
1671
+ } catch {
1672
+ return null;
1673
+ }
1674
+ };
1675
+ const changedKeys = (uid, params) => {
1676
+ const data = params?.data;
1677
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
1678
+ let attributes = {};
1679
+ try {
1680
+ attributes = strapi.getModel(uid)?.attributes ?? {};
1681
+ } catch {
1682
+ return null;
1683
+ }
1684
+ const keys = Object.keys(data).filter(
1685
+ (key) => key in attributes && !SKIPPED_ATTRIBUTES.has(key)
1686
+ );
1687
+ return keys.length > 0 ? keys : [];
1688
+ };
1689
+ const snapshotByLocale = async (uid, documentId, locales, status, query, depth) => {
1690
+ const snapshot = plugin().service("snapshot");
1691
+ const rows = await snapshot.fetchRows(uid, { documentId, locales, status }, query, depth);
1692
+ const byLocale = /* @__PURE__ */ new Map();
1693
+ for (const row of rows) byLocale.set(row.locale ?? "__default__", row);
1694
+ return byLocale;
1695
+ };
1696
+ const localeKey = (row) => row?.locale ?? "__default__";
1697
+ const resultRows = (result) => {
1698
+ if (!result) return [];
1699
+ const asRecord = result;
1700
+ if (Array.isArray(asRecord.entries)) return asRecord.entries;
1701
+ return [asRecord];
1702
+ };
1703
+ const dedupeByLocale = (rows) => {
1704
+ const byLocale = /* @__PURE__ */ new Map();
1705
+ for (const row of rows) {
1706
+ const key = localeKey(row);
1707
+ const existing = byLocale.get(key);
1708
+ if (!existing || existing.publishedAt != null && row.publishedAt == null) {
1709
+ byLocale.set(key, row);
1710
+ }
1711
+ }
1712
+ return [...byLocale.values()];
1713
+ };
1714
+ const buildEntries = async (ctx, action, result, before, query, keys) => {
1715
+ const config2 = getConfig();
1716
+ const snapshot = plugin().service("snapshot");
1717
+ const diff = plugin().service("diff");
1718
+ const auditContext = plugin().service("context").resolve();
1719
+ const { uid } = ctx;
1720
+ const rows = dedupeByLocale(resultRows(result));
1721
+ const documentId = asId(ctx.params?.documentId) ?? asId(result?.documentId) ?? asId(rows[0]?.documentId);
1722
+ let after = /* @__PURE__ */ new Map();
1723
+ const needsAfter = (config2.storeAfter || config2.storeChanges) && action !== "delete" && action !== "unpublish";
1724
+ if (needsAfter && documentId) {
1725
+ const locales = rows.length > 0 ? rows.map((row) => row.locale).filter(Boolean) : paramLocales(ctx.params);
1726
+ after = await snapshotByLocale(
1727
+ uid,
1728
+ documentId,
1729
+ locales,
1730
+ action === "publish" ? "published" : "draft",
1731
+ query,
1732
+ config2.maxPopulateDepth
1733
+ );
1734
+ }
1735
+ const displayName = displayNameOf(uid);
1736
+ const targets = rows.length > 0 ? rows.map((row) => ({ locale: row.locale ?? null, row })) : [...before.size > 0 ? before.values() : []].map((row) => ({ locale: row.locale ?? null, row }));
1737
+ if (targets.length === 0) return [];
1738
+ return targets.map(({ locale, row }) => {
1739
+ const key = localeKey(row);
1740
+ const beforeRow = before.get(key) ?? null;
1741
+ const afterRow = after.get(key) ?? (action === "delete" || action === "unpublish" ? null : row);
1742
+ const beforeSnapshot = sanitizeValue(snapshot.toSnapshot(beforeRow), config2.isIgnoredForSnapshot);
1743
+ const afterSnapshot = sanitizeValue(snapshot.toSnapshot(afterRow), config2.isIgnoredForSnapshot);
1744
+ const changes = config2.storeChanges ? diff.buildDiff(beforeSnapshot, afterSnapshot, {
1745
+ isIgnored: config2.isIgnoredForChanges,
1746
+ keys: action === "update" ? keys : null,
1747
+ maxDepth: Math.max(2, config2.maxPopulateDepth + 3)
1748
+ }) : null;
1749
+ return {
1750
+ action,
1751
+ contentType: uid,
1752
+ contentTypeDisplayName: displayName,
1753
+ contentDocumentId: documentId,
1754
+ contentId: asId(row?.id ?? beforeRow?.id),
1755
+ locale: locale ?? beforeRow?.locale ?? null,
1756
+ changes,
1757
+ before: config2.storeBefore ? beforeSnapshot : null,
1758
+ after: config2.storeAfter ? afterSnapshot : null,
1759
+ ...auditContext
1760
+ };
1761
+ });
1762
+ };
1763
+ const createMiddleware = () => {
1764
+ return async (ctx, next) => {
1765
+ const action = TRACKED_ACTIONS[ctx.action];
1766
+ if (!action) return next();
1767
+ const configService2 = plugin().service("config");
1768
+ if (!configService2.isAuditedContentType(ctx.uid) || !configService2.isAuditedAction(action)) {
1769
+ return next();
1770
+ }
1771
+ const config2 = getConfig();
1772
+ let before = /* @__PURE__ */ new Map();
1773
+ let query = { select: [], populate: void 0, dynamicZones: [] };
1774
+ let keys = null;
1775
+ const onError = (stage, error) => {
1776
+ const message = error?.message ?? String(error);
1777
+ strapi.log.error(`[audit-log] ${stage} failed for ${action} on ${ctx.uid}: ${message}`);
1778
+ if (config2.failOnAuditError) throw error;
1779
+ };
1780
+ try {
1781
+ keys = action === "update" ? changedKeys(ctx.uid, ctx.params) : null;
1782
+ query = plugin().service("snapshot").buildSnapshotQuery(ctx.uid, keys, {
1783
+ depth: config2.maxPopulateDepth,
1784
+ isIgnored: config2.isIgnoredForSnapshot
1785
+ });
1786
+ const documentId = asId(ctx.params?.documentId);
1787
+ const needsBefore = (config2.storeBefore || config2.storeChanges) && action !== "create";
1788
+ if (needsBefore && documentId) {
1789
+ before = await snapshotByLocale(
1790
+ ctx.uid,
1791
+ documentId,
1792
+ paramLocales(ctx.params),
1793
+ // A publish diffs the version being replaced; everything else works
1794
+ // against the draft the editor was editing.
1795
+ action === "publish" || action === "unpublish" ? "published" : "draft",
1796
+ query,
1797
+ config2.maxPopulateDepth
1798
+ );
1799
+ }
1800
+ } catch (error) {
1801
+ onError("pre-write snapshot", error);
1802
+ }
1803
+ const result = await next();
1804
+ try {
1805
+ const alsoPublished = (action === "create" || action === "update") && ctx.params?.status === "published" && plugin().service("config").isAuditedAction("publish");
1806
+ const entries = await buildEntries(ctx, action, result, before, query, keys);
1807
+ for (const entry of entries) {
1808
+ await plugin().service("audit").record(entry);
1809
+ }
1810
+ if (alsoPublished) {
1811
+ for (const entry of entries) {
1812
+ await plugin().service("audit").record({ ...entry, action: "publish" });
1813
+ }
1814
+ }
1815
+ } catch (error) {
1816
+ onError("audit write", error);
1817
+ }
1818
+ return result;
1819
+ };
1820
+ };
1821
+ const register2 = () => {
1822
+ strapi.documents.use(createMiddleware());
1823
+ };
1824
+ return { createMiddleware, register: register2, changedKeys, resultRows };
1825
+ };
1826
+ const services = {
1827
+ access: accessService,
1828
+ audit: auditService,
1829
+ config: configService,
1830
+ context: contextService,
1831
+ diff: diffService,
1832
+ immutability: immutabilityService,
1833
+ retention: retentionService,
1834
+ security: securityService,
1835
+ snapshot: snapshotService,
1836
+ tracker: trackerService
1837
+ };
1838
+ const index = {
1839
+ register,
1840
+ bootstrap,
1841
+ destroy,
1842
+ config,
1843
+ contentTypes,
1844
+ controllers,
1845
+ routes,
1846
+ services
1847
+ };
1848
+ exports.ALL_SECURITY_ACTIONS = ALL_SECURITY_ACTIONS;
1849
+ exports.AUDIT_LOG_UID = AUDIT_LOG_UID;
1850
+ exports.CONTENT_ACTIONS = CONTENT_ACTIONS;
1851
+ exports.DEFAULT_IGNORED_FIELDS = DEFAULT_IGNORED_FIELDS;
1852
+ exports.PERMISSIONS = PERMISSIONS;
1853
+ exports.PLUGIN_ID = PLUGIN_ID;
1854
+ exports.SECURITY_EVENT_MAP = SECURITY_EVENT_MAP;
1855
+ exports.SUBJECTS = SUBJECTS;
1856
+ exports.default = index;