strapi-cms-audit-log 1.1.0 → 1.2.1

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 +78 -0
  2. package/README.md +12 -12
  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 +321 -94
  17. package/dist/server/index.mjs +321 -94
  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 +10 -6
@@ -1,12 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } });
3
3
  const node_async_hooks = require("node:async_hooks");
4
+ const node_crypto = require("node:crypto");
4
5
  const PLUGIN_ID = "audit-log";
5
6
  const AUDIT_LOG_UID = "plugin::audit-log.audit-log";
6
7
  const ALL_ACTIONS = ["create", "update", "delete", "publish", "unpublish"];
7
8
  const PERMISSIONS = {
8
9
  read: `plugin::${PLUGIN_ID}.read`,
9
- delete: `plugin::${PLUGIN_ID}.delete`,
10
10
  settings: `plugin::${PLUGIN_ID}.settings`
11
11
  };
12
12
  const DEFAULT_IGNORED_FIELDS = [
@@ -33,6 +33,8 @@ const SKIPPED_ATTRIBUTES = /* @__PURE__ */ new Set([
33
33
  "createdBy",
34
34
  "updatedBy"
35
35
  ]);
36
+ const REQUEST_ID_RESPONSE_HEADER = "X-Request-Id";
37
+ const CHAIN_LOCK_KEY = 1874206233;
36
38
  const REQUEST_ID_HEADERS = ["x-request-id", "request-id", "x-correlation-id", "x-amzn-trace-id"];
37
39
  const RETENTION_JOB_NAME = "auditLogRetention";
38
40
  const CONTENT_ACTIONS = ALL_ACTIONS;
@@ -57,6 +59,7 @@ const ALL_SECURITY_ACTIONS = [
57
59
  "media-folder.update",
58
60
  "media-folder.delete"
59
61
  ];
62
+ const ALL_MAINTENANCE_ACTIONS = ["retention.purge"];
60
63
  const SUBJECTS = {
61
64
  auth: "admin::auth",
62
65
  access: "admin::access",
@@ -64,7 +67,9 @@ const SUBJECTS = {
64
67
  role: "admin::role",
65
68
  permission: "admin::permission",
66
69
  file: "plugin::upload.file",
67
- folder: "plugin::upload.folder"
70
+ folder: "plugin::upload.folder",
71
+ /** The audit log itself, for records about its own maintenance. */
72
+ auditLog: "admin::audit-log"
68
73
  };
69
74
  const SUBJECT_DISPLAY_NAMES = {
70
75
  [SUBJECTS.auth]: "Authentication",
@@ -73,7 +78,8 @@ const SUBJECT_DISPLAY_NAMES = {
73
78
  [SUBJECTS.role]: "Admin role",
74
79
  [SUBJECTS.permission]: "Permission",
75
80
  [SUBJECTS.file]: "Media file",
76
- [SUBJECTS.folder]: "Media folder"
81
+ [SUBJECTS.folder]: "Media folder",
82
+ [SUBJECTS.auditLog]: "Audit log"
77
83
  };
78
84
  const SECURITY_EVENT_MAP = {
79
85
  "admin.auth.success": { action: "login.success", subject: SUBJECTS.auth },
@@ -143,7 +149,8 @@ const config = {
143
149
  // see the note above on `defaultsDeep` splicing arrays element-wise.
144
150
  securityEvents: null,
145
151
  forwardToLogger: false,
146
- forwardLogLevel: "info"
152
+ forwardLogLevel: "info",
153
+ correlationId: true
147
154
  },
148
155
  /**
149
156
  * Runs once at boot, before anything else touches the config. Throwing here
@@ -207,6 +214,9 @@ const config = {
207
214
  if (config2.forwardToLogger != null && typeof config2.forwardToLogger !== "boolean") {
208
215
  fail('"forwardToLogger" must be a boolean');
209
216
  }
217
+ if (config2.correlationId != null && typeof config2.correlationId !== "boolean") {
218
+ fail('"correlationId" must be a boolean');
219
+ }
210
220
  if (config2.forwardLogLevel != null && !["debug", "info", "warn", "error"].includes(config2.forwardLogLevel)) {
211
221
  fail('"forwardLogLevel" must be one of: debug, info, warn, error');
212
222
  }
@@ -289,7 +299,22 @@ const schema = {
289
299
  ipAddress: { type: "string" },
290
300
  userAgent: { type: "text" },
291
301
  source: { type: "string" },
292
- requestId: { type: "string" }
302
+ requestId: { type: "string" },
303
+ /**
304
+ * Hash chain, for tamper-evidence (ASVS V7.3.3).
305
+ *
306
+ * `hash` is SHA-256 over this record's content fields plus `prevHash`, and
307
+ * `prevHash` is the `hash` of the row written immediately before. Editing any
308
+ * hashed field, or removing a row from the middle, breaks the link that the
309
+ * next row asserts — and `services/integrity.ts` can say exactly which row.
310
+ *
311
+ * Both are nullable: rows written before 1.2.0 have no hash and are never
312
+ * backfilled. Hashing them now would vouch for content whose integrity in
313
+ * the interval cannot be known, which is the opposite of what a hash is for.
314
+ * 64 lowercase hex characters when present.
315
+ */
316
+ hash: { type: "string", maxLength: 64 },
317
+ prevHash: { type: "string", maxLength: 64 }
293
318
  },
294
319
  /**
295
320
  * Secondary indexes, declared here so Strapi's schema sync owns them and they
@@ -376,16 +401,6 @@ const auditLogController = ({ strapi }) => {
376
401
  if (!log) return ctx.notFound("Audit log not found.");
377
402
  ctx.body = { data: log };
378
403
  },
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
404
  /** Filter dropdown options, derived from the rows that actually exist. */
390
405
  async filters(ctx) {
391
406
  ctx.body = { data: await service("audit").getFilterOptions() };
@@ -393,6 +408,10 @@ const auditLogController = ({ strapi }) => {
393
408
  /** The effective configuration, so the UI can hide controls for disabled features. */
394
409
  async config(ctx) {
395
410
  ctx.body = { data: service("config").getPublicConfig() };
411
+ },
412
+ /** Verifies the hash chain end to end. See `services/integrity.ts`. */
413
+ async integrity(ctx) {
414
+ ctx.body = { data: await service("integrity").verify() };
396
415
  }
397
416
  };
398
417
  };
@@ -424,12 +443,6 @@ const ACTIONS = [
424
443
  uid: "read",
425
444
  pluginName: PLUGIN_ID
426
445
  },
427
- {
428
- section: "plugins",
429
- displayName: "Delete audit logs",
430
- uid: "delete",
431
- pluginName: PLUGIN_ID
432
- },
433
446
  {
434
447
  section: "plugins",
435
448
  displayName: "Read audit log settings",
@@ -439,6 +452,7 @@ const ACTIONS = [
439
452
  ];
440
453
  const register = async ({ strapi }) => {
441
454
  await strapi.service("admin::permission").actionProvider.registerMany(ACTIONS);
455
+ strapi.plugin(PLUGIN_ID).service("correlation").register();
442
456
  strapi.plugin(PLUGIN_ID).service("access").register();
443
457
  };
444
458
  const protectedBy = (action) => ({
@@ -468,6 +482,14 @@ const admin = {
468
482
  handler: "audit-log.config",
469
483
  config: protectedBy(PERMISSIONS.read)
470
484
  },
485
+ {
486
+ // Walks the whole hash chain. Read-only, and behind `read` — being able
487
+ // to see the log is the right bar for being able to check it.
488
+ method: "GET",
489
+ path: "/integrity",
490
+ handler: "audit-log.integrity",
491
+ config: protectedBy(PERMISSIONS.read)
492
+ },
471
493
  {
472
494
  // Declared after the two static paths above so `/logs/filters` can never
473
495
  // be swallowed by `:id`.
@@ -475,14 +497,6 @@ const admin = {
475
497
  path: "/logs/:id",
476
498
  handler: "audit-log.findOne",
477
499
  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
500
  }
487
501
  ]
488
502
  };
@@ -490,7 +504,7 @@ const routes = {
490
504
  admin
491
505
  };
492
506
  const MAX_PATH_LENGTH = 512;
493
- const asString$2 = (value, maxLength = 256) => {
507
+ const asString$3 = (value, maxLength = 256) => {
494
508
  if (value === null || value === void 0) return null;
495
509
  const text = String(value).trim();
496
510
  return text.length === 0 ? null : text.slice(0, maxLength);
@@ -502,19 +516,19 @@ const accessService = ({ strapi }) => {
502
516
  const actorOf = (ctx) => {
503
517
  const user = ctx.state?.user ?? null;
504
518
  if (!user) return { userId: null, userEmail: null, userName: null };
505
- const firstname = asString$2(user.firstname);
506
- const lastname = asString$2(user.lastname);
519
+ const firstname = asString$3(user.firstname);
520
+ const lastname = asString$3(user.lastname);
507
521
  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)
522
+ userId: asString$3(user.id),
523
+ userEmail: asString$3(user.email),
524
+ userName: firstname && lastname ? `${firstname} ${lastname}` : asString$3(user.username) ?? firstname ?? lastname ?? asString$3(user.email)
511
525
  };
512
526
  };
513
527
  const sourceOf = (ctx) => {
514
- const routeType = asString$2(ctx.state?.route?.info?.type);
528
+ const routeType = asString$3(ctx.state?.route?.info?.type);
515
529
  if (routeType === "admin") return "admin";
516
530
  if (routeType === "content-api") return "api";
517
- const strategy = asString$2(ctx.state?.auth?.strategy?.name);
531
+ const strategy = asString$3(ctx.state?.auth?.strategy?.name);
518
532
  if (strategy === "admin") return "admin";
519
533
  if (strategy === "api-token" || strategy === "users-permissions") return "api";
520
534
  return "unknown";
@@ -533,20 +547,20 @@ const accessService = ({ strapi }) => {
533
547
  after: null,
534
548
  outcome: "failure",
535
549
  metadata: {
536
- method: asString$2(ctx.request?.method, 16),
537
- path: asString$2(ctx.request?.path, MAX_PATH_LENGTH),
550
+ method: asString$3(ctx.request?.method, 16),
551
+ path: asString$3(ctx.request?.path, MAX_PATH_LENGTH),
538
552
  statusCode: ctx.status,
539
553
  // Strapi's error handler puts a reason in the body. It is the difference
540
554
  // between "no session" and "your role lacks this permission", which is
541
555
  // 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)
556
+ reason: asString$3(ctx.body?.error?.message) ?? null,
557
+ routeType: asString$3(ctx.state?.route?.info?.type)
544
558
  },
545
559
  source: sourceOf(ctx),
546
560
  ...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"])
561
+ ipAddress: asString$3(ctx.request?.ip ?? ctx.ip, 64),
562
+ userAgent: asString$3(headers["user-agent"], 512),
563
+ requestId: asString$3(ctx.state?.requestId ?? headers["x-request-id"])
550
564
  });
551
565
  };
552
566
  const createMiddleware = () => {
@@ -646,38 +660,83 @@ const auditService = ({ strapi }) => {
646
660
  );
647
661
  return { __omitted__: `Snapshot omitted: ~${bytes} bytes exceeds maxSnapshotBytes (${maxBytes}).` };
648
662
  };
663
+ let chainQueue = Promise.resolve();
664
+ const serialised = (task) => {
665
+ const run = chainQueue.then(task, task);
666
+ chainQueue = run.catch(() => void 0);
667
+ return run;
668
+ };
669
+ const withChainLock = (task) => (
670
+ // `transaction` types its result as the callback's own return, which for an
671
+ // async callback is a Promise of a Promise; at runtime it is flattened.
672
+ strapi.db.transaction(async ({ trx }) => {
673
+ const client = String(strapi.db.dialect?.client ?? "");
674
+ if (client === "postgres") {
675
+ await trx.raw("select pg_advisory_xact_lock(?)", [CHAIN_LOCK_KEY]);
676
+ } else if (client === "mysql") {
677
+ await trx.raw("select get_lock(?, 10)", [`audit-log-chain-${CHAIN_LOCK_KEY}`]);
678
+ try {
679
+ return await task();
680
+ } finally {
681
+ await trx.raw("select release_lock(?)", [`audit-log-chain-${CHAIN_LOCK_KEY}`]);
682
+ }
683
+ }
684
+ return task();
685
+ })
686
+ );
649
687
  const write = async (entry) => {
650
688
  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);
689
+ const integrity = strapi.plugin(PLUGIN_ID).service("integrity");
690
+ const data = {
691
+ action: entry.action,
692
+ contentType: entry.contentType,
693
+ contentTypeDisplayName: entry.contentTypeDisplayName,
694
+ contentDocumentId: entry.contentDocumentId,
695
+ contentId: entry.contentId,
696
+ locale: entry.locale,
697
+ userId: entry.userId,
698
+ userEmail: entry.userEmail,
699
+ userName: entry.userName,
700
+ changes: config2.storeChanges ? entry.changes : null,
701
+ before: capSnapshot(config2.storeBefore ? entry.before : null, config2.maxSnapshotBytes, "before"),
702
+ after: capSnapshot(config2.storeAfter ? entry.after : null, config2.maxSnapshotBytes, "after"),
703
+ ipAddress: entry.ipAddress,
704
+ userAgent: entry.userAgent,
705
+ source: entry.source,
706
+ requestId: entry.requestId,
707
+ // A content write only ever reaches the tracker once it has succeeded,
708
+ // so the default is the honest value rather than a placeholder.
709
+ outcome: entry.outcome ?? "success",
710
+ metadata: entry.metadata ?? null,
711
+ // Set here rather than left to the database's timestamp hook, because it
712
+ // is part of the hash: a record whose time can be changed without
713
+ // detection is a record whose place in the story can be changed.
714
+ createdAt: /* @__PURE__ */ new Date()
715
+ };
716
+ const created = await serialised(
717
+ () => withChainLock(async () => {
718
+ const [head] = await strapi.db.query(AUDIT_LOG_UID).findMany({
719
+ select: ["id", "hash"],
720
+ orderBy: { id: "desc" },
721
+ limit: 1
722
+ });
723
+ data.prevHash = head?.hash ?? null;
724
+ data.hash = integrity.computeHash(data);
725
+ return await strapi.db.query(AUDIT_LOG_UID).create({ data });
726
+ })
727
+ );
728
+ if (config2.forwardToLogger) forwardToLogger(entry, config2, created);
676
729
  };
677
- const forwardToLogger = (entry, config2) => {
730
+ const forwardToLogger = (entry, config2, created) => {
678
731
  try {
679
732
  const line = JSON.stringify({
680
733
  type: "audit-log",
734
+ // The row's identity and its place in the chain, so the external copy
735
+ // can be reconciled against the database — and can prove a row that
736
+ // has since vanished from it was there.
737
+ id: created?.id ?? null,
738
+ hash: created?.hash ?? null,
739
+ prevHash: created?.prevHash ?? null,
681
740
  action: entry.action,
682
741
  outcome: entry.outcome ?? "success",
683
742
  contentType: entry.contentType,
@@ -775,7 +834,6 @@ const auditService = ({ strapi }) => {
775
834
  return { results, pagination };
776
835
  };
777
836
  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
837
  const deleteOlderThan = async (date) => {
780
838
  const { count } = await strapi.db.query(AUDIT_LOG_UID).deleteMany({
781
839
  where: { createdAt: { $lt: date.toISOString() } }
@@ -850,7 +908,7 @@ const auditService = ({ strapi }) => {
850
908
  contentTypes: registry.contentTypes,
851
909
  users: await adminUsers(),
852
910
  locales: await configuredLocales(),
853
- actions: [...CONTENT_ACTIONS, ...ALL_SECURITY_ACTIONS],
911
+ actions: [...CONTENT_ACTIONS, ...ALL_SECURITY_ACTIONS, ...ALL_MAINTENANCE_ACTIONS],
854
912
  sources: [...ALL_SOURCES],
855
913
  outcomes: ["success", "failure"]
856
914
  };
@@ -891,8 +949,13 @@ const auditService = ({ strapi }) => {
891
949
  sources: [...options.sources].sort(),
892
950
  outcomes: [...options.outcomes].sort()
893
951
  });
894
- const readStoredOptions = async () => {
895
- const knex = strapi.db.connection;
952
+ const readStoredOptions = () => (
953
+ // Never raw `strapi.db.connection`: from inside an open transaction it takes
954
+ // a second pooled connection, and `Promise.all` below would take six. On the
955
+ // transaction's one connection the six queries simply run in turn.
956
+ strapi.db.transaction(({ trx }) => readStoredOptionsWith(trx))
957
+ );
958
+ const readStoredOptionsWith = async (knex) => {
896
959
  const metadata = strapi.db.metadata.get(AUDIT_LOG_UID);
897
960
  const table = metadata.tableName;
898
961
  const column = (attribute) => {
@@ -940,7 +1003,6 @@ const auditService = ({ strapi }) => {
940
1003
  flush,
941
1004
  find,
942
1005
  findOne,
943
- deleteOne,
944
1006
  deleteOlderThan,
945
1007
  getFilterOptions,
946
1008
  buildWhere,
@@ -1048,6 +1110,7 @@ const configService = ({ strapi }) => {
1048
1110
  auditSystemOperations: raw.auditSystemOperations !== false,
1049
1111
  securityEvents: Array.isArray(raw.securityEvents) ? raw.securityEvents.map(String) : "*",
1050
1112
  forwardToLogger: raw.forwardToLogger === true,
1113
+ correlationId: raw.correlationId !== false,
1051
1114
  forwardLogLevel: ["debug", "info", "warn", "error"].includes(
1052
1115
  raw.forwardLogLevel
1053
1116
  ) ? raw.forwardLogLevel : "info",
@@ -1106,16 +1169,16 @@ const EMPTY_CONTEXT = {
1106
1169
  userAgent: null,
1107
1170
  requestId: null
1108
1171
  };
1109
- const asString$1 = (value) => {
1172
+ const asString$2 = (value) => {
1110
1173
  if (value === null || value === void 0) return null;
1111
1174
  const text = String(value).trim();
1112
1175
  return text.length === 0 ? null : text;
1113
1176
  };
1114
1177
  const resolveUserName = (user) => {
1115
- const firstname = asString$1(user.firstname);
1116
- const lastname = asString$1(user.lastname);
1178
+ const firstname = asString$2(user.firstname);
1179
+ const lastname = asString$2(user.lastname);
1117
1180
  if (firstname && lastname) return `${firstname} ${lastname}`;
1118
- return asString$1(user.username) ?? firstname ?? lastname ?? asString$1(user.email);
1181
+ return asString$2(user.username) ?? firstname ?? lastname ?? asString$2(user.email);
1119
1182
  };
1120
1183
  const contextService = ({ strapi }) => {
1121
1184
  const runAs = (override, callback) => overrideStorage.run(override, callback);
@@ -1127,12 +1190,12 @@ const contextService = ({ strapi }) => {
1127
1190
  }
1128
1191
  };
1129
1192
  const resolveSource = (ctx) => {
1130
- const declared = asString$1(ctx?.state?.auditSource);
1193
+ const declared = asString$2(ctx?.state?.auditSource);
1131
1194
  if (declared) return declared;
1132
- const routeType = asString$1(ctx?.state?.route?.info?.type);
1195
+ const routeType = asString$2(ctx?.state?.route?.info?.type);
1133
1196
  if (routeType === "admin") return "admin";
1134
1197
  if (routeType === "content-api") return "api";
1135
- const strategyName = asString$1(ctx?.state?.auth?.strategy?.name);
1198
+ const strategyName = asString$2(ctx?.state?.auth?.strategy?.name);
1136
1199
  if (strategyName === "admin") return "admin";
1137
1200
  if (strategyName === "api-token" || strategyName === "users-permissions") return "api";
1138
1201
  return ctx ? "unknown" : "system";
@@ -1145,32 +1208,69 @@ const contextService = ({ strapi }) => {
1145
1208
  return {
1146
1209
  ...EMPTY_CONTEXT,
1147
1210
  source: override.source,
1148
- userId: asString$1(override.userId),
1149
- userEmail: asString$1(override.userEmail),
1150
- userName: asString$1(override.userName)
1211
+ userId: asString$2(override.userId),
1212
+ userEmail: asString$2(override.userEmail),
1213
+ userName: asString$2(override.userName)
1151
1214
  };
1152
1215
  }
1153
1216
  const user = ctx.state?.user ?? null;
1154
1217
  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;
1218
+ const requestId = asString$2(ctx.state?.requestId) ?? REQUEST_ID_HEADERS.map((header) => asString$2(headers[header])).find(Boolean) ?? null;
1156
1219
  return {
1157
1220
  // An explicit override wins even inside a request: a controller that
1158
1221
  // deliberately labels its work knows more than the route type does.
1159
1222
  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,
1223
+ userId: asString$2(override?.userId ?? user?.id),
1224
+ userEmail: asString$2(override?.userEmail ?? user?.email),
1225
+ userName: override?.userName !== void 0 ? asString$2(override.userName) : user ? resolveUserName(user) : null,
1163
1226
  // `ctx.request.ip` honours Koa's `proxy` setting, so behind a correctly
1164
1227
  // configured load balancer this is the client address rather than the
1165
1228
  // proxy's. With `proxy` off it ignores `X-Forwarded-For` entirely, which
1166
1229
  // 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"]),
1230
+ ipAddress: asString$2(ctx.request?.ip ?? ctx.ip),
1231
+ userAgent: asString$2(headers["user-agent"]),
1169
1232
  requestId
1170
1233
  };
1171
1234
  };
1172
1235
  return { resolve, runAs };
1173
1236
  };
1237
+ const MAX_ID_LENGTH = 128;
1238
+ const SAFE_ID = /^[A-Za-z0-9._:-]+$/;
1239
+ const asString$1 = (value) => {
1240
+ if (value === null || value === void 0) return null;
1241
+ const text = String(Array.isArray(value) ? value[0] : value).trim();
1242
+ return text.length === 0 ? null : text;
1243
+ };
1244
+ const correlationService = ({ strapi }) => {
1245
+ let registered = false;
1246
+ const inboundId = (ctx) => {
1247
+ const headers = ctx.request?.headers ?? {};
1248
+ for (const header of REQUEST_ID_HEADERS) {
1249
+ const value = asString$1(headers[header]);
1250
+ if (!value) continue;
1251
+ if (value.length <= MAX_ID_LENGTH && SAFE_ID.test(value)) return value;
1252
+ }
1253
+ return null;
1254
+ };
1255
+ const createMiddleware = () => {
1256
+ return async (ctx, next) => {
1257
+ const id = asString$1(ctx.state?.requestId) ?? inboundId(ctx) ?? node_crypto.randomUUID();
1258
+ ctx.state.requestId = id;
1259
+ try {
1260
+ ctx.set(REQUEST_ID_RESPONSE_HEADER, id);
1261
+ } catch {
1262
+ }
1263
+ await next();
1264
+ };
1265
+ };
1266
+ const register2 = () => {
1267
+ if (registered) return;
1268
+ if (!strapi.plugin("audit-log").service("config").resolve().correlationId) return;
1269
+ strapi.server.use(createMiddleware());
1270
+ registered = true;
1271
+ };
1272
+ return { createMiddleware, register: register2, inboundId };
1273
+ };
1174
1274
  const DEFAULT_MAX_DEPTH = 6;
1175
1275
  const DEFAULT_MAX_CHANGES = 500;
1176
1276
  const diffService = ({ strapi }) => {
@@ -1243,13 +1343,13 @@ const diffService = ({ strapi }) => {
1243
1343
  };
1244
1344
  return { buildDiff };
1245
1345
  };
1246
- const FORBIDDEN_ACTIONS = /* @__PURE__ */ new Set(["update", "publish", "unpublish", "discardDraft", "clone"]);
1346
+ const FORBIDDEN_ACTIONS = /* @__PURE__ */ new Set(["update", "delete", "publish", "unpublish", "discardDraft", "clone"]);
1247
1347
  const immutabilityService = ({ strapi }) => {
1248
1348
  const createMiddleware = () => {
1249
1349
  return async (ctx, next) => {
1250
1350
  if (ctx.uid === AUDIT_LOG_UID && FORBIDDEN_ACTIONS.has(ctx.action)) {
1251
1351
  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.`
1352
+ `[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.`
1253
1353
  );
1254
1354
  }
1255
1355
  return next();
@@ -1260,6 +1360,109 @@ const immutabilityService = ({ strapi }) => {
1260
1360
  };
1261
1361
  return { createMiddleware, register: register2 };
1262
1362
  };
1363
+ const HASHED_FIELDS = [
1364
+ "action",
1365
+ "contentType",
1366
+ "contentTypeDisplayName",
1367
+ "contentDocumentId",
1368
+ "contentId",
1369
+ "locale",
1370
+ "userId",
1371
+ "userEmail",
1372
+ "userName",
1373
+ "source",
1374
+ "outcome",
1375
+ "ipAddress",
1376
+ "userAgent",
1377
+ "requestId",
1378
+ "metadata",
1379
+ "changes",
1380
+ "before",
1381
+ "after",
1382
+ "createdAt",
1383
+ "prevHash"
1384
+ ];
1385
+ const PAGE_SIZE = 500;
1386
+ const canonicalize = (value) => {
1387
+ if (value === void 0 || value === null) return "null";
1388
+ if (value instanceof Date) return JSON.stringify(value.toISOString());
1389
+ if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
1390
+ if (typeof value === "object") {
1391
+ const entries = Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`);
1392
+ return `{${entries.join(",")}}`;
1393
+ }
1394
+ return JSON.stringify(value);
1395
+ };
1396
+ const toIso = (value) => {
1397
+ if (value === null || value === void 0) return null;
1398
+ if (value instanceof Date) return value.toISOString();
1399
+ const parsed = new Date(String(value));
1400
+ return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toISOString();
1401
+ };
1402
+ const integrityService = ({ strapi }) => {
1403
+ const computeHash = (record) => {
1404
+ const material = {};
1405
+ for (const field of HASHED_FIELDS) {
1406
+ const value = record[field];
1407
+ material[field] = field === "createdAt" ? toIso(value) : value ?? null;
1408
+ }
1409
+ return node_crypto.createHash("sha256").update(canonicalize(material)).digest("hex");
1410
+ };
1411
+ const verify = async () => {
1412
+ const report = {
1413
+ ok: true,
1414
+ checked: 0,
1415
+ hashed: 0,
1416
+ legacy: 0,
1417
+ head: null,
1418
+ start: null,
1419
+ verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
1420
+ };
1421
+ let previous = null;
1422
+ let afterId = 0;
1423
+ for (; ; ) {
1424
+ const rows = await strapi.db.query(AUDIT_LOG_UID).findMany({
1425
+ where: { id: { $gt: afterId } },
1426
+ orderBy: { id: "asc" },
1427
+ limit: PAGE_SIZE
1428
+ });
1429
+ if (rows.length === 0) break;
1430
+ for (const row of rows) {
1431
+ report.checked += 1;
1432
+ afterId = row.id;
1433
+ if (!row.hash) {
1434
+ report.legacy += 1;
1435
+ continue;
1436
+ }
1437
+ report.hashed += 1;
1438
+ const expected = computeHash(row);
1439
+ if (expected !== row.hash) {
1440
+ report.ok = false;
1441
+ report.brokenAt = {
1442
+ id: row.id,
1443
+ reason: "stored hash does not match the record content — a hashed field was modified"
1444
+ };
1445
+ return report;
1446
+ }
1447
+ if (previous === null) {
1448
+ report.start = { id: row.id };
1449
+ } else if (row.prevHash !== previous.hash) {
1450
+ report.ok = false;
1451
+ report.brokenAt = {
1452
+ id: row.id,
1453
+ reason: `prevHash does not match the hash of record #${previous.id} — a record between them was removed or inserted`
1454
+ };
1455
+ return report;
1456
+ }
1457
+ previous = { id: row.id, hash: row.hash };
1458
+ }
1459
+ if (rows.length < PAGE_SIZE) break;
1460
+ }
1461
+ report.head = previous;
1462
+ return report;
1463
+ };
1464
+ return { computeHash, canonicalize, verify, HASHED_FIELDS };
1465
+ };
1263
1466
  const MS_PER_DAY = 24 * 60 * 60 * 1e3;
1264
1467
  const retentionService = ({ strapi }) => {
1265
1468
  const getConfig = () => strapi.plugin("audit-log").service("config").resolve();
@@ -1268,11 +1471,32 @@ const retentionService = ({ strapi }) => {
1268
1471
  const { retentionDays } = getConfig();
1269
1472
  if (retentionDays <= 0) return 0;
1270
1473
  const cutoff = cutoffDate(retentionDays);
1271
- const deleted = await strapi.plugin("audit-log").service("audit").deleteOlderThan(cutoff);
1474
+ const audit = strapi.plugin("audit-log").service("audit");
1475
+ const deleted = await audit.deleteOlderThan(cutoff);
1272
1476
  if (deleted > 0) {
1273
1477
  strapi.log.info(
1274
1478
  `[audit-log] retention removed ${deleted} record(s) created before ${cutoff.toISOString()}.`
1275
1479
  );
1480
+ await audit.record({
1481
+ action: "retention.purge",
1482
+ contentType: SUBJECTS.auditLog,
1483
+ contentTypeDisplayName: SUBJECT_DISPLAY_NAMES[SUBJECTS.auditLog] ?? "Audit log",
1484
+ contentDocumentId: null,
1485
+ contentId: null,
1486
+ locale: null,
1487
+ changes: null,
1488
+ before: null,
1489
+ after: null,
1490
+ outcome: "success",
1491
+ metadata: { deletedCount: deleted, cutoff: cutoff.toISOString(), retentionDays },
1492
+ source: "cron",
1493
+ userId: null,
1494
+ userEmail: null,
1495
+ userName: null,
1496
+ ipAddress: null,
1497
+ userAgent: null,
1498
+ requestId: null
1499
+ });
1276
1500
  }
1277
1501
  return deleted;
1278
1502
  };
@@ -1828,8 +2052,10 @@ const services = {
1828
2052
  audit: auditService,
1829
2053
  config: configService,
1830
2054
  context: contextService,
2055
+ correlation: correlationService,
1831
2056
  diff: diffService,
1832
2057
  immutability: immutabilityService,
2058
+ integrity: integrityService,
1833
2059
  retention: retentionService,
1834
2060
  security: securityService,
1835
2061
  snapshot: snapshotService,
@@ -1845,6 +2071,7 @@ const index = {
1845
2071
  routes,
1846
2072
  services
1847
2073
  };
2074
+ exports.ALL_MAINTENANCE_ACTIONS = ALL_MAINTENANCE_ACTIONS;
1848
2075
  exports.ALL_SECURITY_ACTIONS = ALL_SECURITY_ACTIONS;
1849
2076
  exports.AUDIT_LOG_UID = AUDIT_LOG_UID;
1850
2077
  exports.CONTENT_ACTIONS = CONTENT_ACTIONS;