Initial commit
This commit is contained in:
33
packages/core-dsr/AGENTS.md
Normal file
33
packages/core-dsr/AGENTS.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @repo/core-dsr
|
||||
|
||||
Optional core package providing GDPR Data Subject Rights (DSR) interfaces and implementations. Scaffold via `pnpm turbo gen core-package dsr`.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
data-export.interface.ts # IDataExport — exportSubjectData
|
||||
data-delete.interface.ts # IDataDelete — deleteSubjectData
|
||||
data-rectify.interface.ts # IDataRectify — updateSubjectField
|
||||
processing-restriction.interface.ts # IProcessingRestriction — setRestriction, isRestricted
|
||||
dsr-types.ts # UserDataBundle, DeletionCertificate, DSR value types
|
||||
contexts/
|
||||
user-data.jsonld # schema.org JSON-LD @context
|
||||
index.ts # Barrel export
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
Four interfaces map directly to GDPR Articles 15–18 + 20:
|
||||
|
||||
- `IDataExport` (Art. 15/20) — export a subject's data as `UserDataBundle`
|
||||
- `IDataDelete` (Art. 17) — soft-delete or cascade-hard-delete subject data; returns `DeletionCertificate`
|
||||
- `IDataRectify` (Art. 16) — update a specific field for a subject
|
||||
- `IProcessingRestriction` (Art. 18) — toggle and read the processing restriction flag
|
||||
|
||||
Implementations walk `custom.pii`-tagged fields and `custom.subject`-linked collections. Row semantics:
|
||||
|
||||
- `kind: "self" | "owner"` → directly owned by the subject (export full; delete hard or soft)
|
||||
- `kind: "reference"` → references the subject from another entity (export redacted; redact link on delete)
|
||||
|
||||
See `docs/architecture/agent-first-workflow-and-conformance.md` for the DI conventions.
|
||||
3
packages/core-dsr/eslint.config.js
Normal file
3
packages/core-dsr/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "@repo/core-eslint/base";
|
||||
|
||||
export default baseConfig;
|
||||
30
packages/core-dsr/package.json
Normal file
30
packages/core-dsr/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@repo/core-dsr",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./api": "./src/dsr.router.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/core-shared": "workspace:*",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"payload": "^3.0.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/core-eslint": "workspace:*",
|
||||
"@repo/core-testing": "workspace:*",
|
||||
"@repo/core-typescript": "workspace:*",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
56
packages/core-dsr/src/__tests__/di-binders.test.ts
Normal file
56
packages/core-dsr/src/__tests__/di-binders.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { bindProductionDsr } from "@/di/bind-production";
|
||||
import { bindDevSeedDsr } from "@/di/bind-dev-seed";
|
||||
|
||||
const emptyConfig = { collections: [] } as unknown as SanitizedConfig;
|
||||
|
||||
describe("bindProductionDsr", () => {
|
||||
it("returns a DsrBinding with all four implementations", () => {
|
||||
const binding = bindProductionDsr({ config: emptyConfig });
|
||||
|
||||
expect(binding.dataExport).toBeDefined();
|
||||
expect(binding.dataDelete).toBeDefined();
|
||||
expect(binding.dataRectify).toBeDefined();
|
||||
expect(binding.processingRestriction).toBeDefined();
|
||||
});
|
||||
|
||||
it("each binding exposes the expected interface methods", () => {
|
||||
const binding = bindProductionDsr({ config: emptyConfig });
|
||||
|
||||
expect(typeof binding.dataExport.exportSubjectData).toBe("function");
|
||||
expect(typeof binding.dataDelete.deleteSubjectData).toBe("function");
|
||||
expect(typeof binding.dataRectify.updateSubjectField).toBe("function");
|
||||
expect(typeof binding.processingRestriction.setRestriction).toBe(
|
||||
"function",
|
||||
);
|
||||
expect(typeof binding.processingRestriction.isRestricted).toBe("function");
|
||||
});
|
||||
|
||||
it("uses the noop auditLog when none is provided", () => {
|
||||
expect(() => bindProductionDsr({ config: emptyConfig })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bindDevSeedDsr", () => {
|
||||
it("returns a DsrBinding with all four in-memory implementations", () => {
|
||||
const binding = bindDevSeedDsr();
|
||||
|
||||
expect(binding.dataExport).toBeDefined();
|
||||
expect(binding.dataDelete).toBeDefined();
|
||||
expect(binding.dataRectify).toBeDefined();
|
||||
expect(binding.processingRestriction).toBeDefined();
|
||||
});
|
||||
|
||||
it("each binding exposes the expected interface methods", () => {
|
||||
const binding = bindDevSeedDsr();
|
||||
|
||||
expect(typeof binding.dataExport.exportSubjectData).toBe("function");
|
||||
expect(typeof binding.dataDelete.deleteSubjectData).toBe("function");
|
||||
expect(typeof binding.dataRectify.updateSubjectField).toBe("function");
|
||||
expect(typeof binding.processingRestriction.setRestriction).toBe(
|
||||
"function",
|
||||
);
|
||||
expect(typeof binding.processingRestriction.isRestricted).toBe("function");
|
||||
});
|
||||
});
|
||||
222
packages/core-dsr/src/__tests__/dsr.router.test.ts
Normal file
222
packages/core-dsr/src/__tests__/dsr.router.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createDsrRouter, dsrRouter } from "@/dsr.router";
|
||||
import type { DsrTrpcUser } from "@/dsr.router";
|
||||
import type { DsrBinding } from "@/di/bind-production";
|
||||
import {
|
||||
RecordingDataExport,
|
||||
RecordingDataDelete,
|
||||
RecordingDataRectify,
|
||||
RecordingProcessingRestriction,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
|
||||
// Recording doubles use local type aliases to avoid circular deps with core-dsr.
|
||||
// The alias types are structurally compatible at runtime; cast via unknown to
|
||||
// satisfy the DsrBinding constraint without modifying core-testing.
|
||||
function makeBinding() {
|
||||
return {
|
||||
dataExport: new RecordingDataExport(),
|
||||
dataDelete: new RecordingDataDelete(),
|
||||
dataRectify: new RecordingDataRectify(),
|
||||
processingRestriction: new RecordingProcessingRestriction(),
|
||||
};
|
||||
}
|
||||
|
||||
type TestBinding = ReturnType<typeof makeBinding>;
|
||||
|
||||
function makeCaller(binding: TestBinding, user?: DsrTrpcUser) {
|
||||
const router = createDsrRouter(binding as unknown as DsrBinding);
|
||||
return router.createCaller({ user } as Record<string, unknown>);
|
||||
}
|
||||
|
||||
const authenticatedUser: DsrTrpcUser = { id: "alice", roles: ["user"] };
|
||||
const adminUser: DsrTrpcUser = { id: "admin-user", roles: ["admin"] };
|
||||
|
||||
describe("dsrRouter.export", () => {
|
||||
it("returns UserDataBundle body for authenticated user", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.export({ subjectId: "alice", format: "json" });
|
||||
|
||||
expect(result.subjectId).toBe("alice");
|
||||
expect(result.format).toBe("json");
|
||||
expect(binding.dataExport.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("works with json-ld format", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.export({
|
||||
subjectId: "alice",
|
||||
format: "json-ld",
|
||||
});
|
||||
expect(result.format).toBe("json-ld");
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, undefined);
|
||||
await expect(
|
||||
caller.export({ subjectId: "alice", format: "json" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("propagates errors from dataExport", async () => {
|
||||
const binding = makeBinding();
|
||||
vi.spyOn(binding.dataExport, "exportSubjectData").mockRejectedValue(
|
||||
new Error("export failed"),
|
||||
);
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
await expect(
|
||||
caller.export({ subjectId: "alice", format: "json" }),
|
||||
).rejects.toThrow("export failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dsrRouter.delete", () => {
|
||||
it("returns DeletionCertificate for soft mode (any authenticated user)", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.delete({ subjectId: "alice", mode: "soft" });
|
||||
|
||||
expect(result.subjectId).toBe("alice");
|
||||
expect(result.mode).toBe("soft");
|
||||
expect(binding.dataDelete.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns DeletionCertificate for cascade-hard mode with admin user", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, adminUser);
|
||||
const result = await caller.delete({
|
||||
subjectId: "alice",
|
||||
mode: "cascade-hard",
|
||||
});
|
||||
|
||||
expect(result.mode).toBe("cascade-hard");
|
||||
expect(binding.dataDelete.calls[0]?.mode).toBe("cascade-hard");
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN for cascade-hard mode with non-admin user", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
await expect(
|
||||
caller.delete({ subjectId: "alice", mode: "cascade-hard" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
expect(binding.dataDelete.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws FORBIDDEN for cascade-hard when user has no roles", async () => {
|
||||
const binding = makeBinding();
|
||||
const noRolesUser: DsrTrpcUser = { id: "alice" };
|
||||
const caller = makeCaller(binding, noRolesUser);
|
||||
await expect(
|
||||
caller.delete({ subjectId: "alice", mode: "cascade-hard" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, undefined);
|
||||
await expect(
|
||||
caller.delete({ subjectId: "alice", mode: "soft" }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("dsrRouter.rectify", () => {
|
||||
it("returns { ok: true } for authenticated user", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.rectify({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "Alice New",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(binding.dataRectify.calls[0]).toEqual({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "Alice New",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, undefined);
|
||||
await expect(
|
||||
caller.rectify({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "x",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
|
||||
it("propagates errors from dataRectify", async () => {
|
||||
const binding = makeBinding();
|
||||
vi.spyOn(binding.dataRectify, "updateSubjectField").mockRejectedValue(
|
||||
new Error('Field "secret" is not tagged as PII'),
|
||||
);
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
await expect(
|
||||
caller.rectify({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "secret",
|
||||
value: "x",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dsrRouter.restrict", () => {
|
||||
it("returns { ok: true } when granting restriction", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.restrict({
|
||||
subjectId: "alice",
|
||||
granted: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(binding.processingRestriction.sets[0]).toEqual({
|
||||
subjectId: "alice",
|
||||
granted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns { ok: true } when lifting restriction", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, authenticatedUser);
|
||||
const result = await caller.restrict({
|
||||
subjectId: "alice",
|
||||
granted: false,
|
||||
});
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(binding.processingRestriction.sets[0]?.granted).toBe(false);
|
||||
});
|
||||
|
||||
it("throws UNAUTHORIZED when ctx.user is absent", async () => {
|
||||
const binding = makeBinding();
|
||||
const caller = makeCaller(binding, undefined);
|
||||
await expect(
|
||||
caller.restrict({ subjectId: "alice", granted: true }),
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("dsrRouter singleton guard", () => {
|
||||
it("throws when procedures are called without a real DsrBinding", async () => {
|
||||
// The singleton uses a Proxy that throws on any binding property access.
|
||||
// Procedures access binding lazily, so the Proxy error surfaces at call time.
|
||||
const caller = dsrRouter.createCaller({
|
||||
user: authenticatedUser,
|
||||
} as Record<string, unknown>);
|
||||
await expect(
|
||||
caller.export({ subjectId: "alice", format: "json" }),
|
||||
).rejects.toThrow(/dsrRouter singleton/);
|
||||
});
|
||||
});
|
||||
124
packages/core-dsr/src/__tests__/handlers.test.ts
Normal file
124
packages/core-dsr/src/__tests__/handlers.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createExportHandler } from "@/handlers/export-handler";
|
||||
import { createDeleteHandler } from "@/handlers/delete-handler";
|
||||
import { createRectifyHandler } from "@/handlers/rectify-handler";
|
||||
import { createRestrictHandler } from "@/handlers/restrict-handler";
|
||||
import type { IDataExport } from "@/data-export.interface";
|
||||
import type { IDataDelete } from "@/data-delete.interface";
|
||||
import type { IDataRectify } from "@/data-rectify.interface";
|
||||
import type { IProcessingRestriction } from "@/processing-restriction.interface";
|
||||
import {
|
||||
RecordingDataExport,
|
||||
RecordingDataDelete,
|
||||
RecordingDataRectify,
|
||||
RecordingProcessingRestriction,
|
||||
} from "@repo/core-testing/instrumentation";
|
||||
|
||||
// Recording doubles use local type aliases to avoid circular deps with core-dsr.
|
||||
// Cast via unknown so they satisfy the interface at the TypeScript level while
|
||||
// remaining structurally compatible at runtime.
|
||||
|
||||
describe("createExportHandler", () => {
|
||||
it("calls exportSubjectData and returns status 200 with bundle body", async () => {
|
||||
const dataExport = new RecordingDataExport();
|
||||
const handler = createExportHandler(dataExport as unknown as IDataExport);
|
||||
const res = await handler({ subjectId: "alice", format: "json" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.subjectId).toBe("alice");
|
||||
expect(res.body.format).toBe("json");
|
||||
expect(dataExport.calls).toHaveLength(1);
|
||||
expect(dataExport.calls[0]).toEqual({ subjectId: "alice", format: "json" });
|
||||
});
|
||||
|
||||
it("sets Content-Type: application/json for json format", async () => {
|
||||
const dataExport = new RecordingDataExport();
|
||||
const handler = createExportHandler(dataExport as unknown as IDataExport);
|
||||
const res = await handler({ subjectId: "alice", format: "json" });
|
||||
expect(res.headers?.["Content-Type"]).toBe("application/json");
|
||||
});
|
||||
|
||||
it("sets Content-Type: application/ld+json for json-ld format", async () => {
|
||||
const dataExport = new RecordingDataExport();
|
||||
const handler = createExportHandler(dataExport as unknown as IDataExport);
|
||||
const res = await handler({ subjectId: "alice", format: "json-ld" });
|
||||
expect(res.headers?.["Content-Type"]).toBe("application/ld+json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDeleteHandler", () => {
|
||||
it("calls deleteSubjectData and returns status 200 with certificate body", async () => {
|
||||
const dataDelete = new RecordingDataDelete();
|
||||
const handler = createDeleteHandler(dataDelete as unknown as IDataDelete);
|
||||
const res = await handler({ subjectId: "alice", mode: "soft" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.subjectId).toBe("alice");
|
||||
expect(res.body.mode).toBe("soft");
|
||||
expect(dataDelete.calls).toHaveLength(1);
|
||||
expect(dataDelete.calls[0]).toEqual({ subjectId: "alice", mode: "soft" });
|
||||
});
|
||||
|
||||
it("passes cascade-hard mode to deleteSubjectData", async () => {
|
||||
const dataDelete = new RecordingDataDelete();
|
||||
const handler = createDeleteHandler(dataDelete as unknown as IDataDelete);
|
||||
const res = await handler({ subjectId: "alice", mode: "cascade-hard" });
|
||||
expect(res.body.mode).toBe("cascade-hard");
|
||||
expect(dataDelete.calls[0]?.mode).toBe("cascade-hard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRectifyHandler", () => {
|
||||
it("calls updateSubjectField and returns status 200 with ok body", async () => {
|
||||
const dataRectify = new RecordingDataRectify();
|
||||
const handler = createRectifyHandler(
|
||||
dataRectify as unknown as IDataRectify,
|
||||
);
|
||||
const res = await handler({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "Alice New",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(dataRectify.calls).toHaveLength(1);
|
||||
expect(dataRectify.calls[0]).toEqual({
|
||||
subjectId: "alice",
|
||||
collection: "users",
|
||||
field: "name",
|
||||
value: "Alice New",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createRestrictHandler", () => {
|
||||
it("calls setRestriction with granted=true and returns status 200", async () => {
|
||||
const processingRestriction = new RecordingProcessingRestriction();
|
||||
const handler = createRestrictHandler(
|
||||
processingRestriction as unknown as IProcessingRestriction,
|
||||
);
|
||||
const res = await handler({ subjectId: "alice", granted: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true });
|
||||
expect(processingRestriction.sets).toHaveLength(1);
|
||||
expect(processingRestriction.sets[0]).toEqual({
|
||||
subjectId: "alice",
|
||||
granted: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls setRestriction with granted=false", async () => {
|
||||
const processingRestriction = new RecordingProcessingRestriction();
|
||||
const handler = createRestrictHandler(
|
||||
processingRestriction as unknown as IProcessingRestriction,
|
||||
);
|
||||
await handler({ subjectId: "alice", granted: false });
|
||||
expect(processingRestriction.sets[0]).toEqual({
|
||||
subjectId: "alice",
|
||||
granted: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
83
packages/core-dsr/src/__tests__/in-memory.test.ts
Normal file
83
packages/core-dsr/src/__tests__/in-memory.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { InMemoryDataExport } from "@/in-memory-data-export";
|
||||
import { InMemoryDataDelete } from "@/in-memory-data-delete";
|
||||
import { InMemoryDataRectify } from "@/in-memory-data-rectify";
|
||||
import { InMemoryProcessingRestriction } from "@/in-memory-processing-restriction";
|
||||
|
||||
describe("InMemoryDataExport", () => {
|
||||
it("returns an empty bundle with the correct shape", async () => {
|
||||
const exporter = new InMemoryDataExport();
|
||||
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||
|
||||
expect(bundle.subjectId).toBe("alice");
|
||||
expect(bundle.format).toBe("json");
|
||||
expect(typeof bundle.exportedAt).toBe("string");
|
||||
expect(bundle.data).toEqual({});
|
||||
});
|
||||
|
||||
it("preserves format in the returned bundle", async () => {
|
||||
const exporter = new InMemoryDataExport();
|
||||
const bundle = await exporter.exportSubjectData("bob", "json-ld");
|
||||
|
||||
expect(bundle.format).toBe("json-ld");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryDataDelete", () => {
|
||||
it("returns a DeletionCertificate with the correct shape", async () => {
|
||||
const deleter = new InMemoryDataDelete();
|
||||
const cert = await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
expect(cert.subjectId).toBe("alice");
|
||||
expect(cert.mode).toBe("soft");
|
||||
expect(cert.reason).toBe("art-17-request");
|
||||
expect(cert.affected).toEqual([]);
|
||||
expect(typeof cert.auditEntryId).toBe("string");
|
||||
expect(typeof cert.timestamp).toBe("string");
|
||||
});
|
||||
|
||||
it("preserves mode in the certificate", async () => {
|
||||
const deleter = new InMemoryDataDelete();
|
||||
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
|
||||
|
||||
expect(cert.mode).toBe("cascade-hard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryDataRectify", () => {
|
||||
it("resolves without error", async () => {
|
||||
const rectifier = new InMemoryDataRectify();
|
||||
await expect(
|
||||
rectifier.updateSubjectField("alice", "users", "name", "Alice New"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("InMemoryProcessingRestriction", () => {
|
||||
it("isRestricted returns false for unknown subjects", async () => {
|
||||
const restriction = new InMemoryProcessingRestriction();
|
||||
expect(await restriction.isRestricted("alice")).toBe(false);
|
||||
});
|
||||
|
||||
it("setRestriction(true) makes isRestricted return true", async () => {
|
||||
const restriction = new InMemoryProcessingRestriction();
|
||||
await restriction.setRestriction("alice", true);
|
||||
expect(await restriction.isRestricted("alice")).toBe(true);
|
||||
});
|
||||
|
||||
it("setRestriction(false) makes isRestricted return false", async () => {
|
||||
const restriction = new InMemoryProcessingRestriction();
|
||||
await restriction.setRestriction("alice", true);
|
||||
await restriction.setRestriction("alice", false);
|
||||
expect(await restriction.isRestricted("alice")).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks restriction per subject independently", async () => {
|
||||
const restriction = new InMemoryProcessingRestriction();
|
||||
await restriction.setRestriction("alice", true);
|
||||
await restriction.setRestriction("bob", false);
|
||||
|
||||
expect(await restriction.isRestricted("alice")).toBe(true);
|
||||
expect(await restriction.isRestricted("bob")).toBe(false);
|
||||
});
|
||||
});
|
||||
84
packages/core-dsr/src/__tests__/jsonld-context.test.ts
Normal file
84
packages/core-dsr/src/__tests__/jsonld-context.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { join, dirname } from "node:path";
|
||||
import { vi } from "vitest";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { PayloadDataExport } from "@/payload-data-export";
|
||||
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
|
||||
|
||||
const __dir = dirname(fileURLToPath(import.meta.url));
|
||||
const contextFilePath = join(__dir, "../../src/contexts/user-data.jsonld");
|
||||
|
||||
function loadContextFile(): Record<string, unknown> {
|
||||
const raw = JSON.parse(readFileSync(contextFilePath, "utf-8")) as {
|
||||
"@context": Record<string, unknown>;
|
||||
};
|
||||
return raw["@context"];
|
||||
}
|
||||
|
||||
describe("user-data.jsonld context", () => {
|
||||
it("is valid JSON with an @context key", () => {
|
||||
const raw = JSON.parse(readFileSync(contextFilePath, "utf-8")) as unknown;
|
||||
expect(raw).toMatchObject({ "@context": expect.any(Object) });
|
||||
});
|
||||
|
||||
it("declares @vocab pointing to schema.org", () => {
|
||||
const ctx = loadContextFile();
|
||||
expect(ctx["@vocab"]).toBe("https://schema.org/");
|
||||
});
|
||||
|
||||
it("declares dsr namespace pointing to DPV", () => {
|
||||
const ctx = loadContextFile();
|
||||
expect(ctx["dsr"]).toBe("https://w3.org/ns/dpv#");
|
||||
});
|
||||
|
||||
it("declares prov namespace", () => {
|
||||
const ctx = loadContextFile();
|
||||
expect(ctx["prov"]).toBe("https://www.w3.org/ns/prov#");
|
||||
});
|
||||
|
||||
it("maps subjectId to schema.org identifier", () => {
|
||||
const ctx = loadContextFile();
|
||||
expect(ctx["subjectId"]).toBe("identifier");
|
||||
});
|
||||
|
||||
it("maps exportedAt to schema.org dateCreated", () => {
|
||||
const ctx = loadContextFile();
|
||||
expect(ctx["exportedAt"]).toBe("dateCreated");
|
||||
});
|
||||
|
||||
it("maps data as an @index container", () => {
|
||||
const ctx = loadContextFile();
|
||||
expect(ctx["data"]).toMatchObject({ "@container": "@index" });
|
||||
});
|
||||
|
||||
it("maps asSelf to a DSR handling type", () => {
|
||||
const ctx = loadContextFile();
|
||||
expect(ctx["asSelf"]).toMatchObject({ "@type": "@id" });
|
||||
});
|
||||
|
||||
it("maps asReference to a data subject right type", () => {
|
||||
const ctx = loadContextFile();
|
||||
expect(ctx["asReference"]).toMatchObject({ "@type": "@id" });
|
||||
});
|
||||
|
||||
it("json-ld bundle includes the inline context matching the file", async () => {
|
||||
const auditLog = new RecordingAuditLog();
|
||||
const mockPayload = { find: vi.fn().mockResolvedValue({ docs: [] }) };
|
||||
const mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
|
||||
const config = { collections: [] } as unknown as SanitizedConfig;
|
||||
|
||||
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||
const bundle = await exporter.exportSubjectData("alice", "json-ld");
|
||||
|
||||
const fileCtx = loadContextFile();
|
||||
const bundleCtx = bundle["@context"] as Record<string, unknown>;
|
||||
|
||||
// Core keys must match between the file and the inline constant
|
||||
expect(bundleCtx["@vocab"]).toBe(fileCtx["@vocab"]);
|
||||
expect(bundleCtx["dsr"]).toBe(fileCtx["dsr"]);
|
||||
expect(bundleCtx["subjectId"]).toBe(fileCtx["subjectId"]);
|
||||
expect(bundleCtx["exportedAt"]).toBe(fileCtx["exportedAt"]);
|
||||
});
|
||||
});
|
||||
369
packages/core-dsr/src/__tests__/payload-data-delete.test.ts
Normal file
369
packages/core-dsr/src/__tests__/payload-data-delete.test.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { PayloadDataDelete } from "@/payload-data-delete";
|
||||
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
|
||||
|
||||
type MockPayload = {
|
||||
find: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeMockPayload(): MockPayload {
|
||||
return {
|
||||
find: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockConfig(
|
||||
collections: Array<{ slug: string; custom?: unknown }>,
|
||||
): SanitizedConfig {
|
||||
return { collections } as unknown as SanitizedConfig;
|
||||
}
|
||||
|
||||
describe("PayloadDataDelete", () => {
|
||||
let auditLog: RecordingAuditLog;
|
||||
let mockPayload: MockPayload;
|
||||
let mockGetPayload: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
auditLog = new RecordingAuditLog();
|
||||
mockPayload = makeMockPayload();
|
||||
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
|
||||
});
|
||||
|
||||
describe("soft mode", () => {
|
||||
it("happy path — self role: NULLs exportable PII fields and emits RESTRICT", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true }, name: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "alice", email: "a@ex.com", name: "Alice" }],
|
||||
});
|
||||
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
const cert = await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
expect(mockPayload.update).toHaveBeenCalledWith({
|
||||
collection: "users",
|
||||
id: "alice",
|
||||
data: expect.objectContaining({ email: null, name: null }),
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
expect(cert.subjectId).toBe("alice");
|
||||
expect(cert.mode).toBe("soft");
|
||||
expect(cert.affected).toHaveLength(1);
|
||||
expect(cert.affected[0]).toMatchObject({
|
||||
collection: "users",
|
||||
action: "redacted",
|
||||
fields: expect.arrayContaining(["email", "name"]),
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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);
|
||||
const cert = await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
expect(mockPayload.update).toHaveBeenCalledWith({
|
||||
collection: "orders",
|
||||
id: "o-1",
|
||||
data: { shippingAddress: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(cert.affected[0]?.action).toBe("redacted");
|
||||
});
|
||||
|
||||
it("happy path — reference role: NULLs only the linked field, preserves row", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "comments",
|
||||
custom: {
|
||||
subject: { field: "mentionedUser", kind: "reference" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "c-1", mentionedUser: "alice", body: "hello alice" }],
|
||||
});
|
||||
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
// Only the linking field is NULLed — not the body
|
||||
expect(mockPayload.update).toHaveBeenCalledWith({
|
||||
collection: "comments",
|
||||
id: "c-1",
|
||||
data: { mentionedUser: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
// Row is NOT deleted
|
||||
expect(mockPayload.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("multi-subject row: only the requesting subject's link is redacted", async () => {
|
||||
// Two rows in a reference collection: one for alice, one for bob
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "comments",
|
||||
custom: {
|
||||
subject: { field: "mentionedUser", kind: "reference" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// find returns only alice's row (Payload's where clause filters for alice)
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "c-alice", mentionedUser: "alice", body: "hi alice" }],
|
||||
});
|
||||
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
// Only c-alice is updated
|
||||
expect(mockPayload.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "c-alice" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits RESTRICT audit entries per affected collection", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true } },
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "orders",
|
||||
custom: {
|
||||
subject: { field: "userId", kind: "owner" },
|
||||
pii: { total: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find
|
||||
.mockResolvedValueOnce({ docs: [{ id: "alice", email: "a@ex.com" }] })
|
||||
.mockResolvedValueOnce({
|
||||
docs: [{ id: "o-1", userId: "alice", total: 50 }],
|
||||
});
|
||||
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
const restrictEntries = auditLog.recorded.filter(
|
||||
(e) => e.action === "RESTRICT",
|
||||
);
|
||||
expect(restrictEntries).toHaveLength(2);
|
||||
expect(restrictEntries.map((e) => e.resource.type)).toEqual(
|
||||
expect.arrayContaining(["users", "orders"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns DeletionCertificate with correct shape", async () => {
|
||||
const config = makeMockConfig([{ slug: "posts" }]);
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
const cert = await deleter.deleteSubjectData("alice", "soft");
|
||||
|
||||
expect(cert.subjectId).toBe("alice");
|
||||
expect(cert.mode).toBe("soft");
|
||||
expect(cert.reason).toBe("art-17-request");
|
||||
expect(cert.auditEntryId).toBeTruthy();
|
||||
expect(typeof cert.timestamp).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cascade-hard mode", () => {
|
||||
it("happy path — self/owner: hard-deletes rows", 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);
|
||||
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
|
||||
|
||||
expect(mockPayload.delete).toHaveBeenCalledWith({
|
||||
collection: "users",
|
||||
id: "alice",
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(mockPayload.update).not.toHaveBeenCalled();
|
||||
expect(cert.affected[0]?.action).toBe("deleted");
|
||||
});
|
||||
|
||||
it("cascade-hard — reference: NULLs linked field (does not delete row)", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "comments",
|
||||
custom: { subject: { field: "mentionedUser", kind: "reference" } },
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "c-1", mentionedUser: "alice" }],
|
||||
});
|
||||
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
await deleter.deleteSubjectData("alice", "cascade-hard");
|
||||
|
||||
expect(mockPayload.delete).not.toHaveBeenCalled();
|
||||
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: { mentionedUser: null } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits DELETE audit entries for self/owner collections", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "alice" }],
|
||||
});
|
||||
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
await deleter.deleteSubjectData("alice", "cascade-hard");
|
||||
|
||||
const deleteEntries = auditLog.recorded.filter(
|
||||
(e) => e.action === "DELETE",
|
||||
);
|
||||
expect(deleteEntries).toHaveLength(1);
|
||||
expect(deleteEntries[0]!.resource.type).toBe("users");
|
||||
});
|
||||
|
||||
it("anonymises the subjectId in the certificate", async () => {
|
||||
const config = makeMockConfig([{ slug: "posts" }]);
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
|
||||
|
||||
expect(cert.subjectId).toMatch(/^erased-[0-9a-f]+$/);
|
||||
expect(cert.subjectId).not.toContain("alice");
|
||||
});
|
||||
|
||||
it("correlationId is shared across all audit entries for one operation", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true } },
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "orders",
|
||||
custom: {
|
||||
subject: { field: "userId", kind: "owner" },
|
||||
pii: { total: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find
|
||||
.mockResolvedValueOnce({ docs: [{ id: "alice" }] })
|
||||
.mockResolvedValueOnce({ docs: [{ id: "o-1", userId: "alice" }] });
|
||||
|
||||
const deleter = new PayloadDataDelete(config, auditLog, mockGetPayload);
|
||||
const cert = await deleter.deleteSubjectData("alice", "cascade-hard");
|
||||
|
||||
const ids = auditLog.recorded.map((e) => e.correlationId);
|
||||
expect(new Set(ids).size).toBe(1);
|
||||
expect(ids[0]).toBe(cert.auditEntryId);
|
||||
});
|
||||
});
|
||||
});
|
||||
209
packages/core-dsr/src/__tests__/payload-data-export.test.ts
Normal file
209
packages/core-dsr/src/__tests__/payload-data-export.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { PayloadDataExport } from "@/payload-data-export";
|
||||
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
|
||||
|
||||
type MockPayload = {
|
||||
find: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeMockPayload(): MockPayload {
|
||||
return { find: vi.fn() };
|
||||
}
|
||||
|
||||
function makeMockConfig(
|
||||
collections: Array<{ slug: string; custom?: unknown }>,
|
||||
): SanitizedConfig {
|
||||
return { collections } as unknown as SanitizedConfig;
|
||||
}
|
||||
|
||||
describe("PayloadDataExport", () => {
|
||||
let auditLog: RecordingAuditLog;
|
||||
let mockPayload: MockPayload;
|
||||
let mockGetPayload: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
auditLog = new RecordingAuditLog();
|
||||
mockPayload = makeMockPayload();
|
||||
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
|
||||
});
|
||||
|
||||
it("returns an empty bundle when no collections have custom.subject", async () => {
|
||||
const config = makeMockConfig([{ slug: "posts" }]);
|
||||
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||
|
||||
expect(bundle.subjectId).toBe("alice");
|
||||
expect(bundle.format).toBe("json");
|
||||
expect(bundle.data).toEqual({});
|
||||
expect(mockPayload.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("happy path — self role: includes exportable PII fields only", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: {
|
||||
email: { exportable: true },
|
||||
name: { exportable: true },
|
||||
secret: { exportable: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "alice", email: "a@ex.com", name: "Alice", secret: "x" }],
|
||||
});
|
||||
|
||||
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||
|
||||
expect(bundle.data["users"]?.asSelf).toHaveLength(1);
|
||||
const row = bundle.data["users"]!.asSelf![0]!;
|
||||
expect(row).toMatchObject({
|
||||
id: "alice",
|
||||
email: "a@ex.com",
|
||||
name: "Alice",
|
||||
});
|
||||
expect(row).not.toHaveProperty("secret");
|
||||
expect(bundle.data["users"]?.asReference).toBeUndefined();
|
||||
});
|
||||
|
||||
it("happy path — owner role: includes exportable PII fields", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "orders",
|
||||
custom: {
|
||||
subject: { field: "userId", kind: "owner" },
|
||||
pii: { shippingAddress: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "order-1", userId: "alice", shippingAddress: "1 Main St" }],
|
||||
});
|
||||
|
||||
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||
|
||||
const row = bundle.data["orders"]?.asSelf?.[0];
|
||||
expect(row).toMatchObject({ id: "order-1", shippingAddress: "1 Main St" });
|
||||
});
|
||||
|
||||
it("happy path — reference role: returns SubjectReference, not row content", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "comments",
|
||||
custom: {
|
||||
subject: { field: "mentionedUser", kind: "reference" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "comment-42", mentionedUser: "alice", body: "hello" }],
|
||||
});
|
||||
|
||||
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||
|
||||
expect(bundle.data["comments"]?.asSelf).toBeUndefined();
|
||||
const refs = bundle.data["comments"]?.asReference;
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs![0]).toEqual({
|
||||
rowId: "comment-42",
|
||||
linkedField: "mentionedUser",
|
||||
linkedThrough: "comments",
|
||||
});
|
||||
// Row content must NOT appear in the reference bucket
|
||||
expect(JSON.stringify(refs)).not.toContain("hello");
|
||||
});
|
||||
|
||||
it("skips collections that return no matching rows", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "orders",
|
||||
custom: {
|
||||
subject: { field: "userId", kind: "owner" },
|
||||
pii: { total: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({ docs: [] });
|
||||
|
||||
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||
|
||||
expect(bundle.data).toEqual({});
|
||||
});
|
||||
|
||||
it("emits an EXPORT audit entry", async () => {
|
||||
const config = makeMockConfig([{ slug: "posts" }]);
|
||||
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||
await exporter.exportSubjectData("alice", "json");
|
||||
|
||||
expect(auditLog.recorded).toHaveLength(1);
|
||||
const entry = auditLog.recorded[0]!;
|
||||
expect(entry.action).toBe("EXPORT");
|
||||
expect(entry.actorId).toBe("alice");
|
||||
expect(entry.resource.type).toBe("subject-data");
|
||||
expect(entry.outcome).toBe("success");
|
||||
});
|
||||
|
||||
it("attaches @context when format is json-ld", async () => {
|
||||
const config = makeMockConfig([{ slug: "posts" }]);
|
||||
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||
const bundle = await exporter.exportSubjectData("alice", "json-ld");
|
||||
|
||||
expect(bundle["@context"]).toBeDefined();
|
||||
expect(typeof bundle["@context"]).toBe("object");
|
||||
const ctx = bundle["@context"] as Record<string, unknown>;
|
||||
expect(ctx["@vocab"]).toBe("https://schema.org/");
|
||||
expect(ctx["dsr"]).toBe("https://w3.org/ns/dpv#");
|
||||
expect(ctx["subjectId"]).toBe("identifier");
|
||||
});
|
||||
|
||||
it("does NOT attach @context when format is json", async () => {
|
||||
const config = makeMockConfig([{ slug: "posts" }]);
|
||||
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||
|
||||
expect(bundle["@context"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("multi-collection: walks all subject-linked collections", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true } },
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: "orders",
|
||||
custom: {
|
||||
subject: { field: "userId", kind: "owner" },
|
||||
pii: { total: { exportable: true } },
|
||||
},
|
||||
},
|
||||
{ slug: "posts" }, // no subject linkage — should be skipped
|
||||
]);
|
||||
|
||||
mockPayload.find
|
||||
.mockResolvedValueOnce({ docs: [{ id: "alice", email: "a@ex.com" }] })
|
||||
.mockResolvedValueOnce({
|
||||
docs: [{ id: "o-1", userId: "alice", total: 99 }],
|
||||
});
|
||||
|
||||
const exporter = new PayloadDataExport(config, auditLog, mockGetPayload);
|
||||
const bundle = await exporter.exportSubjectData("alice", "json");
|
||||
|
||||
expect(Object.keys(bundle.data)).toEqual(["users", "orders"]);
|
||||
});
|
||||
});
|
||||
146
packages/core-dsr/src/__tests__/payload-data-rectify.test.ts
Normal file
146
packages/core-dsr/src/__tests__/payload-data-rectify.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { PayloadDataRectify } from "@/payload-data-rectify";
|
||||
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
|
||||
|
||||
type MockPayload = {
|
||||
find: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeMockPayload(): MockPayload {
|
||||
return {
|
||||
find: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeMockConfig(
|
||||
collections: Array<{ slug: string; custom?: unknown }>,
|
||||
): SanitizedConfig {
|
||||
return { collections } as unknown as SanitizedConfig;
|
||||
}
|
||||
|
||||
describe("PayloadDataRectify", () => {
|
||||
let auditLog: RecordingAuditLog;
|
||||
let mockPayload: MockPayload;
|
||||
let mockGetPayload: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
auditLog = new RecordingAuditLog();
|
||||
mockPayload = makeMockPayload();
|
||||
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
|
||||
});
|
||||
|
||||
it("happy path: updates a PII-tagged field and emits RESTRICT with art-16-request reason", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { name: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "alice", name: "Alice Old" }],
|
||||
});
|
||||
|
||||
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
|
||||
await rectifier.updateSubjectField("alice", "users", "name", "Alice New");
|
||||
|
||||
expect(mockPayload.update).toHaveBeenCalledWith({
|
||||
collection: "users",
|
||||
id: "alice",
|
||||
data: { name: "Alice New" },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
expect(auditLog.recorded).toHaveLength(1);
|
||||
const entry = auditLog.recorded[0]!;
|
||||
expect(entry.action).toBe("RESTRICT");
|
||||
expect(entry.reason).toBe("art-16-request");
|
||||
expect(entry.actorId).toBe("alice");
|
||||
expect(entry.changedFields).toContain("name");
|
||||
});
|
||||
|
||||
it("updates all subject rows if multiple exist", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "profiles",
|
||||
custom: {
|
||||
subject: { field: "userId", kind: "owner" },
|
||||
pii: { bio: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [
|
||||
{ id: "p-1", userId: "alice", bio: "old" },
|
||||
{ id: "p-2", userId: "alice", bio: "old2" },
|
||||
],
|
||||
});
|
||||
|
||||
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
|
||||
await rectifier.updateSubjectField("alice", "profiles", "bio", "new bio");
|
||||
|
||||
expect(mockPayload.update).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("throws when collection not found in config", async () => {
|
||||
const config = makeMockConfig([{ slug: "users" }]);
|
||||
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
|
||||
|
||||
await expect(
|
||||
rectifier.updateSubjectField("alice", "nonexistent", "field", "val"),
|
||||
).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it("throws when collection has no DSR subject linkage", async () => {
|
||||
const config = makeMockConfig([{ slug: "posts" }]);
|
||||
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
|
||||
|
||||
await expect(
|
||||
rectifier.updateSubjectField("alice", "posts", "title", "new"),
|
||||
).rejects.toThrow(/no DSR subject linkage/i);
|
||||
});
|
||||
|
||||
it("throws when field is not PII-tagged", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { email: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
|
||||
|
||||
await expect(
|
||||
rectifier.updateSubjectField("alice", "users", "internalNote", "oops"),
|
||||
).rejects.toThrow(/not tagged as PII/i);
|
||||
});
|
||||
|
||||
it("emits audit entry with resource type matching the collection slug", async () => {
|
||||
const config = makeMockConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
subject: { field: "id", kind: "self" },
|
||||
pii: { name: { exportable: true } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockPayload.find.mockResolvedValue({ docs: [{ id: "alice" }] });
|
||||
|
||||
const rectifier = new PayloadDataRectify(config, auditLog, mockGetPayload);
|
||||
await rectifier.updateSubjectField("alice", "users", "name", "New");
|
||||
|
||||
expect(auditLog.recorded[0]!.resource.type).toBe("users");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { PayloadProcessingRestriction } from "@/payload-processing-restriction";
|
||||
import { RecordingAuditLog } from "@repo/core-testing/instrumentation";
|
||||
|
||||
type MockPayload = {
|
||||
find: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function makeMockPayload(): MockPayload {
|
||||
return {
|
||||
find: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
const emptyConfig = { collections: [] } as unknown as SanitizedConfig;
|
||||
|
||||
describe("PayloadProcessingRestriction", () => {
|
||||
let auditLog: RecordingAuditLog;
|
||||
let mockPayload: MockPayload;
|
||||
let mockGetPayload: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
auditLog = new RecordingAuditLog();
|
||||
mockPayload = makeMockPayload();
|
||||
mockGetPayload = vi.fn().mockResolvedValue(mockPayload);
|
||||
});
|
||||
|
||||
describe("setRestriction", () => {
|
||||
it("sets processingRestrictedAt when granted=true", async () => {
|
||||
const restriction = new PayloadProcessingRestriction(
|
||||
emptyConfig,
|
||||
auditLog,
|
||||
mockGetPayload,
|
||||
);
|
||||
await restriction.setRestriction("alice", true);
|
||||
|
||||
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: "users",
|
||||
id: "alice",
|
||||
data: expect.objectContaining({
|
||||
processingRestrictedAt: expect.any(String),
|
||||
}),
|
||||
overrideAccess: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears processingRestrictedAt when granted=false", async () => {
|
||||
const restriction = new PayloadProcessingRestriction(
|
||||
emptyConfig,
|
||||
auditLog,
|
||||
mockGetPayload,
|
||||
);
|
||||
await restriction.setRestriction("alice", false);
|
||||
|
||||
expect(mockPayload.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { processingRestrictedAt: null },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits RESTRICT audit entry when granting restriction", async () => {
|
||||
const restriction = new PayloadProcessingRestriction(
|
||||
emptyConfig,
|
||||
auditLog,
|
||||
mockGetPayload,
|
||||
);
|
||||
await restriction.setRestriction("alice", true);
|
||||
|
||||
expect(auditLog.recorded).toHaveLength(1);
|
||||
const entry = auditLog.recorded[0]!;
|
||||
expect(entry.action).toBe("RESTRICT");
|
||||
expect(entry.actorId).toBe("alice");
|
||||
expect(entry.changedFields).toContain("processingRestrictedAt");
|
||||
});
|
||||
|
||||
it("emits UNRESTRICT audit entry when lifting restriction", async () => {
|
||||
const restriction = new PayloadProcessingRestriction(
|
||||
emptyConfig,
|
||||
auditLog,
|
||||
mockGetPayload,
|
||||
);
|
||||
await restriction.setRestriction("alice", false);
|
||||
|
||||
expect(auditLog.recorded[0]!.action).toBe("UNRESTRICT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isRestricted", () => {
|
||||
it("returns true when processingRestrictedAt is set", async () => {
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [
|
||||
{ id: "alice", processingRestrictedAt: "2024-01-01T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
const restriction = new PayloadProcessingRestriction(
|
||||
emptyConfig,
|
||||
auditLog,
|
||||
mockGetPayload,
|
||||
);
|
||||
expect(await restriction.isRestricted("alice")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when processingRestrictedAt is null", async () => {
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [{ id: "alice", processingRestrictedAt: null }],
|
||||
});
|
||||
|
||||
const restriction = new PayloadProcessingRestriction(
|
||||
emptyConfig,
|
||||
auditLog,
|
||||
mockGetPayload,
|
||||
);
|
||||
expect(await restriction.isRestricted("alice")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when user record not found", async () => {
|
||||
mockPayload.find.mockResolvedValue({ docs: [] });
|
||||
|
||||
const restriction = new PayloadProcessingRestriction(
|
||||
emptyConfig,
|
||||
auditLog,
|
||||
mockGetPayload,
|
||||
);
|
||||
expect(await restriction.isRestricted("ghost")).toBe(false);
|
||||
});
|
||||
|
||||
it("restriction flag honored: setRestriction(true) then isRestricted returns true", async () => {
|
||||
// setRestriction updates the DB; simulate the updated state via find mock
|
||||
mockPayload.find.mockResolvedValue({
|
||||
docs: [
|
||||
{ id: "alice", processingRestrictedAt: new Date().toISOString() },
|
||||
],
|
||||
});
|
||||
|
||||
const restriction = new PayloadProcessingRestriction(
|
||||
emptyConfig,
|
||||
auditLog,
|
||||
mockGetPayload,
|
||||
);
|
||||
await restriction.setRestriction("alice", true);
|
||||
const result = await restriction.isRestricted("alice");
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
39
packages/core-dsr/src/contexts/user-data.jsonld
Normal file
39
packages/core-dsr/src/contexts/user-data.jsonld
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"@context": {
|
||||
"@vocab": "https://schema.org/",
|
||||
"dsr": "https://w3.org/ns/dpv#",
|
||||
"prov": "https://www.w3.org/ns/prov#",
|
||||
|
||||
"subjectId": "identifier",
|
||||
"exportedAt": "dateCreated",
|
||||
"format": "encodingFormat",
|
||||
|
||||
"data": {
|
||||
"@id": "prov:hadMember",
|
||||
"@container": "@index"
|
||||
},
|
||||
|
||||
"asSelf": {
|
||||
"@id": "dsr:hasPersonalDataHandling",
|
||||
"@type": "@id"
|
||||
},
|
||||
"asReference": {
|
||||
"@id": "dsr:hasDataSubjectRight",
|
||||
"@type": "@id"
|
||||
},
|
||||
"rowId": "identifier",
|
||||
"linkedField": "name",
|
||||
"linkedThrough": {
|
||||
"@id": "isPartOf",
|
||||
"@type": "@id"
|
||||
},
|
||||
|
||||
"auditLog": {
|
||||
"@id": "prov:wasGeneratedBy",
|
||||
"@type": "@id"
|
||||
},
|
||||
|
||||
"UserDataBundle": "dsr:RightOfAccess",
|
||||
"SubjectReference": "dsr:DataSubjectRight"
|
||||
}
|
||||
}
|
||||
29
packages/core-dsr/src/data-delete.interface.ts
Normal file
29
packages/core-dsr/src/data-delete.interface.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { DeletionCertificate, DeletionMode } from "./dsr-types";
|
||||
|
||||
/**
|
||||
* GDPR Art. 17 (right to erasure / "right to be forgotten").
|
||||
*
|
||||
* Two modes:
|
||||
*
|
||||
* - `"soft"` — sets `processingRestrictedAt`, NULLs all `exportable: true`
|
||||
* PII fields, and redacts `reference`-role linked fields to null. The row
|
||||
* structure is preserved so other subjects' data in shared rows remains
|
||||
* intact. Emits one RESTRICT audit entry per affected collection.
|
||||
*
|
||||
* - `"cascade-hard"` — hard-deletes `self` and `owner` rows immediately, then
|
||||
* redacts `reference` fields. Auth-guarded at the procedure layer; must not
|
||||
* be called from user-facing flows. Emits DELETE audit entries.
|
||||
*/
|
||||
export interface IDataDelete {
|
||||
/**
|
||||
* Delete or erase all personal data held for the given subject.
|
||||
*
|
||||
* @param subjectId - The subject's canonical ID.
|
||||
* @param mode - Deletion strategy (soft redaction vs hard cascade).
|
||||
* @returns A signed `DeletionCertificate` linking to the audit log entry.
|
||||
*/
|
||||
deleteSubjectData(
|
||||
subjectId: string,
|
||||
mode: DeletionMode,
|
||||
): Promise<DeletionCertificate>;
|
||||
}
|
||||
22
packages/core-dsr/src/data-export.interface.ts
Normal file
22
packages/core-dsr/src/data-export.interface.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { DsrFormat, UserDataBundle } from "./dsr-types";
|
||||
|
||||
/**
|
||||
* GDPR Art. 15 (right of access) + Art. 20 (right to data portability).
|
||||
*
|
||||
* Implementations walk all Payload collections with `custom.subject` linkage,
|
||||
* segment rows by role (self/owner vs reference), and filter to fields marked
|
||||
* `exportable: true` in `custom.pii`.
|
||||
*/
|
||||
export interface IDataExport {
|
||||
/**
|
||||
* Export all personal data held for the given subject.
|
||||
*
|
||||
* @param subjectId - The subject's canonical ID (e.g. users.id).
|
||||
* @param format - "json" for a plain JSON bundle; "json-ld" attaches the
|
||||
* @context from `contexts/user-data.jsonld`.
|
||||
*/
|
||||
exportSubjectData(
|
||||
subjectId: string,
|
||||
format: DsrFormat,
|
||||
): Promise<UserDataBundle>;
|
||||
}
|
||||
24
packages/core-dsr/src/data-rectify.interface.ts
Normal file
24
packages/core-dsr/src/data-rectify.interface.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* GDPR Art. 16 (right to rectification).
|
||||
*
|
||||
* Allows a subject to correct inaccurate personal data held about them.
|
||||
* Implementations verify the field is PII-tagged before updating and emit a
|
||||
* RESTRICT audit entry with `reason: "art-16-request"` as the tamper-evident
|
||||
* record of the correction.
|
||||
*/
|
||||
export interface IDataRectify {
|
||||
/**
|
||||
* Update a single PII field for the given subject in the specified collection.
|
||||
*
|
||||
* @param subjectId - The subject's canonical ID.
|
||||
* @param collection - Payload collection slug (e.g. "users").
|
||||
* @param field - Name of the field to update (must be `custom.pii`-tagged).
|
||||
* @param value - New value; must satisfy the field's Payload field type.
|
||||
*/
|
||||
updateSubjectField(
|
||||
subjectId: string,
|
||||
collection: string,
|
||||
field: string,
|
||||
value: unknown,
|
||||
): Promise<void>;
|
||||
}
|
||||
21
packages/core-dsr/src/di/bind-dev-seed.ts
Normal file
21
packages/core-dsr/src/di/bind-dev-seed.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { InMemoryDataExport } from "../in-memory-data-export";
|
||||
import { InMemoryDataDelete } from "../in-memory-data-delete";
|
||||
import { InMemoryDataRectify } from "../in-memory-data-rectify";
|
||||
import { InMemoryProcessingRestriction } from "../in-memory-processing-restriction";
|
||||
import type { DsrBinding } from "./bind-production";
|
||||
|
||||
/**
|
||||
* Returns in-memory DSR implementations for dev-seed and storybook contexts
|
||||
* where Payload is unavailable.
|
||||
*
|
||||
* No config or auditLog needed: all operations are no-ops that return
|
||||
* well-shaped responses.
|
||||
*/
|
||||
export function bindDevSeedDsr(): DsrBinding {
|
||||
return {
|
||||
dataExport: new InMemoryDataExport(),
|
||||
dataDelete: new InMemoryDataDelete(),
|
||||
dataRectify: new InMemoryDataRectify(),
|
||||
processingRestriction: new InMemoryProcessingRestriction(),
|
||||
};
|
||||
}
|
||||
44
packages/core-dsr/src/di/bind-production.ts
Normal file
44
packages/core-dsr/src/di/bind-production.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
||||
import type { IDataExport } from "../data-export.interface";
|
||||
import type { IDataDelete } from "../data-delete.interface";
|
||||
import type { IDataRectify } from "../data-rectify.interface";
|
||||
import type { IProcessingRestriction } from "../processing-restriction.interface";
|
||||
import { PayloadDataExport } from "../payload-data-export";
|
||||
import { PayloadDataDelete } from "../payload-data-delete";
|
||||
import { PayloadDataRectify } from "../payload-data-rectify";
|
||||
import { PayloadProcessingRestriction } from "../payload-processing-restriction";
|
||||
|
||||
export type DsrBinding = {
|
||||
dataExport: IDataExport;
|
||||
dataDelete: IDataDelete;
|
||||
dataRectify: IDataRectify;
|
||||
processingRestriction: IProcessingRestriction;
|
||||
};
|
||||
|
||||
export type BindProductionDsrOpts = {
|
||||
config: SanitizedConfig;
|
||||
auditLog?: AuditLogProtocol;
|
||||
};
|
||||
|
||||
const noopAuditLog: AuditLogProtocol = { record: async () => {} };
|
||||
|
||||
/**
|
||||
* Returns Payload-backed DSR implementations pre-wired with config + auditLog.
|
||||
*
|
||||
* Wired by the app aggregator alongside feature binders. The returned binding
|
||||
* is passed as a dependency to dsrRouter and any feature that needs DSR
|
||||
* operations.
|
||||
*/
|
||||
export function bindProductionDsr(opts: BindProductionDsrOpts): DsrBinding {
|
||||
const auditLog = opts.auditLog ?? noopAuditLog;
|
||||
return {
|
||||
dataExport: new PayloadDataExport(opts.config, auditLog),
|
||||
dataDelete: new PayloadDataDelete(opts.config, auditLog),
|
||||
dataRectify: new PayloadDataRectify(opts.config, auditLog),
|
||||
processingRestriction: new PayloadProcessingRestriction(
|
||||
opts.config,
|
||||
auditLog,
|
||||
),
|
||||
};
|
||||
}
|
||||
65
packages/core-dsr/src/dsr-collection-custom.ts
Normal file
65
packages/core-dsr/src/dsr-collection-custom.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { FieldPii } from "@repo/core-shared/payload";
|
||||
|
||||
export type { FieldPii };
|
||||
|
||||
/**
|
||||
* DSR-specific Payload collection custom metadata.
|
||||
*
|
||||
* Collection authors annotate their collection configs with these shapes so the
|
||||
* DSR implementations can walk `custom.subject` linkage and filter by each
|
||||
* field's `custom.pii.exportable` flag.
|
||||
*
|
||||
* The `pii` map mirrors the field-level `FieldPii` shape from
|
||||
* `@repo/core-shared/payload` so that the `pii-declaration-must-be-complete`
|
||||
* ESLint rule is satisfied for each declared field.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import type { DsrCollectionCustom } from "@repo/core-dsr";
|
||||
*
|
||||
* const ordersCollection: CollectionConfig = {
|
||||
* slug: "orders",
|
||||
* custom: {
|
||||
* subject: { field: "userId", kind: "owner" },
|
||||
* pii: {
|
||||
* shippingAddress: {
|
||||
* category: "contact-address",
|
||||
* purpose: ["service-delivery"],
|
||||
* exportable: true,
|
||||
* restrictable: true,
|
||||
* },
|
||||
* },
|
||||
* } satisfies DsrCollectionCustom,
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
|
||||
export type DsrSubjectLinkKind = "self" | "owner" | "reference";
|
||||
|
||||
export type DsrSubjectLinkage = {
|
||||
/**
|
||||
* Field name in this collection that contains the subject's canonical ID.
|
||||
* For "self" collections (e.g. "users"), use the literal "id".
|
||||
*/
|
||||
field: string;
|
||||
/**
|
||||
* - "self" — this collection IS the subject record (e.g. users).
|
||||
* - "owner" — the subject owns these rows (e.g. orders, blog posts).
|
||||
* - "reference" — the subject is referenced but does not own the row.
|
||||
*/
|
||||
kind: DsrSubjectLinkKind;
|
||||
};
|
||||
|
||||
/** Shape expected in `collection.custom` for DSR-enabled collections. */
|
||||
export type DsrCollectionCustom = {
|
||||
/** Subject linkage metadata. Omit for collections with no subject data. */
|
||||
subject?: DsrSubjectLinkage;
|
||||
/**
|
||||
* Map of field name → FieldPii metadata. Must include all required fields
|
||||
* (`category`, `purpose`, `exportable`, `restrictable`) to satisfy the
|
||||
* `pii-declaration-must-be-complete` ESLint conformance rule.
|
||||
*
|
||||
* Omit for collections with no PII fields.
|
||||
*/
|
||||
pii?: Record<string, FieldPii>;
|
||||
};
|
||||
75
packages/core-dsr/src/dsr-types.ts
Normal file
75
packages/core-dsr/src/dsr-types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { AuditEntry } from "@repo/core-shared/audit";
|
||||
|
||||
export type DsrFormat = "json" | "json-ld";
|
||||
|
||||
export type DeletionMode = "soft" | "cascade-hard";
|
||||
|
||||
export type DeletionReason =
|
||||
| "art-17-request"
|
||||
| "admin-expunge"
|
||||
| "retention-policy";
|
||||
|
||||
export type DeletionAction = "deleted" | "redacted" | "pseudonymized";
|
||||
|
||||
/** Row reference from a collection where the subject appears as a non-owner link. */
|
||||
export type SubjectReference = {
|
||||
rowId: string;
|
||||
/** Field name in the collection that links to the subject. */
|
||||
linkedField: string;
|
||||
/** Slug of the collection containing the reference. */
|
||||
linkedThrough: string;
|
||||
};
|
||||
|
||||
/** Per-collection data bucket within a UserDataBundle. */
|
||||
export type CollectionDataBucket = {
|
||||
/** Rows directly owned by the subject (kind: "self" | "owner"). */
|
||||
asSelf?: Array<Record<string, unknown>>;
|
||||
/** Rows referencing the subject without owning the row (kind: "reference"). */
|
||||
asReference?: SubjectReference[];
|
||||
};
|
||||
|
||||
/**
|
||||
* GDPR Art. 15/20 export payload.
|
||||
*
|
||||
* `data` is keyed by Payload collection slug. asSelf contains exportable-PII-
|
||||
* filtered rows the subject owns; asReference lists row IDs + link coordinates
|
||||
* for rows that merely reference the subject.
|
||||
*/
|
||||
export type UserDataBundle = {
|
||||
subjectId: string;
|
||||
/** ISO 8601 timestamp of when the export was produced. */
|
||||
exportedAt: string;
|
||||
format: DsrFormat;
|
||||
data: Record<string, CollectionDataBucket>;
|
||||
/** Audit entries scoped to this subject's activity. */
|
||||
auditLog?: AuditEntry[];
|
||||
/** JSON-LD @context URI or inline object; populated when format === "json-ld". */
|
||||
"@context"?: string | Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Per-collection summary of what the deletion touched. */
|
||||
export type DeletionAffected = {
|
||||
collection: string;
|
||||
rowsAffected: number;
|
||||
action: DeletionAction;
|
||||
/** PII field names that were NULLed when action === "redacted". */
|
||||
fields?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Immutable proof of a completed GDPR Art. 17 deletion / erasure request.
|
||||
*
|
||||
* The `auditEntryId` links back to the audit log entry created at deletion
|
||||
* time, forming a tamper-evident chain for regulatory inspection.
|
||||
*/
|
||||
export type DeletionCertificate = {
|
||||
/** Subject ID, or "erased-{hash}" if the ID itself was purged. */
|
||||
subjectId: string;
|
||||
mode: DeletionMode;
|
||||
/** ISO 8601 timestamp of the deletion. */
|
||||
timestamp: string;
|
||||
reason: DeletionReason;
|
||||
affected: DeletionAffected[];
|
||||
/** ID of the audit log entry that recorded this operation. */
|
||||
auditEntryId: string;
|
||||
};
|
||||
139
packages/core-dsr/src/dsr.router.ts
Normal file
139
packages/core-dsr/src/dsr.router.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { t } from "@repo/core-shared/trpc/init";
|
||||
import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
|
||||
import type { DsrBinding } from "./di/bind-production";
|
||||
import { createExportHandler } from "./handlers/export-handler";
|
||||
import { createDeleteHandler } from "./handlers/delete-handler";
|
||||
import { createRectifyHandler } from "./handlers/rectify-handler";
|
||||
import type { RectifyHandlerInput } from "./handlers/rectify-handler";
|
||||
import { createRestrictHandler } from "./handlers/restrict-handler";
|
||||
|
||||
export type DsrTrpcUser = {
|
||||
id?: string;
|
||||
roles?: string[];
|
||||
};
|
||||
|
||||
const requireAuthenticated = t.middleware(({ ctx, next }) => {
|
||||
const user = (ctx as { user?: DsrTrpcUser }).user;
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
});
|
||||
}
|
||||
return next({ ctx: { ...ctx, user } });
|
||||
});
|
||||
|
||||
const dsrProcedure = t.procedure
|
||||
.use(requireAuthenticated)
|
||||
.use(defineErrorMiddleware([]));
|
||||
|
||||
/**
|
||||
* Creates the DSR tRPC router.
|
||||
*
|
||||
* Capture `binding` at router-creation time. Apps that mount this router
|
||||
* must pass the `DsrBinding` returned by `bindProductionDsr` or `bindDevSeedDsr`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const binding = bindProductionDsr({ config, auditLog });
|
||||
* const appRouter = t.router({ ..., dsr: createDsrRouter(binding) });
|
||||
* ```
|
||||
*/
|
||||
export function createDsrRouter(binding: DsrBinding) {
|
||||
// Handlers are created lazily (inside procedure closures) so that the
|
||||
// dsrRouter singleton proxy doesn't trigger at module init time.
|
||||
return t.router({
|
||||
export: dsrProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
subjectId: z.string().min(1),
|
||||
format: z.enum(["json", "json-ld"]),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const res = await createExportHandler(binding.dataExport)(input);
|
||||
return res.body;
|
||||
}),
|
||||
|
||||
delete: dsrProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
subjectId: z.string().min(1),
|
||||
mode: z.enum(["soft", "cascade-hard"]),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.mode === "cascade-hard") {
|
||||
const user = (ctx as { user: DsrTrpcUser }).user;
|
||||
if (!user.roles?.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Admin role required for cascade-hard deletion",
|
||||
});
|
||||
}
|
||||
}
|
||||
const res = await createDeleteHandler(binding.dataDelete)(input);
|
||||
return res.body;
|
||||
}),
|
||||
|
||||
rectify: dsrProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
subjectId: z.string().min(1),
|
||||
collection: z.string().min(1),
|
||||
field: z.string().min(1),
|
||||
value: z.unknown(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
// tRPC infers z.unknown() as value?: unknown; cast to assert presence
|
||||
const res = await createRectifyHandler(binding.dataRectify)(
|
||||
input as RectifyHandlerInput,
|
||||
);
|
||||
return res.body;
|
||||
}),
|
||||
|
||||
restrict: dsrProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
subjectId: z.string().min(1),
|
||||
granted: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const res = await createRestrictHandler(binding.processingRestriction)(
|
||||
input,
|
||||
);
|
||||
return res.body;
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience singleton for projects with a single DSR binding instance.
|
||||
* Most callers should use `createDsrRouter(binding)` and pass the binding
|
||||
* explicitly. This export exists for type inference (`DsrRouter`) only.
|
||||
*/
|
||||
export const dsrRouter = createDsrRouter(
|
||||
new Proxy({} as DsrBinding, {
|
||||
get(_target, prop) {
|
||||
if (prop === "then") return undefined; // not a Promise
|
||||
throw new Error(
|
||||
`dsrRouter singleton used without providing a DsrBinding. ` +
|
||||
`Use createDsrRouter(binding) instead.`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export type DsrRouter = ReturnType<typeof createDsrRouter>;
|
||||
20
packages/core-dsr/src/handlers/delete-handler.ts
Normal file
20
packages/core-dsr/src/handlers/delete-handler.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { IDataDelete } from "../data-delete.interface";
|
||||
import type { DeletionMode, DeletionCertificate } from "../dsr-types";
|
||||
import type { HandlerResponse } from "./handler-types";
|
||||
|
||||
export type DeleteHandlerInput = {
|
||||
subjectId: string;
|
||||
mode: DeletionMode;
|
||||
};
|
||||
|
||||
export function createDeleteHandler(dataDelete: IDataDelete) {
|
||||
return async (
|
||||
input: DeleteHandlerInput,
|
||||
): Promise<HandlerResponse<DeletionCertificate>> => {
|
||||
const cert = await dataDelete.deleteSubjectData(
|
||||
input.subjectId,
|
||||
input.mode,
|
||||
);
|
||||
return { status: 200, body: cert };
|
||||
};
|
||||
}
|
||||
24
packages/core-dsr/src/handlers/export-handler.ts
Normal file
24
packages/core-dsr/src/handlers/export-handler.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { IDataExport } from "../data-export.interface";
|
||||
import type { DsrFormat, UserDataBundle } from "../dsr-types";
|
||||
import type { HandlerResponse } from "./handler-types";
|
||||
|
||||
export type ExportHandlerInput = {
|
||||
subjectId: string;
|
||||
format: DsrFormat;
|
||||
};
|
||||
|
||||
export function createExportHandler(dataExport: IDataExport) {
|
||||
return async (
|
||||
input: ExportHandlerInput,
|
||||
): Promise<HandlerResponse<UserDataBundle>> => {
|
||||
const bundle = await dataExport.exportSubjectData(
|
||||
input.subjectId,
|
||||
input.format,
|
||||
);
|
||||
const headers: Record<string, string> =
|
||||
input.format === "json-ld"
|
||||
? { "Content-Type": "application/ld+json" }
|
||||
: { "Content-Type": "application/json" };
|
||||
return { status: 200, body: bundle, headers };
|
||||
};
|
||||
}
|
||||
5
packages/core-dsr/src/handlers/handler-types.ts
Normal file
5
packages/core-dsr/src/handlers/handler-types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type HandlerResponse<T = unknown> = {
|
||||
status: number;
|
||||
body: T;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
23
packages/core-dsr/src/handlers/rectify-handler.ts
Normal file
23
packages/core-dsr/src/handlers/rectify-handler.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { IDataRectify } from "../data-rectify.interface";
|
||||
import type { HandlerResponse } from "./handler-types";
|
||||
|
||||
export type RectifyHandlerInput = {
|
||||
subjectId: string;
|
||||
collection: string;
|
||||
field: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export function createRectifyHandler(dataRectify: IDataRectify) {
|
||||
return async (
|
||||
input: RectifyHandlerInput,
|
||||
): Promise<HandlerResponse<{ ok: true }>> => {
|
||||
await dataRectify.updateSubjectField(
|
||||
input.subjectId,
|
||||
input.collection,
|
||||
input.field,
|
||||
input.value,
|
||||
);
|
||||
return { status: 200, body: { ok: true as const } };
|
||||
};
|
||||
}
|
||||
18
packages/core-dsr/src/handlers/restrict-handler.ts
Normal file
18
packages/core-dsr/src/handlers/restrict-handler.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { IProcessingRestriction } from "../processing-restriction.interface";
|
||||
import type { HandlerResponse } from "./handler-types";
|
||||
|
||||
export type RestrictHandlerInput = {
|
||||
subjectId: string;
|
||||
granted: boolean;
|
||||
};
|
||||
|
||||
export function createRestrictHandler(
|
||||
processingRestriction: IProcessingRestriction,
|
||||
) {
|
||||
return async (
|
||||
input: RestrictHandlerInput,
|
||||
): Promise<HandlerResponse<{ ok: true }>> => {
|
||||
await processingRestriction.setRestriction(input.subjectId, input.granted);
|
||||
return { status: 200, body: { ok: true as const } };
|
||||
};
|
||||
}
|
||||
27
packages/core-dsr/src/in-memory-data-delete.ts
Normal file
27
packages/core-dsr/src/in-memory-data-delete.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { IDataDelete } from "./data-delete.interface";
|
||||
import type { DeletionMode, DeletionCertificate } from "./dsr-types";
|
||||
|
||||
/**
|
||||
* Volatile in-memory IDataDelete. Returns a well-shaped DeletionCertificate
|
||||
* without touching any persistence layer.
|
||||
*
|
||||
* Used in dev-seed and storybook contexts where Payload is unavailable.
|
||||
* Not a recording double — use RecordingDataDelete from @repo/core-testing for
|
||||
* call-assertion in unit tests.
|
||||
*/
|
||||
export class InMemoryDataDelete implements IDataDelete {
|
||||
async deleteSubjectData(
|
||||
subjectId: string,
|
||||
mode: DeletionMode,
|
||||
): Promise<DeletionCertificate> {
|
||||
return {
|
||||
subjectId,
|
||||
mode,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: "art-17-request",
|
||||
affected: [],
|
||||
auditEntryId: randomUUID(),
|
||||
};
|
||||
}
|
||||
}
|
||||
24
packages/core-dsr/src/in-memory-data-export.ts
Normal file
24
packages/core-dsr/src/in-memory-data-export.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { IDataExport } from "./data-export.interface";
|
||||
import type { DsrFormat, UserDataBundle } from "./dsr-types";
|
||||
|
||||
/**
|
||||
* Volatile in-memory IDataExport. Returns an empty bundle with the correct
|
||||
* shape.
|
||||
*
|
||||
* Used in dev-seed and storybook contexts where Payload is unavailable.
|
||||
* Not a recording double — use RecordingDataExport from @repo/core-testing for
|
||||
* call-assertion in unit tests.
|
||||
*/
|
||||
export class InMemoryDataExport implements IDataExport {
|
||||
async exportSubjectData(
|
||||
subjectId: string,
|
||||
format: DsrFormat,
|
||||
): Promise<UserDataBundle> {
|
||||
return {
|
||||
subjectId,
|
||||
exportedAt: new Date().toISOString(),
|
||||
format,
|
||||
data: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
19
packages/core-dsr/src/in-memory-data-rectify.ts
Normal file
19
packages/core-dsr/src/in-memory-data-rectify.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { IDataRectify } from "./data-rectify.interface";
|
||||
|
||||
/**
|
||||
* Volatile in-memory IDataRectify. Accepts calls but makes no changes.
|
||||
*
|
||||
* Used in dev-seed and storybook contexts where Payload is unavailable.
|
||||
* Not a recording double — use RecordingDataRectify from @repo/core-testing for
|
||||
* call-assertion in unit tests.
|
||||
*/
|
||||
export class InMemoryDataRectify implements IDataRectify {
|
||||
async updateSubjectField(
|
||||
_subjectId: string,
|
||||
_collection: string,
|
||||
_field: string,
|
||||
_value: unknown,
|
||||
): Promise<void> {
|
||||
// no-op in dev-seed
|
||||
}
|
||||
}
|
||||
25
packages/core-dsr/src/in-memory-processing-restriction.ts
Normal file
25
packages/core-dsr/src/in-memory-processing-restriction.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { IProcessingRestriction } from "./processing-restriction.interface";
|
||||
|
||||
/**
|
||||
* Volatile in-memory IProcessingRestriction. Tracks restriction state in
|
||||
* memory; state is lost on process restart.
|
||||
*
|
||||
* Used in dev-seed and storybook contexts where Payload is unavailable.
|
||||
* Not a recording double — use RecordingProcessingRestriction from
|
||||
* @repo/core-testing for call-assertion in unit tests.
|
||||
*/
|
||||
export class InMemoryProcessingRestriction implements IProcessingRestriction {
|
||||
private readonly restricted = new Set<string>();
|
||||
|
||||
async setRestriction(subjectId: string, granted: boolean): Promise<void> {
|
||||
if (granted) {
|
||||
this.restricted.add(subjectId);
|
||||
} else {
|
||||
this.restricted.delete(subjectId);
|
||||
}
|
||||
}
|
||||
|
||||
async isRestricted(subjectId: string): Promise<boolean> {
|
||||
return this.restricted.has(subjectId);
|
||||
}
|
||||
}
|
||||
50
packages/core-dsr/src/index.ts
Normal file
50
packages/core-dsr/src/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export type {
|
||||
DsrFormat,
|
||||
DeletionMode,
|
||||
DeletionReason,
|
||||
DeletionAction,
|
||||
SubjectReference,
|
||||
CollectionDataBucket,
|
||||
UserDataBundle,
|
||||
DeletionAffected,
|
||||
DeletionCertificate,
|
||||
} from "./dsr-types";
|
||||
|
||||
export type { IDataExport } from "./data-export.interface";
|
||||
export type { IDataDelete } from "./data-delete.interface";
|
||||
export type { IDataRectify } from "./data-rectify.interface";
|
||||
export type { IProcessingRestriction } from "./processing-restriction.interface";
|
||||
|
||||
export type {
|
||||
DsrSubjectLinkKind,
|
||||
DsrSubjectLinkage,
|
||||
DsrCollectionCustom,
|
||||
FieldPii,
|
||||
} from "./dsr-collection-custom";
|
||||
|
||||
export { PayloadDataExport } from "./payload-data-export";
|
||||
export { PayloadDataDelete } from "./payload-data-delete";
|
||||
export { PayloadDataRectify } from "./payload-data-rectify";
|
||||
export { PayloadProcessingRestriction } from "./payload-processing-restriction";
|
||||
|
||||
export { InMemoryDataExport } from "./in-memory-data-export";
|
||||
export { InMemoryDataDelete } from "./in-memory-data-delete";
|
||||
export { InMemoryDataRectify } from "./in-memory-data-rectify";
|
||||
export { InMemoryProcessingRestriction } from "./in-memory-processing-restriction";
|
||||
|
||||
export { bindProductionDsr } from "./di/bind-production";
|
||||
export type { DsrBinding, BindProductionDsrOpts } from "./di/bind-production";
|
||||
export { bindDevSeedDsr } from "./di/bind-dev-seed";
|
||||
|
||||
export { createDsrRouter, dsrRouter } from "./dsr.router";
|
||||
export type { DsrRouter, DsrTrpcUser } from "./dsr.router";
|
||||
|
||||
export type { HandlerResponse } from "./handlers/handler-types";
|
||||
export { createExportHandler } from "./handlers/export-handler";
|
||||
export type { ExportHandlerInput } from "./handlers/export-handler";
|
||||
export { createDeleteHandler } from "./handlers/delete-handler";
|
||||
export type { DeleteHandlerInput } from "./handlers/delete-handler";
|
||||
export { createRectifyHandler } from "./handlers/rectify-handler";
|
||||
export type { RectifyHandlerInput } from "./handlers/rectify-handler";
|
||||
export { createRestrictHandler } from "./handlers/restrict-handler";
|
||||
export type { RestrictHandlerInput } from "./handlers/restrict-handler";
|
||||
307
packages/core-dsr/src/payload-data-delete.ts
Normal file
307
packages/core-dsr/src/payload-data-delete.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { getPayload as _getPayload } from "payload";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
||||
import type { IDataDelete } from "./data-delete.interface";
|
||||
import type {
|
||||
DeletionMode,
|
||||
DeletionCertificate,
|
||||
DeletionAffected,
|
||||
} from "./dsr-types";
|
||||
import type { DsrCollectionCustom } from "./dsr-collection-custom";
|
||||
|
||||
type PayloadDoc = Record<string, unknown>;
|
||||
|
||||
type PayloadAPI = {
|
||||
find(args: {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
limit: number;
|
||||
}): Promise<{ docs: PayloadDoc[] }>;
|
||||
update(args: {
|
||||
collection: string;
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
}): Promise<PayloadDoc>;
|
||||
delete(args: {
|
||||
collection: string;
|
||||
id: string;
|
||||
overrideAccess: true;
|
||||
}): Promise<PayloadDoc>;
|
||||
};
|
||||
|
||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
|
||||
|
||||
function buildWhere(field: string, subjectId: string): Record<string, unknown> {
|
||||
return field === "id"
|
||||
? { id: { equals: subjectId } }
|
||||
: { [field]: { equals: subjectId } };
|
||||
}
|
||||
|
||||
async function softRedactOwnerRows(
|
||||
payload: PayloadAPI,
|
||||
slug: string,
|
||||
docs: PayloadDoc[],
|
||||
exportableFields: string[],
|
||||
extraData: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
const hasContent =
|
||||
exportableFields.length > 0 || Object.keys(extraData).length > 0;
|
||||
if (!hasContent) return;
|
||||
const nullData = Object.fromEntries(exportableFields.map((f) => [f, null]));
|
||||
const data = { ...nullData, ...extraData };
|
||||
for (const doc of docs) {
|
||||
await payload.update({
|
||||
collection: slug,
|
||||
id: String(doc["id"]),
|
||||
data,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function cascadeHardDeleteOwnerRows(
|
||||
payload: PayloadAPI,
|
||||
slug: string,
|
||||
docs: PayloadDoc[],
|
||||
): Promise<void> {
|
||||
for (const doc of docs) {
|
||||
await payload.delete({
|
||||
collection: slug,
|
||||
id: String(doc["id"]),
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function redactReferenceField(
|
||||
payload: PayloadAPI,
|
||||
slug: string,
|
||||
field: string,
|
||||
docs: PayloadDoc[],
|
||||
): Promise<void> {
|
||||
for (const doc of docs) {
|
||||
await payload.update({
|
||||
collection: slug,
|
||||
id: String(doc["id"]),
|
||||
data: { [field]: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload-backed IDataDelete. Implements both GDPR Art. 17 deletion modes:
|
||||
*
|
||||
* - "soft" — restricts processing, NULLs exportable PII, redacts reference
|
||||
* links. Row structure preserved (other subjects' data in shared rows intact).
|
||||
* - "cascade-hard" — hard-deletes self/owner rows and redacts reference fields.
|
||||
* Admin-only; auth enforcement at the tRPC procedure layer.
|
||||
*
|
||||
* Emits RESTRICT or DELETE audit entries per affected collection.
|
||||
*/
|
||||
export class PayloadDataDelete implements IDataDelete {
|
||||
constructor(
|
||||
private readonly config: SanitizedConfig,
|
||||
private readonly auditLog: AuditLogProtocol,
|
||||
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
|
||||
) {}
|
||||
|
||||
async deleteSubjectData(
|
||||
subjectId: string,
|
||||
mode: DeletionMode,
|
||||
): Promise<DeletionCertificate> {
|
||||
const payload = await this.getPayloadFn({ config: this.config });
|
||||
const correlationId = randomUUID();
|
||||
const timestamp = new Date().toISOString();
|
||||
const affected: DeletionAffected[] = [];
|
||||
|
||||
for (const collection of this.config.collections) {
|
||||
const custom = (collection.custom ?? {}) as DsrCollectionCustom;
|
||||
if (!custom.subject) continue;
|
||||
|
||||
const { field, kind } = custom.subject;
|
||||
const result = await payload.find({
|
||||
collection: collection.slug,
|
||||
where: buildWhere(field, subjectId),
|
||||
overrideAccess: true,
|
||||
limit: 1000,
|
||||
});
|
||||
if (result.docs.length === 0) continue;
|
||||
|
||||
if (kind === "self" || kind === "owner") {
|
||||
await this.processOwnerRows(
|
||||
payload,
|
||||
collection.slug,
|
||||
mode,
|
||||
custom,
|
||||
result.docs,
|
||||
subjectId,
|
||||
correlationId,
|
||||
affected,
|
||||
);
|
||||
} else {
|
||||
await this.processReferenceRows(
|
||||
payload,
|
||||
collection.slug,
|
||||
field,
|
||||
mode,
|
||||
result.docs,
|
||||
subjectId,
|
||||
correlationId,
|
||||
affected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.buildCertificate(
|
||||
subjectId,
|
||||
mode,
|
||||
timestamp,
|
||||
correlationId,
|
||||
affected,
|
||||
);
|
||||
}
|
||||
|
||||
private async processOwnerRows(
|
||||
payload: PayloadAPI,
|
||||
slug: string,
|
||||
mode: DeletionMode,
|
||||
custom: DsrCollectionCustom,
|
||||
docs: PayloadDoc[],
|
||||
subjectId: string,
|
||||
correlationId: string,
|
||||
affected: DeletionAffected[],
|
||||
): Promise<void> {
|
||||
const piiMeta = custom.pii ?? {};
|
||||
const exportableFields = Object.entries(piiMeta)
|
||||
.filter(([, m]) => m.exportable)
|
||||
.map(([name]) => name);
|
||||
|
||||
if (mode === "soft") {
|
||||
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({
|
||||
collection: slug,
|
||||
rowsAffected: docs.length,
|
||||
action: "redacted",
|
||||
fields: exportableFields,
|
||||
});
|
||||
await this.recordAudit(
|
||||
subjectId,
|
||||
"RESTRICT",
|
||||
slug,
|
||||
exportableFields,
|
||||
correlationId,
|
||||
"art-17-request",
|
||||
);
|
||||
} else {
|
||||
await cascadeHardDeleteOwnerRows(payload, slug, docs);
|
||||
affected.push({
|
||||
collection: slug,
|
||||
rowsAffected: docs.length,
|
||||
action: "deleted",
|
||||
});
|
||||
await this.recordAudit(
|
||||
subjectId,
|
||||
"DELETE",
|
||||
slug,
|
||||
[],
|
||||
correlationId,
|
||||
"art-17-request",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processReferenceRows(
|
||||
payload: PayloadAPI,
|
||||
slug: string,
|
||||
field: string,
|
||||
mode: DeletionMode,
|
||||
docs: PayloadDoc[],
|
||||
subjectId: string,
|
||||
correlationId: string,
|
||||
affected: DeletionAffected[],
|
||||
): Promise<void> {
|
||||
await redactReferenceField(payload, slug, field, docs);
|
||||
affected.push({
|
||||
collection: slug,
|
||||
rowsAffected: docs.length,
|
||||
action: "redacted",
|
||||
fields: [field],
|
||||
});
|
||||
const action = mode === "soft" ? "RESTRICT" : "DELETE";
|
||||
await this.recordAudit(
|
||||
subjectId,
|
||||
action,
|
||||
slug,
|
||||
[field],
|
||||
correlationId,
|
||||
"art-17-request",
|
||||
);
|
||||
}
|
||||
|
||||
private async recordAudit(
|
||||
subjectId: string,
|
||||
action: "RESTRICT" | "DELETE" | "UNRESTRICT",
|
||||
resourceType: string,
|
||||
changedFields: string[],
|
||||
correlationId: string,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await this.auditLog.record({
|
||||
actorId: subjectId,
|
||||
actorType: "user",
|
||||
actorRoles: [],
|
||||
action,
|
||||
resource: { type: resourceType },
|
||||
changedFields: changedFields.length > 0 ? changedFields : undefined,
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "core-dsr",
|
||||
environment: process.env["NODE_ENV"] ?? "development",
|
||||
tenant: "default",
|
||||
},
|
||||
reason,
|
||||
correlationId,
|
||||
from: { ipTruncated: "system", userAgent: "system" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
});
|
||||
}
|
||||
|
||||
private buildCertificate(
|
||||
subjectId: string,
|
||||
mode: DeletionMode,
|
||||
timestamp: string,
|
||||
correlationId: string,
|
||||
affected: DeletionAffected[],
|
||||
): DeletionCertificate {
|
||||
const certSubjectId =
|
||||
mode === "cascade-hard"
|
||||
? `erased-${createHash("sha256").update(subjectId).digest("hex").slice(0, 16)}`
|
||||
: subjectId;
|
||||
|
||||
return {
|
||||
subjectId: certSubjectId,
|
||||
mode,
|
||||
timestamp,
|
||||
reason: "art-17-request",
|
||||
affected,
|
||||
auditEntryId: correlationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
145
packages/core-dsr/src/payload-data-export.ts
Normal file
145
packages/core-dsr/src/payload-data-export.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { getPayload as _getPayload } from "payload";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
||||
import type { IDataExport } from "./data-export.interface";
|
||||
import type {
|
||||
DsrFormat,
|
||||
UserDataBundle,
|
||||
CollectionDataBucket,
|
||||
SubjectReference,
|
||||
} from "./dsr-types";
|
||||
import type { DsrCollectionCustom } from "./dsr-collection-custom";
|
||||
|
||||
// Mirrors packages/core-dsr/src/contexts/user-data.jsonld — update both together.
|
||||
const USER_DATA_JSONLD_CONTEXT: Record<string, unknown> = {
|
||||
"@vocab": "https://schema.org/",
|
||||
dsr: "https://w3.org/ns/dpv#",
|
||||
prov: "https://www.w3.org/ns/prov#",
|
||||
subjectId: "identifier",
|
||||
exportedAt: "dateCreated",
|
||||
format: "encodingFormat",
|
||||
data: { "@id": "prov:hadMember", "@container": "@index" },
|
||||
asSelf: { "@id": "dsr:hasPersonalDataHandling", "@type": "@id" },
|
||||
asReference: { "@id": "dsr:hasDataSubjectRight", "@type": "@id" },
|
||||
rowId: "identifier",
|
||||
linkedField: "name",
|
||||
linkedThrough: { "@id": "isPartOf", "@type": "@id" },
|
||||
auditLog: { "@id": "prov:wasGeneratedBy", "@type": "@id" },
|
||||
UserDataBundle: "dsr:RightOfAccess",
|
||||
SubjectReference: "dsr:DataSubjectRight",
|
||||
};
|
||||
|
||||
type PayloadDoc = Record<string, unknown>;
|
||||
|
||||
type PayloadAPI = {
|
||||
find(args: {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
limit: number;
|
||||
}): Promise<{ docs: PayloadDoc[] }>;
|
||||
};
|
||||
|
||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
|
||||
|
||||
/**
|
||||
* Payload-backed IDataExport. Walks all collections annotated with
|
||||
* `custom.subject` linkage, segments rows by role (self/owner vs reference),
|
||||
* and filters to fields marked `exportable: true` in `custom.pii`.
|
||||
*
|
||||
* Emits an EXPORT audit entry for every call. The `getPayloadFn` parameter is
|
||||
* injectable for unit tests; production code omits it.
|
||||
*/
|
||||
export class PayloadDataExport implements IDataExport {
|
||||
constructor(
|
||||
private readonly config: SanitizedConfig,
|
||||
private readonly auditLog: AuditLogProtocol,
|
||||
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
|
||||
) {}
|
||||
|
||||
async exportSubjectData(
|
||||
subjectId: string,
|
||||
format: DsrFormat,
|
||||
): Promise<UserDataBundle> {
|
||||
const payload = await this.getPayloadFn({ config: this.config });
|
||||
const data: Record<string, CollectionDataBucket> = {};
|
||||
|
||||
for (const collection of this.config.collections) {
|
||||
const custom = (collection.custom ?? {}) as DsrCollectionCustom;
|
||||
if (!custom.subject) continue;
|
||||
|
||||
const { field, kind } = custom.subject;
|
||||
const where =
|
||||
field === "id"
|
||||
? { id: { equals: subjectId } }
|
||||
: { [field]: { equals: subjectId } };
|
||||
|
||||
const result = await payload.find({
|
||||
collection: collection.slug,
|
||||
where,
|
||||
overrideAccess: true,
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
if (result.docs.length === 0) continue;
|
||||
|
||||
if (kind === "self" || kind === "owner") {
|
||||
const piiMeta = custom.pii ?? {};
|
||||
const exportableFields = Object.entries(piiMeta)
|
||||
.filter(([, m]) => m.exportable)
|
||||
.map(([name]) => name);
|
||||
|
||||
data[collection.slug] = {
|
||||
asSelf: result.docs.map((doc) => {
|
||||
const row: PayloadDoc = { id: doc["id"] };
|
||||
for (const f of exportableFields) {
|
||||
if (f in doc) row[f] = doc[f];
|
||||
}
|
||||
return row;
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// reference kind — expose only linking coordinates, not row content
|
||||
data[collection.slug] = {
|
||||
asReference: result.docs.map(
|
||||
(doc): SubjectReference => ({
|
||||
rowId: String(doc["id"]),
|
||||
linkedField: field,
|
||||
linkedThrough: collection.slug,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await this.auditLog.record({
|
||||
actorId: subjectId,
|
||||
actorType: "user",
|
||||
actorRoles: [],
|
||||
action: "EXPORT",
|
||||
resource: { type: "subject-data" },
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "core-dsr",
|
||||
environment: process.env["NODE_ENV"] ?? "development",
|
||||
tenant: "default",
|
||||
},
|
||||
from: { ipTruncated: "system", userAgent: "system" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
});
|
||||
|
||||
const bundle: UserDataBundle = {
|
||||
subjectId,
|
||||
exportedAt: new Date().toISOString(),
|
||||
format,
|
||||
data,
|
||||
};
|
||||
|
||||
if (format === "json-ld") {
|
||||
bundle["@context"] = USER_DATA_JSONLD_CONTEXT;
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
}
|
||||
105
packages/core-dsr/src/payload-data-rectify.ts
Normal file
105
packages/core-dsr/src/payload-data-rectify.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { getPayload as _getPayload } from "payload";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
||||
import type { IDataRectify } from "./data-rectify.interface";
|
||||
import type { DsrCollectionCustom } from "./dsr-collection-custom";
|
||||
|
||||
type PayloadDoc = Record<string, unknown>;
|
||||
|
||||
type PayloadAPI = {
|
||||
find(args: {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
limit: number;
|
||||
}): Promise<{ docs: PayloadDoc[] }>;
|
||||
update(args: {
|
||||
collection: string;
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
}): Promise<PayloadDoc>;
|
||||
};
|
||||
|
||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
|
||||
|
||||
/**
|
||||
* Payload-backed IDataRectify. Verifies the target field is PII-tagged before
|
||||
* updating and emits a RESTRICT audit entry with `reason: "art-16-request"` as
|
||||
* the tamper-evident record of the Art. 16 correction.
|
||||
*/
|
||||
export class PayloadDataRectify implements IDataRectify {
|
||||
constructor(
|
||||
private readonly config: SanitizedConfig,
|
||||
private readonly auditLog: AuditLogProtocol,
|
||||
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
|
||||
) {}
|
||||
|
||||
async updateSubjectField(
|
||||
subjectId: string,
|
||||
collectionSlug: string,
|
||||
field: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const collectionCfg = this.config.collections.find(
|
||||
(c) => c.slug === collectionSlug,
|
||||
);
|
||||
if (!collectionCfg) {
|
||||
throw new Error(`Collection "${collectionSlug}" not found in config`);
|
||||
}
|
||||
|
||||
const custom = (collectionCfg.custom ?? {}) as DsrCollectionCustom;
|
||||
if (!custom.subject) {
|
||||
throw new Error(
|
||||
`Collection "${collectionSlug}" has no DSR subject linkage`,
|
||||
);
|
||||
}
|
||||
if (!custom.pii?.[field]) {
|
||||
throw new Error(
|
||||
`Field "${field}" in "${collectionSlug}" is not tagged as PII`,
|
||||
);
|
||||
}
|
||||
|
||||
const { field: subjectField } = custom.subject;
|
||||
const where =
|
||||
subjectField === "id"
|
||||
? { id: { equals: subjectId } }
|
||||
: { [subjectField]: { equals: subjectId } };
|
||||
|
||||
const payload = await this.getPayloadFn({ config: this.config });
|
||||
const result = await payload.find({
|
||||
collection: collectionSlug,
|
||||
where,
|
||||
overrideAccess: true,
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
for (const doc of result.docs) {
|
||||
await payload.update({
|
||||
collection: collectionSlug,
|
||||
id: String(doc["id"]),
|
||||
data: { [field]: value },
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
await this.auditLog.record({
|
||||
actorId: subjectId,
|
||||
actorType: "user",
|
||||
actorRoles: [],
|
||||
action: "RESTRICT",
|
||||
resource: { type: collectionSlug },
|
||||
changedFields: [field],
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "core-dsr",
|
||||
environment: process.env["NODE_ENV"] ?? "development",
|
||||
tenant: "default",
|
||||
},
|
||||
reason: "art-16-request",
|
||||
from: { ipTruncated: "system", userAgent: "system" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
});
|
||||
}
|
||||
}
|
||||
87
packages/core-dsr/src/payload-processing-restriction.ts
Normal file
87
packages/core-dsr/src/payload-processing-restriction.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { getPayload as _getPayload } from "payload";
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { AuditLogProtocol } from "@repo/core-shared/di";
|
||||
import type { IProcessingRestriction } from "./processing-restriction.interface";
|
||||
|
||||
type PayloadDoc = Record<string, unknown>;
|
||||
|
||||
type PayloadAPI = {
|
||||
find(args: {
|
||||
collection: string;
|
||||
where: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
limit: number;
|
||||
}): Promise<{ docs: PayloadDoc[] }>;
|
||||
update(args: {
|
||||
collection: string;
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
overrideAccess: true;
|
||||
}): Promise<PayloadDoc>;
|
||||
};
|
||||
|
||||
type GetPayload = (args: { config: SanitizedConfig }) => Promise<PayloadAPI>;
|
||||
|
||||
const USERS_COLLECTION = "users";
|
||||
const RESTRICTION_FIELD = "processingRestrictedAt";
|
||||
|
||||
/**
|
||||
* Payload-backed IProcessingRestriction. Toggles and reads the
|
||||
* `processingRestrictedAt` date field on the subject's user record.
|
||||
*
|
||||
* Emits RESTRICT or UNRESTRICT audit entries on every state change.
|
||||
* The `subjectId` is the document ID in the `users` collection.
|
||||
*/
|
||||
export class PayloadProcessingRestriction implements IProcessingRestriction {
|
||||
constructor(
|
||||
private readonly config: SanitizedConfig,
|
||||
private readonly auditLog: AuditLogProtocol,
|
||||
private readonly getPayloadFn: GetPayload = _getPayload as unknown as GetPayload,
|
||||
) {}
|
||||
|
||||
async setRestriction(subjectId: string, granted: boolean): Promise<void> {
|
||||
const payload = await this.getPayloadFn({ config: this.config });
|
||||
|
||||
await payload.update({
|
||||
collection: USERS_COLLECTION,
|
||||
id: subjectId,
|
||||
data: { [RESTRICTION_FIELD]: granted ? new Date().toISOString() : null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
await this.auditLog.record({
|
||||
actorId: subjectId,
|
||||
actorType: "user",
|
||||
actorRoles: [],
|
||||
action: granted ? "RESTRICT" : "UNRESTRICT",
|
||||
resource: { type: USERS_COLLECTION, id: subjectId },
|
||||
changedFields: [RESTRICTION_FIELD],
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "core-dsr",
|
||||
environment: process.env["NODE_ENV"] ?? "development",
|
||||
tenant: "default",
|
||||
},
|
||||
from: { ipTruncated: "system", userAgent: "system" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
});
|
||||
}
|
||||
|
||||
async isRestricted(subjectId: string): Promise<boolean> {
|
||||
const payload = await this.getPayloadFn({ config: this.config });
|
||||
|
||||
const result = await payload.find({
|
||||
collection: USERS_COLLECTION,
|
||||
where: { id: { equals: subjectId } },
|
||||
overrideAccess: true,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const doc = result.docs[0];
|
||||
if (!doc) return false;
|
||||
|
||||
const restrictedAt = doc[RESTRICTION_FIELD];
|
||||
return restrictedAt !== null && restrictedAt !== undefined;
|
||||
}
|
||||
}
|
||||
25
packages/core-dsr/src/processing-restriction.interface.ts
Normal file
25
packages/core-dsr/src/processing-restriction.interface.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* GDPR Art. 18 (right to restriction of processing).
|
||||
*
|
||||
* Toggles and reads the `processingRestrictedAt` flag on the subject's user
|
||||
* record. When restricted, downstream use cases should call `isRestricted`
|
||||
* before processing personal data and short-circuit if true.
|
||||
*
|
||||
* Emits RESTRICT / UNRESTRICT audit entries on every state change.
|
||||
*/
|
||||
export interface IProcessingRestriction {
|
||||
/**
|
||||
* Grant or revoke processing restriction for the given subject.
|
||||
*
|
||||
* @param subjectId - The subject's canonical ID.
|
||||
* @param granted - true to restrict processing; false to lift restriction.
|
||||
*/
|
||||
setRestriction(subjectId: string, granted: boolean): Promise<void>;
|
||||
|
||||
/**
|
||||
* Return whether processing is currently restricted for the given subject.
|
||||
*
|
||||
* @param subjectId - The subject's canonical ID.
|
||||
*/
|
||||
isRestricted(subjectId: string): Promise<boolean>;
|
||||
}
|
||||
12
packages/core-dsr/tsconfig.json
Normal file
12
packages/core-dsr/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@repo/core-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
4
packages/core-dsr/turbo.json
Normal file
4
packages/core-dsr/turbo.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["core"]
|
||||
}
|
||||
9
packages/core-dsr/vitest.config.ts
Normal file
9
packages/core-dsr/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import path from "node:path";
|
||||
import { mergeConfig } from "vitest/config";
|
||||
import { nodeVitestConfig } from "@repo/core-typescript/vitest.base.node";
|
||||
|
||||
export default mergeConfig(nodeVitestConfig, {
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user