fix(core-dsr): flip processingRestrictedAt in soft-delete for self role

PayloadDataDelete.deleteSubjectData('soft') was NULLing PII fields and
emitting RESTRICT audit entries, but never setting processingRestrictedAt
on self-kind rows — violating GDPR Art. 17 restriction semantics.

softRedactOwnerRows now accepts optional extraData; processOwnerRows
passes { processingRestrictedAt: new Date().toISOString() } when
kind === 'self' and mode === 'soft'. Owner-kind rows are intentionally
excluded (restriction flag belongs on the subject record, not owned rows).

Added two dedicated tests: one asserting the field is present for self,
one asserting it is absent for owner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 20:12:41 +00:00
parent 6606b59d1e
commit cbea635fac
3 changed files with 86 additions and 18 deletions

View File

@@ -1,16 +1,16 @@
{ {
"generatedAt": "2026-05-19T19:59:56.279Z", "generatedAt": "2026-05-19T20:12:30.651Z",
"commit": "8068d1b", "commit": "6606b59",
"repo": { "repo": {
"statements": 97.04, "statements": 97.08,
"branches": 92.09, "branches": 92.03,
"functions": 96.87, "functions": 96.87,
"lines": 97.04, "lines": 97.08,
"counts": { "counts": {
"lf": 5107, "lf": 5172,
"lh": 4956, "lh": 5021,
"brf": 1012, "brf": 1016,
"brh": 932, "brh": 935,
"fnf": 319, "fnf": 319,
"fnh": 309 "fnh": 309
} }
@@ -74,14 +74,14 @@
}, },
"@repo/core-dsr": { "@repo/core-dsr": {
"statements": 100, "statements": 100,
"branches": 92, "branches": 91.35,
"functions": 96.88, "functions": 96.88,
"lines": 100, "lines": 100,
"counts": { "counts": {
"lf": 475, "lf": 540,
"lh": 475, "lh": 540,
"brf": 100, "brf": 104,
"brh": 92, "brh": 95,
"fnf": 32, "fnf": 32,
"fnh": 31 "fnh": 31
} }

View File

@@ -56,7 +56,7 @@ describe("PayloadDataDelete", () => {
expect(mockPayload.update).toHaveBeenCalledWith({ expect(mockPayload.update).toHaveBeenCalledWith({
collection: "users", collection: "users",
id: "alice", id: "alice",
data: { email: null, name: null }, data: expect.objectContaining({ email: null, name: null }),
overrideAccess: true, overrideAccess: true,
}); });
@@ -70,6 +70,59 @@ describe("PayloadDataDelete", () => {
}); });
}); });
it("self role: sets processingRestrictedAt in the update call", 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");
expect(mockPayload.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
processingRestrictedAt: expect.any(String),
}),
}),
);
});
it("owner role: does NOT set processingRestrictedAt", async () => {
const config = makeMockConfig([
{
slug: "orders",
custom: {
subject: { field: "userId", kind: "owner" },
pii: { shippingAddress: { exportable: true } },
},
},
]);
mockPayload.find.mockResolvedValue({
docs: [{ id: "o-1", userId: "alice", shippingAddress: "1 Main" }],
});
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
await deleter.deleteSubjectData("alice", "soft");
expect(mockPayload.update).toHaveBeenCalledWith({
collection: "orders",
id: "o-1",
data: { shippingAddress: null },
overrideAccess: true,
});
});
it("happy path — owner role: NULLs exportable fields in owned rows", async () => { it("happy path — owner role: NULLs exportable fields in owned rows", async () => {
const config = makeMockConfig([ const config = makeMockConfig([
{ {

View File

@@ -46,14 +46,18 @@ async function softRedactOwnerRows(
slug: string, slug: string,
docs: PayloadDoc[], docs: PayloadDoc[],
exportableFields: string[], exportableFields: string[],
extraData: Record<string, unknown> = {},
): Promise<void> { ): Promise<void> {
if (exportableFields.length === 0) return; const hasContent =
exportableFields.length > 0 || Object.keys(extraData).length > 0;
if (!hasContent) return;
const nullData = Object.fromEntries(exportableFields.map((f) => [f, null])); const nullData = Object.fromEntries(exportableFields.map((f) => [f, null]));
const data = { ...nullData, ...extraData };
for (const doc of docs) { for (const doc of docs) {
await payload.update({ await payload.update({
collection: slug, collection: slug,
id: String(doc["id"]), id: String(doc["id"]),
data: nullData, data,
overrideAccess: true, overrideAccess: true,
}); });
} }
@@ -178,7 +182,18 @@ export class PayloadDataDelete implements IDataDelete {
.map(([name]) => name); .map(([name]) => name);
if (mode === "soft") { if (mode === "soft") {
await softRedactOwnerRows(payload, slug, docs, exportableFields); const kind = custom.subject?.kind;
const extraData: Record<string, unknown> =
kind === "self"
? { processingRestrictedAt: new Date().toISOString() }
: {};
await softRedactOwnerRows(
payload,
slug,
docs,
exportableFields,
extraData,
);
affected.push({ affected.push({
collection: slug, collection: slug,
rowsAffected: docs.length, rowsAffected: docs.length,