strapi-cms-audit-log 1.1.0 → 1.2.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 (34) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +11 -11
  3. package/dist/admin/{App-BLt4lqEM.js → App-CmbG2k9E.js} +110 -100
  4. package/dist/admin/{App-ou5hy99f.mjs → App-jWvaMXIw.mjs} +113 -103
  5. package/dist/admin/{en-BMlJxq3g.js → en-Bs4tlklI.js} +11 -9
  6. package/dist/admin/{en-B0rPiE2W.mjs → en-C3Og1VyK.mjs} +11 -9
  7. package/dist/admin/{index-DdmY-p3Q.mjs → index-BIIMJgKU.mjs} +3 -5
  8. package/dist/admin/{index-BYi8OPTw.js → index-BsVOGSDf.js} +3 -5
  9. package/dist/admin/index.js +1 -1
  10. package/dist/admin/index.mjs +1 -1
  11. package/dist/admin/src/components/AuditLogTable.d.ts +1 -2
  12. package/dist/admin/src/components/IntegrityStatus.d.ts +13 -0
  13. package/dist/admin/src/hooks/useAuditLogs.d.ts +14 -3
  14. package/dist/admin/src/permissions.d.ts +0 -8
  15. package/dist/admin/src/types.d.ts +26 -2
  16. package/dist/server/index.js +314 -92
  17. package/dist/server/index.mjs +314 -92
  18. package/dist/server/src/config/index.d.ts +1 -0
  19. package/dist/server/src/constants.d.ts +24 -2
  20. package/dist/server/src/content-types/audit-log/index.d.ts +8 -0
  21. package/dist/server/src/content-types/audit-log/schema.d.ts +21 -0
  22. package/dist/server/src/content-types/index.d.ts +8 -0
  23. package/dist/server/src/controllers/audit-log.d.ts +5 -3
  24. package/dist/server/src/controllers/index.d.ts +4 -4
  25. package/dist/server/src/index.d.ts +36 -7
  26. package/dist/server/src/register.d.ts +7 -3
  27. package/dist/server/src/routes/admin.d.ts +8 -0
  28. package/dist/server/src/services/audit.d.ts +0 -1
  29. package/dist/server/src/services/correlation.d.ts +35 -0
  30. package/dist/server/src/services/immutability.d.ts +5 -5
  31. package/dist/server/src/services/index.d.ts +21 -1
  32. package/dist/server/src/services/integrity.d.ts +69 -0
  33. package/dist/server/src/types/index.d.ts +54 -4
  34. package/package.json +9 -5
@@ -1,10 +1,10 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID, createHash } from "node:crypto";
2
3
  const PLUGIN_ID = "audit-log";
3
4
  const AUDIT_LOG_UID = "plugin::audit-log.audit-log";
4
5
  const ALL_ACTIONS = ["create", "update", "delete", "publish", "unpublish"];
5
6
  const PERMISSIONS = {
6
7
  read: `plugin::${PLUGIN_ID}.read`,
7
- delete: `plugin::${PLUGIN_ID}.delete`,
8
8
  settings: `plugin::${PLUGIN_ID}.settings`
9
9
  };
10
10
  const DEFAULT_IGNORED_FIELDS = [
@@ -31,6 +31,8 @@ const SKIPPED_ATTRIBUTES = /* @__PURE__ */ new Set([
31
31
  "createdBy",
32
32
  "updatedBy"
33
33
  ]);
34
+ const REQUEST_ID_RESPONSE_HEADER = "X-Request-Id";
35
+ const CHAIN_LOCK_KEY = 1874206233;
34
36
  const REQUEST_ID_HEADERS = ["x-request-id", "request-id", "x-correlation-id", "x-amzn-trace-id"];
35
37
  const RETENTION_JOB_NAME = "auditLogRetention";
36
38
  const CONTENT_ACTIONS = ALL_ACTIONS;
@@ -55,6 +57,7 @@ const ALL_SECURITY_ACTIONS = [
55
57
  "media-folder.update",
56
58
  "media-folder.delete"
57
59
  ];
60
+ const ALL_MAINTENANCE_ACTIONS = ["retention.purge"];
58
61
  const SUBJECTS = {
59
62
  auth: "admin::auth",
60
63
  access: "admin::access",
@@ -62,7 +65,9 @@ const SUBJECTS = {
62
65
  role: "admin::role",
63
66
  permission: "admin::permission",
64
67
  file: "plugin::upload.file",
65
- folder: "plugin::upload.folder"
68
+ folder: "plugin::upload.folder",
69
+ /** The audit log itself, for records about its own maintenance. */
70
+ auditLog: "admin::audit-log"
66
71
  };
67
72
  const SUBJECT_DISPLAY_NAMES = {
68
73
  [SUBJECTS.auth]: "Authentication",
@@ -71,7 +76,8 @@ const SUBJECT_DISPLAY_NAMES = {
71
76
  [SUBJECTS.role]: "Admin role",
72
77
  [SUBJECTS.permission]: "Permission",
73
78
  [SUBJECTS.file]: "Media file",
74
- [SUBJECTS.folder]: "Media folder"
79
+ [SUBJECTS.folder]: "Media folder",
80
+ [SUBJECTS.auditLog]: "Audit log"
75
81
  };
76
82
  const SECURITY_EVENT_MAP = {
77
83
  "admin.auth.success": { action: "login.success", subject: SUBJECTS.auth },
@@ -141,7 +147,8 @@ const config = {
141
147
  // see the note above on `defaultsDeep` splicing arrays element-wise.
142
148
  securityEvents: null,
143
149
  forwardToLogger: false,
144
- forwardLogLevel: "info"
150
+ forwardLogLevel: "info",
151
+ correlationId: true
145
152
  },
146
153
  /**
147
154
  * Runs once at boot, before anything else touches the config. Throwing here
@@ -205,6 +212,9 @@ const config = {
205
212
  if (config2.forwardToLogger != null && typeof config2.forwardToLogger !== "boolean") {
206
213
  fail('"forwardToLogger" must be a boolean');
207
214
  }
215
+ if (config2.correlationId != null && typeof config2.correlationId !== "boolean") {
216
+ fail('"correlationId" must be a boolean');
217
+ }
208
218
  if (config2.forwardLogLevel != null && !["debug", "info", "warn", "error"].includes(config2.forwardLogLevel)) {
209
219
  fail('"forwardLogLevel" must be one of: debug, info, warn, error');
210
220
  }
@@ -287,7 +297,22 @@ const schema = {
287
297
  ipAddress: { type: "string" },
288
298
  userAgent: { type: "text" },
289
299
  source: { type: "string" },
290
- requestId: { type: "string" }
300
+ requestId: { type: "string" },
301
+ /**
302
+ * Hash chain, for tamper-evidence (ASVS V7.3.3).
303
+ *
304
+ * `hash` is SHA-256 over this record's content fields plus `prevHash`, and
305
+ * `prevHash` is the `hash` of the row written immediately before. Editing any
306
+ * hashed field, or removing a row from the middle, breaks the link that the
307
+ * next row asserts — and `services/integrity.ts` can say exactly which row.
308
+ *
309
+ * Both are nullable: rows written before 1.2.0 have no hash and are never
310
+ * backfilled. Hashing them now would vouch for content whose integrity in
311
+ * the interval cannot be known, which is the opposite of what a hash is for.
312
+ * 64 lowercase hex characters when present.
313
+ */
314
+ hash: { type: "string", maxLength: 64 },
315
+ prevHash: { type: "string", maxLength: 64 }
291
316
  },
292
317
  /**
293
318
  * Secondary indexes, declared here so Strapi's schema sync owns them and they
@@ -374,16 +399,6 @@ const auditLogController = ({ strapi }) => {
374
399
  if (!log) return ctx.notFound("Audit log not found.");
375
400
  ctx.body = { data: log };
376
401
  },
377
- async delete(ctx) {
378
- const id = parseId(ctx.params.id);
379
- if (id === null) return ctx.badRequest("Invalid audit log id.");
380
- const deleted = await service("audit").deleteOne(id);
381
- if (!deleted) return ctx.notFound("Audit log not found.");
382
- strapi.log.info(
383
- `[audit-log] record #${id} deleted by ${service("context").resolve().userEmail ?? "an admin user"}.`
384
- );
385
- ctx.body = { data: deleted };
386
- },
387
402
  /** Filter dropdown options, derived from the rows that actually exist. */
388
403
  async filters(ctx) {
389
404
  ctx.body = { data: await service("audit").getFilterOptions() };
@@ -391,6 +406,10 @@ const auditLogController = ({ strapi }) => {
391
406
  /** The effective configuration, so the UI can hide controls for disabled features. */
392
407
  async config(ctx) {
393
408
  ctx.body = { data: service("config").getPublicConfig() };
409
+ },
410
+ /** Verifies the hash chain end to end. See `services/integrity.ts`. */
411
+ async integrity(ctx) {
412
+ ctx.body = { data: await service("integrity").verify() };
394
413
  }
395
414
  };
396
415
  };
@@ -422,12 +441,6 @@ const ACTIONS = [
422
441
  uid: "read",
423
442
  pluginName: PLUGIN_ID
424
443
  },
425
- {
426
- section: "plugins",
427
- displayName: "Delete audit logs",
428
- uid: "delete",
429
- pluginName: PLUGIN_ID
430
- },
431
444
  {
432
445
  section: "plugins",
433
446
  displayName: "Read audit log settings",
@@ -437,6 +450,7 @@ const ACTIONS = [
437
450
  ];
438
451
  const register = async ({ strapi }) => {
439
452
  await strapi.service("admin::permission").actionProvider.registerMany(ACTIONS);
453
+ strapi.plugin(PLUGIN_ID).service("correlation").register();
440
454
  strapi.plugin(PLUGIN_ID).service("access").register();
441
455
  };
442
456
  const protectedBy = (action) => ({
@@ -466,6 +480,14 @@ const admin = {
466
480
  handler: "audit-log.config",
467
481
  config: protectedBy(PERMISSIONS.read)
468
482
  },
483
+ {
484
+ // Walks the whole hash chain. Read-only, and behind `read` — being able
485
+ // to see the log is the right bar for being able to check it.
486
+ method: "GET",
487
+ path: "/integrity",
488
+ handler: "audit-log.integrity",
489
+ config: protectedBy(PERMISSIONS.read)
490
+ },
469
491
  {
470
492
  // Declared after the two static paths above so `/logs/filters` can never
471
493
  // be swallowed by `:id`.
@@ -473,14 +495,6 @@ const admin = {
473
495
  path: "/logs/:id",
474
496
  handler: "audit-log.findOne",
475
497
  config: protectedBy(PERMISSIONS.read)
476
- },
477
- {
478
- method: "DELETE",
479
- path: "/logs/:id",
480
- handler: "audit-log.delete",
481
- // A separate permission from `read`: being allowed to investigate an
482
- // incident must not imply being allowed to erase the evidence.
483
- config: protectedBy(PERMISSIONS.delete)
484
498
  }
485
499
  ]
486
500
  };
@@ -488,7 +502,7 @@ const routes = {
488
502
  admin
489
503
  };
490
504
  const MAX_PATH_LENGTH = 512;
491
- const asString$2 = (value, maxLength = 256) => {
505
+ const asString$3 = (value, maxLength = 256) => {
492
506
  if (value === null || value === void 0) return null;
493
507
  const text = String(value).trim();
494
508
  return text.length === 0 ? null : text.slice(0, maxLength);
@@ -500,19 +514,19 @@ const accessService = ({ strapi }) => {
500
514
  const actorOf = (ctx) => {
501
515
  const user = ctx.state?.user ?? null;
502
516
  if (!user) return { userId: null, userEmail: null, userName: null };
503
- const firstname = asString$2(user.firstname);
504
- const lastname = asString$2(user.lastname);
517
+ const firstname = asString$3(user.firstname);
518
+ const lastname = asString$3(user.lastname);
505
519
  return {
506
- userId: asString$2(user.id),
507
- userEmail: asString$2(user.email),
508
- userName: firstname && lastname ? `${firstname} ${lastname}` : asString$2(user.username) ?? firstname ?? lastname ?? asString$2(user.email)
520
+ userId: asString$3(user.id),
521
+ userEmail: asString$3(user.email),
522
+ userName: firstname && lastname ? `${firstname} ${lastname}` : asString$3(user.username) ?? firstname ?? lastname ?? asString$3(user.email)
509
523
  };
510
524
  };
511
525
  const sourceOf = (ctx) => {
512
- const routeType = asString$2(ctx.state?.route?.info?.type);
526
+ const routeType = asString$3(ctx.state?.route?.info?.type);
513
527
  if (routeType === "admin") return "admin";
514
528
  if (routeType === "content-api") return "api";
515
- const strategy = asString$2(ctx.state?.auth?.strategy?.name);
529
+ const strategy = asString$3(ctx.state?.auth?.strategy?.name);
516
530
  if (strategy === "admin") return "admin";
517
531
  if (strategy === "api-token" || strategy === "users-permissions") return "api";
518
532
  return "unknown";
@@ -531,20 +545,20 @@ const accessService = ({ strapi }) => {
531
545
  after: null,
532
546
  outcome: "failure",
533
547
  metadata: {
534
- method: asString$2(ctx.request?.method, 16),
535
- path: asString$2(ctx.request?.path, MAX_PATH_LENGTH),
548
+ method: asString$3(ctx.request?.method, 16),
549
+ path: asString$3(ctx.request?.path, MAX_PATH_LENGTH),
536
550
  statusCode: ctx.status,
537
551
  // Strapi's error handler puts a reason in the body. It is the difference
538
552
  // between "no session" and "your role lacks this permission", which is
539
553
  // the first thing anyone reading the row wants to know.
540
- reason: asString$2(ctx.body?.error?.message) ?? null,
541
- routeType: asString$2(ctx.state?.route?.info?.type)
554
+ reason: asString$3(ctx.body?.error?.message) ?? null,
555
+ routeType: asString$3(ctx.state?.route?.info?.type)
542
556
  },
543
557
  source: sourceOf(ctx),
544
558
  ...actorOf(ctx),
545
- ipAddress: asString$2(ctx.request?.ip ?? ctx.ip, 64),
546
- userAgent: asString$2(headers["user-agent"], 512),
547
- requestId: asString$2(ctx.state?.requestId ?? headers["x-request-id"])
559
+ ipAddress: asString$3(ctx.request?.ip ?? ctx.ip, 64),
560
+ userAgent: asString$3(headers["user-agent"], 512),
561
+ requestId: asString$3(ctx.state?.requestId ?? headers["x-request-id"])
548
562
  });
549
563
  };
550
564
  const createMiddleware = () => {
@@ -644,38 +658,83 @@ const auditService = ({ strapi }) => {
644
658
  );
645
659
  return { __omitted__: `Snapshot omitted: ~${bytes} bytes exceeds maxSnapshotBytes (${maxBytes}).` };
646
660
  };
661
+ let chainQueue = Promise.resolve();
662
+ const serialised = (task) => {
663
+ const run = chainQueue.then(task, task);
664
+ chainQueue = run.catch(() => void 0);
665
+ return run;
666
+ };
667
+ const withChainLock = (task) => (
668
+ // `transaction` types its result as the callback's own return, which for an
669
+ // async callback is a Promise of a Promise; at runtime it is flattened.
670
+ strapi.db.transaction(async ({ trx }) => {
671
+ const client = String(strapi.db.dialect?.client ?? "");
672
+ if (client === "postgres") {
673
+ await trx.raw("select pg_advisory_xact_lock(?)", [CHAIN_LOCK_KEY]);
674
+ } else if (client === "mysql") {
675
+ await trx.raw("select get_lock(?, 10)", [`audit-log-chain-${CHAIN_LOCK_KEY}`]);
676
+ try {
677
+ return await task();
678
+ } finally {
679
+ await trx.raw("select release_lock(?)", [`audit-log-chain-${CHAIN_LOCK_KEY}`]);
680
+ }
681
+ }
682
+ return task();
683
+ })
684
+ );
647
685
  const write = async (entry) => {
648
686
  const config2 = getConfig();
649
- await strapi.db.query(AUDIT_LOG_UID).create({
650
- data: {
651
- action: entry.action,
652
- contentType: entry.contentType,
653
- contentTypeDisplayName: entry.contentTypeDisplayName,
654
- contentDocumentId: entry.contentDocumentId,
655
- contentId: entry.contentId,
656
- locale: entry.locale,
657
- userId: entry.userId,
658
- userEmail: entry.userEmail,
659
- userName: entry.userName,
660
- changes: config2.storeChanges ? entry.changes : null,
661
- before: capSnapshot(config2.storeBefore ? entry.before : null, config2.maxSnapshotBytes, "before"),
662
- after: capSnapshot(config2.storeAfter ? entry.after : null, config2.maxSnapshotBytes, "after"),
663
- ipAddress: entry.ipAddress,
664
- userAgent: entry.userAgent,
665
- source: entry.source,
666
- requestId: entry.requestId,
667
- // A content write only ever reaches the tracker once it has succeeded,
668
- // so the default is the honest value rather than a placeholder.
669
- outcome: entry.outcome ?? "success",
670
- metadata: entry.metadata ?? null
671
- }
672
- });
673
- if (config2.forwardToLogger) forwardToLogger(entry, config2);
687
+ const integrity = strapi.plugin(PLUGIN_ID).service("integrity");
688
+ const data = {
689
+ action: entry.action,
690
+ contentType: entry.contentType,
691
+ contentTypeDisplayName: entry.contentTypeDisplayName,
692
+ contentDocumentId: entry.contentDocumentId,
693
+ contentId: entry.contentId,
694
+ locale: entry.locale,
695
+ userId: entry.userId,
696
+ userEmail: entry.userEmail,
697
+ userName: entry.userName,
698
+ changes: config2.storeChanges ? entry.changes : null,
699
+ before: capSnapshot(config2.storeBefore ? entry.before : null, config2.maxSnapshotBytes, "before"),
700
+ after: capSnapshot(config2.storeAfter ? entry.after : null, config2.maxSnapshotBytes, "after"),
701
+ ipAddress: entry.ipAddress,
702
+ userAgent: entry.userAgent,
703
+ source: entry.source,
704
+ requestId: entry.requestId,
705
+ // A content write only ever reaches the tracker once it has succeeded,
706
+ // so the default is the honest value rather than a placeholder.
707
+ outcome: entry.outcome ?? "success",
708
+ metadata: entry.metadata ?? null,
709
+ // Set here rather than left to the database's timestamp hook, because it
710
+ // is part of the hash: a record whose time can be changed without
711
+ // detection is a record whose place in the story can be changed.
712
+ createdAt: /* @__PURE__ */ new Date()
713
+ };
714
+ const created = await serialised(
715
+ () => withChainLock(async () => {
716
+ const [head] = await strapi.db.query(AUDIT_LOG_UID).findMany({
717
+ select: ["id", "hash"],
718
+ orderBy: { id: "desc" },
719
+ limit: 1
720
+ });
721
+ data.prevHash = head?.hash ?? null;
722
+ data.hash = integrity.computeHash(data);
723
+ return await strapi.db.query(AUDIT_LOG_UID).create({ data });
724
+ })
725
+ );
726
+ if (config2.forwardToLogger) forwardToLogger(entry, config2, created);
674
727
  };
675
- const forwardToLogger = (entry, config2) => {
728
+ const forwardToLogger = (entry, config2, created) => {
676
729
  try {
677
730
  const line = JSON.stringify({
678
731
  type: "audit-log",
732
+ // The row's identity and its place in the chain, so the external copy
733
+ // can be reconciled against the database — and can prove a row that
734
+ // has since vanished from it was there.
735
+ id: created?.id ?? null,
736
+ hash: created?.hash ?? null,
737
+ prevHash: created?.prevHash ?? null,
679
738
  action: entry.action,
680
739
  outcome: entry.outcome ?? "success",
681
740
  contentType: entry.contentType,
@@ -773,7 +832,6 @@ const auditService = ({ strapi }) => {
773
832
  return { results, pagination };
774
833
  };
775
834
  const findOne = async (id) => await strapi.db.query(AUDIT_LOG_UID).findOne({ where: { id } });
776
- const deleteOne = async (id) => await strapi.db.query(AUDIT_LOG_UID).delete({ where: { id } });
777
835
  const deleteOlderThan = async (date) => {
778
836
  const { count } = await strapi.db.query(AUDIT_LOG_UID).deleteMany({
779
837
  where: { createdAt: { $lt: date.toISOString() } }
@@ -848,7 +906,7 @@ const auditService = ({ strapi }) => {
848
906
  contentTypes: registry.contentTypes,
849
907
  users: await adminUsers(),
850
908
  locales: await configuredLocales(),
851
- actions: [...CONTENT_ACTIONS, ...ALL_SECURITY_ACTIONS],
909
+ actions: [...CONTENT_ACTIONS, ...ALL_SECURITY_ACTIONS, ...ALL_MAINTENANCE_ACTIONS],
852
910
  sources: [...ALL_SOURCES],
853
911
  outcomes: ["success", "failure"]
854
912
  };
@@ -938,7 +996,6 @@ const auditService = ({ strapi }) => {
938
996
  flush,
939
997
  find,
940
998
  findOne,
941
- deleteOne,
942
999
  deleteOlderThan,
943
1000
  getFilterOptions,
944
1001
  buildWhere,
@@ -1046,6 +1103,7 @@ const configService = ({ strapi }) => {
1046
1103
  auditSystemOperations: raw.auditSystemOperations !== false,
1047
1104
  securityEvents: Array.isArray(raw.securityEvents) ? raw.securityEvents.map(String) : "*",
1048
1105
  forwardToLogger: raw.forwardToLogger === true,
1106
+ correlationId: raw.correlationId !== false,
1049
1107
  forwardLogLevel: ["debug", "info", "warn", "error"].includes(
1050
1108
  raw.forwardLogLevel
1051
1109
  ) ? raw.forwardLogLevel : "info",
@@ -1104,16 +1162,16 @@ const EMPTY_CONTEXT = {
1104
1162
  userAgent: null,
1105
1163
  requestId: null
1106
1164
  };
1107
- const asString$1 = (value) => {
1165
+ const asString$2 = (value) => {
1108
1166
  if (value === null || value === void 0) return null;
1109
1167
  const text = String(value).trim();
1110
1168
  return text.length === 0 ? null : text;
1111
1169
  };
1112
1170
  const resolveUserName = (user) => {
1113
- const firstname = asString$1(user.firstname);
1114
- const lastname = asString$1(user.lastname);
1171
+ const firstname = asString$2(user.firstname);
1172
+ const lastname = asString$2(user.lastname);
1115
1173
  if (firstname && lastname) return `${firstname} ${lastname}`;
1116
- return asString$1(user.username) ?? firstname ?? lastname ?? asString$1(user.email);
1174
+ return asString$2(user.username) ?? firstname ?? lastname ?? asString$2(user.email);
1117
1175
  };
1118
1176
  const contextService = ({ strapi }) => {
1119
1177
  const runAs = (override, callback) => overrideStorage.run(override, callback);
@@ -1125,12 +1183,12 @@ const contextService = ({ strapi }) => {
1125
1183
  }
1126
1184
  };
1127
1185
  const resolveSource = (ctx) => {
1128
- const declared = asString$1(ctx?.state?.auditSource);
1186
+ const declared = asString$2(ctx?.state?.auditSource);
1129
1187
  if (declared) return declared;
1130
- const routeType = asString$1(ctx?.state?.route?.info?.type);
1188
+ const routeType = asString$2(ctx?.state?.route?.info?.type);
1131
1189
  if (routeType === "admin") return "admin";
1132
1190
  if (routeType === "content-api") return "api";
1133
- const strategyName = asString$1(ctx?.state?.auth?.strategy?.name);
1191
+ const strategyName = asString$2(ctx?.state?.auth?.strategy?.name);
1134
1192
  if (strategyName === "admin") return "admin";
1135
1193
  if (strategyName === "api-token" || strategyName === "users-permissions") return "api";
1136
1194
  return ctx ? "unknown" : "system";
@@ -1143,32 +1201,69 @@ const contextService = ({ strapi }) => {
1143
1201
  return {
1144
1202
  ...EMPTY_CONTEXT,
1145
1203
  source: override.source,
1146
- userId: asString$1(override.userId),
1147
- userEmail: asString$1(override.userEmail),
1148
- userName: asString$1(override.userName)
1204
+ userId: asString$2(override.userId),
1205
+ userEmail: asString$2(override.userEmail),
1206
+ userName: asString$2(override.userName)
1149
1207
  };
1150
1208
  }
1151
1209
  const user = ctx.state?.user ?? null;
1152
1210
  const headers = ctx.request?.headers ?? ctx.req?.headers ?? {};
1153
- const requestId = asString$1(ctx.state?.requestId) ?? REQUEST_ID_HEADERS.map((header) => asString$1(headers[header])).find(Boolean) ?? null;
1211
+ const requestId = asString$2(ctx.state?.requestId) ?? REQUEST_ID_HEADERS.map((header) => asString$2(headers[header])).find(Boolean) ?? null;
1154
1212
  return {
1155
1213
  // An explicit override wins even inside a request: a controller that
1156
1214
  // deliberately labels its work knows more than the route type does.
1157
1215
  source: override?.source ?? resolveSource(ctx),
1158
- userId: asString$1(override?.userId ?? user?.id),
1159
- userEmail: asString$1(override?.userEmail ?? user?.email),
1160
- userName: override?.userName !== void 0 ? asString$1(override.userName) : user ? resolveUserName(user) : null,
1216
+ userId: asString$2(override?.userId ?? user?.id),
1217
+ userEmail: asString$2(override?.userEmail ?? user?.email),
1218
+ userName: override?.userName !== void 0 ? asString$2(override.userName) : user ? resolveUserName(user) : null,
1161
1219
  // `ctx.request.ip` honours Koa's `proxy` setting, so behind a correctly
1162
1220
  // configured load balancer this is the client address rather than the
1163
1221
  // proxy's. With `proxy` off it ignores `X-Forwarded-For` entirely, which
1164
1222
  // is the safe default — a spoofable header is worse than no address.
1165
- ipAddress: asString$1(ctx.request?.ip ?? ctx.ip),
1166
- userAgent: asString$1(headers["user-agent"]),
1223
+ ipAddress: asString$2(ctx.request?.ip ?? ctx.ip),
1224
+ userAgent: asString$2(headers["user-agent"]),
1167
1225
  requestId
1168
1226
  };
1169
1227
  };
1170
1228
  return { resolve, runAs };
1171
1229
  };
1230
+ const MAX_ID_LENGTH = 128;
1231
+ const SAFE_ID = /^[A-Za-z0-9._:-]+$/;
1232
+ const asString$1 = (value) => {
1233
+ if (value === null || value === void 0) return null;
1234
+ const text = String(Array.isArray(value) ? value[0] : value).trim();
1235
+ return text.length === 0 ? null : text;
1236
+ };
1237
+ const correlationService = ({ strapi }) => {
1238
+ let registered = false;
1239
+ const inboundId = (ctx) => {
1240
+ const headers = ctx.request?.headers ?? {};
1241
+ for (const header of REQUEST_ID_HEADERS) {
1242
+ const value = asString$1(headers[header]);
1243
+ if (!value) continue;
1244
+ if (value.length <= MAX_ID_LENGTH && SAFE_ID.test(value)) return value;
1245
+ }
1246
+ return null;
1247
+ };
1248
+ const createMiddleware = () => {
1249
+ return async (ctx, next) => {
1250
+ const id = asString$1(ctx.state?.requestId) ?? inboundId(ctx) ?? randomUUID();
1251
+ ctx.state.requestId = id;
1252
+ try {
1253
+ ctx.set(REQUEST_ID_RESPONSE_HEADER, id);
1254
+ } catch {
1255
+ }
1256
+ await next();
1257
+ };
1258
+ };
1259
+ const register2 = () => {
1260
+ if (registered) return;
1261
+ if (!strapi.plugin("audit-log").service("config").resolve().correlationId) return;
1262
+ strapi.server.use(createMiddleware());
1263
+ registered = true;
1264
+ };
1265
+ return { createMiddleware, register: register2, inboundId };
1266
+ };
1172
1267
  const DEFAULT_MAX_DEPTH = 6;
1173
1268
  const DEFAULT_MAX_CHANGES = 500;
1174
1269
  const diffService = ({ strapi }) => {
@@ -1241,13 +1336,13 @@ const diffService = ({ strapi }) => {
1241
1336
  };
1242
1337
  return { buildDiff };
1243
1338
  };
1244
- const FORBIDDEN_ACTIONS = /* @__PURE__ */ new Set(["update", "publish", "unpublish", "discardDraft", "clone"]);
1339
+ const FORBIDDEN_ACTIONS = /* @__PURE__ */ new Set(["update", "delete", "publish", "unpublish", "discardDraft", "clone"]);
1245
1340
  const immutabilityService = ({ strapi }) => {
1246
1341
  const createMiddleware = () => {
1247
1342
  return async (ctx, next) => {
1248
1343
  if (ctx.uid === AUDIT_LOG_UID && FORBIDDEN_ACTIONS.has(ctx.action)) {
1249
1344
  throw new Error(
1250
- `[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.`
1345
+ `[audit-log] Audit records are immutable: "${ctx.action}" is not permitted on ${AUDIT_LOG_UID}. Audit logs are written by the plugin and removed only by its retention job. There is no route, permission or service that deletes an individual record.`
1251
1346
  );
1252
1347
  }
1253
1348
  return next();
@@ -1258,6 +1353,109 @@ const immutabilityService = ({ strapi }) => {
1258
1353
  };
1259
1354
  return { createMiddleware, register: register2 };
1260
1355
  };
1356
+ const HASHED_FIELDS = [
1357
+ "action",
1358
+ "contentType",
1359
+ "contentTypeDisplayName",
1360
+ "contentDocumentId",
1361
+ "contentId",
1362
+ "locale",
1363
+ "userId",
1364
+ "userEmail",
1365
+ "userName",
1366
+ "source",
1367
+ "outcome",
1368
+ "ipAddress",
1369
+ "userAgent",
1370
+ "requestId",
1371
+ "metadata",
1372
+ "changes",
1373
+ "before",
1374
+ "after",
1375
+ "createdAt",
1376
+ "prevHash"
1377
+ ];
1378
+ const PAGE_SIZE = 500;
1379
+ const canonicalize = (value) => {
1380
+ if (value === void 0 || value === null) return "null";
1381
+ if (value instanceof Date) return JSON.stringify(value.toISOString());
1382
+ if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
1383
+ if (typeof value === "object") {
1384
+ const entries = Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`);
1385
+ return `{${entries.join(",")}}`;
1386
+ }
1387
+ return JSON.stringify(value);
1388
+ };
1389
+ const toIso = (value) => {
1390
+ if (value === null || value === void 0) return null;
1391
+ if (value instanceof Date) return value.toISOString();
1392
+ const parsed = new Date(String(value));
1393
+ return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toISOString();
1394
+ };
1395
+ const integrityService = ({ strapi }) => {
1396
+ const computeHash = (record) => {
1397
+ const material = {};
1398
+ for (const field of HASHED_FIELDS) {
1399
+ const value = record[field];
1400
+ material[field] = field === "createdAt" ? toIso(value) : value ?? null;
1401
+ }
1402
+ return createHash("sha256").update(canonicalize(material)).digest("hex");
1403
+ };
1404
+ const verify = async () => {
1405
+ const report = {
1406
+ ok: true,
1407
+ checked: 0,
1408
+ hashed: 0,
1409
+ legacy: 0,
1410
+ head: null,
1411
+ start: null,
1412
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
1413
+ };
1414
+ let previous = null;
1415
+ let afterId = 0;
1416
+ for (; ; ) {
1417
+ const rows = await strapi.db.query(AUDIT_LOG_UID).findMany({
1418
+ where: { id: { $gt: afterId } },
1419
+ orderBy: { id: "asc" },
1420
+ limit: PAGE_SIZE
1421
+ });
1422
+ if (rows.length === 0) break;
1423
+ for (const row of rows) {
1424
+ report.checked += 1;
1425
+ afterId = row.id;
1426
+ if (!row.hash) {
1427
+ report.legacy += 1;
1428
+ continue;
1429
+ }
1430
+ report.hashed += 1;
1431
+ const expected = computeHash(row);
1432
+ if (expected !== row.hash) {
1433
+ report.ok = false;
1434
+ report.brokenAt = {
1435
+ id: row.id,
1436
+ reason: "stored hash does not match the record content — a hashed field was modified"
1437
+ };
1438
+ return report;
1439
+ }
1440
+ if (previous === null) {
1441
+ report.start = { id: row.id };
1442
+ } else if (row.prevHash !== previous.hash) {
1443
+ report.ok = false;
1444
+ report.brokenAt = {
1445
+ id: row.id,
1446
+ reason: `prevHash does not match the hash of record #${previous.id} — a record between them was removed or inserted`
1447
+ };
1448
+ return report;
1449
+ }
1450
+ previous = { id: row.id, hash: row.hash };
1451
+ }
1452
+ if (rows.length < PAGE_SIZE) break;
1453
+ }
1454
+ report.head = previous;
1455
+ return report;
1456
+ };
1457
+ return { computeHash, canonicalize, verify, HASHED_FIELDS };
1458
+ };
1261
1459
  const MS_PER_DAY = 24 * 60 * 60 * 1e3;
1262
1460
  const retentionService = ({ strapi }) => {
1263
1461
  const getConfig = () => strapi.plugin("audit-log").service("config").resolve();
@@ -1266,11 +1464,32 @@ const retentionService = ({ strapi }) => {
1266
1464
  const { retentionDays } = getConfig();
1267
1465
  if (retentionDays <= 0) return 0;
1268
1466
  const cutoff = cutoffDate(retentionDays);
1269
- const deleted = await strapi.plugin("audit-log").service("audit").deleteOlderThan(cutoff);
1467
+ const audit = strapi.plugin("audit-log").service("audit");
1468
+ const deleted = await audit.deleteOlderThan(cutoff);
1270
1469
  if (deleted > 0) {
1271
1470
  strapi.log.info(
1272
1471
  `[audit-log] retention removed ${deleted} record(s) created before ${cutoff.toISOString()}.`
1273
1472
  );
1473
+ await audit.record({
1474
+ action: "retention.purge",
1475
+ contentType: SUBJECTS.auditLog,
1476
+ contentTypeDisplayName: SUBJECT_DISPLAY_NAMES[SUBJECTS.auditLog] ?? "Audit log",
1477
+ contentDocumentId: null,
1478
+ contentId: null,
1479
+ locale: null,
1480
+ changes: null,
1481
+ before: null,
1482
+ after: null,
1483
+ outcome: "success",
1484
+ metadata: { deletedCount: deleted, cutoff: cutoff.toISOString(), retentionDays },
1485
+ source: "cron",
1486
+ userId: null,
1487
+ userEmail: null,
1488
+ userName: null,
1489
+ ipAddress: null,
1490
+ userAgent: null,
1491
+ requestId: null
1492
+ });
1274
1493
  }
1275
1494
  return deleted;
1276
1495
  };
@@ -1826,8 +2045,10 @@ const services = {
1826
2045
  audit: auditService,
1827
2046
  config: configService,
1828
2047
  context: contextService,
2048
+ correlation: correlationService,
1829
2049
  diff: diffService,
1830
2050
  immutability: immutabilityService,
2051
+ integrity: integrityService,
1831
2052
  retention: retentionService,
1832
2053
  security: securityService,
1833
2054
  snapshot: snapshotService,
@@ -1844,6 +2065,7 @@ const index = {
1844
2065
  services
1845
2066
  };
1846
2067
  export {
2068
+ ALL_MAINTENANCE_ACTIONS,
1847
2069
  ALL_SECURITY_ACTIONS,
1848
2070
  AUDIT_LOG_UID,
1849
2071
  CONTENT_ACTIONS,
@@ -39,6 +39,7 @@ declare const _default: {
39
39
  securityEvents: null;
40
40
  forwardToLogger: boolean;
41
41
  forwardLogLevel: string;
42
+ correlationId: boolean;
42
43
  };
43
44
  /**
44
45
  * Runs once at boot, before anything else touches the config. Throwing here