fix(compliance): port DSR/consent/audit/retention audit fixes
Ports the upstream compliance-core audit fixes onto the kept core-dsr, core-consent, core-audit, core-cms and core-shared packages: - core-dsr: scope DSR operations to the caller's own subject (A11); include the subject's audit trail in exports; resolve the per-request binding from ctx instead of a throwing singleton proxy. - core-consent: build the consent router from the shared superjson transformer (A10); merge per-category on persist instead of replacing; validate migrated categories against an allow-list. - core-audit: keyed 128-bit pseudonyms + salted DSR certificate; add the audit-logs collection and the req-scoped GDPR audit-erasure afterDelete hook (A6). - core-shared: grace-purge soft-deleted rows via a retention-purge task + tombstone field and boot registration (A2/A3); add the require-authenticated tRPC helper; derive clientIp + resolve the session user in createTrpcContext (B2/A11). - core-cms: register audit-logs, wire the audit-erasure hook and retention-purge tasks; adapted to the clean-slate collection set (users only — no workspaces feature on this branch). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
@@ -17,6 +17,8 @@
|
||||
"@payloadcms/db-postgres": "^3.14.0",
|
||||
"@payloadcms/richtext-lexical": "^3.14.0",
|
||||
"@repo/auth": "workspace:*",
|
||||
"@repo/core-audit": "workspace:*",
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"payload": "^3.14.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,6 +8,39 @@ describe("payloadConfig composition", () => {
|
||||
expect(slugs).toEqual(expect.arrayContaining(["users"]));
|
||||
});
|
||||
|
||||
it("adds the deletedAt tombstone to postDeletion collections (A2)", async () => {
|
||||
const resolved = await config;
|
||||
for (const slug of ["users"]) {
|
||||
const collection = resolved.collections?.find((c) => c.slug === slug);
|
||||
const names =
|
||||
collection?.fields.map((f) => (f as { name?: string }).name) ?? [];
|
||||
expect(names, `collection ${slug}`).toContain("deletedAt");
|
||||
}
|
||||
});
|
||||
|
||||
it("registers a retention purge task per purgeSchedule collection (A3)", async () => {
|
||||
const resolved = await config;
|
||||
const taskSlugs =
|
||||
(
|
||||
resolved.jobs as { tasks?: Array<{ slug: string }> } | undefined
|
||||
)?.tasks?.map((t) => t.slug) ?? [];
|
||||
expect(taskSlugs).toEqual(
|
||||
expect.arrayContaining(["retention-purge--users"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("registers the audit-logs collection (A6)", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.collections?.map((c) => c.slug) ?? [];
|
||||
expect(slugs).toContain("audit-logs");
|
||||
});
|
||||
|
||||
it("wires the audit erasure afterDelete hook on users (A6)", async () => {
|
||||
const resolved = await config;
|
||||
const users = resolved.collections?.find((c) => c.slug === "users");
|
||||
expect(users?.hooks?.afterDelete?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("registers no feature globals (none remain)", async () => {
|
||||
const resolved = await config;
|
||||
const slugs = resolved.globals?.map((g) => g.slug) ?? [];
|
||||
|
||||
@@ -4,14 +4,45 @@ import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { users } from "@repo/auth/cms";
|
||||
import {
|
||||
withRetentionTombstone,
|
||||
buildRetentionPurgeTask,
|
||||
} from "@repo/core-shared/payload";
|
||||
import {
|
||||
auditLogsCollection,
|
||||
createReqScopedAuditErasureHook,
|
||||
} from "@repo/core-audit";
|
||||
import { users as usersBase } from "@repo/auth/cms";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
// GDPR audit erasure (audit finding A6): when a users row is hard-deleted
|
||||
// (admin expunge, DSR cascade-hard, retention purge), pseudonymize that
|
||||
// subject's audit-log entries so the trail keeps its shape without PII linkage.
|
||||
const users = {
|
||||
...usersBase,
|
||||
hooks: {
|
||||
...usersBase.hooks,
|
||||
afterDelete: [
|
||||
...(usersBase.hooks?.afterDelete ?? []),
|
||||
createReqScopedAuditErasureHook(),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Collections declaring custom.retention.postDeletion get the soft-delete
|
||||
// tombstone field (`deletedAt`) so the DSR soft delete can stamp rows and the
|
||||
// retention purge job can grace-purge them (audit finding A2).
|
||||
const collections = [
|
||||
...[users].map(withRetentionTombstone),
|
||||
// Local audit sink (A6) — required for PayloadAuditLog.record() to work.
|
||||
auditLogsCollection,
|
||||
];
|
||||
|
||||
export default buildConfig({
|
||||
editor: lexicalEditor(),
|
||||
collections: [users],
|
||||
collections,
|
||||
globals: [],
|
||||
secret: process.env.PAYLOAD_SECRET || "default-secret-change-me",
|
||||
db: postgresAdapter({
|
||||
@@ -21,6 +52,14 @@ export default buildConfig({
|
||||
"postgresql://postgres:postgres@localhost:5433/template",
|
||||
},
|
||||
}),
|
||||
jobs: {
|
||||
// Task definitions for the retention purge (audit finding A3):
|
||||
// registerRetentionPurgeJobs (called from bindAllProduction) enqueues
|
||||
// `retention-purge--<slug>` jobs; these definitions let Payload run them.
|
||||
tasks: collections
|
||||
.filter((c) => Boolean(c.custom?.retention?.purgeSchedule))
|
||||
.map((c) => buildRetentionPurgeTask(c.slug)) as never,
|
||||
},
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, "generated-types.ts"),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user