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 { 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";
|
||||||
|
|||||||
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",
|
"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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user