- 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>
118 lines
4.5 KiB
TypeScript
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,
|
|
};
|
|
}
|