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.
- package/CHANGELOG.md +78 -0
- package/README.md +12 -12
- package/dist/admin/{App-BLt4lqEM.js → App-CmbG2k9E.js} +110 -100
- package/dist/admin/{App-ou5hy99f.mjs → App-jWvaMXIw.mjs} +113 -103
- package/dist/admin/{en-BMlJxq3g.js → en-Bs4tlklI.js} +11 -9
- package/dist/admin/{en-B0rPiE2W.mjs → en-C3Og1VyK.mjs} +11 -9
- package/dist/admin/{index-DdmY-p3Q.mjs → index-BIIMJgKU.mjs} +3 -5
- package/dist/admin/{index-BYi8OPTw.js → index-BsVOGSDf.js} +3 -5
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/admin/src/components/AuditLogTable.d.ts +1 -2
- package/dist/admin/src/components/IntegrityStatus.d.ts +13 -0
- package/dist/admin/src/hooks/useAuditLogs.d.ts +14 -3
- package/dist/admin/src/permissions.d.ts +0 -8
- package/dist/admin/src/types.d.ts +26 -2
- package/dist/server/index.js +321 -94
- package/dist/server/index.mjs +321 -94
- package/dist/server/src/config/index.d.ts +1 -0
- package/dist/server/src/constants.d.ts +24 -2
- package/dist/server/src/content-types/audit-log/index.d.ts +8 -0
- package/dist/server/src/content-types/audit-log/schema.d.ts +21 -0
- package/dist/server/src/content-types/index.d.ts +8 -0
- package/dist/server/src/controllers/audit-log.d.ts +5 -3
- package/dist/server/src/controllers/index.d.ts +4 -4
- package/dist/server/src/index.d.ts +36 -7
- package/dist/server/src/register.d.ts +7 -3
- package/dist/server/src/routes/admin.d.ts +8 -0
- package/dist/server/src/services/audit.d.ts +0 -1
- package/dist/server/src/services/correlation.d.ts +35 -0
- package/dist/server/src/services/immutability.d.ts +5 -5
- package/dist/server/src/services/index.d.ts +21 -1
- package/dist/server/src/services/integrity.d.ts +69 -0
- package/dist/server/src/types/index.d.ts +54 -4
- package/package.json +10 -6
package/dist/server/index.mjs
CHANGED
|
@@ -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$
|
|
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$
|
|
504
|
-
const lastname = asString$
|
|
517
|
+
const firstname = asString$3(user.firstname);
|
|
518
|
+
const lastname = asString$3(user.lastname);
|
|
505
519
|
return {
|
|
506
|
-
userId: asString$
|
|
507
|
-
userEmail: asString$
|
|
508
|
-
userName: firstname && lastname ? `${firstname} ${lastname}` : asString$
|
|
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$
|
|
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$
|
|
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$
|
|
535
|
-
path: asString$
|
|
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$
|
|
541
|
-
routeType: asString$
|
|
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$
|
|
546
|
-
userAgent: asString$
|
|
547
|
-
requestId: asString$
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
};
|
|
@@ -889,8 +947,13 @@ const auditService = ({ strapi }) => {
|
|
|
889
947
|
sources: [...options.sources].sort(),
|
|
890
948
|
outcomes: [...options.outcomes].sort()
|
|
891
949
|
});
|
|
892
|
-
const readStoredOptions =
|
|
893
|
-
|
|
950
|
+
const readStoredOptions = () => (
|
|
951
|
+
// Never raw `strapi.db.connection`: from inside an open transaction it takes
|
|
952
|
+
// a second pooled connection, and `Promise.all` below would take six. On the
|
|
953
|
+
// transaction's one connection the six queries simply run in turn.
|
|
954
|
+
strapi.db.transaction(({ trx }) => readStoredOptionsWith(trx))
|
|
955
|
+
);
|
|
956
|
+
const readStoredOptionsWith = async (knex) => {
|
|
894
957
|
const metadata = strapi.db.metadata.get(AUDIT_LOG_UID);
|
|
895
958
|
const table = metadata.tableName;
|
|
896
959
|
const column = (attribute) => {
|
|
@@ -938,7 +1001,6 @@ const auditService = ({ strapi }) => {
|
|
|
938
1001
|
flush,
|
|
939
1002
|
find,
|
|
940
1003
|
findOne,
|
|
941
|
-
deleteOne,
|
|
942
1004
|
deleteOlderThan,
|
|
943
1005
|
getFilterOptions,
|
|
944
1006
|
buildWhere,
|
|
@@ -1046,6 +1108,7 @@ const configService = ({ strapi }) => {
|
|
|
1046
1108
|
auditSystemOperations: raw.auditSystemOperations !== false,
|
|
1047
1109
|
securityEvents: Array.isArray(raw.securityEvents) ? raw.securityEvents.map(String) : "*",
|
|
1048
1110
|
forwardToLogger: raw.forwardToLogger === true,
|
|
1111
|
+
correlationId: raw.correlationId !== false,
|
|
1049
1112
|
forwardLogLevel: ["debug", "info", "warn", "error"].includes(
|
|
1050
1113
|
raw.forwardLogLevel
|
|
1051
1114
|
) ? raw.forwardLogLevel : "info",
|
|
@@ -1104,16 +1167,16 @@ const EMPTY_CONTEXT = {
|
|
|
1104
1167
|
userAgent: null,
|
|
1105
1168
|
requestId: null
|
|
1106
1169
|
};
|
|
1107
|
-
const asString$
|
|
1170
|
+
const asString$2 = (value) => {
|
|
1108
1171
|
if (value === null || value === void 0) return null;
|
|
1109
1172
|
const text = String(value).trim();
|
|
1110
1173
|
return text.length === 0 ? null : text;
|
|
1111
1174
|
};
|
|
1112
1175
|
const resolveUserName = (user) => {
|
|
1113
|
-
const firstname = asString$
|
|
1114
|
-
const lastname = asString$
|
|
1176
|
+
const firstname = asString$2(user.firstname);
|
|
1177
|
+
const lastname = asString$2(user.lastname);
|
|
1115
1178
|
if (firstname && lastname) return `${firstname} ${lastname}`;
|
|
1116
|
-
return asString$
|
|
1179
|
+
return asString$2(user.username) ?? firstname ?? lastname ?? asString$2(user.email);
|
|
1117
1180
|
};
|
|
1118
1181
|
const contextService = ({ strapi }) => {
|
|
1119
1182
|
const runAs = (override, callback) => overrideStorage.run(override, callback);
|
|
@@ -1125,12 +1188,12 @@ const contextService = ({ strapi }) => {
|
|
|
1125
1188
|
}
|
|
1126
1189
|
};
|
|
1127
1190
|
const resolveSource = (ctx) => {
|
|
1128
|
-
const declared = asString$
|
|
1191
|
+
const declared = asString$2(ctx?.state?.auditSource);
|
|
1129
1192
|
if (declared) return declared;
|
|
1130
|
-
const routeType = asString$
|
|
1193
|
+
const routeType = asString$2(ctx?.state?.route?.info?.type);
|
|
1131
1194
|
if (routeType === "admin") return "admin";
|
|
1132
1195
|
if (routeType === "content-api") return "api";
|
|
1133
|
-
const strategyName = asString$
|
|
1196
|
+
const strategyName = asString$2(ctx?.state?.auth?.strategy?.name);
|
|
1134
1197
|
if (strategyName === "admin") return "admin";
|
|
1135
1198
|
if (strategyName === "api-token" || strategyName === "users-permissions") return "api";
|
|
1136
1199
|
return ctx ? "unknown" : "system";
|
|
@@ -1143,32 +1206,69 @@ const contextService = ({ strapi }) => {
|
|
|
1143
1206
|
return {
|
|
1144
1207
|
...EMPTY_CONTEXT,
|
|
1145
1208
|
source: override.source,
|
|
1146
|
-
userId: asString$
|
|
1147
|
-
userEmail: asString$
|
|
1148
|
-
userName: asString$
|
|
1209
|
+
userId: asString$2(override.userId),
|
|
1210
|
+
userEmail: asString$2(override.userEmail),
|
|
1211
|
+
userName: asString$2(override.userName)
|
|
1149
1212
|
};
|
|
1150
1213
|
}
|
|
1151
1214
|
const user = ctx.state?.user ?? null;
|
|
1152
1215
|
const headers = ctx.request?.headers ?? ctx.req?.headers ?? {};
|
|
1153
|
-
const requestId = asString$
|
|
1216
|
+
const requestId = asString$2(ctx.state?.requestId) ?? REQUEST_ID_HEADERS.map((header) => asString$2(headers[header])).find(Boolean) ?? null;
|
|
1154
1217
|
return {
|
|
1155
1218
|
// An explicit override wins even inside a request: a controller that
|
|
1156
1219
|
// deliberately labels its work knows more than the route type does.
|
|
1157
1220
|
source: override?.source ?? resolveSource(ctx),
|
|
1158
|
-
userId: asString$
|
|
1159
|
-
userEmail: asString$
|
|
1160
|
-
userName: override?.userName !== void 0 ? asString$
|
|
1221
|
+
userId: asString$2(override?.userId ?? user?.id),
|
|
1222
|
+
userEmail: asString$2(override?.userEmail ?? user?.email),
|
|
1223
|
+
userName: override?.userName !== void 0 ? asString$2(override.userName) : user ? resolveUserName(user) : null,
|
|
1161
1224
|
// `ctx.request.ip` honours Koa's `proxy` setting, so behind a correctly
|
|
1162
1225
|
// configured load balancer this is the client address rather than the
|
|
1163
1226
|
// proxy's. With `proxy` off it ignores `X-Forwarded-For` entirely, which
|
|
1164
1227
|
// is the safe default — a spoofable header is worse than no address.
|
|
1165
|
-
ipAddress: asString$
|
|
1166
|
-
userAgent: asString$
|
|
1228
|
+
ipAddress: asString$2(ctx.request?.ip ?? ctx.ip),
|
|
1229
|
+
userAgent: asString$2(headers["user-agent"]),
|
|
1167
1230
|
requestId
|
|
1168
1231
|
};
|
|
1169
1232
|
};
|
|
1170
1233
|
return { resolve, runAs };
|
|
1171
1234
|
};
|
|
1235
|
+
const MAX_ID_LENGTH = 128;
|
|
1236
|
+
const SAFE_ID = /^[A-Za-z0-9._:-]+$/;
|
|
1237
|
+
const asString$1 = (value) => {
|
|
1238
|
+
if (value === null || value === void 0) return null;
|
|
1239
|
+
const text = String(Array.isArray(value) ? value[0] : value).trim();
|
|
1240
|
+
return text.length === 0 ? null : text;
|
|
1241
|
+
};
|
|
1242
|
+
const correlationService = ({ strapi }) => {
|
|
1243
|
+
let registered = false;
|
|
1244
|
+
const inboundId = (ctx) => {
|
|
1245
|
+
const headers = ctx.request?.headers ?? {};
|
|
1246
|
+
for (const header of REQUEST_ID_HEADERS) {
|
|
1247
|
+
const value = asString$1(headers[header]);
|
|
1248
|
+
if (!value) continue;
|
|
1249
|
+
if (value.length <= MAX_ID_LENGTH && SAFE_ID.test(value)) return value;
|
|
1250
|
+
}
|
|
1251
|
+
return null;
|
|
1252
|
+
};
|
|
1253
|
+
const createMiddleware = () => {
|
|
1254
|
+
return async (ctx, next) => {
|
|
1255
|
+
const id = asString$1(ctx.state?.requestId) ?? inboundId(ctx) ?? randomUUID();
|
|
1256
|
+
ctx.state.requestId = id;
|
|
1257
|
+
try {
|
|
1258
|
+
ctx.set(REQUEST_ID_RESPONSE_HEADER, id);
|
|
1259
|
+
} catch {
|
|
1260
|
+
}
|
|
1261
|
+
await next();
|
|
1262
|
+
};
|
|
1263
|
+
};
|
|
1264
|
+
const register2 = () => {
|
|
1265
|
+
if (registered) return;
|
|
1266
|
+
if (!strapi.plugin("audit-log").service("config").resolve().correlationId) return;
|
|
1267
|
+
strapi.server.use(createMiddleware());
|
|
1268
|
+
registered = true;
|
|
1269
|
+
};
|
|
1270
|
+
return { createMiddleware, register: register2, inboundId };
|
|
1271
|
+
};
|
|
1172
1272
|
const DEFAULT_MAX_DEPTH = 6;
|
|
1173
1273
|
const DEFAULT_MAX_CHANGES = 500;
|
|
1174
1274
|
const diffService = ({ strapi }) => {
|
|
@@ -1241,13 +1341,13 @@ const diffService = ({ strapi }) => {
|
|
|
1241
1341
|
};
|
|
1242
1342
|
return { buildDiff };
|
|
1243
1343
|
};
|
|
1244
|
-
const FORBIDDEN_ACTIONS = /* @__PURE__ */ new Set(["update", "publish", "unpublish", "discardDraft", "clone"]);
|
|
1344
|
+
const FORBIDDEN_ACTIONS = /* @__PURE__ */ new Set(["update", "delete", "publish", "unpublish", "discardDraft", "clone"]);
|
|
1245
1345
|
const immutabilityService = ({ strapi }) => {
|
|
1246
1346
|
const createMiddleware = () => {
|
|
1247
1347
|
return async (ctx, next) => {
|
|
1248
1348
|
if (ctx.uid === AUDIT_LOG_UID && FORBIDDEN_ACTIONS.has(ctx.action)) {
|
|
1249
1349
|
throw new Error(
|
|
1250
|
-
`[audit-log] Audit records are immutable: "${ctx.action}" is not permitted on ${AUDIT_LOG_UID}. Audit logs
|
|
1350
|
+
`[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
1351
|
);
|
|
1252
1352
|
}
|
|
1253
1353
|
return next();
|
|
@@ -1258,6 +1358,109 @@ const immutabilityService = ({ strapi }) => {
|
|
|
1258
1358
|
};
|
|
1259
1359
|
return { createMiddleware, register: register2 };
|
|
1260
1360
|
};
|
|
1361
|
+
const HASHED_FIELDS = [
|
|
1362
|
+
"action",
|
|
1363
|
+
"contentType",
|
|
1364
|
+
"contentTypeDisplayName",
|
|
1365
|
+
"contentDocumentId",
|
|
1366
|
+
"contentId",
|
|
1367
|
+
"locale",
|
|
1368
|
+
"userId",
|
|
1369
|
+
"userEmail",
|
|
1370
|
+
"userName",
|
|
1371
|
+
"source",
|
|
1372
|
+
"outcome",
|
|
1373
|
+
"ipAddress",
|
|
1374
|
+
"userAgent",
|
|
1375
|
+
"requestId",
|
|
1376
|
+
"metadata",
|
|
1377
|
+
"changes",
|
|
1378
|
+
"before",
|
|
1379
|
+
"after",
|
|
1380
|
+
"createdAt",
|
|
1381
|
+
"prevHash"
|
|
1382
|
+
];
|
|
1383
|
+
const PAGE_SIZE = 500;
|
|
1384
|
+
const canonicalize = (value) => {
|
|
1385
|
+
if (value === void 0 || value === null) return "null";
|
|
1386
|
+
if (value instanceof Date) return JSON.stringify(value.toISOString());
|
|
1387
|
+
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
|
1388
|
+
if (typeof value === "object") {
|
|
1389
|
+
const entries = Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`);
|
|
1390
|
+
return `{${entries.join(",")}}`;
|
|
1391
|
+
}
|
|
1392
|
+
return JSON.stringify(value);
|
|
1393
|
+
};
|
|
1394
|
+
const toIso = (value) => {
|
|
1395
|
+
if (value === null || value === void 0) return null;
|
|
1396
|
+
if (value instanceof Date) return value.toISOString();
|
|
1397
|
+
const parsed = new Date(String(value));
|
|
1398
|
+
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toISOString();
|
|
1399
|
+
};
|
|
1400
|
+
const integrityService = ({ strapi }) => {
|
|
1401
|
+
const computeHash = (record) => {
|
|
1402
|
+
const material = {};
|
|
1403
|
+
for (const field of HASHED_FIELDS) {
|
|
1404
|
+
const value = record[field];
|
|
1405
|
+
material[field] = field === "createdAt" ? toIso(value) : value ?? null;
|
|
1406
|
+
}
|
|
1407
|
+
return createHash("sha256").update(canonicalize(material)).digest("hex");
|
|
1408
|
+
};
|
|
1409
|
+
const verify = async () => {
|
|
1410
|
+
const report = {
|
|
1411
|
+
ok: true,
|
|
1412
|
+
checked: 0,
|
|
1413
|
+
hashed: 0,
|
|
1414
|
+
legacy: 0,
|
|
1415
|
+
head: null,
|
|
1416
|
+
start: null,
|
|
1417
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1418
|
+
};
|
|
1419
|
+
let previous = null;
|
|
1420
|
+
let afterId = 0;
|
|
1421
|
+
for (; ; ) {
|
|
1422
|
+
const rows = await strapi.db.query(AUDIT_LOG_UID).findMany({
|
|
1423
|
+
where: { id: { $gt: afterId } },
|
|
1424
|
+
orderBy: { id: "asc" },
|
|
1425
|
+
limit: PAGE_SIZE
|
|
1426
|
+
});
|
|
1427
|
+
if (rows.length === 0) break;
|
|
1428
|
+
for (const row of rows) {
|
|
1429
|
+
report.checked += 1;
|
|
1430
|
+
afterId = row.id;
|
|
1431
|
+
if (!row.hash) {
|
|
1432
|
+
report.legacy += 1;
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
report.hashed += 1;
|
|
1436
|
+
const expected = computeHash(row);
|
|
1437
|
+
if (expected !== row.hash) {
|
|
1438
|
+
report.ok = false;
|
|
1439
|
+
report.brokenAt = {
|
|
1440
|
+
id: row.id,
|
|
1441
|
+
reason: "stored hash does not match the record content — a hashed field was modified"
|
|
1442
|
+
};
|
|
1443
|
+
return report;
|
|
1444
|
+
}
|
|
1445
|
+
if (previous === null) {
|
|
1446
|
+
report.start = { id: row.id };
|
|
1447
|
+
} else if (row.prevHash !== previous.hash) {
|
|
1448
|
+
report.ok = false;
|
|
1449
|
+
report.brokenAt = {
|
|
1450
|
+
id: row.id,
|
|
1451
|
+
reason: `prevHash does not match the hash of record #${previous.id} — a record between them was removed or inserted`
|
|
1452
|
+
};
|
|
1453
|
+
return report;
|
|
1454
|
+
}
|
|
1455
|
+
previous = { id: row.id, hash: row.hash };
|
|
1456
|
+
}
|
|
1457
|
+
if (rows.length < PAGE_SIZE) break;
|
|
1458
|
+
}
|
|
1459
|
+
report.head = previous;
|
|
1460
|
+
return report;
|
|
1461
|
+
};
|
|
1462
|
+
return { computeHash, canonicalize, verify, HASHED_FIELDS };
|
|
1463
|
+
};
|
|
1261
1464
|
const MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
1262
1465
|
const retentionService = ({ strapi }) => {
|
|
1263
1466
|
const getConfig = () => strapi.plugin("audit-log").service("config").resolve();
|
|
@@ -1266,11 +1469,32 @@ const retentionService = ({ strapi }) => {
|
|
|
1266
1469
|
const { retentionDays } = getConfig();
|
|
1267
1470
|
if (retentionDays <= 0) return 0;
|
|
1268
1471
|
const cutoff = cutoffDate(retentionDays);
|
|
1269
|
-
const
|
|
1472
|
+
const audit = strapi.plugin("audit-log").service("audit");
|
|
1473
|
+
const deleted = await audit.deleteOlderThan(cutoff);
|
|
1270
1474
|
if (deleted > 0) {
|
|
1271
1475
|
strapi.log.info(
|
|
1272
1476
|
`[audit-log] retention removed ${deleted} record(s) created before ${cutoff.toISOString()}.`
|
|
1273
1477
|
);
|
|
1478
|
+
await audit.record({
|
|
1479
|
+
action: "retention.purge",
|
|
1480
|
+
contentType: SUBJECTS.auditLog,
|
|
1481
|
+
contentTypeDisplayName: SUBJECT_DISPLAY_NAMES[SUBJECTS.auditLog] ?? "Audit log",
|
|
1482
|
+
contentDocumentId: null,
|
|
1483
|
+
contentId: null,
|
|
1484
|
+
locale: null,
|
|
1485
|
+
changes: null,
|
|
1486
|
+
before: null,
|
|
1487
|
+
after: null,
|
|
1488
|
+
outcome: "success",
|
|
1489
|
+
metadata: { deletedCount: deleted, cutoff: cutoff.toISOString(), retentionDays },
|
|
1490
|
+
source: "cron",
|
|
1491
|
+
userId: null,
|
|
1492
|
+
userEmail: null,
|
|
1493
|
+
userName: null,
|
|
1494
|
+
ipAddress: null,
|
|
1495
|
+
userAgent: null,
|
|
1496
|
+
requestId: null
|
|
1497
|
+
});
|
|
1274
1498
|
}
|
|
1275
1499
|
return deleted;
|
|
1276
1500
|
};
|
|
@@ -1826,8 +2050,10 @@ const services = {
|
|
|
1826
2050
|
audit: auditService,
|
|
1827
2051
|
config: configService,
|
|
1828
2052
|
context: contextService,
|
|
2053
|
+
correlation: correlationService,
|
|
1829
2054
|
diff: diffService,
|
|
1830
2055
|
immutability: immutabilityService,
|
|
2056
|
+
integrity: integrityService,
|
|
1831
2057
|
retention: retentionService,
|
|
1832
2058
|
security: securityService,
|
|
1833
2059
|
snapshot: snapshotService,
|
|
@@ -1844,6 +2070,7 @@ const index = {
|
|
|
1844
2070
|
services
|
|
1845
2071
|
};
|
|
1846
2072
|
export {
|
|
2073
|
+
ALL_MAINTENANCE_ACTIONS,
|
|
1847
2074
|
ALL_SECURITY_ACTIONS,
|
|
1848
2075
|
AUDIT_LOG_UID,
|
|
1849
2076
|
CONTENT_ACTIONS,
|