feat(core-audit): append-only auditLogs Payload collection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
47
packages/core-audit/src/audit-logs-collection.test.ts
Normal file
47
packages/core-audit/src/audit-logs-collection.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { auditLogsCollection } from "./audit-logs-collection";
|
||||||
|
|
||||||
|
describe("auditLogsCollection", () => {
|
||||||
|
it("uses slug 'audit-logs'", () => {
|
||||||
|
expect(auditLogsCollection.slug).toBe("audit-logs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is append-only (update: () => false)", () => {
|
||||||
|
const access = auditLogsCollection.access as Record<string, () => boolean>;
|
||||||
|
expect(access.update()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has the required fields", () => {
|
||||||
|
const fieldNames = (auditLogsCollection.fields as Array<{ name: string }>).map((f) => f.name);
|
||||||
|
// WHO
|
||||||
|
expect(fieldNames).toContain("actorId");
|
||||||
|
expect(fieldNames).toContain("actorType");
|
||||||
|
expect(fieldNames).toContain("actorRoles");
|
||||||
|
// WHAT
|
||||||
|
expect(fieldNames).toContain("action");
|
||||||
|
expect(fieldNames).toContain("resourceType");
|
||||||
|
expect(fieldNames).toContain("resourceId");
|
||||||
|
expect(fieldNames).toContain("changedFields");
|
||||||
|
// SCOPE
|
||||||
|
expect(fieldNames).toContain("scopeFeature");
|
||||||
|
expect(fieldNames).toContain("scopeEnvironment");
|
||||||
|
expect(fieldNames).toContain("scopeTenant");
|
||||||
|
// WHY
|
||||||
|
expect(fieldNames).toContain("reason");
|
||||||
|
expect(fieldNames).toContain("correlationId");
|
||||||
|
expect(fieldNames).toContain("requestId");
|
||||||
|
// FROM
|
||||||
|
expect(fieldNames).toContain("ipTruncated");
|
||||||
|
expect(fieldNames).toContain("userAgent");
|
||||||
|
// PII
|
||||||
|
expect(fieldNames).toContain("containsPii");
|
||||||
|
expect(fieldNames).toContain("piiCategories");
|
||||||
|
// OUTCOME
|
||||||
|
expect(fieldNames).toContain("outcome");
|
||||||
|
expect(fieldNames).toContain("errorCode");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables timestamps so createdAt maps to AuditEntry.at", () => {
|
||||||
|
expect(auditLogsCollection.timestamps).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
82
packages/core-audit/src/audit-logs-collection.ts
Normal file
82
packages/core-audit/src/audit-logs-collection.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only Payload collection for audit entries. Mounted by core-cms
|
||||||
|
* when this package is scaffolded (manual wiring step printed by generator).
|
||||||
|
*
|
||||||
|
* Access rules:
|
||||||
|
* - read: admins only
|
||||||
|
* - create: any authenticated context (filtered upstream by PayloadAuditLog)
|
||||||
|
* - update: NEVER (compliance requires append-only)
|
||||||
|
* - delete: admins only (used by the GDPR erasure path with overrideAccess)
|
||||||
|
*
|
||||||
|
* The `update: () => false` rule is the compliance backbone. The erasure
|
||||||
|
* path uses `overrideAccess: true` to bypass for pseudonymization — that's
|
||||||
|
* Payload's documented escape hatch for privileged operations.
|
||||||
|
*/
|
||||||
|
export const auditLogsCollection: CollectionConfig = {
|
||||||
|
slug: "audit-logs",
|
||||||
|
access: {
|
||||||
|
read: ({ req }) => {
|
||||||
|
const user = req.user as { roles?: string[] } | null | undefined;
|
||||||
|
return Array.isArray(user?.roles) && user.roles.includes("admin");
|
||||||
|
},
|
||||||
|
create: () => true,
|
||||||
|
update: () => false,
|
||||||
|
delete: ({ req }) => {
|
||||||
|
const user = req.user as { roles?: string[] } | null | undefined;
|
||||||
|
return Array.isArray(user?.roles) && user.roles.includes("admin");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamps: true,
|
||||||
|
fields: [
|
||||||
|
// WHO
|
||||||
|
{ name: "actorId", type: "text", required: true, index: true },
|
||||||
|
{
|
||||||
|
name: "actorType",
|
||||||
|
type: "select",
|
||||||
|
options: ["user", "system", "service"],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{ name: "actorRoles", type: "json", required: true },
|
||||||
|
|
||||||
|
// WHAT
|
||||||
|
{
|
||||||
|
name: "action",
|
||||||
|
type: "select",
|
||||||
|
options: ["VIEW", "CREATE", "UPDATE", "DELETE", "EXPORT", "PERMISSION_CHANGE"],
|
||||||
|
required: true,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
{ name: "resourceType", type: "text", required: true, index: true },
|
||||||
|
{ name: "resourceId", type: "text" },
|
||||||
|
{ name: "changedFields", type: "json" },
|
||||||
|
|
||||||
|
// SCOPE
|
||||||
|
{ name: "scopeFeature", type: "text", required: true, index: true },
|
||||||
|
{ name: "scopeEnvironment", type: "text", required: true },
|
||||||
|
{ name: "scopeTenant", type: "text", required: true, index: true },
|
||||||
|
|
||||||
|
// WHY
|
||||||
|
{ name: "reason", type: "text" },
|
||||||
|
{ name: "correlationId", type: "text", index: true },
|
||||||
|
{ name: "requestId", type: "text" },
|
||||||
|
|
||||||
|
// FROM
|
||||||
|
{ name: "ipTruncated", type: "text", required: true },
|
||||||
|
{ name: "userAgent", type: "text", required: true },
|
||||||
|
|
||||||
|
// PII
|
||||||
|
{ name: "containsPii", type: "checkbox", required: true },
|
||||||
|
{ name: "piiCategories", type: "json" },
|
||||||
|
|
||||||
|
// OUTCOME
|
||||||
|
{
|
||||||
|
name: "outcome",
|
||||||
|
type: "select",
|
||||||
|
options: ["success", "denied", "error"],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{ name: "errorCode", type: "text" },
|
||||||
|
],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user