feat(core-shared): grace-purge soft-deleted rows + boot registration
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, unknown> =
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user