feat(protokoll): Audit-Log mit Filtern, Kettenpruefung und CSV (#027)
Das Protokoll war bisher nur in der Datenbank lesbar. Die Ansicht zeigt es jeder Rolle mit lesbaren Aktionstexten, aufklappbarem Diff, Details und Hashes; ein Test haelt fest, dass jede Aktion im Code einen Text hat. Geblaettert wird ueber die ID statt OFFSET, weil das Protokoll waehrend des Lesens waechst. Die Kettenpruefung liest jede Zeile und laeuft deshalb nur auf Knopfdruck, mit 5 Minuten Zwischenspeicher; ein Bruch wird selbst protokolliert. Der CSV-Export streamt in Stapeln, entschaerft Formeln und steht selbst im Protokoll. protokollieren() reicht jeden Eintrag nach dem Schreiben an Syslog weiter, ohne Details und Diff. Kein Weg aendert oder loescht Eintraege; ein struktureller Test sichert das ab, zusaetzlich zum Trigger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { aktionenDerGruppe } from "@/lib/protokoll-aktionen";
|
||||
import { zeitraum, type ProtokollFilter } from "@/lib/protokoll-filter";
|
||||
|
||||
/**
|
||||
* Lesemodell des Protokolls (#027). Jede Rolle darf das ganze Protokoll lesen (PLAN §4);
|
||||
* die Rechtepruefung machen Seite, Action und Route selbst.
|
||||
*
|
||||
* Geblaettert wird ueber die ID („älter als“), nicht ueber OFFSET: das Protokoll waechst
|
||||
* waehrend man liest, und OFFSET wuerde dann Zeilen doppelt zeigen oder ueberspringen –
|
||||
* ausserdem muesste die Datenbank bei OFFSET 10 000 erst 10 000 Zeilen lesen und verwerfen.
|
||||
*/
|
||||
export const SEITENGROESSE = 50;
|
||||
|
||||
export type ProtokollZeile = {
|
||||
readonly id: string;
|
||||
readonly zeitpunkt: string;
|
||||
readonly akteurName: string;
|
||||
readonly aktion: string;
|
||||
readonly ergebnis: "ERFOLG" | "FEHLER" | "VERWEIGERT";
|
||||
readonly hostName: string | null;
|
||||
readonly zielbenutzer: string | null;
|
||||
readonly fingerprint: string | null;
|
||||
readonly diff: string | null;
|
||||
/** Bereits beim Schreiben geschwaerzt (#005); oberste Ebene als Liste, Werte formatiert. */
|
||||
readonly details: { readonly name: string; readonly wert: string }[] | null;
|
||||
readonly vorgaengerHash: string;
|
||||
readonly hash: string;
|
||||
};
|
||||
|
||||
const AUSWAHL = {
|
||||
id: true,
|
||||
zeitpunkt: true,
|
||||
akteurName: true,
|
||||
aktion: true,
|
||||
ergebnis: true,
|
||||
hostName: true,
|
||||
zielbenutzer: true,
|
||||
fingerprint: true,
|
||||
diff: true,
|
||||
details: true,
|
||||
vorgaengerHash: true,
|
||||
hash: true,
|
||||
} satisfies Prisma.ProtokollSelect;
|
||||
|
||||
type Rohzeile = Prisma.ProtokollGetPayload<{ select: typeof AUSWAHL }>;
|
||||
|
||||
export function protokollWhere(filter: ProtokollFilter, vor?: bigint): Prisma.ProtokollWhereInput {
|
||||
const und: Prisma.ProtokollWhereInput[] = [];
|
||||
const { ab, vor: bis } = zeitraum(filter);
|
||||
if (ab || bis) und.push({ zeitpunkt: { ...(ab ? { gte: ab } : {}), ...(bis ? { lt: bis } : {}) } });
|
||||
if (filter.gruppe) und.push({ aktion: { in: aktionenDerGruppe(filter.gruppe) } });
|
||||
if (filter.akteur) und.push({ akteurName: filter.akteur });
|
||||
if (filter.host) und.push({ hostName: { contains: filter.host, mode: "insensitive" } });
|
||||
if (filter.ergebnis) und.push({ ergebnis: filter.ergebnis });
|
||||
if (filter.q) {
|
||||
const enthaelt = { contains: filter.q, mode: "insensitive" } as const;
|
||||
und.push({ OR: [{ fingerprint: enthaelt }, { hostName: enthaelt }, { zielbenutzer: enthaelt }] });
|
||||
}
|
||||
if (vor !== undefined) und.push({ id: { lt: vor } });
|
||||
return und.length ? { AND: und } : {};
|
||||
}
|
||||
|
||||
/** Texte bleiben Texte; Zahlen, Listen und Objekte als eingerueckte JSON. */
|
||||
function detailsListe(details: Prisma.JsonValue): ProtokollZeile["details"] {
|
||||
if (details == null) return null;
|
||||
const alsText = (wert: unknown) => (typeof wert === "string" ? wert : JSON.stringify(wert, null, 2));
|
||||
if (typeof details !== "object" || Array.isArray(details))
|
||||
return [{ name: "Wert", wert: alsText(details) }];
|
||||
const eintraege = Object.entries(details).map(([name, wert]) => ({ name, wert: alsText(wert) }));
|
||||
return eintraege.length ? eintraege : null;
|
||||
}
|
||||
|
||||
function zeileAus(z: Rohzeile): ProtokollZeile {
|
||||
return {
|
||||
id: String(z.id),
|
||||
zeitpunkt: z.zeitpunkt.toISOString(),
|
||||
akteurName: z.akteurName,
|
||||
aktion: z.aktion,
|
||||
ergebnis: z.ergebnis,
|
||||
hostName: z.hostName,
|
||||
zielbenutzer: z.zielbenutzer,
|
||||
fingerprint: z.fingerprint,
|
||||
diff: z.diff,
|
||||
details: detailsListe(z.details),
|
||||
vorgaengerHash: z.vorgaengerHash,
|
||||
hash: z.hash,
|
||||
};
|
||||
}
|
||||
|
||||
export type ProtokollSeite = {
|
||||
readonly zeilen: ProtokollZeile[];
|
||||
/** ID fuer „ältere laden“; fehlt, wenn es nichts Aelteres mehr gibt. */
|
||||
readonly weiterVor: string | null;
|
||||
};
|
||||
|
||||
/** Eine Seite, neueste zuerst; `vor` ist die kleinste ID der vorigen Seite. */
|
||||
export async function protokollSeite(filter: ProtokollFilter, vor?: bigint): Promise<ProtokollSeite> {
|
||||
const roh = await prisma.protokoll.findMany({
|
||||
where: protokollWhere(filter, vor),
|
||||
orderBy: { id: "desc" },
|
||||
take: SEITENGROESSE + 1,
|
||||
select: AUSWAHL,
|
||||
});
|
||||
const mehr = roh.length > SEITENGROESSE;
|
||||
const zeilen = roh.slice(0, SEITENGROESSE).map(zeileAus);
|
||||
return { zeilen, weiterVor: mehr ? (zeilen.at(-1)?.id ?? null) : null };
|
||||
}
|
||||
|
||||
/** Kopfzeile der Seite: Gesamtzahl und Treffer des Filters. */
|
||||
export async function protokollZaehlen(
|
||||
filter: ProtokollFilter,
|
||||
): Promise<{ gesamt: number; treffer: number }> {
|
||||
const [gesamt, treffer] = await Promise.all([
|
||||
prisma.protokoll.count(),
|
||||
prisma.protokoll.count({ where: protokollWhere(filter) }),
|
||||
]);
|
||||
return { gesamt, treffer };
|
||||
}
|
||||
|
||||
/** Namen fuer den Akteur-Filter – haeufigste zuerst, begrenzt, damit die Liste handlich bleibt. */
|
||||
export async function protokollAkteure(): Promise<string[]> {
|
||||
const gruppen = await prisma.protokoll.groupBy({
|
||||
by: ["akteurName"],
|
||||
_count: { _all: true },
|
||||
orderBy: { _count: { akteurName: "desc" } },
|
||||
take: 100,
|
||||
});
|
||||
return gruppen.map((g) => g.akteurName).sort((a, b) => a.localeCompare(b, "de"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Treffer in Stapeln, neueste zuerst – fuer den CSV-Export. Liest je Stapel nur
|
||||
* `stapel` Zeilen, damit auch ein grosses Protokoll nie ganz im Speicher liegt.
|
||||
*/
|
||||
export async function* protokollStapelweise(
|
||||
filter: ProtokollFilter,
|
||||
stapel = 500,
|
||||
): AsyncGenerator<Rohzeile[], void, undefined> {
|
||||
let vor: bigint | undefined;
|
||||
for (;;) {
|
||||
const zeilen = await prisma.protokoll.findMany({
|
||||
where: protokollWhere(filter, vor),
|
||||
orderBy: { id: "desc" },
|
||||
take: stapel,
|
||||
select: AUSWAHL,
|
||||
});
|
||||
if (zeilen.length === 0) return;
|
||||
yield zeilen;
|
||||
if (zeilen.length < stapel) return;
|
||||
vor = zeilen.at(-1)!.id;
|
||||
}
|
||||
}
|
||||
|
||||
export type { Rohzeile as ProtokollRohzeile };
|
||||
Reference in New Issue
Block a user