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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user