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:
2026-10-12 16:47:44 +02:00
co-authored by Claude Opus 5.5
parent 7bb2ca6a51
commit 0154343fd2
16 changed files with 1756 additions and 15 deletions
+51
View File
@@ -0,0 +1,51 @@
"use server";
import { cursorAus, protokollFilterAus } from "@/lib/protokoll-filter";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { protokollSeite, type ProtokollSeite } from "@/server/queries/protokoll";
import { kettePruefenZwischengespeichert } from "@/server/services/protokoll";
/**
* Protokollansicht (#027): nur lesende Aktionen. Bewusst gibt es hier nichts, was einen
* Eintrag aendert oder loescht – das verbietet ohnehin der Trigger, und
* `tests/protokoll-ansicht.test.ts` haelt fest, dass auch kein Code es versucht.
*/
export type KettenAntwort = {
readonly fehlerhafteId?: string | null;
readonly eintraege?: number;
readonly geprueftAm?: string;
readonly fehler?: string;
};
export async function kettePruefenAktion(): Promise<KettenAntwort> {
try {
const { benutzer } = await rechtErforderlich("lesen");
const ergebnis = await kettePruefenZwischengespeichert({ id: benutzer.id, name: benutzer.name });
return {
fehlerhafteId: ergebnis.fehlerhafteId,
eintraege: ergebnis.eintraege,
geprueftAm: ergebnis.geprueftAm.toISOString(),
};
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
throw fehler;
}
}
/** „Ältere laden“: dieselben GET-Filter wie die Seite, Cursor ist die kleinste gezeigte ID. */
export async function aeltereLadenAktion(
query: string,
vor: string,
): Promise<ProtokollSeite | { fehler: string }> {
try {
await rechtErforderlich("lesen");
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: fehler.message };
throw fehler;
}
const cursor = cursorAus(String(vor));
if (cursor === undefined) return { zeilen: [], weiterVor: null };
const filter = protokollFilterAus(Object.fromEntries(new URLSearchParams(String(query).slice(0, 2000))));
return protokollSeite(filter, cursor);
}
+156
View File
@@ -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 };
+109 -2
View File
@@ -2,6 +2,7 @@ import type { Prisma } from "@/generated/prisma/client";
import type { ProtokollErgebnis } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { schwaerzen } from "@/lib/protokoll-schwaerzen";
import { syslogKonfigAus, syslogSender, type SyslogKonfig, type SyslogSender } from "@/lib/syslog";
/**
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
@@ -26,7 +27,7 @@ export async function protokollieren(
client: Prisma.TransactionClient = prisma,
): Promise<void> {
const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur;
await client.protokoll.create({
const zeile = await client.protokoll.create({
data: {
akteurId: akteur.id,
akteurName: akteur.name,
@@ -40,13 +41,119 @@ export async function protokollieren(
diff: eintrag.diff ? (schwaerzen(eintrag.diff) as string) : null,
details: eintrag.details ? (schwaerzen(eintrag.details) as Prisma.InputJsonValue) : undefined,
},
select: { id: true, zeitpunkt: true, hash: true },
});
// Erst nach dem erfolgreichen Schreiben, ohne zu warten (#027): Syslog ist die Kopie,
// die Datenbank die Quelle. Innerhalb einer Transaktion kann ein spaeterer Rollback eine
// Syslog-Zeile ohne Gegenstueck hinterlassen – deren Hash fehlt dann in der Kette und faellt
// beim Vergleich auf; umgekehrt (Eintrag ohne Syslog-Zeile) waere schlimmer.
syslogWeitergeben({
id: zeile.id,
zeitpunkt: zeile.zeitpunkt,
akteur: akteur.name,
aktion: eintrag.aktion,
ergebnis: eintrag.ergebnis,
hostName: eintrag.hostName ?? null,
zielbenutzer: eintrag.zielbenutzer ?? null,
fingerprint: eintrag.fingerprint ?? null,
hash: zeile.hash,
});
}
/** Prueft die Hash-Kette in der Datenbank; liefert die erste fehlerhafte ID oder null. */
let syslog: { konfig: SyslogKonfig; sender: SyslogSender } | null | undefined;
/** Konfiguration einmal je Prozess lesen; Web und Worker haben jeder einen Sender. */
export function syslogKonfig(): SyslogKonfig | null {
if (syslog === undefined) {
const konfig = syslogKonfigAus(process.env);
syslog = konfig ? { konfig, sender: syslogSender(konfig) } : null;
}
return syslog?.konfig ?? null;
}
/** Nur fuer Tests: Umgebung neu lesen. */
export function syslogZuruecksetzen(): void {
syslog?.sender.schliessen();
syslog = undefined;
}
function syslogWeitergeben(eintrag: Parameters<SyslogSender["senden"]>[0]): void {
try {
if (!syslogKonfig()) return;
syslog?.sender.senden(eintrag);
} catch {
// senden() wirft nicht; das hier faengt nur eine kaputte Konfiguration ab.
}
}
/**
* Prueft die Hash-Kette in der Datenbank; liefert die erste fehlerhafte ID oder null.
* Liest jede Zeile – fuer die Oberflaeche `ketteGeprueft()` mit Zwischenspeicher nehmen.
*/
export async function protokollKettePruefen(): Promise<bigint | null> {
const [zeile] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
return zeile?.ergebnis ?? null;
}
export type Kettenpruefung = {
readonly fehlerhafteId: string | null;
readonly eintraege: number;
readonly geprueftAm: Date;
};
/** Wie lange ein Pruefergebnis gilt, bevor „Kette prüfen“ wieder rechnet (#027). */
export const KETTE_ZWISCHENSPEICHER_MS = 5 * 60 * 1000;
let letztePruefung: Kettenpruefung | null = null;
let laufendePruefung: Promise<Kettenpruefung> | null = null;
/**
* Die Pruefung rechnet SHA-256 ueber jede Zeile und waechst mit dem Protokoll; deshalb laeuft
* sie nie beim blossen Seitenaufruf, sondern auf Knopfdruck, und das Ergebnis gilt 5 Minuten
* je Prozess. Gleichzeitige Klicks teilen sich eine laufende Pruefung (#027).
*/
export function ketteZwischengespeichert(jetzt: Date = new Date()): Kettenpruefung | null {
if (!letztePruefung) return null;
return jetzt.getTime() - letztePruefung.geprueftAm.getTime() < KETTE_ZWISCHENSPEICHER_MS
? letztePruefung
: null;
}
export async function kettePruefenZwischengespeichert(
akteur: ProtokollEintrag["akteur"],
): Promise<Kettenpruefung> {
const vorhanden = ketteZwischengespeichert();
if (vorhanden) return vorhanden;
laufendePruefung ??= (async () => {
try {
const eintraege = await prisma.protokoll.count();
const fehlerhaft = await protokollKettePruefen();
const ergebnis = {
fehlerhafteId: fehlerhaft === null ? null : String(fehlerhaft),
eintraege,
geprueftAm: new Date(),
};
if (fehlerhaft !== null) {
// Ein Bruch ist ein Sicherheitsereignis und soll auch beim Syslog ankommen.
await protokollieren({
akteur,
aktion: "protokoll.kette_beschaedigt",
ergebnis: "FEHLER",
details: { fehlerhafteId: String(fehlerhaft) },
});
}
letztePruefung = ergebnis;
return ergebnis;
} finally {
laufendePruefung = null;
}
})();
return laufendePruefung;
}
/** Nur fuer Tests. */
export function ketteZwischenspeicherLeeren(): void {
letztePruefung = null;
}