Files
craftvia/src/server/services/audit/viewer.ts
T
msolarczekandClaude Opus 5 b8b8bddefe Fundament: Audit mit IP/User-Agent, Mail-Absender je Mandant
- Migration audit_request_context: ip_address, user_agent an audit_logs (Spec §26)
- writeAuditLog/writePlatformAudit erfassen IP (X-Forwarded-For) und User-Agent
  aus dem Request; außerhalb eines Requests (Worker/Skripte) null
- Audit-Viewer liest die neuen Spalten statt Heuristik aus before/after
- deliverMail nutzt Anzeigename und Reply-To aus TenantSettings (Spec §33.2);
  Absenderadresse bleibt Plattform-Domain (SPF/DKIM), Header-Injection bereinigt

Gate: tsc, lint, build, 24/24 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 12:20:41 +02:00

118 lines
4.5 KiB
TypeScript

import type { Prisma } from "@prisma/client";
import { z } from "zod";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* Read-only audit log viewer (spec §26). Requires `audit:read`; always tenant-bound via ctx.db
* (the tenant client filters AuditLog by tenantId). Audit rows are never modified here.
*/
export const AUDIT_PAGE_SIZE = 50;
const dateStr = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional()
.catch(undefined);
const optStr = (max: number) =>
z
.string()
.trim()
.max(max)
.transform((v) => v || undefined)
.optional()
.catch(undefined);
export const auditFilterSchema = z.object({
from: dateStr,
to: dateStr,
actorId: optStr(64),
action: optStr(40),
entity: optStr(60),
entityId: optStr(128),
page: z.coerce.number().int().min(1).max(100_000).catch(1),
});
export type AuditFilter = z.infer<typeof auditFilterSchema>;
export function auditWhere(f: AuditFilter): Prisma.AuditLogWhereInput {
const createdAt: Prisma.DateTimeFilter = {};
if (f.from) createdAt.gte = new Date(`${f.from}T00:00:00.000Z`);
if (f.to) createdAt.lt = new Date(new Date(`${f.to}T00:00:00.000Z`).getTime() + 86_400_000);
return {
...(f.from || f.to ? { createdAt } : {}),
...(f.actorId ? { actorId: f.actorId } : {}),
...(f.action ? { action: f.action } : {}),
...(f.entity ? { entity: f.entity } : {}),
...(f.entityId ? { entityId: { contains: f.entityId } } : {}),
};
}
export async function queryAuditLog(ctx: ServiceCtx, input: unknown) {
assertCan(ctx, "audit:read");
const filter = auditFilterSchema.parse(input ?? {});
const where = auditWhere(filter);
const [rows, total, actions, entities, users] = await Promise.all([
ctx.db.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (filter.page - 1) * AUDIT_PAGE_SIZE,
take: AUDIT_PAGE_SIZE,
select: { id: true, createdAt: true, actorId: true, action: true, entity: true, entityId: true, scope: true },
}),
ctx.db.auditLog.count({ where }),
ctx.db.auditLog.findMany({ distinct: ["action"], select: { action: true }, orderBy: { action: "asc" } }),
ctx.db.auditLog.findMany({ distinct: ["entity"], select: { entity: true }, orderBy: { entity: "asc" } }),
ctx.db.user.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
]);
return {
rows,
total,
page: filter.page,
pages: Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE)),
filter,
facets: { actions: actions.map((a) => a.action), entities: entities.map((e) => e.entity) },
actors: users,
};
}
export type DiffRow = { key: string; before: string | null; after: string | null; changed: boolean };
function show(v: unknown): string | null {
if (v === undefined) return null;
if (v === null) return "null";
return typeof v === "string" ? v : JSON.stringify(v);
}
/** Field-wise before/after comparison (top-level keys; nested values rendered as JSON). */
export function diffAudit(before: unknown, after: unknown): DiffRow[] {
const b = before && typeof before === "object" && !Array.isArray(before) ? (before as Record<string, unknown>) : null;
const a = after && typeof after === "object" && !Array.isArray(after) ? (after as Record<string, unknown>) : null;
if (!b && !a) {
if (before === undefined && after === undefined) return [];
if (before == null && after == null) return [];
return [{ key: "value", before: show(before ?? undefined), after: show(after ?? undefined), changed: show(before) !== show(after) }];
}
const keys = [...new Set([...Object.keys(b ?? {}), ...Object.keys(a ?? {})])].sort();
return keys.map((key) => {
const bv = show(b?.[key]);
const av = show(a?.[key]);
return { key, before: bv, after: av, changed: bv !== av };
});
}
export async function getAuditEntry(ctx: ServiceCtx, id: unknown) {
assertCan(ctx, "audit:read");
const entryId = z.string().min(1).max(64).parse(id);
const row = await ctx.db.auditLog.findFirst({ where: { id: entryId } });
if (!row) throw new ServiceError("not_found", "audit entry not found");
const actor = row.actorId ? await ctx.db.user.findFirst({ where: { id: row.actorId }, select: { name: true } }) : null;
return {
...row,
actorName: actor?.name ?? null,
diff: diffAudit(row.before ?? undefined, row.after ?? undefined),
// captured by writeAuditLog from the request (spec §26); null for worker/script writes
ip: row.ipAddress,
userAgent: row.userAgent,
};
}