From c98d27e70ca608bb8ae18485790859a8b605011b Mon Sep 17 00:00:00 2001 From: Raffael Sheikh Date: Sun, 7 Jun 2026 12:06:13 +0200 Subject: [PATCH] api: Audit-Log verdrahtet (auditedProcedure an kritische Mutationen) withAudit war als tRPC-Wrapper definiert, aber an keine Procedure gehaengt (audit_event blieb leer). Jetzt eine echte tRPC-v11-Middleware-Procedure (auditedProcedure), die nach erfolgreicher Mutation best-effort einen audit_event schreibt (Fehler beim Audit kippen die Mutation nicht). Verdrahtet an die folgenreichen Zustandsuebergaenge: - blocker.resolve - project.archive, project.setPhase, project.addPhaseGate Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/routers/blocker.ts | 4 +-- apps/api/src/routers/project.ts | 8 +++--- apps/api/src/trpc.ts | 47 ++++++++++++++++++--------------- 3 files changed, 32 insertions(+), 27 deletions(-) diff --git a/apps/api/src/routers/blocker.ts b/apps/api/src/routers/blocker.ts index a9c093d..8894830 100644 --- a/apps/api/src/routers/blocker.ts +++ b/apps/api/src/routers/blocker.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { TRPCError } from '@trpc/server'; import { eq, isNull, desc } from 'drizzle-orm'; import { blocker, project, user } from '@cockpit/db'; -import { router, protectedProcedure } from '../trpc.js'; +import { router, protectedProcedure, auditedProcedure } from '../trpc.js'; export const blockerRouter = router({ listOpen: protectedProcedure.query(async ({ ctx }) => { @@ -52,7 +52,7 @@ export const blockerRouter = router({ return created; }), - resolve: protectedProcedure + resolve: auditedProcedure('blocker.resolve') .input( z.object({ blockerId: z.string().uuid(), diff --git a/apps/api/src/routers/project.ts b/apps/api/src/routers/project.ts index 7ba843c..3b9d0dc 100644 --- a/apps/api/src/routers/project.ts +++ b/apps/api/src/routers/project.ts @@ -11,7 +11,7 @@ import { type Project, } from '@cockpit/db'; import { ProjectSchemas } from '@cockpit/shared'; -import { router, protectedProcedure } from '../trpc.js'; +import { router, protectedProcedure, auditedProcedure } from '../trpc.js'; const PHASE_ORDER = ['definition', 'planung', 'umsetzung', 'abschluss'] as const; function nextPhase(p: (typeof PHASE_ORDER)[number]): (typeof PHASE_ORDER)[number] { @@ -116,7 +116,7 @@ export const projectRouter = router({ return result; }), - archive: protectedProcedure + archive: auditedProcedure('project.archive') .input(ProjectSchemas.archiveProjectInput) .mutation(async ({ ctx, input }) => { const p = ( @@ -145,7 +145,7 @@ export const projectRouter = router({ }), // Phase direkt setzen (Leitfaden Kap. 2). - setPhase: protectedProcedure + setPhase: auditedProcedure('project.setPhase') .input(ProjectSchemas.setPhaseInput) .mutation(async ({ ctx, input }) => { const [updated] = await ctx.db @@ -158,7 +158,7 @@ export const projectRouter = router({ }), // Gate-Review am Phasenende (Leitfaden Kap. 5), optional Folgephase freigeben. - addPhaseGate: protectedProcedure + addPhaseGate: auditedProcedure('project.addPhaseGate') .input(ProjectSchemas.addPhaseGateInput) .mutation(async ({ ctx, input }) => { const [gate] = await ctx.db diff --git a/apps/api/src/trpc.ts b/apps/api/src/trpc.ts index a78dfab..f7648f7 100644 --- a/apps/api/src/trpc.ts +++ b/apps/api/src/trpc.ts @@ -3,8 +3,9 @@ import './types.js'; import { initTRPC, TRPCError } from '@trpc/server'; import type { FastifyRequest, FastifyReply } from 'fastify'; -import type { User } from '@cockpit/db'; +import { auditEvent, type User } from '@cockpit/db'; import { getDb } from './db.js'; +import { logger } from './lib/logger.js'; export interface Context { user: User | null; @@ -45,25 +46,29 @@ export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => { return next({ ctx: { ...ctx, user: ctx.user } }); }); -// Audit-Wrapper: schreibt nach erfolgreicher Mutation einen audit_event. -// Genutzt als zusätzliche Middleware auf Mutations. -export function withAudit | unknown>( - action: string, -) { - return async function audit(opts: { - ctx: Context; - input: TInput; - next: () => Promise<{ ok: true; data: TResult } | TResult>; - }) { - const result = await opts.next(); - if (!opts.ctx.user) return result; - const { auditEvent } = await import('@cockpit/db'); - await opts.ctx.db.insert(auditEvent).values({ - actorUserId: opts.ctx.user.id, - action, - entityKind: action.split('.')[0] ?? 'unknown', - payload: { input: opts.input as unknown }, - }); +/** + * Auditierte Procedure: wie `protectedProcedure`, schreibt aber nach erfolgreicher + * Ausführung einen `audit_event`. Nutzung: + * auditedProcedure('blocker.resolve').input(schema).mutation(...) + * Das Audit ist best-effort — ein Fehler beim Schreiben darf die eigentliche + * Mutation nicht scheitern lassen. + */ +export function auditedProcedure(action: string) { + return protectedProcedure.use(async ({ ctx, next, getRawInput }) => { + const result = await next(); + if (result.ok) { + try { + const rawInput = await getRawInput(); + await ctx.db.insert(auditEvent).values({ + actorUserId: ctx.user.id, + action, + entityKind: action.split('.')[0] ?? 'unknown', + payload: { input: (rawInput ?? null) as unknown }, + }); + } catch (err) { + logger.warn({ err, action }, 'audit_event konnte nicht geschrieben werden'); + } + } return result; - }; + }); }