fix(compliance): port DSR/consent/audit/retention audit fixes
Ports the upstream compliance-core audit fixes onto the kept core-dsr, core-consent, core-audit, core-cms and core-shared packages (pristine template state here, so taken to the fixed end-state): - core-dsr: scope DSR operations to the caller's own subject (A11); include the subject's audit trail in exports; resolve the per-request binding from ctx instead of a throwing singleton proxy. - core-consent: build the consent router from the shared superjson transformer (A10); merge per-category on persist instead of replacing; validate migrated categories against an allow-list. - core-audit: keyed 128-bit pseudonyms + salted DSR certificate; add the audit-logs collection and the req-scoped GDPR audit-erasure afterDelete hook (A6). - core-shared: grace-purge soft-deleted rows via a retention-purge task + tombstone field and boot registration (A2/A3); add the require-authenticated tRPC helper; derive clientIp + resolve the session user in createTrpcContext (B2/A11). - core-cms: register audit-logs, wire the audit-erasure hook and retention-purge tasks; adapted to our collection set (users, workspaces). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
@@ -25,6 +25,15 @@ export {
|
||||
buildPurgeHandler,
|
||||
registerRetentionPurgeJobs,
|
||||
} from "./retention-purge/retention-purge.job";
|
||||
export {
|
||||
RETENTION_TOMBSTONE_FIELD,
|
||||
hasPostDeletionPolicy,
|
||||
withRetentionTombstone,
|
||||
} from "./retention-purge/tombstone";
|
||||
export {
|
||||
buildRetentionPurgeTask,
|
||||
type RetentionPurgeTask,
|
||||
} from "./retention-purge/task";
|
||||
export type {
|
||||
PayloadPurgeApi,
|
||||
GetPayloadFn,
|
||||
|
||||
@@ -377,6 +377,189 @@ describe("buildPurgeHandler — hard-delete", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildPurgeHandler — postDeletion grace purge (A2) ----
|
||||
|
||||
describe("buildPurgeHandler — postDeletion grace purge", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function postDeletionOnlyConfig(
|
||||
action: "hard-delete" | "pseudonymize",
|
||||
fields: MockCollection["fields"] = [],
|
||||
) {
|
||||
return makeConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
action,
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
it("queries soft-deleted rows by the deletedAt tombstone cutoff", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(payload.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: "users",
|
||||
where: {
|
||||
deletedAt: {
|
||||
less_than: new Date(
|
||||
Date.now() - parseDurationMs("P30D"),
|
||||
).toISOString(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hard-deletes rows whose tombstone is past the grace period (postDeletion-only collection)", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-11-01T00:00:00.000Z" }, // 61 days
|
||||
{ id: "row-fresh", deletedAt: "2025-12-25T00:00:00.000Z" }, // 7 days
|
||||
{ id: "row-live" }, // never soft-deleted
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(payload.delete).toHaveBeenCalledTimes(1);
|
||||
expect(payload.delete).toHaveBeenCalledWith({
|
||||
collection: "users",
|
||||
id: "row-old",
|
||||
overrideAccess: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("pseudonymizes PII fields when postDeletion.action is pseudonymize", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("pseudonymize", [
|
||||
{ name: "email", custom: { pii: { category: "contact-email" } } },
|
||||
{ name: "status" },
|
||||
]),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(payload.update).toHaveBeenCalledWith({
|
||||
collection: "users",
|
||||
id: "row-old",
|
||||
data: { email: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
expect(payload.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records a retention-policy audit entry per purged row", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const { auditLog, record } = makeAuditLog();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
auditLog,
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(record).toHaveBeenCalledTimes(1);
|
||||
expect(record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "DELETE",
|
||||
reason: "retention-policy",
|
||||
resource: { type: "users", id: "row-old" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("still runs activeRetention alongside postDeletion", async () => {
|
||||
const { queue } = makeQueue();
|
||||
const payload = makePayloadApi([
|
||||
{ id: "row-old", deletedAt: "2025-10-01T00:00:00.000Z" },
|
||||
]);
|
||||
const config = makeConfig([
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
activeRetention: { duration: "P2Y", trigger: "from-creation" },
|
||||
postDeletion: {
|
||||
action: "hard-delete",
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config,
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
// one find per branch: createdAt (activeRetention) + deletedAt (postDeletion)
|
||||
expect(payload.find).toHaveBeenCalledTimes(2);
|
||||
// the fake returns the tombstoned row for both branches: the
|
||||
// activeRetention branch deletes it by date, the postDeletion branch by
|
||||
// tombstone — 2 delete calls for the same doc through different policies.
|
||||
expect(payload.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-enqueues the next cycle for postDeletion-only collections", async () => {
|
||||
const { queue, enqueue } = makeQueue();
|
||||
const payload = makePayloadApi([]);
|
||||
const deps: RetentionPurgeJobDeps = {
|
||||
queue,
|
||||
config: postDeletionOnlyConfig("hard-delete"),
|
||||
getPayload: vi.fn().mockResolvedValue(payload),
|
||||
};
|
||||
await buildPurgeHandler("users", deps)();
|
||||
|
||||
expect(enqueue).toHaveBeenCalledWith(
|
||||
"retention-purge--users",
|
||||
{},
|
||||
{ runAt: new Date("2026-01-02T00:00:00.000Z") },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildPurgeHandler — pseudonymize branch ----
|
||||
|
||||
describe("buildPurgeHandler — pseudonymize", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SanitizedConfig } from "payload";
|
||||
import type { IJobQueue } from "../../jobs/job-queue.interface";
|
||||
import type { AuditLogProtocol } from "../../di/bind-protocols";
|
||||
import { RETENTION_TOMBSTONE_FIELD } from "./tombstone";
|
||||
|
||||
/**
|
||||
* Minimal Payload API surface needed by the retention purge job.
|
||||
@@ -70,13 +71,19 @@ export function scheduleDelayMs(schedule: string): number {
|
||||
return MS_PER_DAY; // "daily" and cron fallback
|
||||
}
|
||||
|
||||
type RetentionActionName = "pseudonymize" | "hard-delete";
|
||||
|
||||
/**
|
||||
* Build the purge handler for a single collection. The returned async function
|
||||
* is intended to be registered as a Payload job task handler.
|
||||
*
|
||||
* Per run:
|
||||
* 1. Query rows past their activeRetention period.
|
||||
* 2. Apply postDeletion.action (pseudonymize | hard-delete).
|
||||
* 1. activeRetention (when declared): query rows past the active period
|
||||
* (createdAt/updatedAt) and apply the retention action.
|
||||
* 2. postDeletion (when declared, audit finding A2): query soft-deleted rows
|
||||
* — tombstoned with `deletedAt` by the DSR soft-delete path — whose
|
||||
* tombstone is older than postDeletion.duration, and apply
|
||||
* postDeletion.action (pseudonymize | hard-delete).
|
||||
* 3. Emit one audit entry per processed row (skipped when auditLog is absent).
|
||||
* 4. Re-enqueue itself for the next purge cycle.
|
||||
*
|
||||
@@ -101,8 +108,64 @@ export function buildPurgeHandler(
|
||||
);
|
||||
}
|
||||
|
||||
// Re-bind after the guard: narrowing does not flow into hoisted closures.
|
||||
const targetCollection = collection;
|
||||
|
||||
const taskSlug = `retention-purge--${collectionSlug}`;
|
||||
|
||||
async function applyAction(
|
||||
payload: PayloadPurgeApi,
|
||||
doc: Record<string, unknown>,
|
||||
action: RetentionActionName,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const id = doc["id"] as string | number;
|
||||
|
||||
if (action === "pseudonymize") {
|
||||
const piiFields: Record<string, null> = {};
|
||||
for (const field of targetCollection.fields) {
|
||||
const f = field as { name?: string; custom?: { pii?: unknown } };
|
||||
if (f.name && f.custom?.pii) {
|
||||
piiFields[f.name] = null;
|
||||
}
|
||||
}
|
||||
if (Object.keys(piiFields).length > 0) {
|
||||
await payload.update({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
data: piiFields,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await payload.delete({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
await auditLog.record({
|
||||
actorId: "system",
|
||||
actorType: "system",
|
||||
actorRoles: [],
|
||||
action: "DELETE",
|
||||
resource: { type: collectionSlug, id: String(id) },
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "core-shared",
|
||||
environment: process.env["NODE_ENV"] ?? "production",
|
||||
tenant: "default",
|
||||
},
|
||||
reason,
|
||||
from: { ipTruncated: "system", userAgent: "background-job" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return async () => {
|
||||
const payload = await getPayload({ config });
|
||||
const now = Date.now();
|
||||
@@ -123,51 +186,37 @@ export function buildPurgeHandler(
|
||||
const action = retention.postDeletion?.action ?? "hard-delete";
|
||||
|
||||
for (const doc of docs) {
|
||||
const id = doc["id"] as string | number;
|
||||
await applyAction(payload, doc, action, "retention-policy");
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "pseudonymize") {
|
||||
const piiFields: Record<string, null> = {};
|
||||
for (const field of collection.fields) {
|
||||
const f = field as { name?: string; custom?: { pii?: unknown } };
|
||||
if (f.name && f.custom?.pii) {
|
||||
piiFields[f.name] = null;
|
||||
}
|
||||
}
|
||||
if (Object.keys(piiFields).length > 0) {
|
||||
await payload.update({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
data: piiFields,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await payload.delete({
|
||||
collection: collectionSlug,
|
||||
id,
|
||||
overrideAccess: true,
|
||||
});
|
||||
}
|
||||
if (retention.postDeletion) {
|
||||
// Grace-period purge of soft-deleted rows (A2): the DSR soft delete
|
||||
// stamps RETENTION_TOMBSTONE_FIELD; once the grace period elapses the
|
||||
// declared action runs. Rows are re-checked client-side so a fake or
|
||||
// permissive backend can never purge an un-tombstoned/unexpired row.
|
||||
const { duration, action } = retention.postDeletion;
|
||||
const cutoffMs = now - parseDurationMs(duration);
|
||||
const cutoff = new Date(cutoffMs).toISOString();
|
||||
|
||||
if (auditLog) {
|
||||
await auditLog.record({
|
||||
actorId: "system",
|
||||
actorType: "system",
|
||||
actorRoles: [],
|
||||
action: "DELETE",
|
||||
resource: { type: collectionSlug, id: String(id) },
|
||||
at: new Date(),
|
||||
scope: {
|
||||
feature: "core-shared",
|
||||
environment: process.env["NODE_ENV"] ?? "production",
|
||||
tenant: "default",
|
||||
},
|
||||
reason: "retention-policy",
|
||||
from: { ipTruncated: "system", userAgent: "background-job" },
|
||||
containsPii: false,
|
||||
outcome: "success",
|
||||
});
|
||||
const { docs } = await payload.find({
|
||||
collection: collectionSlug,
|
||||
where: { [RETENTION_TOMBSTONE_FIELD]: { less_than: cutoff } },
|
||||
limit: 1000,
|
||||
overrideAccess: true,
|
||||
});
|
||||
|
||||
const expired = docs.filter((doc) => {
|
||||
const tombstone = doc[RETENTION_TOMBSTONE_FIELD];
|
||||
if (typeof tombstone !== "string" || tombstone.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const tombstoneMs = Date.parse(tombstone);
|
||||
return Number.isFinite(tombstoneMs) && tombstoneMs < cutoffMs;
|
||||
});
|
||||
|
||||
for (const doc of expired) {
|
||||
await applyAction(payload, doc, action, "retention-policy");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,7 +238,7 @@ export async function registerRetentionPurgeJobs(
|
||||
const { queue, config } = deps;
|
||||
const now = Date.now();
|
||||
|
||||
for (const collection of config.collections) {
|
||||
for (const collection of config.collections ?? []) {
|
||||
const retention = collection.custom?.retention;
|
||||
if (!retention?.purgeSchedule) continue;
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { Payload } from "payload";
|
||||
import { buildRetentionPurgeTask } from "@/payload/retention-purge/task";
|
||||
|
||||
function makeFakePayload() {
|
||||
const find = vi.fn().mockResolvedValue({ docs: [] });
|
||||
const jobsQueue = vi.fn().mockResolvedValue({ id: "job-1" });
|
||||
const payload = {
|
||||
config: {
|
||||
collections: [
|
||||
{
|
||||
slug: "users",
|
||||
custom: {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
action: "hard-delete",
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
find,
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
jobs: { queue: jobsQueue },
|
||||
} as unknown as Payload;
|
||||
return { payload, find, jobsQueue };
|
||||
}
|
||||
|
||||
describe("buildRetentionPurgeTask (A3)", () => {
|
||||
it("uses the retention-purge--<slug> task slug", () => {
|
||||
expect(buildRetentionPurgeTask("users").slug).toBe(
|
||||
"retention-purge--users",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs the purge against req.payload and re-enqueues the next cycle", async () => {
|
||||
const { payload, find, jobsQueue } = makeFakePayload();
|
||||
const task = buildRetentionPurgeTask("users");
|
||||
|
||||
const result = await task.handler({ req: { payload } });
|
||||
|
||||
expect(result).toEqual({ output: {} });
|
||||
// postDeletion branch queried the tombstone field
|
||||
expect(find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: "users",
|
||||
where: { deletedAt: expect.anything() },
|
||||
}),
|
||||
);
|
||||
// self-re-enqueue went through the payload job queue
|
||||
expect(jobsQueue).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ task: "retention-purge--users" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
42
packages/core-shared/src/payload/retention-purge/task.ts
Normal file
42
packages/core-shared/src/payload/retention-purge/task.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { Payload } from "payload";
|
||||
import { PayloadJobQueue } from "../../jobs/payload-job-queue";
|
||||
import { buildPurgeHandler, type PayloadPurgeApi } from "./retention-purge.job";
|
||||
|
||||
/**
|
||||
* Minimal shape of a Payload job-task definition — enough for
|
||||
* `payload.config.ts` `jobs.tasks` composition without dragging the full
|
||||
* generated TaskConfig generics through core-shared.
|
||||
*/
|
||||
export type RetentionPurgeTask = {
|
||||
slug: string;
|
||||
handler: (args: { req: { payload: Payload } }) => Promise<{
|
||||
output: Record<string, never>;
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the Payload job-task definition for one collection's retention purge
|
||||
* (audit finding A3): `registerRetentionPurgeJobs` enqueues
|
||||
* `retention-purge--<slug>` tasks at boot, and this definition is what makes
|
||||
* Payload able to RUN them. Everything the handler needs comes from the
|
||||
* running instance on `req.payload` (config, local API, job queue for the
|
||||
* self-re-enqueue), so the task can be declared at config-composition time
|
||||
* with no bootstrapping order problems.
|
||||
*/
|
||||
export function buildRetentionPurgeTask(
|
||||
collectionSlug: string,
|
||||
): RetentionPurgeTask {
|
||||
return {
|
||||
slug: `retention-purge--${collectionSlug}`,
|
||||
handler: async ({ req }) => {
|
||||
const payload = req.payload;
|
||||
const run = buildPurgeHandler(collectionSlug, {
|
||||
queue: new PayloadJobQueue(payload),
|
||||
config: payload.config,
|
||||
getPayload: async () => payload as unknown as PayloadPurgeApi,
|
||||
});
|
||||
await run();
|
||||
return { output: {} };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { CollectionConfig } from "payload";
|
||||
import {
|
||||
RETENTION_TOMBSTONE_FIELD,
|
||||
hasPostDeletionPolicy,
|
||||
withRetentionTombstone,
|
||||
} from "@/payload/retention-purge/tombstone";
|
||||
|
||||
function makeCollection(custom?: Record<string, unknown>): CollectionConfig {
|
||||
return {
|
||||
slug: "things",
|
||||
custom,
|
||||
fields: [{ name: "title", type: "text" }],
|
||||
} as CollectionConfig;
|
||||
}
|
||||
|
||||
const postDeletionRetention = {
|
||||
retention: {
|
||||
purgeSchedule: "daily",
|
||||
postDeletion: {
|
||||
duration: "P30D",
|
||||
trigger: "after-deletion",
|
||||
action: "hard-delete",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("hasPostDeletionPolicy", () => {
|
||||
it("is true when custom.retention.postDeletion is declared", () => {
|
||||
expect(hasPostDeletionPolicy(makeCollection(postDeletionRetention))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false without retention or postDeletion", () => {
|
||||
expect(hasPostDeletionPolicy(makeCollection())).toBe(false);
|
||||
expect(
|
||||
hasPostDeletionPolicy(
|
||||
makeCollection({ retention: { purgeSchedule: "daily" } }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withRetentionTombstone", () => {
|
||||
it("appends the deletedAt field to postDeletion collections", () => {
|
||||
const result = withRetentionTombstone(
|
||||
makeCollection(postDeletionRetention),
|
||||
);
|
||||
const names = result.fields.map((f) => (f as { name?: string }).name);
|
||||
expect(names).toContain(RETENTION_TOMBSTONE_FIELD);
|
||||
const tombstone = result.fields.find(
|
||||
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||
) as { type?: string; index?: boolean };
|
||||
expect(tombstone.type).toBe("date");
|
||||
expect(tombstone.index).toBe(true);
|
||||
});
|
||||
|
||||
it("returns collections without a postDeletion policy unchanged", () => {
|
||||
const collection = makeCollection();
|
||||
expect(withRetentionTombstone(collection)).toBe(collection);
|
||||
});
|
||||
|
||||
it("does not duplicate an already-declared tombstone field", () => {
|
||||
const collection = {
|
||||
...makeCollection(postDeletionRetention),
|
||||
fields: [{ name: RETENTION_TOMBSTONE_FIELD, type: "date" }],
|
||||
} as CollectionConfig;
|
||||
const result = withRetentionTombstone(collection);
|
||||
expect(
|
||||
result.fields.filter(
|
||||
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not mutate the input collection", () => {
|
||||
const collection = makeCollection(postDeletionRetention);
|
||||
const before = collection.fields.length;
|
||||
withRetentionTombstone(collection);
|
||||
expect(collection.fields.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { CollectionConfig, Field } from "payload";
|
||||
import type { CollectionRetention } from "../retention-types";
|
||||
|
||||
/**
|
||||
* Field name marking a row as soft-deleted.
|
||||
*
|
||||
* Written by the DSR soft-delete path (`PayloadDataDelete`, mode "soft") on
|
||||
* rows the subject owns, and read by the retention purge job's
|
||||
* `postDeletion` branch: rows whose tombstone is older than
|
||||
* `postDeletion.duration` are purged with the declared action
|
||||
* (hard-delete | pseudonymize).
|
||||
*
|
||||
* Deliberately distinct from `processingRestrictedAt` — an Art. 18
|
||||
* processing restriction is NOT a deletion request and must never feed the
|
||||
* grace-period purge.
|
||||
*/
|
||||
export const RETENTION_TOMBSTONE_FIELD = "deletedAt";
|
||||
|
||||
function collectionRetention(
|
||||
collection: CollectionConfig,
|
||||
): CollectionRetention | undefined {
|
||||
return (collection.custom as { retention?: CollectionRetention } | undefined)
|
||||
?.retention;
|
||||
}
|
||||
|
||||
/** True when the collection declares a postDeletion grace-purge policy. */
|
||||
export function hasPostDeletionPolicy(collection: {
|
||||
custom?: Record<string, unknown>;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
(collection.custom as { retention?: CollectionRetention } | undefined)
|
||||
?.retention?.postDeletion,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a collection that declares `custom.retention.postDeletion` carries
|
||||
* the soft-delete tombstone field so (a) the DSR soft delete can stamp it and
|
||||
* (b) the purge job can query it. Collections without a postDeletion policy
|
||||
* (or that already define the field) are returned unchanged.
|
||||
*
|
||||
* Apply at config-composition time (core-cms payload.config.ts).
|
||||
*/
|
||||
export function withRetentionTombstone(
|
||||
collection: CollectionConfig,
|
||||
): CollectionConfig {
|
||||
if (!collectionRetention(collection)?.postDeletion) return collection;
|
||||
|
||||
const alreadyDeclared = collection.fields.some(
|
||||
(f) => (f as { name?: string }).name === RETENTION_TOMBSTONE_FIELD,
|
||||
);
|
||||
if (alreadyDeclared) return collection;
|
||||
|
||||
const tombstoneField: Field = {
|
||||
name: RETENTION_TOMBSTONE_FIELD,
|
||||
type: "date",
|
||||
index: true,
|
||||
admin: {
|
||||
hidden: true,
|
||||
description:
|
||||
"Soft-delete tombstone (DSR Art. 17). Rows older than the " +
|
||||
"postDeletion grace period are purged by the retention job.",
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...collection,
|
||||
fields: [...collection.fields, tombstoneField],
|
||||
};
|
||||
}
|
||||
89
packages/core-shared/src/trpc/context.test.ts
Normal file
89
packages/core-shared/src/trpc/context.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { clientIpFromHeaders, createTrpcContext } from "@/trpc/context";
|
||||
|
||||
describe("clientIpFromHeaders", () => {
|
||||
it("takes the first x-forwarded-for hop", () => {
|
||||
const headers = new Headers({
|
||||
"x-forwarded-for": "203.0.113.7, 10.0.0.1, 10.0.0.2",
|
||||
});
|
||||
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("trims whitespace around the first hop", () => {
|
||||
const headers = new Headers({
|
||||
"x-forwarded-for": " 203.0.113.7 , 10.0.0.1",
|
||||
});
|
||||
expect(clientIpFromHeaders(headers)).toBe("203.0.113.7");
|
||||
});
|
||||
|
||||
it("falls back to x-real-ip when x-forwarded-for is absent", () => {
|
||||
const headers = new Headers({ "x-real-ip": "198.51.100.4" });
|
||||
expect(clientIpFromHeaders(headers)).toBe("198.51.100.4");
|
||||
});
|
||||
|
||||
it("returns undefined when neither header is present", () => {
|
||||
expect(clientIpFromHeaders(new Headers())).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty header values", () => {
|
||||
const headers = new Headers({ "x-forwarded-for": " ", "x-real-ip": "" });
|
||||
expect(clientIpFromHeaders(headers)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTrpcContext", () => {
|
||||
it("attaches the derived clientIp from the request", async () => {
|
||||
const req = new Request("https://example.test/api/trpc", {
|
||||
headers: { "x-forwarded-for": "203.0.113.7" },
|
||||
});
|
||||
await expect(createTrpcContext(req)).resolves.toEqual({
|
||||
clientIp: "203.0.113.7",
|
||||
});
|
||||
});
|
||||
|
||||
it("yields an undefined clientIp without a request", async () => {
|
||||
await expect(createTrpcContext()).resolves.toEqual({
|
||||
clientIp: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches the resolved user and mirrors userId (A11)", async () => {
|
||||
const req = new Request("https://example.test/api/trpc");
|
||||
const ctx = await createTrpcContext(req, {
|
||||
resolveUser: async () => ({ id: "user-1", roles: ["admin"] }),
|
||||
});
|
||||
expect(ctx.user).toEqual({ id: "user-1", roles: ["admin"] });
|
||||
expect(ctx.userId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("treats a null resolver result as anonymous", async () => {
|
||||
const req = new Request("https://example.test/api/trpc");
|
||||
const ctx = await createTrpcContext(req, {
|
||||
resolveUser: async () => null,
|
||||
});
|
||||
expect(ctx.user).toBeUndefined();
|
||||
expect(ctx.userId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats a throwing resolver as anonymous instead of failing", async () => {
|
||||
const req = new Request("https://example.test/api/trpc");
|
||||
const ctx = await createTrpcContext(req, {
|
||||
resolveUser: async () => {
|
||||
throw new Error("expired session");
|
||||
},
|
||||
});
|
||||
expect(ctx.user).toBeUndefined();
|
||||
expect(ctx.clientIp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not invoke the resolver without a request", async () => {
|
||||
let called = false;
|
||||
await createTrpcContext(undefined, {
|
||||
resolveUser: async () => {
|
||||
called = true;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,69 @@
|
||||
export async function createTrpcContext() {
|
||||
return {};
|
||||
/**
|
||||
* Derive the client IP from reverse-proxy headers.
|
||||
*
|
||||
* TRUST CAVEAT (audit finding B2): `x-forwarded-for` and `x-real-ip` are
|
||||
* ordinary request headers. They are only trustworthy when the app runs
|
||||
* behind a proxy/load balancer that overwrites (or verifiably appends to)
|
||||
* them on every request. Exposed directly to the internet, a client can
|
||||
* spoof them; deployments that need a hard guarantee must read the socket
|
||||
* address at their edge and strip inbound copies of these headers.
|
||||
*
|
||||
* We take the FIRST `x-forwarded-for` entry — the client as reported by the
|
||||
* first (trusted) hop — falling back to `x-real-ip`.
|
||||
*/
|
||||
export function clientIpFromHeaders(headers: Headers): string | undefined {
|
||||
const forwarded = headers.get("x-forwarded-for");
|
||||
const firstHop = forwarded?.split(",")[0]?.trim();
|
||||
if (firstHop) return firstHop;
|
||||
const realIp = headers.get("x-real-ip")?.trim();
|
||||
return realIp || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-resolved authenticated user attached to the tRPC context.
|
||||
* Resolved from the app's session mechanism (never from client input);
|
||||
* `roles` is a snapshot for role-gated procedures (admin checks).
|
||||
*/
|
||||
export type TrpcSessionUser = {
|
||||
id: string;
|
||||
roles: string[];
|
||||
};
|
||||
|
||||
export type CreateTrpcContextOpts = {
|
||||
/**
|
||||
* App-provided session resolver (audit finding A11). Receives the incoming
|
||||
* request and returns the authenticated user, or null/undefined for
|
||||
* anonymous callers. A throwing resolver is treated as anonymous — an
|
||||
* expired or malformed session cookie must not 500 public queries;
|
||||
* procedures that need a user reject with UNAUTHORIZED instead.
|
||||
*/
|
||||
resolveUser?: (req: Request) => Promise<TrpcSessionUser | null | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the per-request tRPC context. Pass the adapter's incoming fetch
|
||||
* `Request` so server-derived fields (`clientIp`, and — when the app supplies
|
||||
* a `resolveUser` — the authenticated `user`) are attached. Procedures must
|
||||
* never trust client-supplied equivalents (B2).
|
||||
*/
|
||||
export async function createTrpcContext(
|
||||
req?: Request,
|
||||
opts: CreateTrpcContextOpts = {},
|
||||
) {
|
||||
let user: TrpcSessionUser | undefined;
|
||||
if (req && opts.resolveUser) {
|
||||
try {
|
||||
user = (await opts.resolveUser(req)) ?? undefined;
|
||||
} catch {
|
||||
user = undefined;
|
||||
}
|
||||
}
|
||||
return {
|
||||
clientIp: req ? clientIpFromHeaders(req.headers) : undefined,
|
||||
user,
|
||||
/** Convenience mirror of `user.id` (consumed by the consent router). */
|
||||
userId: user?.id,
|
||||
};
|
||||
}
|
||||
|
||||
export type TrpcContext = Awaited<ReturnType<typeof createTrpcContext>>;
|
||||
|
||||
50
packages/core-shared/src/trpc/require-authenticated.test.ts
Normal file
50
packages/core-shared/src/trpc/require-authenticated.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { t } from "@/trpc/init";
|
||||
import {
|
||||
requireAuthenticated,
|
||||
protectedProcedure,
|
||||
} from "@/trpc/require-authenticated";
|
||||
|
||||
const echoRouter = t.router({
|
||||
publicEcho: t.procedure
|
||||
.input(z.object({ value: z.string() }).strict())
|
||||
.query(({ input }) => input.value),
|
||||
protectedEcho: protectedProcedure
|
||||
.input(z.object({ value: z.string() }).strict())
|
||||
.mutation(({ input, ctx }) => ({
|
||||
value: input.value,
|
||||
userId: (ctx as { user: { id: string } }).user.id,
|
||||
})),
|
||||
composedEcho: t.procedure
|
||||
.use(requireAuthenticated)
|
||||
.input(z.object({}).strict())
|
||||
.mutation(() => "ok"),
|
||||
});
|
||||
|
||||
describe("requireAuthenticated middleware (B7)", () => {
|
||||
it("rejects anonymous callers with UNAUTHORIZED", async () => {
|
||||
const caller = echoRouter.createCaller({});
|
||||
await expect(caller.protectedEcho({ value: "x" })).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
await expect(caller.composedEcho({})).rejects.toMatchObject({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through authenticated callers and exposes ctx.user", async () => {
|
||||
const caller = echoRouter.createCaller({
|
||||
user: { id: "user-1", roles: [] },
|
||||
});
|
||||
await expect(caller.protectedEcho({ value: "x" })).resolves.toEqual({
|
||||
value: "x",
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves public procedures untouched", async () => {
|
||||
const caller = echoRouter.createCaller({});
|
||||
await expect(caller.publicEcho({ value: "hi" })).resolves.toBe("hi");
|
||||
});
|
||||
});
|
||||
32
packages/core-shared/src/trpc/require-authenticated.ts
Normal file
32
packages/core-shared/src/trpc/require-authenticated.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { t } from "./init";
|
||||
import type { TrpcSessionUser } from "./context";
|
||||
|
||||
/**
|
||||
* Shared authentication guard for MUTATING tRPC procedures (audit finding
|
||||
* B7). Reads the server-resolved `ctx.user` (attached by `createTrpcContext`
|
||||
* via the app's `resolveUser`) and rejects anonymous callers with
|
||||
* UNAUTHORIZED. Read-only queries stay public; each feature opts its
|
||||
* mutations in by composing this middleware into its procedure chain:
|
||||
*
|
||||
* ```ts
|
||||
* export const blogProtectedProcedure = blogProcedure.use(requireAuthenticated);
|
||||
* ```
|
||||
*
|
||||
* The shared `t` is context-untyped, so the middleware narrows at runtime
|
||||
* (same cast pattern as the dsr/audit routers) and re-publishes `user` into
|
||||
* the downstream ctx with a non-optional type.
|
||||
*/
|
||||
export const requireAuthenticated = t.middleware(({ ctx, next }) => {
|
||||
const user = (ctx as { user?: TrpcSessionUser }).user;
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Authentication required",
|
||||
});
|
||||
}
|
||||
return next({ ctx: { ...ctx, user } });
|
||||
});
|
||||
|
||||
/** Convenience base procedure for apps composing ad-hoc protected routes. */
|
||||
export const protectedProcedure = t.procedure.use(requireAuthenticated);
|
||||
Reference in New Issue
Block a user