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:
@@ -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";
|
||||
|
||||
43
packages/core-audit/src/integrations/api/procedures.ts
Normal file
43
packages/core-audit/src/integrations/api/procedures.ts
Normal 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([]));
|
||||
93
packages/core-audit/src/integrations/api/router.test.ts
Normal file
93
packages/core-audit/src/integrations/api/router.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
54
packages/core-audit/src/integrations/api/router.ts
Normal file
54
packages/core-audit/src/integrations/api/router.ts
Normal 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>;
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user