From 131efd5d2f3b21215da96692f276be6713fe53eb Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Mon, 11 May 2026 16:25:09 +0200 Subject: [PATCH] feat(core-audit): admin tRPC procedure for eraseSubject Adds auditProcedure (adminOnly middleware + defineErrorMiddleware([])) in core-audit/src/integrations/api/procedures.ts. Adds createAuditRouter that captures an IAuditLog and exposes a single eraseSubject mutation with zod input validation. Non-admins receive FORBIDDEN. Barrel re-exports pseudonymize, createAuditErasureHook, createAuditRouter, auditRouter, AuditRouter, auditProcedure, AdminTrpcUser. Adds AUDIT_PSEUDONYM_SALT to turbo.json globalEnv to clear lint warnings. Co-Authored-By: Claude Sonnet 4.6 --- packages/core-audit/src/index.ts | 15 +++ .../src/integrations/api/procedures.ts | 43 +++++++++ .../src/integrations/api/router.test.ts | 93 +++++++++++++++++++ .../core-audit/src/integrations/api/router.ts | 54 +++++++++++ turbo.json | 3 +- 5 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 packages/core-audit/src/integrations/api/procedures.ts create mode 100644 packages/core-audit/src/integrations/api/router.test.ts create mode 100644 packages/core-audit/src/integrations/api/router.ts diff --git a/packages/core-audit/src/index.ts b/packages/core-audit/src/index.ts index 61e6656..d1502b3 100644 --- a/packages/core-audit/src/index.ts +++ b/packages/core-audit/src/index.ts @@ -7,3 +7,18 @@ export { MultiSinkAuditLog } from "./multi-sink-audit-log"; export { auditLogsCollection } from "./audit-logs-collection"; export { bindAudit, type BindAuditOpts } from "./di/bind-audit"; export { AUDIT_SYMBOLS } from "./di/symbols"; +// Phase 3 — GDPR erasure +export { pseudonymize } from "./pseudonymize"; +export { + createAuditErasureHook, + type AuditErasureHookOpts, +} from "./hooks/audit-erasure-hook"; +export { + createAuditRouter, + auditRouter, + type AuditRouter, +} from "./integrations/api/router"; +export { + auditProcedure, + type AdminTrpcUser, +} from "./integrations/api/procedures"; diff --git a/packages/core-audit/src/integrations/api/procedures.ts b/packages/core-audit/src/integrations/api/procedures.ts new file mode 100644 index 0000000..a80e4d1 --- /dev/null +++ b/packages/core-audit/src/integrations/api/procedures.ts @@ -0,0 +1,43 @@ +import { TRPCError } from "@trpc/server"; +import { t } from "@repo/core-shared/trpc/init"; +import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware"; + +/** + * The minimum user shape that the adminOnly middleware expects to find on `ctx`. + * Apps must include this field when creating their tRPC context for requests + * that may reach admin procedures. Unauthenticated requests leave `user` + * undefined, which the middleware treats as non-admin. + */ +export type AdminTrpcUser = { + roles: string[]; +}; + +/** + * Middleware that blocks non-admin callers. + * + * Reads `ctx.user?.roles` from the tRPC context. Throws FORBIDDEN if the + * user is absent or lacks the "admin" role. Apps that mount the auditRouter + * must set `ctx.user` with the authenticated user's roles. + */ +const adminOnly = t.middleware(({ ctx, next }) => { + const user = (ctx as { user?: AdminTrpcUser }).user; + if (!user?.roles.includes("admin")) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Admin role required", + }); + } + return next({ ctx: { ...ctx, user } }); +}); + +/** + * Base procedure for all audit admin routes. + * + * - `adminOnly` middleware gates every mutation/query. + * - `defineErrorMiddleware([])` — no audit-specific domain errors need tRPC + * mapping; the FORBIDDEN thrown by `adminOnly` is a plain TRPCError and + * propagates unchanged. + */ +export const auditProcedure = t.procedure + .use(adminOnly) + .use(defineErrorMiddleware([])); diff --git a/packages/core-audit/src/integrations/api/router.test.ts b/packages/core-audit/src/integrations/api/router.test.ts new file mode 100644 index 0000000..3f3e755 --- /dev/null +++ b/packages/core-audit/src/integrations/api/router.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi } from "vitest"; +import { createAuditRouter } from "./router"; +import type { IAuditLog } from "../../audit-log.interface"; +import type { AdminTrpcUser } from "./procedures"; + +/** + * Minimal harness: create a tRPC caller directly from the router so we + * don't need a real HTTP layer. + */ +function makeCallerWithUser( + auditLog: IAuditLog, + user?: AdminTrpcUser, +) { + const router = createAuditRouter(auditLog); + // Use the tRPC caller factory to invoke mutations directly in tests. + return router.createCaller({ user } as Record); +} + +function makeAuditLog(): IAuditLog { + return { + record: vi.fn().mockResolvedValue(undefined), + eraseSubject: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("auditRouter.eraseSubject", () => { + it("throws FORBIDDEN when ctx.user is absent", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, undefined); + + await expect( + caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(auditLog.eraseSubject).not.toHaveBeenCalled(); + }); + + it("throws FORBIDDEN when user lacks admin role", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, { roles: ["editor", "viewer"] }); + + await expect( + caller.eraseSubject({ actorId: "user_1", mode: "pseudonymize" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(auditLog.eraseSubject).not.toHaveBeenCalled(); + }); + + it("calls eraseSubject with pseudonymize mode for an admin user", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, { roles: ["admin"] }); + + const result = await caller.eraseSubject({ + actorId: "user_1", + mode: "pseudonymize", + }); + + expect(result).toEqual({ ok: true }); + expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_1", "pseudonymize"); + }); + + it("calls eraseSubject with delete mode for an admin user", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, { roles: ["admin"] }); + + const result = await caller.eraseSubject({ + actorId: "user_2", + mode: "delete", + }); + + expect(result).toEqual({ ok: true }); + expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_2", "delete"); + }); + + it("defaults mode to 'pseudonymize' when not provided", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, { roles: ["admin"] }); + + // mode has a .default("pseudonymize") in the schema + await caller.eraseSubject({ actorId: "user_3", mode: "pseudonymize" }); + + expect(auditLog.eraseSubject).toHaveBeenCalledWith("user_3", "pseudonymize"); + }); + + it("rejects empty actorId (schema validation)", async () => { + const auditLog = makeAuditLog(); + const caller = makeCallerWithUser(auditLog, { roles: ["admin"] }); + + await expect( + caller.eraseSubject({ actorId: "", mode: "pseudonymize" }), + ).rejects.toThrow(); + }); +}); diff --git a/packages/core-audit/src/integrations/api/router.ts b/packages/core-audit/src/integrations/api/router.ts new file mode 100644 index 0000000..1026e5b --- /dev/null +++ b/packages/core-audit/src/integrations/api/router.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { t } from "@repo/core-shared/trpc/init"; +import type { IAuditLog } from "../../audit-log.interface"; +import { auditProcedure } from "./procedures"; + +/** + * Creates the audit admin tRPC router. + * + * The `auditLog` parameter is captured at router-creation time. Apps that + * mount this router must pass the `IAuditLog` impl returned by `bindAudit`. + * + * @example + * ```ts + * const { auditLog } = bindAudit(container, { payloadConfig, sinks: ["payload", "stdout"] }); + * const appRouter = t.router({ ..., audit: createAuditRouter(auditLog) }); + * ``` + */ +export function createAuditRouter(auditLog: IAuditLog) { + return t.router({ + eraseSubject: auditProcedure + .input( + z + .object({ + actorId: z.string().min(1), + mode: z.enum(["pseudonymize", "delete"]).default("pseudonymize"), + }) + .strict(), + ) + .mutation(async ({ input }) => { + await auditLog.eraseSubject(input.actorId, input.mode); + return { ok: true as const }; + }), + }); +} + +/** + * Convenience singleton for projects that have a single audit log instance. + * Most callers should use `createAuditRouter` and pass the IAuditLog explicitly. + * This export is a stub that throws at call time if auditLog has not been + * provided — it exists for type inference purposes (`AuditRouter`). + */ +export const auditRouter = createAuditRouter( + new Proxy({} as IAuditLog, { + get(_target, prop) { + if (prop === "then") return undefined; // not a Promise + throw new Error( + `auditRouter singleton used without providing an IAuditLog. ` + + `Use createAuditRouter(auditLog) instead.`, + ); + }, + }), +); + +export type AuditRouter = ReturnType; diff --git a/turbo.json b/turbo.json index f210ad6..694fd7d 100644 --- a/turbo.json +++ b/turbo.json @@ -21,7 +21,8 @@ "SENTRY_ENVIRONMENT", "VERCEL_GIT_COMMIT_SHA", "NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA", - "VERCEL_ENV" + "VERCEL_ENV", + "AUDIT_PSEUDONYM_SALT" ], "boundaries": { "tags": {