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.
- package/CHANGELOG.md +65 -0
- package/README.md +11 -11
- 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 +314 -92
- package/dist/server/index.mjs +314 -92
- 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 +9 -5
package/dist/server/index.js
CHANGED
|
@@ -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$
|
|
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$
|
|
506
|
-
const lastname = asString$
|
|
519
|
+
const firstname = asString$3(user.firstname);
|
|
520
|
+
const lastname = asString$3(user.lastname);
|
|
507
521
|
return {
|
|
508
|
-
userId: asString$
|
|
509
|
-
userEmail: asString$
|
|
510
|
-
userName: firstname && lastname ? `${firstname} ${lastname}` : asString$
|
|
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$
|
|
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$
|
|
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$
|
|
537
|
-
path: asString$
|
|
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$
|
|
543
|
-
routeType: asString$
|
|
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$
|
|
548
|
-
userAgent: asString$
|
|
549
|
-
requestId: asString$
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
};
|
|
@@ -940,7 +998,6 @@ const auditService = ({ strapi }) => {
|
|
|
940
998
|
flush,
|
|
941
999
|
find,
|
|
942
1000
|
findOne,
|
|
943
|
-
deleteOne,
|
|
944
1001
|
deleteOlderThan,
|
|
945
1002
|
getFilterOptions,
|
|
946
1003
|
buildWhere,
|
|
@@ -1048,6 +1105,7 @@ const configService = ({ strapi }) => {
|
|
|
1048
1105
|
auditSystemOperations: raw.auditSystemOperations !== false,
|
|
1049
1106
|
securityEvents: Array.isArray(raw.securityEvents) ? raw.securityEvents.map(String) : "*",
|
|
1050
1107
|
forwardToLogger: raw.forwardToLogger === true,
|
|
1108
|
+
correlationId: raw.correlationId !== false,
|
|
1051
1109
|
forwardLogLevel: ["debug", "info", "warn", "error"].includes(
|
|
1052
1110
|
raw.forwardLogLevel
|
|
1053
1111
|
) ? raw.forwardLogLevel : "info",
|
|
@@ -1106,16 +1164,16 @@ const EMPTY_CONTEXT = {
|
|
|
1106
1164
|
userAgent: null,
|
|
1107
1165
|
requestId: null
|
|
1108
1166
|
};
|
|
1109
|
-
const asString$
|
|
1167
|
+
const asString$2 = (value) => {
|
|
1110
1168
|
if (value === null || value === void 0) return null;
|
|
1111
1169
|
const text = String(value).trim();
|
|
1112
1170
|
return text.length === 0 ? null : text;
|
|
1113
1171
|
};
|
|
1114
1172
|
const resolveUserName = (user) => {
|
|
1115
|
-
const firstname = asString$
|
|
1116
|
-
const lastname = asString$
|
|
1173
|
+
const firstname = asString$2(user.firstname);
|
|
1174
|
+
const lastname = asString$2(user.lastname);
|
|
1117
1175
|
if (firstname && lastname) return `${firstname} ${lastname}`;
|
|
1118
|
-
return asString$
|
|
1176
|
+
return asString$2(user.username) ?? firstname ?? lastname ?? asString$2(user.email);
|
|
1119
1177
|
};
|
|
1120
1178
|
const contextService = ({ strapi }) => {
|
|
1121
1179
|
const runAs = (override, callback) => overrideStorage.run(override, callback);
|
|
@@ -1127,12 +1185,12 @@ const contextService = ({ strapi }) => {
|
|
|
1127
1185
|
}
|
|
1128
1186
|
};
|
|
1129
1187
|
const resolveSource = (ctx) => {
|
|
1130
|
-
const declared = asString$
|
|
1188
|
+
const declared = asString$2(ctx?.state?.auditSource);
|
|
1131
1189
|
if (declared) return declared;
|
|
1132
|
-
const routeType = asString$
|
|
1190
|
+
const routeType = asString$2(ctx?.state?.route?.info?.type);
|
|
1133
1191
|
if (routeType === "admin") return "admin";
|
|
1134
1192
|
if (routeType === "content-api") return "api";
|
|
1135
|
-
const strategyName = asString$
|
|
1193
|
+
const strategyName = asString$2(ctx?.state?.auth?.strategy?.name);
|
|
1136
1194
|
if (strategyName === "admin") return "admin";
|
|
1137
1195
|
if (strategyName === "api-token" || strategyName === "users-permissions") return "api";
|
|
1138
1196
|
return ctx ? "unknown" : "system";
|
|
@@ -1145,32 +1203,69 @@ const contextService = ({ strapi }) => {
|
|
|
1145
1203
|
return {
|
|
1146
1204
|
...EMPTY_CONTEXT,
|
|
1147
1205
|
source: override.source,
|
|
1148
|
-
userId: asString$
|
|
1149
|
-
userEmail: asString$
|
|
1150
|
-
userName: asString$
|
|
1206
|
+
userId: asString$2(override.userId),
|
|
1207
|
+
userEmail: asString$2(override.userEmail),
|
|
1208
|
+
userName: asString$2(override.userName)
|
|
1151
1209
|
};
|
|
1152
1210
|
}
|
|
1153
1211
|
const user = ctx.state?.user ?? null;
|
|
1154
1212
|
const headers = ctx.request?.headers ?? ctx.req?.headers ?? {};
|
|
1155
|
-
const requestId = asString$
|
|
1213
|
+
const requestId = asString$2(ctx.state?.requestId) ?? REQUEST_ID_HEADERS.map((header) => asString$2(headers[header])).find(Boolean) ?? null;
|
|
1156
1214
|
return {
|
|
1157
1215
|
// An explicit override wins even inside a request: a controller that
|
|
1158
1216
|
// deliberately labels its work knows more than the route type does.
|
|
1159
1217
|
source: override?.source ?? resolveSource(ctx),
|
|
1160
|
-
userId: asString$
|
|
1161
|
-
userEmail: asString$
|
|
1162
|
-
userName: override?.userName !== void 0 ? asString$
|
|
1218
|
+
userId: asString$2(override?.userId ?? user?.id),
|
|
1219
|
+
userEmail: asString$2(override?.userEmail ?? user?.email),
|
|
1220
|
+
userName: override?.userName !== void 0 ? asString$2(override.userName) : user ? resolveUserName(user) : null,
|
|
1163
1221
|
// `ctx.request.ip` honours Koa's `proxy` setting, so behind a correctly
|
|
1164
1222
|
// configured load balancer this is the client address rather than the
|
|
1165
1223
|
// proxy's. With `proxy` off it ignores `X-Forwarded-For` entirely, which
|
|
1166
1224
|
// is the safe default — a spoofable header is worse than no address.
|
|
1167
|
-
ipAddress: asString$
|
|
1168
|
-
userAgent: asString$
|
|
1225
|
+
ipAddress: asString$2(ctx.request?.ip ?? ctx.ip),
|
|
1226
|
+
userAgent: asString$2(headers["user-agent"]),
|
|
1169
1227
|
requestId
|
|
1170
1228
|
};
|
|
1171
1229
|
};
|
|
1172
1230
|
return { resolve, runAs };
|
|
1173
1231
|
};
|
|
1232
|
+
const MAX_ID_LENGTH = 128;
|
|
1233
|
+
const SAFE_ID = /^[A-Za-z0-9._:-]+$/;
|
|
1234
|
+
const asString$1 = (value) => {
|
|
1235
|
+
if (value === null || value === void 0) return null;
|
|
1236
|
+
const text = String(Array.isArray(value) ? value[0] : value).trim();
|
|
1237
|
+
return text.length === 0 ? null : text;
|
|
1238
|
+
};
|
|
1239
|
+
const correlationService = ({ strapi }) => {
|
|
1240
|
+
let registered = false;
|
|
1241
|
+
const inboundId = (ctx) => {
|
|
1242
|
+
const headers = ctx.request?.headers ?? {};
|
|
1243
|
+
for (const header of REQUEST_ID_HEADERS) {
|
|
1244
|
+
const value = asString$1(headers[header]);
|
|
1245
|
+
if (!value) continue;
|
|
1246
|
+
if (value.length <= MAX_ID_LENGTH && SAFE_ID.test(value)) return value;
|
|
1247
|
+
}
|
|
1248
|
+
return null;
|
|
1249
|
+
};
|
|
1250
|
+
const createMiddleware = () => {
|
|
1251
|
+
return async (ctx, next) => {
|
|
1252
|
+
const id = asString$1(ctx.state?.requestId) ?? inboundId(ctx) ?? node_crypto.randomUUID();
|
|
1253
|
+
ctx.state.requestId = id;
|
|
1254
|
+
try {
|
|
1255
|
+
ctx.set(REQUEST_ID_RESPONSE_HEADER, id);
|
|
1256
|
+
} catch {
|
|
1257
|
+
}
|
|
1258
|
+
await next();
|
|
1259
|
+
};
|
|
1260
|
+
};
|
|
1261
|
+
const register2 = () => {
|
|
1262
|
+
if (registered) return;
|
|
1263
|
+
if (!strapi.plugin("audit-log").service("config").resolve().correlationId) return;
|
|
1264
|
+
strapi.server.use(createMiddleware());
|
|
1265
|
+
registered = true;
|
|
1266
|
+
};
|
|
1267
|
+
return { createMiddleware, register: register2, inboundId };
|
|
1268
|
+
};
|
|
1174
1269
|
const DEFAULT_MAX_DEPTH = 6;
|
|
1175
1270
|
const DEFAULT_MAX_CHANGES = 500;
|
|
1176
1271
|
const diffService = ({ strapi }) => {
|
|
@@ -1243,13 +1338,13 @@ const diffService = ({ strapi }) => {
|
|
|
1243
1338
|
};
|
|
1244
1339
|
return { buildDiff };
|
|
1245
1340
|
};
|
|
1246
|
-
const FORBIDDEN_ACTIONS = /* @__PURE__ */ new Set(["update", "publish", "unpublish", "discardDraft", "clone"]);
|
|
1341
|
+
const FORBIDDEN_ACTIONS = /* @__PURE__ */ new Set(["update", "delete", "publish", "unpublish", "discardDraft", "clone"]);
|
|
1247
1342
|
const immutabilityService = ({ strapi }) => {
|
|
1248
1343
|
const createMiddleware = () => {
|
|
1249
1344
|
return async (ctx, next) => {
|
|
1250
1345
|
if (ctx.uid === AUDIT_LOG_UID && FORBIDDEN_ACTIONS.has(ctx.action)) {
|
|
1251
1346
|
throw new Error(
|
|
1252
|
-
`[audit-log] Audit records are immutable: "${ctx.action}" is not permitted on ${AUDIT_LOG_UID}. Audit logs
|
|
1347
|
+
`[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
1348
|
);
|
|
1254
1349
|
}
|
|
1255
1350
|
return next();
|
|
@@ -1260,6 +1355,109 @@ const immutabilityService = ({ strapi }) => {
|
|
|
1260
1355
|
};
|
|
1261
1356
|
return { createMiddleware, register: register2 };
|
|
1262
1357
|
};
|
|
1358
|
+
const HASHED_FIELDS = [
|
|
1359
|
+
"action",
|
|
1360
|
+
"contentType",
|
|
1361
|
+
"contentTypeDisplayName",
|
|
1362
|
+
"contentDocumentId",
|
|
1363
|
+
"contentId",
|
|
1364
|
+
"locale",
|
|
1365
|
+
"userId",
|
|
1366
|
+
"userEmail",
|
|
1367
|
+
"userName",
|
|
1368
|
+
"source",
|
|
1369
|
+
"outcome",
|
|
1370
|
+
"ipAddress",
|
|
1371
|
+
"userAgent",
|
|
1372
|
+
"requestId",
|
|
1373
|
+
"metadata",
|
|
1374
|
+
"changes",
|
|
1375
|
+
"before",
|
|
1376
|
+
"after",
|
|
1377
|
+
"createdAt",
|
|
1378
|
+
"prevHash"
|
|
1379
|
+
];
|
|
1380
|
+
const PAGE_SIZE = 500;
|
|
1381
|
+
const canonicalize = (value) => {
|
|
1382
|
+
if (value === void 0 || value === null) return "null";
|
|
1383
|
+
if (value instanceof Date) return JSON.stringify(value.toISOString());
|
|
1384
|
+
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
|
1385
|
+
if (typeof value === "object") {
|
|
1386
|
+
const entries = Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`);
|
|
1387
|
+
return `{${entries.join(",")}}`;
|
|
1388
|
+
}
|
|
1389
|
+
return JSON.stringify(value);
|
|
1390
|
+
};
|
|
1391
|
+
const toIso = (value) => {
|
|
1392
|
+
if (value === null || value === void 0) return null;
|
|
1393
|
+
if (value instanceof Date) return value.toISOString();
|
|
1394
|
+
const parsed = new Date(String(value));
|
|
1395
|
+
return Number.isNaN(parsed.getTime()) ? String(value) : parsed.toISOString();
|
|
1396
|
+
};
|
|
1397
|
+
const integrityService = ({ strapi }) => {
|
|
1398
|
+
const computeHash = (record) => {
|
|
1399
|
+
const material = {};
|
|
1400
|
+
for (const field of HASHED_FIELDS) {
|
|
1401
|
+
const value = record[field];
|
|
1402
|
+
material[field] = field === "createdAt" ? toIso(value) : value ?? null;
|
|
1403
|
+
}
|
|
1404
|
+
return node_crypto.createHash("sha256").update(canonicalize(material)).digest("hex");
|
|
1405
|
+
};
|
|
1406
|
+
const verify = async () => {
|
|
1407
|
+
const report = {
|
|
1408
|
+
ok: true,
|
|
1409
|
+
checked: 0,
|
|
1410
|
+
hashed: 0,
|
|
1411
|
+
legacy: 0,
|
|
1412
|
+
head: null,
|
|
1413
|
+
start: null,
|
|
1414
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1415
|
+
};
|
|
1416
|
+
let previous = null;
|
|
1417
|
+
let afterId = 0;
|
|
1418
|
+
for (; ; ) {
|
|
1419
|
+
const rows = await strapi.db.query(AUDIT_LOG_UID).findMany({
|
|
1420
|
+
where: { id: { $gt: afterId } },
|
|
1421
|
+
orderBy: { id: "asc" },
|
|
1422
|
+
limit: PAGE_SIZE
|
|
1423
|
+
});
|
|
1424
|
+
if (rows.length === 0) break;
|
|
1425
|
+
for (const row of rows) {
|
|
1426
|
+
report.checked += 1;
|
|
1427
|
+
afterId = row.id;
|
|
1428
|
+
if (!row.hash) {
|
|
1429
|
+
report.legacy += 1;
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
report.hashed += 1;
|
|
1433
|
+
const expected = computeHash(row);
|
|
1434
|
+
if (expected !== row.hash) {
|
|
1435
|
+
report.ok = false;
|
|
1436
|
+
report.brokenAt = {
|
|
1437
|
+
id: row.id,
|
|
1438
|
+
reason: "stored hash does not match the record content — a hashed field was modified"
|
|
1439
|
+
};
|
|
1440
|
+
return report;
|
|
1441
|
+
}
|
|
1442
|
+
if (previous === null) {
|
|
1443
|
+
report.start = { id: row.id };
|
|
1444
|
+
} else if (row.prevHash !== previous.hash) {
|
|
1445
|
+
report.ok = false;
|
|
1446
|
+
report.brokenAt = {
|
|
1447
|
+
id: row.id,
|
|
1448
|
+
reason: `prevHash does not match the hash of record #${previous.id} — a record between them was removed or inserted`
|
|
1449
|
+
};
|
|
1450
|
+
return report;
|
|
1451
|
+
}
|
|
1452
|
+
previous = { id: row.id, hash: row.hash };
|
|
1453
|
+
}
|
|
1454
|
+
if (rows.length < PAGE_SIZE) break;
|
|
1455
|
+
}
|
|
1456
|
+
report.head = previous;
|
|
1457
|
+
return report;
|
|
1458
|
+
};
|
|
1459
|
+
return { computeHash, canonicalize, verify, HASHED_FIELDS };
|
|
1460
|
+
};
|
|
1263
1461
|
const MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
1264
1462
|
const retentionService = ({ strapi }) => {
|
|
1265
1463
|
const getConfig = () => strapi.plugin("audit-log").service("config").resolve();
|
|
@@ -1268,11 +1466,32 @@ const retentionService = ({ strapi }) => {
|
|
|
1268
1466
|
const { retentionDays } = getConfig();
|
|
1269
1467
|
if (retentionDays <= 0) return 0;
|
|
1270
1468
|
const cutoff = cutoffDate(retentionDays);
|
|
1271
|
-
const
|
|
1469
|
+
const audit = strapi.plugin("audit-log").service("audit");
|
|
1470
|
+
const deleted = await audit.deleteOlderThan(cutoff);
|
|
1272
1471
|
if (deleted > 0) {
|
|
1273
1472
|
strapi.log.info(
|
|
1274
1473
|
`[audit-log] retention removed ${deleted} record(s) created before ${cutoff.toISOString()}.`
|
|
1275
1474
|
);
|
|
1475
|
+
await audit.record({
|
|
1476
|
+
action: "retention.purge",
|
|
1477
|
+
contentType: SUBJECTS.auditLog,
|
|
1478
|
+
contentTypeDisplayName: SUBJECT_DISPLAY_NAMES[SUBJECTS.auditLog] ?? "Audit log",
|
|
1479
|
+
contentDocumentId: null,
|
|
1480
|
+
contentId: null,
|
|
1481
|
+
locale: null,
|
|
1482
|
+
changes: null,
|
|
1483
|
+
before: null,
|
|
1484
|
+
after: null,
|
|
1485
|
+
outcome: "success",
|
|
1486
|
+
metadata: { deletedCount: deleted, cutoff: cutoff.toISOString(), retentionDays },
|
|
1487
|
+
source: "cron",
|
|
1488
|
+
userId: null,
|
|
1489
|
+
userEmail: null,
|
|
1490
|
+
userName: null,
|
|
1491
|
+
ipAddress: null,
|
|
1492
|
+
userAgent: null,
|
|
1493
|
+
requestId: null
|
|
1494
|
+
});
|
|
1276
1495
|
}
|
|
1277
1496
|
return deleted;
|
|
1278
1497
|
};
|
|
@@ -1828,8 +2047,10 @@ const services = {
|
|
|
1828
2047
|
audit: auditService,
|
|
1829
2048
|
config: configService,
|
|
1830
2049
|
context: contextService,
|
|
2050
|
+
correlation: correlationService,
|
|
1831
2051
|
diff: diffService,
|
|
1832
2052
|
immutability: immutabilityService,
|
|
2053
|
+
integrity: integrityService,
|
|
1833
2054
|
retention: retentionService,
|
|
1834
2055
|
security: securityService,
|
|
1835
2056
|
snapshot: snapshotService,
|
|
@@ -1845,6 +2066,7 @@ const index = {
|
|
|
1845
2066
|
routes,
|
|
1846
2067
|
services
|
|
1847
2068
|
};
|
|
2069
|
+
exports.ALL_MAINTENANCE_ACTIONS = ALL_MAINTENANCE_ACTIONS;
|
|
1848
2070
|
exports.ALL_SECURITY_ACTIONS = ALL_SECURITY_ACTIONS;
|
|
1849
2071
|
exports.AUDIT_LOG_UID = AUDIT_LOG_UID;
|
|
1850
2072
|
exports.CONTENT_ACTIONS = CONTENT_ACTIONS;
|