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 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 16:25:09 +02:00
parent 270897c550
commit 131efd5d2f
5 changed files with 207 additions and 1 deletions

View File

@@ -7,3 +7,18 @@ export { MultiSinkAuditLog } from "./multi-sink-audit-log";
export { auditLogsCollection } from "./audit-logs-collection"; export { auditLogsCollection } from "./audit-logs-collection";
export { bindAudit, type BindAuditOpts } from "./di/bind-audit"; export { bindAudit, type BindAuditOpts } from "./di/bind-audit";
export { AUDIT_SYMBOLS } from "./di/symbols"; 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";

View File

@@ -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([]));

View File

@@ -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<string, unknown>);
}
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();
});
});

View File

@@ -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<typeof createAuditRouter>;

View File

@@ -21,7 +21,8 @@
"SENTRY_ENVIRONMENT", "SENTRY_ENVIRONMENT",
"VERCEL_GIT_COMMIT_SHA", "VERCEL_GIT_COMMIT_SHA",
"NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA", "NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA",
"VERCEL_ENV" "VERCEL_ENV",
"AUDIT_PSEUDONYM_SALT"
], ],
"boundaries": { "boundaries": {
"tags": { "tags": {