diff --git a/packages/core-audit/src/audit-logs-collection.test.ts b/packages/core-audit/src/audit-logs-collection.test.ts new file mode 100644 index 0000000..9fffdac --- /dev/null +++ b/packages/core-audit/src/audit-logs-collection.test.ts @@ -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 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); + }); +}); diff --git a/packages/core-audit/src/audit-logs-collection.ts b/packages/core-audit/src/audit-logs-collection.ts new file mode 100644 index 0000000..7e6123b --- /dev/null +++ b/packages/core-audit/src/audit-logs-collection.ts @@ -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" }, + ], +};