From 413ac0273c30a84218720fb1f20e98cfc796b503 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Fri, 10 Jul 2026 18:02:43 +0200 Subject: [PATCH] feat(core-shared): grace-purge soft-deleted rows + boot registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retention purge job gated its whole body on activeRetention while every collection declares only postDeletion, and no app ever called registerRetentionPurgeJobs — retention was dead end to end (audit findings A2 + A3). The DSR soft delete now stamps a deletedAt tombstone on postDeletion collections (kept distinct from processingRestrictedAt so an Art. 18 restriction never feeds the purge), the job grace-purges tombstoned rows past postDeletion.duration with the declared action, core-cms injects the tombstone field + Payload task definitions, and bindAllProduction enqueues the first purge cycle at boot. Co-Authored-By: Claude Fable 5 --- .../src/server/bind-production.test.ts | 40 +++- apps/web-next/src/server/bind-production.ts | 15 ++ packages/core-cms/package.json | 2 + packages/core-cms/src/payload.config.test.ts | 29 ++- packages/core-cms/src/payload.config.ts | 19 +- .../src/__tests__/payload-data-delete.test.ts | 95 +++++++++ packages/core-dsr/src/payload-data-delete.ts | 19 +- packages/core-shared/src/payload/index.ts | 9 + .../retention-purge.job.test.ts | 183 ++++++++++++++++++ .../retention-purge/retention-purge.job.ts | 136 ++++++++----- .../src/payload/retention-purge/task.test.ts | 61 ++++++ .../src/payload/retention-purge/task.ts | 42 ++++ .../payload/retention-purge/tombstone.test.ts | 83 ++++++++ .../src/payload/retention-purge/tombstone.ts | 70 +++++++ pnpm-lock.yaml | 6 + 15 files changed, 755 insertions(+), 54 deletions(-) create mode 100644 packages/core-shared/src/payload/retention-purge/task.test.ts create mode 100644 packages/core-shared/src/payload/retention-purge/task.ts create mode 100644 packages/core-shared/src/payload/retention-purge/tombstone.test.ts create mode 100644 packages/core-shared/src/payload/retention-purge/tombstone.ts diff --git a/apps/web-next/src/server/bind-production.test.ts b/apps/web-next/src/server/bind-production.test.ts index 8751115..e1dedf1 100644 --- a/apps/web-next/src/server/bind-production.test.ts +++ b/apps/web-next/src/server/bind-production.test.ts @@ -4,9 +4,34 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // without a pseudonym salt (by design). Provide one for the whole suite. process.env.AUDIT_PSEUDONYM_SALT ??= "test-salt-not-for-production"; -vi.mock("@repo/core-cms", () => ({ default: Promise.resolve({}) })); +// Hoisted so the payload mock and assertions share the same jobs.queue spy. +const { jobsQueueMock } = vi.hoisted(() => ({ + jobsQueueMock: vi.fn(async () => ({ id: "job-1" })), +})); + +vi.mock("@repo/core-cms", () => ({ + default: Promise.resolve({ + collections: [ + { + slug: "users", + custom: { + retention: { + purgeSchedule: "daily", + postDeletion: { + duration: "P30D", + trigger: "after-deletion", + action: "hard-delete", + }, + }, + }, + fields: [], + }, + { slug: "pages", fields: [] }, + ], + }), +})); vi.mock("payload", () => ({ - getPayload: vi.fn(async () => ({ jobs: { queue: vi.fn() } })), + getPayload: vi.fn(async () => ({ jobs: { queue: jobsQueueMock } })), })); vi.mock("@repo/blog/di/bind-production", () => ({ bindProductionBlog: vi.fn(), @@ -73,6 +98,17 @@ describe("bindAllProduction", () => { expect(bindProductionMedia).toHaveBeenCalledOnce(); }); + it("registers retention purge jobs at production boot (A3)", async () => { + const { bindAllProduction } = await import("./bind-production"); + await bindAllProduction(); + + // one enqueue per collection declaring custom.retention.purgeSchedule + expect(jobsQueueMock).toHaveBeenCalledTimes(1); + expect(jobsQueueMock).toHaveBeenCalledWith( + expect.objectContaining({ task: "retention-purge--users" }), + ); + }); + it("is idempotent via bindAll — second call does not re-bind", async () => { vi.stubEnv("NODE_ENV", "production"); const { bindAll } = await import("./bind-production"); diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index e9c4f71..5928c32 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -21,6 +21,10 @@ import { NoopRateLimit, type RateLimitBudget, } from "@repo/core-shared/rate-limit"; +import { + registerRetentionPurgeJobs, + type GetPayloadFn, +} from "@repo/core-shared/payload"; import { bindAudit, type IAuditLog } from "@repo/core-audit"; import { bindProductionConsent, @@ -192,6 +196,17 @@ export async function bindAllProduction(): Promise { bindProductionMarketingPages(ctx); bindProductionNavigation(ctx); bindProductionMedia(ctx); + + // Kick off the retention purge cycle (audit finding A3): enqueue the first + // `retention-purge--` job for every collection declaring a + // custom.retention.purgeSchedule. The task definitions live in the Payload + // config (core-cms jobs.tasks); each run re-enqueues the next cycle. + await registerRetentionPurgeJobs({ + queue, + config: resolvedConfig, + getPayload: getPayload as unknown as GetPayloadFn, + auditLog, + }); } /** diff --git a/packages/core-cms/package.json b/packages/core-cms/package.json index f2d4d38..d892c2b 100644 --- a/packages/core-cms/package.json +++ b/packages/core-cms/package.json @@ -18,6 +18,8 @@ "@payloadcms/richtext-lexical": "^3.14.0", "@repo/auth": "workspace:*", "@repo/blog": "workspace:*", + "@repo/core-audit": "workspace:*", + "@repo/core-shared": "workspace:*", "@repo/marketing-pages": "workspace:*", "@repo/media": "workspace:*", "@repo/navigation": "workspace:*", diff --git a/packages/core-cms/src/payload.config.test.ts b/packages/core-cms/src/payload.config.test.ts index 0315a08..9d3c6b3 100644 --- a/packages/core-cms/src/payload.config.test.ts +++ b/packages/core-cms/src/payload.config.test.ts @@ -10,11 +10,34 @@ describe("payloadConfig composition", () => { ); }); + it("adds the deletedAt tombstone to postDeletion collections (A2)", async () => { + const resolved = await config; + for (const slug of ["users", "articles", "media"]) { + 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", + "retention-purge--articles", + "retention-purge--media", + ]), + ); + }); + it("registers all feature globals", async () => { const resolved = await config; const slugs = resolved.globals?.map((g) => g.slug) ?? []; - expect(slugs).toEqual( - expect.arrayContaining(["site-settings", "header"]), - ); + expect(slugs).toEqual(expect.arrayContaining(["site-settings", "header"])); }); }); diff --git a/packages/core-cms/src/payload.config.ts b/packages/core-cms/src/payload.config.ts index 4e32fbe..86c68f5 100644 --- a/packages/core-cms/src/payload.config.ts +++ b/packages/core-cms/src/payload.config.ts @@ -4,6 +4,10 @@ import { lexicalEditor } from "@payloadcms/richtext-lexical"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + withRetentionTombstone, + buildRetentionPurgeTask, +} from "@repo/core-shared/payload"; import { users } from "@repo/auth/cms"; import { articles } from "@repo/blog/cms"; import { media } from "@repo/media/cms"; @@ -13,9 +17,14 @@ import { header } from "@repo/navigation/cms"; const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); +// 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, articles, pages, media].map(withRetentionTombstone); + export default buildConfig({ editor: lexicalEditor(), - collections: [users, articles, pages, media], + collections, globals: [siteSettings, header], secret: process.env.PAYLOAD_SECRET || "default-secret-change-me", db: postgresAdapter({ @@ -25,6 +34,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--` 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"), }, diff --git a/packages/core-dsr/src/__tests__/payload-data-delete.test.ts b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts index 01d6c7b..d8596d5 100644 --- a/packages/core-dsr/src/__tests__/payload-data-delete.test.ts +++ b/packages/core-dsr/src/__tests__/payload-data-delete.test.ts @@ -97,6 +97,101 @@ describe("PayloadDataDelete", () => { ); }); + it("stamps the deletedAt tombstone when the collection declares postDeletion retention (A2)", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { email: { exportable: true } }, + retention: { + purgeSchedule: "daily", + postDeletion: { + duration: "P30D", + trigger: "after-deletion", + action: "hard-delete", + }, + }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "alice", email: "a@ex.com" }], + }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + await deleter.deleteSubjectData("alice", "soft"); + + expect(mockPayload.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + deletedAt: expect.any(String), + processingRestrictedAt: expect.any(String), + }), + }), + ); + }); + + it("does NOT stamp deletedAt without a postDeletion retention policy", async () => { + const config = makeMockConfig([ + { + slug: "users", + custom: { + subject: { field: "id", kind: "self" }, + pii: { email: { exportable: true } }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "alice", email: "a@ex.com" }], + }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + await deleter.deleteSubjectData("alice", "soft"); + + const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record< + string, + unknown + >; + expect(updateData).not.toHaveProperty("deletedAt"); + }); + + it("stamps deletedAt on owner rows of postDeletion collections", async () => { + const config = makeMockConfig([ + { + slug: "orders", + custom: { + subject: { field: "userId", kind: "owner" }, + pii: { shippingAddress: { exportable: true } }, + retention: { + purgeSchedule: "daily", + postDeletion: { + duration: "P90D", + trigger: "after-deletion", + action: "hard-delete", + }, + }, + }, + }, + ]); + + mockPayload.find.mockResolvedValue({ + docs: [{ id: "o1", userId: "alice", shippingAddress: "X" }], + }); + + const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload); + await deleter.deleteSubjectData("alice", "soft"); + + const updateData = mockPayload.update.mock.calls[0]?.[0]?.data as Record< + string, + unknown + >; + expect(updateData["deletedAt"]).toEqual(expect.any(String)); + expect(updateData).not.toHaveProperty("processingRestrictedAt"); + }); + it("owner role: does NOT set processingRestrictedAt", async () => { const config = makeMockConfig([ { diff --git a/packages/core-dsr/src/payload-data-delete.ts b/packages/core-dsr/src/payload-data-delete.ts index 6513a13..096aa42 100644 --- a/packages/core-dsr/src/payload-data-delete.ts +++ b/packages/core-dsr/src/payload-data-delete.ts @@ -3,6 +3,10 @@ import type { SanitizedConfig } from "payload"; import { randomUUID } from "node:crypto"; import { createHash } from "node:crypto"; import type { AuditLogProtocol } from "@repo/core-shared/di"; +import { + RETENTION_TOMBSTONE_FIELD, + hasPostDeletionPolicy, +} from "@repo/core-shared/payload"; import type { IDataDelete } from "./data-delete.interface"; import type { DeletionMode, @@ -142,6 +146,7 @@ export class PayloadDataDelete implements IDataDelete { subjectId, correlationId, affected, + hasPostDeletionPolicy(collection), ); } else { await this.processReferenceRows( @@ -175,6 +180,7 @@ export class PayloadDataDelete implements IDataDelete { subjectId: string, correlationId: string, affected: DeletionAffected[], + postDeletionPolicy: boolean, ): Promise { const piiMeta = custom.pii ?? {}; const exportableFields = Object.entries(piiMeta) @@ -183,10 +189,17 @@ export class PayloadDataDelete implements IDataDelete { if (mode === "soft") { const kind = custom.subject?.kind; + const nowIso = new Date().toISOString(); const extraData: Record = - kind === "self" - ? { processingRestrictedAt: new Date().toISOString() } - : {}; + kind === "self" ? { processingRestrictedAt: nowIso } : {}; + if (postDeletionPolicy) { + // Soft-delete tombstone (A2): collections with a + // custom.retention.postDeletion policy get stamped so the retention + // purge job can hard-delete/pseudonymize them once the grace period + // elapses. Kept separate from processingRestrictedAt — an Art. 18 + // restriction alone must never trigger the purge. + extraData[RETENTION_TOMBSTONE_FIELD] = nowIso; + } await softRedactOwnerRows( payload, slug, diff --git a/packages/core-shared/src/payload/index.ts b/packages/core-shared/src/payload/index.ts index 900c133..1df24e5 100644 --- a/packages/core-shared/src/payload/index.ts +++ b/packages/core-shared/src/payload/index.ts @@ -25,6 +25,15 @@ export { buildPurgeHandler, registerRetentionPurgeJobs, } from "./retention-purge/retention-purge.job"; +export { + RETENTION_TOMBSTONE_FIELD, + hasPostDeletionPolicy, + withRetentionTombstone, +} from "./retention-purge/tombstone"; +export { + buildRetentionPurgeTask, + type RetentionPurgeTask, +} from "./retention-purge/task"; export type { PayloadPurgeApi, GetPayloadFn, diff --git a/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts b/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts index 942a852..4bd6cce 100644 --- a/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts +++ b/packages/core-shared/src/payload/retention-purge/retention-purge.job.test.ts @@ -377,6 +377,189 @@ describe("buildPurgeHandler — hard-delete", () => { }); }); +// ---- buildPurgeHandler — postDeletion grace purge (A2) ---- + +describe("buildPurgeHandler — postDeletion grace purge", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function postDeletionOnlyConfig( + action: "hard-delete" | "pseudonymize", + fields: MockCollection["fields"] = [], + ) { + return makeConfig([ + { + slug: "users", + custom: { + retention: { + purgeSchedule: "daily", + postDeletion: { + action, + duration: "P30D", + trigger: "after-deletion", + }, + }, + }, + fields, + }, + ]); + } + + it("queries soft-deleted rows by the deletedAt tombstone cutoff", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([]); + const deps: RetentionPurgeJobDeps = { + queue, + config: postDeletionOnlyConfig("hard-delete"), + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("users", deps)(); + + expect(payload.find).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "users", + where: { + deletedAt: { + less_than: new Date( + Date.now() - parseDurationMs("P30D"), + ).toISOString(), + }, + }, + }), + ); + }); + + it("hard-deletes rows whose tombstone is past the grace period (postDeletion-only collection)", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([ + { id: "row-old", deletedAt: "2025-11-01T00:00:00.000Z" }, // 61 days + { id: "row-fresh", deletedAt: "2025-12-25T00:00:00.000Z" }, // 7 days + { id: "row-live" }, // never soft-deleted + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config: postDeletionOnlyConfig("hard-delete"), + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("users", deps)(); + + expect(payload.delete).toHaveBeenCalledTimes(1); + expect(payload.delete).toHaveBeenCalledWith({ + collection: "users", + id: "row-old", + overrideAccess: true, + }); + }); + + it("pseudonymizes PII fields when postDeletion.action is pseudonymize", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([ + { id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config: postDeletionOnlyConfig("pseudonymize", [ + { name: "email", custom: { pii: { category: "contact-email" } } }, + { name: "status" }, + ]), + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("users", deps)(); + + expect(payload.update).toHaveBeenCalledWith({ + collection: "users", + id: "row-old", + data: { email: null }, + overrideAccess: true, + }); + expect(payload.delete).not.toHaveBeenCalled(); + }); + + it("records a retention-policy audit entry per purged row", async () => { + const { queue } = makeQueue(); + const { auditLog, record } = makeAuditLog(); + const payload = makePayloadApi([ + { id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config: postDeletionOnlyConfig("hard-delete"), + getPayload: vi.fn().mockResolvedValue(payload), + auditLog, + }; + await buildPurgeHandler("users", deps)(); + + expect(record).toHaveBeenCalledTimes(1); + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + action: "DELETE", + reason: "retention-policy", + resource: { type: "users", id: "row-old" }, + }), + ); + }); + + it("still runs activeRetention alongside postDeletion", async () => { + const { queue } = makeQueue(); + const payload = makePayloadApi([ + { id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" }, + ]); + const config = makeConfig([ + { + slug: "users", + custom: { + retention: { + purgeSchedule: "daily", + activeRetention: { duration: "P2Y", trigger: "from-creation" }, + postDeletion: { + action: "hard-delete", + duration: "P30D", + trigger: "after-deletion", + }, + }, + }, + fields: [], + }, + ]); + const deps: RetentionPurgeJobDeps = { + queue, + config, + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("users", deps)(); + + // one find per branch: createdAt (activeRetention) + deletedAt (postDeletion) + expect(payload.find).toHaveBeenCalledTimes(2); + // the fake returns the tombstoned row for both branches: the + // activeRetention branch deletes it by date, the postDeletion branch by + // tombstone — 2 delete calls for the same doc through different policies. + expect(payload.delete).toHaveBeenCalledTimes(2); + }); + + it("re-enqueues the next cycle for postDeletion-only collections", async () => { + const { queue, enqueue } = makeQueue(); + const payload = makePayloadApi([]); + const deps: RetentionPurgeJobDeps = { + queue, + config: postDeletionOnlyConfig("hard-delete"), + getPayload: vi.fn().mockResolvedValue(payload), + }; + await buildPurgeHandler("users", deps)(); + + expect(enqueue).toHaveBeenCalledWith( + "retention-purge--users", + {}, + { runAt: new Date("2026-01-02T00:00:00.000Z") }, + ); + }); +}); + // ---- buildPurgeHandler — pseudonymize branch ---- describe("buildPurgeHandler — pseudonymize", () => { diff --git a/packages/core-shared/src/payload/retention-purge/retention-purge.job.ts b/packages/core-shared/src/payload/retention-purge/retention-purge.job.ts index 5405fc4..ec57dab 100644 --- a/packages/core-shared/src/payload/retention-purge/retention-purge.job.ts +++ b/packages/core-shared/src/payload/retention-purge/retention-purge.job.ts @@ -1,6 +1,7 @@ import type { SanitizedConfig } from "payload"; import type { IJobQueue } from "../../jobs/job-queue.interface"; import type { AuditLogProtocol } from "../../di/bind-protocols"; +import { RETENTION_TOMBSTONE_FIELD } from "./tombstone"; /** * Minimal Payload API surface needed by the retention purge job. @@ -70,13 +71,19 @@ export function scheduleDelayMs(schedule: string): number { return MS_PER_DAY; // "daily" and cron fallback } +type RetentionActionName = "pseudonymize" | "hard-delete"; + /** * Build the purge handler for a single collection. The returned async function * is intended to be registered as a Payload job task handler. * * Per run: - * 1. Query rows past their activeRetention period. - * 2. Apply postDeletion.action (pseudonymize | hard-delete). + * 1. activeRetention (when declared): query rows past the active period + * (createdAt/updatedAt) and apply the retention action. + * 2. postDeletion (when declared, audit finding A2): query soft-deleted rows + * — tombstoned with `deletedAt` by the DSR soft-delete path — whose + * tombstone is older than postDeletion.duration, and apply + * postDeletion.action (pseudonymize | hard-delete). * 3. Emit one audit entry per processed row (skipped when auditLog is absent). * 4. Re-enqueue itself for the next purge cycle. * @@ -103,6 +110,59 @@ export function buildPurgeHandler( const taskSlug = `retention-purge--${collectionSlug}`; + async function applyAction( + payload: PayloadPurgeApi, + doc: Record, + action: RetentionActionName, + reason: string, + ): Promise { + const id = doc["id"] as string | number; + + if (action === "pseudonymize") { + const piiFields: Record = {}; + for (const field of collection.fields) { + const f = field as { name?: string; custom?: { pii?: unknown } }; + if (f.name && f.custom?.pii) { + piiFields[f.name] = null; + } + } + if (Object.keys(piiFields).length > 0) { + await payload.update({ + collection: collectionSlug, + id, + data: piiFields, + overrideAccess: true, + }); + } + } else { + await payload.delete({ + collection: collectionSlug, + id, + overrideAccess: true, + }); + } + + if (auditLog) { + await auditLog.record({ + actorId: "system", + actorType: "system", + actorRoles: [], + action: "DELETE", + resource: { type: collectionSlug, id: String(id) }, + at: new Date(), + scope: { + feature: "core-shared", + environment: process.env["NODE_ENV"] ?? "production", + tenant: "default", + }, + reason, + from: { ipTruncated: "system", userAgent: "background-job" }, + containsPii: false, + outcome: "success", + }); + } + } + return async () => { const payload = await getPayload({ config }); const now = Date.now(); @@ -123,51 +183,37 @@ export function buildPurgeHandler( const action = retention.postDeletion?.action ?? "hard-delete"; for (const doc of docs) { - const id = doc["id"] as string | number; + await applyAction(payload, doc, action, "retention-policy"); + } + } - if (action === "pseudonymize") { - const piiFields: Record = {}; - for (const field of collection.fields) { - const f = field as { name?: string; custom?: { pii?: unknown } }; - if (f.name && f.custom?.pii) { - piiFields[f.name] = null; - } - } - if (Object.keys(piiFields).length > 0) { - await payload.update({ - collection: collectionSlug, - id, - data: piiFields, - overrideAccess: true, - }); - } - } else { - await payload.delete({ - collection: collectionSlug, - id, - overrideAccess: true, - }); - } + if (retention.postDeletion) { + // Grace-period purge of soft-deleted rows (A2): the DSR soft delete + // stamps RETENTION_TOMBSTONE_FIELD; once the grace period elapses the + // declared action runs. Rows are re-checked client-side so a fake or + // permissive backend can never purge an un-tombstoned/unexpired row. + const { duration, action } = retention.postDeletion; + const cutoffMs = now - parseDurationMs(duration); + const cutoff = new Date(cutoffMs).toISOString(); - if (auditLog) { - await auditLog.record({ - actorId: "system", - actorType: "system", - actorRoles: [], - action: "DELETE", - resource: { type: collectionSlug, id: String(id) }, - at: new Date(), - scope: { - feature: "core-shared", - environment: process.env["NODE_ENV"] ?? "production", - tenant: "default", - }, - reason: "retention-policy", - from: { ipTruncated: "system", userAgent: "background-job" }, - containsPii: false, - outcome: "success", - }); + const { docs } = await payload.find({ + collection: collectionSlug, + where: { [RETENTION_TOMBSTONE_FIELD]: { less_than: cutoff } }, + limit: 1000, + overrideAccess: true, + }); + + const expired = docs.filter((doc) => { + const tombstone = doc[RETENTION_TOMBSTONE_FIELD]; + if (typeof tombstone !== "string" || tombstone.length === 0) { + return false; } + const tombstoneMs = Date.parse(tombstone); + return Number.isFinite(tombstoneMs) && tombstoneMs < cutoffMs; + }); + + for (const doc of expired) { + await applyAction(payload, doc, action, "retention-policy"); } } @@ -189,7 +235,7 @@ export async function registerRetentionPurgeJobs( const { queue, config } = deps; const now = Date.now(); - for (const collection of config.collections) { + for (const collection of config.collections ?? []) { const retention = collection.custom?.retention; if (!retention?.purgeSchedule) continue; diff --git a/packages/core-shared/src/payload/retention-purge/task.test.ts b/packages/core-shared/src/payload/retention-purge/task.test.ts new file mode 100644 index 0000000..259dfc5 --- /dev/null +++ b/packages/core-shared/src/payload/retention-purge/task.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from "vitest"; +import type { Payload } from "payload"; +import { buildRetentionPurgeTask } from "@/payload/retention-purge/task"; + +function makeFakePayload() { + const find = vi.fn().mockResolvedValue({ docs: [] }); + const jobsQueue = vi.fn().mockResolvedValue({ id: "job-1" }); + const payload = { + config: { + collections: [ + { + slug: "users", + custom: { + retention: { + purgeSchedule: "daily", + postDeletion: { + duration: "P30D", + trigger: "after-deletion", + action: "hard-delete", + }, + }, + }, + fields: [], + }, + ], + }, + find, + update: vi.fn(), + delete: vi.fn(), + jobs: { queue: jobsQueue }, + } as unknown as Payload; + return { payload, find, jobsQueue }; +} + +describe("buildRetentionPurgeTask (A3)", () => { + it("uses the retention-purge-- task slug", () => { + expect(buildRetentionPurgeTask("users").slug).toBe( + "retention-purge--users", + ); + }); + + it("runs the purge against req.payload and re-enqueues the next cycle", async () => { + const { payload, find, jobsQueue } = makeFakePayload(); + const task = buildRetentionPurgeTask("users"); + + const result = await task.handler({ req: { payload } }); + + expect(result).toEqual({ output: {} }); + // postDeletion branch queried the tombstone field + expect(find).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "users", + where: { deletedAt: expect.anything() }, + }), + ); + // self-re-enqueue went through the payload job queue + expect(jobsQueue).toHaveBeenCalledWith( + expect.objectContaining({ task: "retention-purge--users" }), + ); + }); +}); diff --git a/packages/core-shared/src/payload/retention-purge/task.ts b/packages/core-shared/src/payload/retention-purge/task.ts new file mode 100644 index 0000000..f54e356 --- /dev/null +++ b/packages/core-shared/src/payload/retention-purge/task.ts @@ -0,0 +1,42 @@ +import type { Payload } from "payload"; +import { PayloadJobQueue } from "../../jobs/payload-job-queue"; +import { buildPurgeHandler, type PayloadPurgeApi } from "./retention-purge.job"; + +/** + * Minimal shape of a Payload job-task definition — enough for + * `payload.config.ts` `jobs.tasks` composition without dragging the full + * generated TaskConfig generics through core-shared. + */ +export type RetentionPurgeTask = { + slug: string; + handler: (args: { req: { payload: Payload } }) => Promise<{ + output: Record; + }>; +}; + +/** + * Build the Payload job-task definition for one collection's retention purge + * (audit finding A3): `registerRetentionPurgeJobs` enqueues + * `retention-purge--` tasks at boot, and this definition is what makes + * Payload able to RUN them. Everything the handler needs comes from the + * running instance on `req.payload` (config, local API, job queue for the + * self-re-enqueue), so the task can be declared at config-composition time + * with no bootstrapping order problems. + */ +export function buildRetentionPurgeTask( + collectionSlug: string, +): RetentionPurgeTask { + return { + slug: `retention-purge--${collectionSlug}`, + handler: async ({ req }) => { + const payload = req.payload; + const run = buildPurgeHandler(collectionSlug, { + queue: new PayloadJobQueue(payload), + config: payload.config, + getPayload: async () => payload as unknown as PayloadPurgeApi, + }); + await run(); + return { output: {} }; + }, + }; +} diff --git a/packages/core-shared/src/payload/retention-purge/tombstone.test.ts b/packages/core-shared/src/payload/retention-purge/tombstone.test.ts new file mode 100644 index 0000000..c670937 --- /dev/null +++ b/packages/core-shared/src/payload/retention-purge/tombstone.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import type { CollectionConfig } from "payload"; +import { + RETENTION_TOMBSTONE_FIELD, + hasPostDeletionPolicy, + withRetentionTombstone, +} from "@/payload/retention-purge/tombstone"; + +function makeCollection(custom?: Record): CollectionConfig { + return { + slug: "things", + custom, + fields: [{ name: "title", type: "text" }], + } as CollectionConfig; +} + +const postDeletionRetention = { + retention: { + purgeSchedule: "daily", + postDeletion: { + duration: "P30D", + trigger: "after-deletion", + action: "hard-delete", + }, + }, +}; + +describe("hasPostDeletionPolicy", () => { + it("is true when custom.retention.postDeletion is declared", () => { + expect(hasPostDeletionPolicy(makeCollection(postDeletionRetention))).toBe( + true, + ); + }); + + it("is false without retention or postDeletion", () => { + expect(hasPostDeletionPolicy(makeCollection())).toBe(false); + expect( + hasPostDeletionPolicy( + makeCollection({ retention: { purgeSchedule: "daily" } }), + ), + ).toBe(false); + }); +}); + +describe("withRetentionTombstone", () => { + it("appends the deletedAt field to postDeletion collections", () => { + const result = withRetentionTombstone( + makeCollection(postDeletionRetention), + ); + const names = result.fields.map((f) => (f as { name?: string }).name); + expect(names).toContain(RETENTION_TOMBSTONE_FIELD); + const tombstone = result.fields.find( + (f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD, + ) as { type?: string; index?: boolean }; + expect(tombstone.type).toBe("date"); + expect(tombstone.index).toBe(true); + }); + + it("returns collections without a postDeletion policy unchanged", () => { + const collection = makeCollection(); + expect(withRetentionTombstone(collection)).toBe(collection); + }); + + it("does not duplicate an already-declared tombstone field", () => { + const collection = { + ...makeCollection(postDeletionRetention), + fields: [{ name: RETENTION_TOMBSTONE_FIELD, type: "date" }], + } as CollectionConfig; + const result = withRetentionTombstone(collection); + expect( + result.fields.filter( + (f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD, + ), + ).toHaveLength(1); + }); + + it("does not mutate the input collection", () => { + const collection = makeCollection(postDeletionRetention); + const before = collection.fields.length; + withRetentionTombstone(collection); + expect(collection.fields.length).toBe(before); + }); +}); diff --git a/packages/core-shared/src/payload/retention-purge/tombstone.ts b/packages/core-shared/src/payload/retention-purge/tombstone.ts new file mode 100644 index 0000000..0d23858 --- /dev/null +++ b/packages/core-shared/src/payload/retention-purge/tombstone.ts @@ -0,0 +1,70 @@ +import type { CollectionConfig, Field } from "payload"; +import type { CollectionRetention } from "../retention-types"; + +/** + * Field name marking a row as soft-deleted. + * + * Written by the DSR soft-delete path (`PayloadDataDelete`, mode "soft") on + * rows the subject owns, and read by the retention purge job's + * `postDeletion` branch: rows whose tombstone is older than + * `postDeletion.duration` are purged with the declared action + * (hard-delete | pseudonymize). + * + * Deliberately distinct from `processingRestrictedAt` — an Art. 18 + * processing restriction is NOT a deletion request and must never feed the + * grace-period purge. + */ +export const RETENTION_TOMBSTONE_FIELD = "deletedAt"; + +function collectionRetention( + collection: CollectionConfig, +): CollectionRetention | undefined { + return (collection.custom as { retention?: CollectionRetention } | undefined) + ?.retention; +} + +/** True when the collection declares a postDeletion grace-purge policy. */ +export function hasPostDeletionPolicy(collection: { + custom?: Record; +}): boolean { + return Boolean( + (collection.custom as { retention?: CollectionRetention } | undefined) + ?.retention?.postDeletion, + ); +} + +/** + * Ensure a collection that declares `custom.retention.postDeletion` carries + * the soft-delete tombstone field so (a) the DSR soft delete can stamp it and + * (b) the purge job can query it. Collections without a postDeletion policy + * (or that already define the field) are returned unchanged. + * + * Apply at config-composition time (core-cms payload.config.ts). + */ +export function withRetentionTombstone( + collection: CollectionConfig, +): CollectionConfig { + if (!collectionRetention(collection)?.postDeletion) return collection; + + const alreadyDeclared = collection.fields.some( + (f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD, + ); + if (alreadyDeclared) return collection; + + const tombstoneField: Field = { + name: RETENTION_TOMBSTONE_FIELD, + type: "date", + index: true, + admin: { + hidden: true, + description: + "Soft-delete tombstone (DSR Art. 17). Rows older than the " + + "postDeletion grace period are purged by the retention job.", + }, + }; + + return { + ...collection, + fields: [...collection.fields, tombstoneField], + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ccd8945..1afb911 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -627,6 +627,12 @@ importers: "@repo/blog": specifier: workspace:* version: link:../blog + "@repo/core-audit": + specifier: workspace:* + version: link:../core-audit + "@repo/core-shared": + specifier: workspace:* + version: link:../core-shared "@repo/marketing-pages": specifier: workspace:* version: link:../marketing-pages