Merge branch 'phase/027-protokoll-ansicht'
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user