Merge branch 'phase/027-protokoll-ansicht'

This commit is contained in:
2026-10-12 16:48:27 +02:00
20 changed files with 2257 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;
}