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:
2026-07-13 06:07:25 +02:00
parent 4e4cd5fa7c
commit f2f24f7bfa
42 changed files with 2065 additions and 129 deletions

View File

@@ -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) ?? [];

View File

@@ -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"),
},