diff --git a/docs/anleitung/bilder/protokoll-desktop.png b/docs/anleitung/bilder/protokoll-desktop.png new file mode 100644 index 0000000..3c1eab3 Binary files /dev/null and b/docs/anleitung/bilder/protokoll-desktop.png differ diff --git a/docs/anleitung/bilder/protokoll-mobil.png b/docs/anleitung/bilder/protokoll-mobil.png new file mode 100644 index 0000000..254c309 Binary files /dev/null and b/docs/anleitung/bilder/protokoll-mobil.png differ diff --git a/docs/issues/027-protokoll-ansicht.md b/docs/issues/027-protokoll-ansicht.md index 815ee77..192aa05 100644 --- a/docs/issues/027-protokoll-ansicht.md +++ b/docs/issues/027-protokoll-ansicht.md @@ -1,19 +1,40 @@ # #027 Protokollansicht, CSV-Export, Syslog -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.5.0 **Labels:** ui, backend -**Geschätzt:** 2 h **Tatsächlich:** – +**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min ## Ziel Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog. ## Aufgaben -- [ ] wird zu Beginn der Phase verfeinert +- [x] `/protokoll` statt Platzhalter, jede Rolle liest: Tabelle mit Zeitpunkt (TT.MM.JJJJ HH:MM:SS), Aktion (lesbar, farbig), Akteur, Host, Zielbenutzer, Fingerprint (gekürzt), Ergebnis; unter `md` Karten +- [x] Zeile aufklappbar: Diff im Diff-Box-Stil, Details als Liste (Werte als JSON), Hash und Vorgänger-Hash +- [x] Lesbare Texte für alle Aktionen in `src/lib/protokoll-aktionen.ts`, gruppiert (Anmeldung, Host-Key, Ausrollen, Schlüssel, Widerruf/Rotation, Zielkonto, Abgleich, Hosts, Personen und Vorlagen, Protokoll) +- [x] Filter per GET: Zeitraum (Berliner Kalendertage), Aktionsgruppe, Akteur, Host, Ergebnis, Freitext (Fingerprint/Host/Benutzer) +- [x] 50 je Seite, „Ältere laden“ hängt per Server Action an; Cursor über `id`, kein `OFFSET`; `?vor=` als Einstieg +- [x] Kopfzeile mit Anzahl und Syslog-Ziel; „Kette prüfen“ (Server Action) mit Ergebnis-Zwischenspeicher für 5 Minuten, Warnung mit Link auf die erste fehlerhafte ID +- [x] CSV-Export `/api/protokoll.csv`: eigene Rechteprüfung, gleiche Filter, gestreamt in Stapeln zu 500, Formel-Entschärfung über `src/lib/csv.ts`, Export selbst als `protokoll.exportiert` protokolliert +- [x] Kein Lösch-/Bearbeitungsweg; struktureller Test gegen `protokoll.update/delete…` und `UPDATE/DELETE/TRUNCATE "Protokoll"` in `src/` +- [x] Syslog nach RFC 5424 (`src/lib/syslog.ts`, UDP oder TCP mit Oktettzählung, ohne neue Abhängigkeit), eingehängt in `protokollieren()` nach dem Schreiben +- [x] `.env.example` und `docker-compose.yml` (web und worker) um `SSHKV_SYSLOG_*` +- [x] Tests: Query/Cursor/Filter, CSV-Route, Kettenprüfung, Syslog (Format, Escaping, Länge, UDP-/TCP-Empfänger), Aktionstexte, Struktur; E2E `tests/e2e/protokoll.spec.ts` +- [x] Screenshots `protokoll-desktop.png` und `protokoll-mobil.png` ## Akzeptanzkriterien -- [ ] wird zu Beginn der Phase verfeinert +- [x] Jede `aktion: "…"` im Quellcode hat einen lesbaren Text (Test) +- [x] Filter und „Ältere laden“ liefern weder Lücken noch Doppelte, auch wenn zwischendurch Einträge dazukommen +- [x] CSV ohne Sitzung → 403; Kopfzeile `id;zeitpunkt;akteur;aktion;…;hash`; große Protokolle werden in Stapeln gelesen +- [x] Manipulierte Zeile → Warnung mit ID; unversehrte Kette → „Hash-Kette unversehrt“ +- [x] Ausgefallener Syslog-Server verhindert keine Aktion; Fehler höchstens einmal je Minute im Log +- [x] 1440 und 390 px ohne Querscrollen ## Abhängigkeiten #005 ## Notizen / Entscheidungen +- **Kettenprüfung nur auf Knopfdruck, 5 Minuten zwischengespeichert (je Prozess).** `protokoll_kette_pruefen()` rechnet SHA-256 über jede Zeile und wächst linear mit dem Protokoll; beim bloßen Seitenaufruf wäre das bei 100 000 Einträgen spürbar. Gleichzeitige Klicks teilen sich eine laufende Prüfung. Ein Bruch wird selbst als `protokoll.kette_beschaedigt` (FEHLER) protokolliert, damit er auch beim Syslog ankommt. +- **CSV gestreamt** über einen `ReadableStream` mit `pull`: je Stapel 500 Zeilen (Cursor über `id`), der nächste Stapel erst, wenn der Client den vorigen abgenommen hat. Der Exporteintrag wird vor dem ersten Byte geschrieben – scheitert er, gibt es keinen Export. +- **IDs sind nicht lückenlos** (Spalten-Default und Trigger ziehen beide aus der Sequenz); Cursor und Tests verlassen sich nur auf die Ordnung. +- **Syslog:** RFC 5424, SD-ID `sshkv@32473`, Parameter id/akteur/aktion/ergebnis/host/zielbenutzer/fingerprint/hash (escaped nach §6.3.3, Steuerzeichen ersetzt, je Wert ≤ 120 Byte, Zeile ≤ 2048 Byte), MSGID = Aktion, Severity ERFOLG=notice, VERWEIGERT=warning, FEHLER=err, Facility standardmäßig `authpriv` (`SSHKV_SYSLOG_FACILITY`). Diff und Details gehen nie hinaus. Fire-and-forget nach dem `create`; innerhalb einer Transaktion kann ein späterer Rollback eine Syslog-Zeile ohne Gegenstück hinterlassen – das ist die harmlosere Richtung (ihr Hash fehlt dann in der Kette). +- `--ok` nur für erfolgreiche Aktionen, die etwas ändern; Anmeldungen, Abrufe und Export bleiben neutral, Fehler/Verweigert immer `--danger`. diff --git a/scripts/screenshots.ts b/scripts/screenshots.ts index d691d4f..74255f5 100644 --- a/scripts/screenshots.ts +++ b/scripts/screenshots.ts @@ -49,6 +49,8 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri pfad: "/vorlagen", folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]', }, + // Audit-Log (#027): unter md Karten statt Tabelle, die Seite darf nicht seitwaerts scrollen. + { name: "protokoll", pfad: "/protokoll" }, ]; const ANSICHTEN = [ diff --git a/src/app/(app)/protokoll/page.tsx b/src/app/(app)/protokoll/page.tsx index 13e57a4..aaed961 100644 --- a/src/app/(app)/protokoll/page.tsx +++ b/src/app/(app)/protokoll/page.tsx @@ -1,18 +1,202 @@ +import { Download, ScrollText, Search } from "lucide-react"; import type { Metadata } from "next"; -import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; +import { KettenPruefung } from "@/components/protokoll/KettenPruefung"; +import { ProtokollListe } from "@/components/protokoll/ProtokollListe"; +import { Button, buttonKlassen } from "@/components/ui/Button"; +import { Auswahlliste, Feld } from "@/components/ui/Feld"; +import { Karte } from "@/components/ui/Karte"; +import { Leerzustand } from "@/components/ui/Leerzustand"; +import { Seitenkopf } from "@/components/ui/Seitenkopf"; +import { AKTIONSGRUPPEN } from "@/lib/protokoll-aktionen"; +import { + cursorAus, + ERGEBNIS_TEXT, + protokollFilterAktiv, + protokollFilterAus, + protokollFilterQuery, +} from "@/lib/protokoll-filter"; +import { syslogZielText } from "@/lib/syslog"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { protokollAkteure, protokollSeite, protokollZaehlen } from "@/server/queries/protokoll"; +import { ketteZwischengespeichert, syslogKonfig } from "@/server/services/protokoll"; export const metadata: Metadata = { title: "Audit-Log" }; -export default async function Protokoll() { +type Suchparameter = Promise>; + +/** + * Audit-Log (#027): jede Rolle darf lesen. Filter per GET, damit Links, „ältere laden“ und + * der CSV-Export denselben Ausschnitt meinen. Es gibt hier bewusst keinen Knopf, der einen + * Eintrag aendert oder loescht. + */ +export default async function Protokoll({ searchParams }: { searchParams: Suchparameter }) { // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); + await sitzungErforderlich("lesen", "/protokoll"); + const parameter = await searchParams; + const filter = protokollFilterAus(parameter); + const vor = cursorAus(parameter["vor"]); + const [seite, zaehler, akteure] = await Promise.all([ + protokollSeite(filter, vor), + protokollZaehlen(filter), + protokollAkteure(), + ]); + const gefiltert = protokollFilterAktiv(filter); + const query = protokollFilterQuery(filter); + const kette = ketteZwischengespeichert(); + const syslog = syslogKonfig(); + return ( - +
+ + + CSV exportieren + + } + /> + + +
+

+ + {zaehler.gesamt.toLocaleString("de-DE")} + {" "} + Einträge insgesamt +

+ {gefiltert && ( +

+ + {zaehler.treffer.toLocaleString("de-DE")} + {" "} + passen zum Filter +

+ )} +

+ Syslog:{" "} + {syslog ? ( + {syslogZielText(syslog)} + ) : ( + nicht eingerichtet + )} +

+
+
+ +
+
+ +
+ + + + + {/* Ein gefilterter Name, der nicht mehr unter den haeufigsten ist, bleibt waehlbar. */} + {[...new Set([...akteure, ...(filter.akteur ? [filter.akteur] : [])])].map((name) => ( + + ))} + + + + {Object.entries(AKTIONSGRUPPEN).map(([wert, text]) => ( + + ))} + + + + {Object.entries(ERGEBNIS_TEXT).map(([wert, text]) => ( + + ))} + + + +
+ + {gefiltert && ( + + )} +
+ + + {vor !== undefined && ( +

+ Einträge älter als #{String(vor)}.{" "} + + Zu den neuesten + +

+ )} + + {seite.zeilen.length === 0 ? ( + gefiltert ? ( + } + titel="Keine Treffer" + text="Kein Eintrag passt zu Suche und Filter." + aktion={} + /> + ) : ( + } + titel="Noch keine Einträge" + text="Sobald jemand sich anmeldet oder etwas ändert, steht es hier." + /> + ) + ) : ( + + {/* key: neue Filter oder ein neuer Cursor beginnen eine frische Liste. */} + + + )} +
); } diff --git a/src/app/api/protokoll.csv/route.ts b/src/app/api/protokoll.csv/route.ts new file mode 100644 index 0000000..d3ff104 --- /dev/null +++ b/src/app/api/protokoll.csv/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { CSV_BOM, csvZeile } from "@/lib/csv"; +import { PROTOKOLL_CSV_KOPF, protokollCsvWerte, protokollFilterAus } from "@/lib/protokoll-filter"; +import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht"; +import { protokollStapelweise } from "@/server/queries/protokoll"; +import { protokollieren } from "@/server/services/protokoll"; + +/** + * Protokoll als CSV (#027) mit denselben Filtern wie die Seite. Jede Rolle darf lesen; der + * Handler prueft das selbst. + * + * Gestreamt: die Datenbank liefert Stapel zu 500 Zeilen, jeder Stapel geht sofort hinaus. + * Ein Protokoll mit Hunderttausenden Eintraegen muss so nie ganz in den Speicher. + */ +export async function GET(anfrage: Request) { + let benutzer; + try { + ({ benutzer } = await rechtErforderlich("lesen")); + } catch (fehler) { + if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 }); + throw fehler; + } + const filter = protokollFilterAus(Object.fromEntries(new URL(anfrage.url).searchParams)); + + // Vor dem ersten Byte: wer das Protokoll mitnimmt, steht selbst darin – und scheitert das + // Schreiben, gibt es auch keinen Export. + await protokollieren({ + akteur: { id: benutzer.id, name: benutzer.name }, + aktion: "protokoll.exportiert", + ergebnis: "ERFOLG", + details: { filter: Object.fromEntries(Object.entries(filter).filter(([, wert]) => wert)) }, + }); + + const kodierer = new TextEncoder(); + const stapel = protokollStapelweise(filter); + const strom = new ReadableStream({ + start(steuerung) { + steuerung.enqueue(kodierer.encode(CSV_BOM + csvZeile(PROTOKOLL_CSV_KOPF) + "\r\n")); + }, + // pull statt Schleife in start: der naechste Stapel wird erst gelesen, wenn der Client + // den vorigen abgenommen hat (Gegendruck). + async pull(steuerung) { + try { + const { value, done } = await stapel.next(); + if (done) { + steuerung.close(); + return; + } + steuerung.enqueue( + kodierer.encode(value.map((z) => csvZeile(protokollCsvWerte(z)) + "\r\n").join("")), + ); + } catch (fehler) { + steuerung.error(fehler); + } + }, + async cancel() { + await stapel.return(undefined); + }, + }); + + const tag = new Date().toISOString().slice(0, 10); + return new NextResponse(strom, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="sshkv-protokoll-${tag}.csv"`, + "Cache-Control": "no-store", + }, + }); +} diff --git a/src/components/protokoll/KettenPruefung.tsx b/src/components/protokoll/KettenPruefung.tsx new file mode 100644 index 0000000..4b0f1f8 --- /dev/null +++ b/src/components/protokoll/KettenPruefung.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { ShieldAlert, ShieldCheck, ShieldQuestion } from "lucide-react"; +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/Button"; +import { uhrzeit } from "@/lib/format"; +import { kettePruefenAktion, type KettenAntwort } from "@/server/actions/protokoll"; + +/** + * Integritaet der Hash-Kette (#027). Geprueft wird nur auf Knopfdruck – die Pruefung liest + * jede Zeile; ein Ergebnis der letzten 5 Minuten bringt die Seite schon mit. + */ +export function KettenPruefung({ anfang }: { anfang: KettenAntwort | null }) { + const [ergebnis, setErgebnis] = useState(anfang); + const [laeuft, starten] = useTransition(); + + const pruefen = () => + starten(async () => { + setErgebnis(await kettePruefenAktion()); + }); + + return ( +
+ + +
+ ); +} + +function Status({ ergebnis }: { ergebnis: KettenAntwort | null }) { + if (!ergebnis) { + return ( +

+ + Hash-Kette noch nicht geprüft +

+ ); + } + if (ergebnis.fehler) { + return

{ergebnis.fehler}

; + } + const wann = ergebnis.geprueftAm ? ` um ${uhrzeit(ergebnis.geprueftAm)} Uhr` : ""; + if (!ergebnis.fehlerhafteId) { + return ( +

+ + + Hash-Kette unversehrt + + {" "} + · {ergebnis.eintraege?.toLocaleString("de-DE")} Einträge geprüft{wann} + + +

+ ); + } + const id = ergebnis.fehlerhafteId; + // Die Seite beginnt beim fehlerhaften Eintrag, der Anker hebt ihn hervor. + const ziel = `/protokoll?vor=${(BigInt(id) + 1n).toString()}#eintrag-${id}`; + return ( +
+ +

+ Hash-Kette beschädigt. Der Eintrag{" "} + + #{id} + {" "} + stimmt nicht mit seinem Hash oder Vorgänger überein – ab hier ist das Protokoll nicht mehr belegbar. + Vergleichen Sie mit der Syslog-Kopie und sichern Sie die Datenbank.{wann && ` Geprüft${wann}.`} +

+
+ ); +} diff --git a/src/components/protokoll/ProtokollListe.tsx b/src/components/protokoll/ProtokollListe.tsx new file mode 100644 index 0000000..cb998bd --- /dev/null +++ b/src/components/protokoll/ProtokollListe.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { clsx } from "clsx"; +import { ChevronDown, History } from "lucide-react"; +import { useState, useTransition } from "react"; +import { Badge, type BadgeVariante } from "@/components/ui/Badge"; +import { Button } from "@/components/ui/Button"; +import { DiffBox } from "@/components/ui/DiffBox"; +import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle"; +import { aktionText, aktionTon, type AktionsTon } from "@/lib/protokoll-aktionen"; +import { diffZeilen, ERGEBNIS_TEXT, protokollZeit } from "@/lib/protokoll-filter"; +import { fingerprintKurz } from "@/lib/schluessel"; +import type { ProtokollSeite, ProtokollZeile } from "@/server/queries/protokoll"; +import { aeltereLadenAktion } from "@/server/actions/protokoll"; + +const TON_FARBE: Record = { ok: "text-ok", gefahr: "text-danger", neutral: "text-text" }; +const ERGEBNIS_BADGE: Record = { + ERFOLG: "neutral", + FEHLER: "gefahr", + VERWEIGERT: "gefahr", +}; + +/** + * Protokolleintraege als Tabelle (ab md) bzw. Karten (darunter), jede Zeile aufklappbar + * (#027). „Ältere laden“ haengt die naechste Seite an, statt die Liste zu ersetzen – wer + * eine Spur verfolgt, will das Neuere dabei nicht aus dem Blick verlieren. + */ +export function ProtokollListe({ seite, query }: { seite: ProtokollSeite; query: string }) { + const [zeilen, setZeilen] = useState(seite.zeilen); + const [weiterVor, setWeiterVor] = useState(seite.weiterVor); + const [offen, setOffen] = useState>(new Set()); + const [fehler, setFehler] = useState(); + const [laedt, starten] = useTransition(); + + const umschalten = (id: string) => + setOffen((vorher) => { + const neu = new Set(vorher); + if (neu.has(id)) neu.delete(id); + else neu.add(id); + return neu; + }); + + const aeltereLaden = () => { + if (!weiterVor) return; + starten(async () => { + const antwort = await aeltereLadenAktion(query, weiterVor); + if ("fehler" in antwort) { + setFehler(antwort.fehler); + return; + } + setFehler(undefined); + setZeilen((vorher) => [...vorher, ...antwort.zeilen]); + setWeiterVor(antwort.weiterVor); + }); + }; + + return ( +
+ {/* Unter md Karten statt der Tabelle: acht Spalten passen nicht auf 390 px. */} +
    + {zeilen.map((z) => ( +
  • + +
    + + + Einzelheiten + + +
    +
  • + ))} +
+ + + {zeilen.map((z) => { + const istOffen = offen.has(z.id); + return umschalten(z.id)} />; + })} + + + {fehler &&

{fehler}

} + {weiterVor && ( +
+ +
+ )} +
+ ); +} + +function AktionZelle({ z }: { z: ProtokollZeile }) { + return ( + + {aktionText(z.aktion)} + + ); +} + +function ErgebnisBadge({ ergebnis }: { ergebnis: ProtokollZeile["ergebnis"] }) { + return {ERGEBNIS_TEXT[ergebnis]}; +} + +function ZeilenPaar({ z, offen, umschalten }: { z: ProtokollZeile; offen: boolean; umschalten: () => void }) { + const detailId = `eintrag-${z.id}-einzelheiten`; + return ( + <> + + + {/* Anker fuer den Sprung aus der Kettenpruefung zur ersten fehlerhaften ID. */} + + {protokollZeit(z.zeitpunkt)} + + + + + + {z.akteurName} + + {z.hostName ?? } + + + {z.zielbenutzer ?? } + + + {z.fingerprint ? {fingerprintKurz(z.fingerprint)} : } + + + + + + + + + {offen && ( + + + + + + )} + + ); +} + +function Leer() { + return –; +} + +/** Kopf einer Karte in der mobilen Ansicht. */ +function ZeileKompakt({ z }: { z: ProtokollZeile }) { + return ( +
+
+ {protokollZeit(z.zeitpunkt)} + +
+ + + + {z.akteurName} + {(z.hostName || z.zielbenutzer) && ( + + {z.zielbenutzer ? `${z.zielbenutzer}@` : ""} + {z.hostName ?? "–"} + + )} + {z.fingerprint && ( + {fingerprintKurz(z.fingerprint)} + )} +
+ ); +} + +function Einzelheiten({ z }: { z: ProtokollZeile }) { + return ( +
+
+
ID
+
{z.id}
+
Aktion (technisch)
+
{z.aktion}
+ {z.fingerprint && ( + <> +
Fingerprint
+
{z.fingerprint}
+ + )} +
Hash
+
+ {z.hash} +
+
Vorgänger-Hash
+
{z.vorgaengerHash || "– (erster Eintrag)"}
+
+ + {z.diff && ( +
+

Diff

+ +
+ )} + + {z.details && ( +
+

Details

+
+ {z.details.map((d) => ( +
+
{d.name}
+
{d.wert}
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/lib/protokoll-aktionen.ts b/src/lib/protokoll-aktionen.ts new file mode 100644 index 0000000..c4494e2 --- /dev/null +++ b/src/lib/protokoll-aktionen.ts @@ -0,0 +1,137 @@ +/** + * Lesbare Beschriftung der technischen Protokoll-Aktionen (#027). + * + * Das Protokoll speichert stabile Schluessel wie `authorized_keys.geschrieben`, damit sich + * Auswertungen (CSV, Syslog, SIEM) nicht an Oberflaechentexten aufhaengen. Die Ansicht + * uebersetzt sie hier. `tests/protokoll-aktionen.test.ts` sucht jede `aktion: "…"` im + * Quellcode und verlangt einen Eintrag – eine neue Aktion ohne Text faellt so sofort auf. + * + * `aendert`: Die Aktion veraendert etwas (Datei geschrieben, Schluessel erzeugt …) und wird bei + * Erfolg in --ok gezeigt. Lesende oder rein informative Aktionen (Anmeldung, Abruf, Export) + * bleiben neutral, damit das Grün die Stellen markiert, an denen sich etwas getan hat. + */ + +export const AKTIONSGRUPPEN = { + anmeldung: "Anmeldung", + hostkey: "Host-Key", + ausrollen: "Ausrollen", + schluessel: "Schlüssel", + widerruf: "Widerruf/Rotation", + zielkonto: "Zielkonto", + abgleich: "Abgleich", + hosts: "Hosts", + stammdaten: "Personen und Vorlagen", + protokoll: "Protokoll", +} as const; + +export type Aktionsgruppe = keyof typeof AKTIONSGRUPPEN; + +type Beschreibung = { readonly text: string; readonly gruppe: Aktionsgruppe; readonly aendert: boolean }; + +const a = (text: string, gruppe: Aktionsgruppe, aendert = true): Beschreibung => ({ text, gruppe, aendert }); + +export const AKTIONEN: Readonly> = { + // Anmeldung und Bestaetigung (#004) + "anmeldung.oidc": a("Anmeldung (OIDC)", "anmeldung", false), + "anmeldung.oidc.erstmals": a("Erste Anmeldung (OIDC)", "anmeldung"), + "anmeldung.verweigert": a("Anmeldung verweigert", "anmeldung", false), + "anmeldung.notfall": a("Anmeldung Notfall-Admin", "anmeldung", false), + "anmeldung.notfall.fehlgeschlagen": a("Notfall-Anmeldung fehlgeschlagen", "anmeldung", false), + "anmeldung.notfall.gesperrt": a("Notfall-Anmeldung gesperrt", "anmeldung", false), + "anmeldung.notfall.totp_falsch": a("Notfall-Anmeldung: TOTP falsch", "anmeldung", false), + "bestaetigung.oidc": a("Anmeldung bestätigt (Re-Auth)", "anmeldung", false), + "notfall.totp_eingerichtet": a("TOTP für Notfall-Admin eingerichtet", "anmeldung"), + + // Host-Keys (#009) + "hostkey.abgerufen": a("Host-Key abgerufen", "hostkey", false), + "hostkey.bestaetigt": a("Host-Key bestätigt", "hostkey"), + "hostkey.ersetzt": a("Host-Key ersetzt", "hostkey"), + "hostkey.abgelehnt": a("Host-Key abgelehnt", "hostkey"), + "hostkey.abweichung": a("Host-Key weicht ab – Verbindung abgebrochen", "hostkey", false), + + // Ausrollen (#016, #017) + "authorized_keys.geschrieben": a("authorized_keys geschrieben", "ausrollen"), + "privatekey.ausgerollt": a("Private Key ausgerollt", "ausrollen"), + "verwendung.public.angelegt": a("Public Key zugewiesen", "ausrollen"), + "verwendung.public.entfernen": a("Public-Key-Zuweisung zum Entfernen vorgemerkt", "ausrollen"), + "verwendung.private.angelegt": a("Private Key zugewiesen", "ausrollen"), + "verwendung.private.entfernen": a("Private-Key-Zuweisung zum Entfernen vorgemerkt", "ausrollen"), + + // Schluessel (#012, #013, #014) + "schluessel.erzeugt": a("Schlüssel erzeugt", "schluessel"), + "schluessel.importiert": a("Schlüssel importiert", "schluessel"), + "schluessel.uebernommen": a("Schlüssel vom Host übernommen", "schluessel"), + "schluessel.selbst_hinterlegt": a("Eigenen Public Key hinterlegt", "schluessel"), + "schluessel.selbst_entfernt": a("Eigenen Public Key entfernt", "schluessel"), + "schluessel.geaendert": a("Schlüssel geändert", "schluessel"), + "privatekey.angefordert": a("Private Key angefordert", "schluessel", false), + "privatekey.angezeigt": a("Private Key angezeigt", "schluessel", false), + "privatekey.heruntergeladen": a("Private Key heruntergeladen", "schluessel", false), + "privatekey.geloescht": a("Private Key gelöscht", "schluessel"), + "deploymentkey.erzeugt": a("Deployment-Key erzeugt", "schluessel"), + + // Widerruf und Rotation (#023, #024) + "schluessel.widerruf.eingeleitet": a("Widerruf eingeleitet", "widerruf"), + "schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"), + "schluessel.rotiert": a("Schlüssel rotiert", "widerruf"), + "schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"), + + // Zielkonten (#018) + "zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"), + "zielkonto.geaendert": a("Zielkonto geändert", "zielkonto"), + "zielkonto.entfernt": a("Zielkonto entfernt", "zielkonto"), + + // Abgleich und Uebernahme (#021, #022) + abgleich: a("Abgleich durchgeführt", "abgleich", false), + "uebernahme.verwalten": a("Fremden Key übernommen und verwaltet", "abgleich"), + "uebernahme.bekannt": a("Fremden Key als bekannt aufgenommen", "abgleich"), + + // Hosts, Gruppen, Kunden (#008, #010) + "host.angelegt": a("Host angelegt", "hosts"), + "host.geaendert": a("Host geändert", "hosts"), + "host.archiviert": a("Host archiviert", "hosts"), + "host.bootstrap.angefordert": a("Einrichtung angefordert", "hosts", false), + "host.bootstrap": a("Host eingerichtet (Bootstrap)", "hosts"), + "host.bootstrap.geprueft": a("Einrichtung geprüft", "hosts"), + "hostgruppe.angelegt": a("Hostgruppe angelegt", "hosts"), + "hostgruppe.geaendert": a("Hostgruppe geändert", "hosts"), + "kunde.angelegt": a("Kunde angelegt", "hosts"), + verbindungstest: a("Verbindung geprüft", "hosts", false), + + // Personen und Vorlagen (#013, #019) + "person.angelegt": a("Person angelegt", "stammdaten"), + "person.geaendert": a("Person geändert", "stammdaten"), + "person.archiviert": a("Person archiviert", "stammdaten"), + "person.verknuepft": a("Person mit Konto verknüpft", "stammdaten"), + "vorlage.angelegt": a("Vorlage angelegt", "stammdaten"), + "vorlage.geaendert": a("Vorlage geändert", "stammdaten"), + "vorlage.archiviert": a("Vorlage archiviert", "stammdaten"), + + // Protokoll selbst (#027) + "protokoll.exportiert": a("Protokoll exportiert (CSV)", "protokoll", false), + "protokoll.kette_beschaedigt": a("Hash-Kette beschädigt gefunden", "protokoll", false), +}; + +/** Text zur Aktion; unbekannte (etwa aus aelteren Versionen) bleiben technisch lesbar. */ +export function aktionText(aktion: string): string { + return AKTIONEN[aktion]?.text ?? aktion; +} + +/** Alle bekannten Aktionen einer Gruppe – Grundlage fuer den Filter `aktion IN (…)`. */ +export function aktionenDerGruppe(gruppe: Aktionsgruppe): string[] { + return Object.entries(AKTIONEN) + .filter(([, b]) => b.gruppe === gruppe) + .map(([aktion]) => aktion); +} + +export type AktionsTon = "ok" | "gefahr" | "neutral"; + +/** Farbe in der Liste: Fehlschlag und Verweigerung immer --danger, Aenderungen --ok. */ +export function aktionTon(aktion: string, ergebnis: "ERFOLG" | "FEHLER" | "VERWEIGERT"): AktionsTon { + if (ergebnis !== "ERFOLG") return "gefahr"; + return AKTIONEN[aktion]?.aendert ? "ok" : "neutral"; +} + +export function istAktionsgruppe(wert: string): wert is Aktionsgruppe { + return Object.hasOwn(AKTIONSGRUPPEN, wert); +} diff --git a/src/lib/protokoll-filter.ts b/src/lib/protokoll-filter.ts new file mode 100644 index 0000000..bb323c8 --- /dev/null +++ b/src/lib/protokoll-filter.ts @@ -0,0 +1,211 @@ +import { istAktionsgruppe, type Aktionsgruppe } from "@/lib/protokoll-aktionen"; +import { ZEITZONE } from "@/lib/format"; + +/** + * Filter der Protokollansicht (#027). Framework-frei: Seite, CSV-Route und Server Action + * lesen dieselben GET-Parameter, damit der Export genau den Ausschnitt der Seite liefert. + */ +export const PROTOKOLL_ERGEBNISSE = ["ERFOLG", "FEHLER", "VERWEIGERT"] as const; +export type ProtokollErgebnisWert = (typeof PROTOKOLL_ERGEBNISSE)[number]; + +export const ERGEBNIS_TEXT: Record = { + ERFOLG: "Erfolg", + FEHLER: "Fehler", + VERWEIGERT: "Verweigert", +}; + +export type ProtokollFilter = { + /** Tag im Format JJJJ-MM-TT (Europe/Berlin), einschliesslich. */ + readonly von: string; + readonly bis: string; + readonly gruppe: Aktionsgruppe | ""; + readonly akteur: string; + readonly host: string; + readonly ergebnis: ProtokollErgebnisWert | ""; + readonly q: string; +}; + +export const LEERER_FILTER: ProtokollFilter = { + von: "", + bis: "", + gruppe: "", + akteur: "", + host: "", + ergebnis: "", + q: "", +}; + +const TAG = /^\d{4}-\d{2}-\d{2}$/; + +function gueltigerTag(wert: string): string { + if (!TAG.test(wert)) return ""; + return Number.isNaN(new Date(`${wert}T00:00:00Z`).getTime()) ? "" : wert; +} + +export function protokollFilterAus( + parameter: Record, +): ProtokollFilter { + const einzeln = (name: string) => { + const wert = parameter[name]; + return ((Array.isArray(wert) ? wert[0] : wert) ?? "").trim().slice(0, 200); + }; + const gruppe = einzeln("gruppe"); + const ergebnis = einzeln("ergebnis"); + return { + von: gueltigerTag(einzeln("von")), + bis: gueltigerTag(einzeln("bis")), + gruppe: istAktionsgruppe(gruppe) ? gruppe : "", + akteur: einzeln("akteur"), + host: einzeln("host"), + ergebnis: (PROTOKOLL_ERGEBNISSE as readonly string[]).includes(ergebnis) + ? (ergebnis as ProtokollErgebnisWert) + : "", + q: einzeln("q"), + }; +} + +export function protokollFilterAktiv(filter: ProtokollFilter): boolean { + return Object.values(filter).some(Boolean); +} + +/** Dieselben Filter als Query-String (CSV-Link, „ältere laden“, Anker auf eine ID). */ +export function protokollFilterQuery(filter: ProtokollFilter, extra: Record = {}): string { + const p = new URLSearchParams(); + for (const [name, wert] of Object.entries(filter)) if (wert) p.set(name, wert); + for (const [name, wert] of Object.entries(extra)) p.set(name, wert); + const text = p.toString(); + return text ? `?${text}` : ""; +} + +/** Cursor „älter als ID“; nur positive Ganzzahlen, sonst keiner. */ +export function cursorAus(wert: string | string[] | undefined): bigint | undefined { + const text = Array.isArray(wert) ? wert[0] : wert; + if (!text || !/^\d{1,19}$/.test(text)) return undefined; + const zahl = BigInt(text); + return zahl > 0n ? zahl : undefined; +} + +/** Abstand Europe/Berlin zu UTC in Millisekunden zu einem Zeitpunkt (Sommer-/Winterzeit). */ +function berlinVersatz(zeitpunkt: Date): number { + const teile = new Intl.DateTimeFormat("en-US", { + timeZone: ZEITZONE, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).formatToParts(zeitpunkt); + const wert = (typ: string) => Number(teile.find((t) => t.type === typ)?.value ?? 0); + const alsUtc = Date.UTC( + wert("year"), + wert("month") - 1, + wert("day"), + wert("hour"), + wert("minute"), + wert("second"), + ); + return alsUtc - Math.floor(zeitpunkt.getTime() / 1000) * 1000; +} + +/** + * Mitternacht des Tages in Berlin als Zeitpunkt. Die Oberflaeche zeigt Berliner Zeit (#002), + * also muss „von 12.10.“ auch Berliner Mitternacht meinen – nicht UTC. + */ +export function berlinTagesbeginn(tag: string): Date { + const utcMitternacht = new Date(`${tag}T00:00:00Z`); + const erst = new Date(utcMitternacht.getTime() - berlinVersatz(utcMitternacht)); + // Zweiter Schritt fuer Tage, an denen die Uhr umgestellt wird. + return new Date(utcMitternacht.getTime() - berlinVersatz(erst)); +} + +/** Zeitraum als halboffenes Intervall [ab, vor) – „bis“ schliesst den ganzen Tag ein. */ +export function zeitraum(filter: Pick): { ab?: Date; vor?: Date } { + const ergebnis: { ab?: Date; vor?: Date } = {}; + if (filter.von) ergebnis.ab = berlinTagesbeginn(filter.von); + if (filter.bis) { + const folgetag = new Date(`${filter.bis}T00:00:00Z`); + folgetag.setUTCDate(folgetag.getUTCDate() + 1); + ergebnis.vor = berlinTagesbeginn(folgetag.toISOString().slice(0, 10)); + } + return ergebnis; +} + +/** „12.10.2026 08:42:17“ – Sekunden gehoeren ins Protokoll, Komma nicht (#027). */ +const ZEITSTEMPEL = new Intl.DateTimeFormat("de-DE", { + timeZone: ZEITZONE, + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", +}); + +export function protokollZeit(wert: Date | string): string { + return ZEITSTEMPEL.format(new Date(wert)).replace(",", ""); +} + +export type ProtokollDiffZeile = { art: "hinzu" | "entfernt" | "kontext"; text: string }; + +/** + * Der gespeicherte Diff ist Text mit `+ `/`- ` je Zeile (#016); Private-Key-Ausrollen schreibt + * nur „anlegen ~/.ssh/…“ – solche Zeilen bleiben Kontext. + */ +export function diffZeilen(diff: string): ProtokollDiffZeile[] { + return diff.split("\n").map((zeile) => { + if (zeile.startsWith("+")) return { art: "hinzu", text: zeile.replace(/^\+ ?/, "") }; + if (zeile.startsWith("-")) return { art: "entfernt", text: zeile.replace(/^- ?/, "") }; + return { art: "kontext", text: zeile }; + }); +} + +/** Spalten des CSV-Exports (#027): technisch, damit Auswertungen stabil bleiben. */ +export const PROTOKOLL_CSV_KOPF = [ + "id", + "zeitpunkt", + "akteur", + "aktion", + "ergebnis", + "host", + "zielbenutzer", + "fingerprint", + "diff", + "details", + "vorgaengerHash", + "hash", +] as const; + +export type ProtokollCsvQuelle = { + readonly id: bigint | string; + readonly zeitpunkt: Date; + readonly akteurName: string; + readonly aktion: string; + readonly ergebnis: string; + readonly hostName: string | null; + readonly zielbenutzer: string | null; + readonly fingerprint: string | null; + readonly diff: string | null; + readonly details: unknown; + readonly vorgaengerHash: string; + readonly hash: string; +}; + +export function protokollCsvWerte(z: ProtokollCsvQuelle): (string | null)[] { + return [ + String(z.id), + z.zeitpunkt.toISOString(), + z.akteurName, + z.aktion, + z.ergebnis, + z.hostName, + z.zielbenutzer, + z.fingerprint, + z.diff, + z.details == null ? null : JSON.stringify(z.details), + z.vorgaengerHash, + z.hash, + ]; +} diff --git a/src/server/actions/protokoll.ts b/src/server/actions/protokoll.ts new file mode 100644 index 0000000..0560281 --- /dev/null +++ b/src/server/actions/protokoll.ts @@ -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 { + 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 { + 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); +} diff --git a/src/server/queries/protokoll.ts b/src/server/queries/protokoll.ts new file mode 100644 index 0000000..10ed0ca --- /dev/null +++ b/src/server/queries/protokoll.ts @@ -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 { + 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 { + 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 { + 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 }; diff --git a/src/server/services/protokoll.ts b/src/server/services/protokoll.ts index f6cd9fd..74344b6 100644 --- a/src/server/services/protokoll.ts +++ b/src/server/services/protokoll.ts @@ -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 { 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[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 { 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 | 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 { + 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; +} diff --git a/tests/e2e/protokoll.spec.ts b/tests/e2e/protokoll.spec.ts new file mode 100644 index 0000000..1d0fbe0 --- /dev/null +++ b/tests/e2e/protokoll.spec.ts @@ -0,0 +1,52 @@ +import { readFile } from "node:fs/promises"; +import { expect, test } from "@playwright/test"; +import { anmelden } from "./hilfen"; + +/** + * Audit-Log (#027) gegen den gebauten Stand: Liste, Filter, Aufklappen, Kettenpruefung und + * CSV-Export. Die Anmeldung selbst erzeugt den ersten Eintrag – ohne SSH-Testziele lauffaehig. + */ +test("Audit-Log zeigt, filtert, prüft und exportiert", async ({ page }) => { + await anmelden(page); + await page.goto("/protokoll"); + await expect(page.getByRole("heading", { name: "Audit-Log" })).toBeVisible(); + + const tabelle = page.getByRole("table", { name: "Protokolleinträge" }); + await expect(tabelle.getByRole("row").nth(1)).toBeVisible(); + + // Filter nach Aktionsgruppe: nur noch Anmeldungen. + await page.getByLabel("Aktion").selectOption({ label: "Anmeldung" }); + await page.getByRole("button", { name: "Filtern" }).click(); + await expect(page).toHaveURL(/gruppe=anmeldung/); + const zeilen = tabelle.locator("tbody > tr"); + await expect(zeilen.first()).toBeVisible(); + const aktionen = await zeilen.locator("td:nth-child(2)").allInnerTexts(); + expect(aktionen.length).toBeGreaterThan(0); + for (const aktion of aktionen) expect(aktion).toMatch(/Anmeld|TOTP/); + + // Zeile aufklappen: Hash und Vorgaenger-Hash erscheinen. + await tabelle + .getByRole("button", { name: /Einzelheiten zu Eintrag/ }) + .first() + .click(); + await expect(tabelle.locator('[data-feld="hash"]').first()).toHaveText(/^[0-9a-f]{64}$/); + + // Kettenpruefung auf Knopfdruck. + await page.getByRole("button", { name: "Kette prüfen" }).click(); + await expect(page.getByText("Hash-Kette unversehrt")).toBeVisible({ timeout: 30_000 }); + + // CSV mit denselben Filtern; der Export landet selbst im Protokoll. + const [download] = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("link", { name: "CSV exportieren" }).click(), + ]); + expect(download.suggestedFilename()).toMatch(/^sshkv-protokoll-\d{4}-\d{2}-\d{2}\.csv$/); + const text = (await readFile((await download.path())!, "utf8")).replace(/^/, ""); + expect(text.split("\r\n")[0]).toBe( + '"id";"zeitpunkt";"akteur";"aktion";"ergebnis";"host";"zielbenutzer";"fingerprint";"diff";"details";"vorgaengerHash";"hash"', + ); + expect(text).toContain("anmeldung."); + + await page.goto("/protokoll?gruppe=protokoll"); + await expect(tabelle.getByText("Protokoll exportiert (CSV)").first()).toBeVisible(); +}); diff --git a/tests/protokoll-aktionen.test.ts b/tests/protokoll-aktionen.test.ts new file mode 100644 index 0000000..107a215 --- /dev/null +++ b/tests/protokoll-aktionen.test.ts @@ -0,0 +1,75 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { AKTIONEN, AKTIONSGRUPPEN, aktionenDerGruppe, aktionText, aktionTon } from "@/lib/protokoll-aktionen"; + +/** + * Jede Protokoll-Aktion im Quellcode hat einen lesbaren Text (#027). Gesucht wird in allen + * Dateien, die `protokollieren(` rufen, nach `aktion: …`/`aktion = …` (auch in Ternaries), und + * ueberall nach Literalen, die mit einem bekannten Praefix beginnen (`"schluessel.uebernommen"` + * wird z. B. als Argument durchgereicht). + */ +function dateien(verzeichnis: string): string[] { + return readdirSync(verzeichnis).flatMap((name) => { + const pfad = path.join(verzeichnis, name); + if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad); + return /\.tsx?$/.test(name) ? [pfad] : []; + }); +} + +const QUELLEN = dateien("src").map((datei) => ({ datei, inhalt: readFileSync(datei, "utf8") })); +const LITERAL = /"([^"\n]+)"/g; + +function aktionenImCode(): Map { + const gefunden = new Map(); + const praefixe = new Set(Object.keys(AKTIONEN).map((a) => a.split(".")[0]!)); + for (const { datei, inhalt } of QUELLEN) { + if (inhalt.includes("protokollieren(")) { + for (const treffer of inhalt.matchAll(/\baktion\s*[:=]\s*([^,\n]+)/g)) { + // Bei `a === "x" ? "eins" : "zwei"` zaehlen nur die Zweige, nicht die Bedingung. + const ausdruck = treffer[1]!.includes("?") + ? treffer[1]!.slice(treffer[1]!.indexOf("?")) + : treffer[1]!; + for (const [, literal] of ausdruck.matchAll(LITERAL)) gefunden.set(literal!, datei); + } + } + for (const [, literal] of inhalt.matchAll(/"([a-z_]+(?:\.[a-z_]+)+)"/g)) { + if (praefixe.has(literal!.split(".")[0]!) && !literal!.endsWith(".ts")) gefunden.set(literal!, datei); + } + } + return gefunden; +} + +describe("Protokoll-Aktionen", () => { + const imCode = aktionenImCode(); + + it("findet die Aktionen im Quellcode", () => { + expect(imCode.size).toBeGreaterThan(40); + expect(imCode.has("authorized_keys.geschrieben")).toBe(true); + expect(imCode.has("hostkey.ersetzt")).toBe(true); + expect(imCode.has("privatekey.angezeigt")).toBe(true); + expect(imCode.has("schluessel.uebernommen")).toBe(true); + }); + + it.each([...imCode])("%s hat einen lesbaren Text (%s)", (aktion) => { + expect(AKTIONEN[aktion], `${aktion} fehlt in src/lib/protokoll-aktionen.ts`).toBeDefined(); + }); + + it("jede Gruppe hat Aktionen", () => { + for (const gruppe of Object.keys(AKTIONSGRUPPEN) as (keyof typeof AKTIONSGRUPPEN)[]) { + expect(aktionenDerGruppe(gruppe).length, gruppe).toBeGreaterThan(0); + } + }); + + it("faellt fuer unbekannte Aktionen auf den technischen Namen zurueck", () => { + expect(aktionText("hostkey.ersetzt")).toBe("Host-Key ersetzt"); + expect(aktionText("gibt.es.nicht")).toBe("gibt.es.nicht"); + }); + + it("faerbt nach Ergebnis und Art", () => { + expect(aktionTon("authorized_keys.geschrieben", "ERFOLG")).toBe("ok"); + expect(aktionTon("anmeldung.oidc", "ERFOLG")).toBe("neutral"); + expect(aktionTon("anmeldung.verweigert", "VERWEIGERT")).toBe("gefahr"); + expect(aktionTon("verbindungstest", "FEHLER")).toBe("gefahr"); + }); +}); diff --git a/tests/protokoll-ansicht.test.ts b/tests/protokoll-ansicht.test.ts new file mode 100644 index 0000000..9c2c649 --- /dev/null +++ b/tests/protokoll-ansicht.test.ts @@ -0,0 +1,349 @@ +import { createSocket } from "node:dgram"; +import type { AddressInfo } from "node:net"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { prisma } from "@/lib/db"; +import { LEERER_FILTER, protokollFilterAus, type ProtokollFilter } from "@/lib/protokoll-filter"; +import { + protokollAkteure, + protokollSeite, + protokollStapelweise, + protokollZaehlen, + SEITENGROESSE, +} from "@/server/queries/protokoll"; +import { + kettePruefenZwischengespeichert, + ketteZwischengespeichert, + ketteZwischenspeicherLeeren, + protokollieren, + syslogKonfig, + syslogZuruecksetzen, +} from "@/server/services/protokoll"; +import { datenbankLeeren } from "./hilfen/db"; + +/** + * Protokollansicht (#027) gegen die Testdatenbank: Filter, Cursor, CSV-Route, Kettenpruefung + * und Syslog-Weiterleitung. Die Sitzung der Route wird nachgebildet – geprueft wird, dass der + * Handler das Recht selbst verlangt. + */ +const recht = vi.hoisted(() => ({ erlaubt: true })); +vi.mock("@/server/auth/sitzungspflicht", async (original) => { + const echt = await original(); + return { + ...echt, + rechtErforderlich: vi.fn(async () => { + if (!recht.erlaubt) throw new echt.RechteFehler("Nicht angemeldet."); + return { benutzer: { id: "b-leser", name: "Lea Leser", rolle: "LESER" } }; + }), + }; +}); + +type Roh = { + aktion: string; + akteurName?: string; + ergebnis?: "ERFOLG" | "FEHLER" | "VERWEIGERT"; + hostName?: string | null; + zielbenutzer?: string | null; + fingerprint?: string | null; + zeitpunkt?: Date; + diff?: string; +}; + +async function eintragen(daten: Roh) { + return prisma.protokoll.create({ + data: { + akteurName: daten.akteurName ?? "Florian", + aktion: daten.aktion, + ergebnis: daten.ergebnis ?? "ERFOLG", + hostName: daten.hostName ?? null, + zielbenutzer: daten.zielbenutzer ?? null, + fingerprint: daten.fingerprint ?? null, + diff: daten.diff ?? null, + ...(daten.zeitpunkt ? { zeitpunkt: daten.zeitpunkt } : {}), + }, + }); +} + +const filter = (teil: Partial): ProtokollFilter => ({ ...LEERER_FILTER, ...teil }); + +beforeEach(async () => { + recht.erlaubt = true; + ketteZwischenspeicherLeeren(); + await datenbankLeeren(); +}); + +describe("protokollSeite", () => { + it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => { + // IDs sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – nur die Ordnung zaehlt. + const ids: string[] = []; + for (let i = 0; i < SEITENGROESSE + 7; i += 1) + ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id)); + const erste = await protokollSeite(LEERER_FILTER); + expect(erste.zeilen.map((z) => z.id)).toEqual(ids.slice(0, SEITENGROESSE)); + expect(erste.weiterVor).toBe(ids[SEITENGROESSE - 1]); + + // Ein neuer Eintrag zwischen zwei Seiten verschiebt den Cursor nicht (anders als OFFSET). + await eintragen({ aktion: "host.geaendert" }); + const zweite = await protokollSeite(LEERER_FILTER, BigInt(erste.weiterVor!)); + expect(zweite.zeilen.map((z) => z.id)).toEqual(ids.slice(SEITENGROESSE)); + expect(zweite.weiterVor).toBeNull(); + }); + + it("liefert Hash, Vorgaenger-Hash und Details als Liste", async () => { + await protokollieren({ + akteur: "System", + aktion: "abgleich", + ergebnis: "ERFOLG", + details: { befunde: 2, benutzer: ["root"], hinweis: "ok" }, + }); + const [zeile] = (await protokollSeite(LEERER_FILTER)).zeilen; + expect(zeile!.hash).toMatch(/^[0-9a-f]{64}$/); + expect(zeile!.vorgaengerHash).toBe(""); + // jsonb ordnet die Schluessel selbst; die Reihenfolge ist daher nicht Teil der Zusage. + expect(zeile!.details).toHaveLength(3); + expect(zeile!.details).toEqual( + expect.arrayContaining([ + { name: "befunde", wert: "2" }, + { name: "benutzer", wert: '[\n "root"\n]' }, + { name: "hinweis", wert: "ok" }, + ]), + ); + }); + + it("filtert nach Aktionsgruppe, Ergebnis, Akteur, Host und Freitext", async () => { + await eintragen({ aktion: "anmeldung.oidc", akteurName: "Anna" }); + await eintragen({ aktion: "anmeldung.verweigert", akteurName: "Mallory", ergebnis: "VERWEIGERT" }); + await eintragen({ + aktion: "authorized_keys.geschrieben", + hostName: "srv-pve01", + zielbenutzer: "backup", + fingerprint: "SHA256:abcdef", + }); + await eintragen({ aktion: "hostkey.ersetzt", hostName: "srv-db02" }); + + const aktionen = async (f: Partial) => + (await protokollSeite(filter(f))).zeilen.map((z) => z.aktion); + + expect(await aktionen({ gruppe: "anmeldung" })).toEqual(["anmeldung.verweigert", "anmeldung.oidc"]); + expect(await aktionen({ gruppe: "hostkey" })).toEqual(["hostkey.ersetzt"]); + expect(await aktionen({ ergebnis: "VERWEIGERT" })).toEqual(["anmeldung.verweigert"]); + expect(await aktionen({ akteur: "Anna" })).toEqual(["anmeldung.oidc"]); + expect(await aktionen({ host: "PVE" })).toEqual(["authorized_keys.geschrieben"]); + expect(await aktionen({ q: "abcdef" })).toEqual(["authorized_keys.geschrieben"]); + expect(await aktionen({ q: "BACKUP" })).toEqual(["authorized_keys.geschrieben"]); + expect(await aktionen({ q: "db02" })).toEqual(["hostkey.ersetzt"]); + expect(await aktionen({ gruppe: "ausrollen", host: "db02" })).toEqual([]); + + expect(await protokollZaehlen(filter({ gruppe: "anmeldung" }))).toEqual({ gesamt: 4, treffer: 2 }); + expect(await protokollAkteure()).toEqual(["Anna", "Florian", "Mallory"]); + }); + + it("filtert den Zeitraum nach Berliner Kalendertagen", async () => { + // 11.10. 23:30 Berlin = 21:30 UTC; 12.10. 00:30 Berlin = 22:30 UTC am 11.10. + await eintragen({ aktion: "host.angelegt", zeitpunkt: new Date("2026-10-11T21:30:00Z") }); + await eintragen({ aktion: "host.geaendert", zeitpunkt: new Date("2026-10-11T22:30:00Z") }); + await eintragen({ aktion: "host.archiviert", zeitpunkt: new Date("2026-10-12T21:59:00Z") }); + await eintragen({ aktion: "kunde.angelegt", zeitpunkt: new Date("2026-10-12T22:01:00Z") }); + + const aktionen = async (f: Partial) => + (await protokollSeite(filter(f))).zeilen.map((z) => z.aktion); + expect(await aktionen({ von: "2026-10-12", bis: "2026-10-12" })).toEqual([ + "host.archiviert", + "host.geaendert", + ]); + expect(await aktionen({ bis: "2026-10-11" })).toEqual(["host.angelegt"]); + expect(await aktionen({ von: "2026-10-13" })).toEqual(["kunde.angelegt"]); + }); + + it("ignoriert unbekannte Filterwerte", () => { + expect( + protokollFilterAus({ + gruppe: "drop table", + ergebnis: "VIELLEICHT", + von: "12.10.2026", + bis: "2026-13-45", + }), + ).toEqual(LEERER_FILTER); + }); +}); + +describe("protokollStapelweise", () => { + it("liest alle Treffer in Stapeln", async () => { + const erwartet: bigint[] = []; + for (let i = 0; i < 12; i += 1) { + const zeile = await eintragen({ aktion: i % 2 ? "host.angelegt" : "anmeldung.oidc" }); + if (i % 2) erwartet.unshift(zeile.id); + } + const groessen: number[] = []; + const ids: bigint[] = []; + for await (const stapel of protokollStapelweise(filter({ gruppe: "hosts" }), 4)) { + groessen.push(stapel.length); + ids.push(...stapel.map((z) => z.id)); + } + expect(groessen).toEqual([4, 2]); + expect(ids).toEqual(erwartet); + }); +}); + +describe("GET /api/protokoll.csv", () => { + async function abrufen(query = "") { + const { GET } = await import("@/app/api/protokoll.csv/route"); + return GET(new Request(`http://localhost/api/protokoll.csv${query}`)); + } + + it("verweigert ohne Recht und protokolliert dann nichts", async () => { + recht.erlaubt = false; + const antwort = await abrufen(); + expect(antwort.status).toBe(403); + expect(await prisma.protokoll.count()).toBe(0); + }); + + it("liefert Kopfzeile, Zeilen und entschaerft Formeln", async () => { + await eintragen({ + aktion: "authorized_keys.geschrieben", + akteurName: "=HYPERLINK(1)", + hostName: "srv-pve01", + zielbenutzer: "root", + fingerprint: "SHA256:abc", + diff: '+ ssh-ed25519 AAAA "x"\n- ssh-rsa BBBB', + }); + const antwort = await abrufen(); + expect(antwort.status).toBe(200); + expect(antwort.headers.get("content-type")).toContain("text/csv"); + expect(antwort.headers.get("content-disposition")).toMatch(/sshkv-protokoll-\d{4}-\d{2}-\d{2}\.csv/); + const text = (await antwort.text()).replace(/^/, ""); + const [kopf] = text.split("\r\n"); + expect(kopf).toBe( + '"id";"zeitpunkt";"akteur";"aktion";"ergebnis";"host";"zielbenutzer";"fingerprint";"diff";"details";"vorgaengerHash";"hash"', + ); + expect(text).toContain(`"'=HYPERLINK(1)";"authorized_keys.geschrieben";"ERFOLG";"srv-pve01";"root"`); + // Mehrzeiliger Diff bleibt eine Zelle; das fuehrende „+“ wird wie eine Formel entschaerft. + expect(text).toContain(`"'+ ssh-ed25519 AAAA ""x""\n- ssh-rsa BBBB"`); + expect(text).toMatch(/\r\n"\d+";"\d{4}-\d{2}-\d{2}T[\d:.]+Z";"'=HYPERLINK/); + }); + + it("protokolliert den Export mit den Filtern", async () => { + await (await abrufen("?gruppe=hostkey&q=srv")).text(); + const [eintrag] = await prisma.protokoll.findMany({ where: { aktion: "protokoll.exportiert" } }); + expect(eintrag).toMatchObject({ akteurName: "Lea Leser", akteurId: "b-leser", ergebnis: "ERFOLG" }); + expect(eintrag!.details).toEqual({ filter: { gruppe: "hostkey", q: "srv" } }); + }); + + it("wendet die Filter der Seite an", async () => { + await eintragen({ aktion: "hostkey.ersetzt", hostName: "srv-a" }); + await eintragen({ aktion: "anmeldung.oidc" }); + const text = await (await abrufen("?gruppe=hostkey")).text(); + expect(text).toContain("hostkey.ersetzt"); + expect(text).not.toContain("anmeldung.oidc"); + }); + + it("streamt grosse Protokolle ueber mehrere Stapel", async () => { + await prisma.protokoll.createMany({ + data: Array.from({ length: 1203 }, (_, i) => ({ + akteurName: "System", + aktion: "abgleich", + ergebnis: "ERFOLG" as const, + hostName: `h${i}`, + })), + }); + const text = await (await abrufen("?gruppe=abgleich")).text(); + expect(text.trimEnd().split("\r\n")).toHaveLength(1 + 1203); + }); +}); + +describe("Kettenpruefung", () => { + it("prueft auf Anfrage und haelt das Ergebnis 5 Minuten", async () => { + await eintragen({ aktion: "host.angelegt" }); + await eintragen({ aktion: "host.geaendert" }); + expect(ketteZwischengespeichert()).toBeNull(); + const ergebnis = await kettePruefenZwischengespeichert("System"); + expect(ergebnis).toMatchObject({ fehlerhafteId: null, eintraege: 2 }); + expect(ketteZwischengespeichert()).toBe(ergebnis); + expect(ketteZwischengespeichert(new Date(Date.now() + 6 * 60 * 1000))).toBeNull(); + }); + + it("meldet die erste fehlerhafte ID und protokolliert den Fund", async () => { + await eintragen({ aktion: "host.angelegt" }); + const zweiter = await eintragen({ aktion: "host.geaendert" }); + await eintragen({ aktion: "host.archiviert" }); + await prisma.$transaction([ + prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" DISABLE TRIGGER "protokoll_kein_update"`), + prisma.$executeRawUnsafe(`UPDATE "Protokoll" SET "aktion" = 'gefaelscht' WHERE "id" = ${zweiter.id}`), + prisma.$executeRawUnsafe(`ALTER TABLE "Protokoll" ENABLE TRIGGER "protokoll_kein_update"`), + ]); + const ergebnis = await kettePruefenZwischengespeichert({ id: "b1", name: "Florian" }); + expect(ergebnis.fehlerhafteId).toBe(String(zweiter.id)); + expect(await prisma.protokoll.count({ where: { aktion: "protokoll.kette_beschaedigt" } })).toBe(1); + }); +}); + +describe("Syslog-Weiterleitung in protokollieren()", () => { + afterEach(() => { + delete process.env["SSHKV_SYSLOG_HOST"]; + delete process.env["SSHKV_SYSLOG_PORT"]; + syslogZuruecksetzen(); + }); + + it("ist ohne Umgebung aus", () => { + syslogZuruecksetzen(); + expect(syslogKonfig()).toBeNull(); + }); + + it("sendet nach dem Schreiben – ohne Details und Diff", async () => { + const empfaenger = createSocket("udp4"); + const eingang = new Promise((fertig) => + empfaenger.once("message", (d) => fertig(d.toString("utf8"))), + ); + await new Promise((fertig) => empfaenger.bind(0, "127.0.0.1", fertig)); + process.env["SSHKV_SYSLOG_HOST"] = "127.0.0.1"; + process.env["SSHKV_SYSLOG_PORT"] = String((empfaenger.address() as AddressInfo).port); + syslogZuruecksetzen(); + try { + await protokollieren({ + akteur: { id: "b1", name: "Florian" }, + aktion: "privatekey.angezeigt", + ergebnis: "ERFOLG", + fingerprint: "SHA256:xyz", + diff: "+ geheimer-diff", + details: { pfad: "/geheim/pfad", passwort: "x" }, + }); + const zeile = await eingang; + const [eintrag] = await prisma.protokoll.findMany(); + expect(zeile).toMatch(/^<85>1 /); + expect(zeile).toContain(`hash="${eintrag!.hash}"`); + expect(zeile).toContain(`id="${eintrag!.id}"`); + expect(zeile).toContain('fingerprint="SHA256:xyz"'); + expect(zeile).not.toContain("geheimer-diff"); + expect(zeile).not.toContain("/geheim/pfad"); + } finally { + empfaenger.close(); + } + }); + + it("schreibt auch, wenn der Syslog-Server nicht erreichbar ist", async () => { + process.env["SSHKV_SYSLOG_HOST"] = "nicht-aufloesbar.invalid"; + syslogZuruecksetzen(); + const fehler = vi.spyOn(console, "error").mockImplementation(() => {}); + await protokollieren({ akteur: "System", aktion: "abgleich", ergebnis: "ERFOLG" }); + expect(await prisma.protokoll.count()).toBe(1); + fehler.mockRestore(); + }); +}); + +describe("Protokoll ist ueber die Anwendung nicht aenderbar", () => { + function dateien(verzeichnis: string): string[] { + return readdirSync(verzeichnis).flatMap((name) => { + const pfad = path.join(verzeichnis, name); + if (statSync(pfad).isDirectory()) return name === "generated" ? [] : dateien(pfad); + return /\.(ts|tsx|sql)$/.test(name) ? [pfad] : []; + }); + } + + it.each(dateien("src"))("%s aendert und loescht keine Protokolleintraege", (datei) => { + const inhalt = readFileSync(datei, "utf8"); + expect(inhalt).not.toMatch(/\.protokoll\s*\.\s*(update|updateMany|upsert|delete|deleteMany)\b/); + expect(inhalt).not.toMatch(/(UPDATE|DELETE\s+FROM|TRUNCATE)\s+"?Protokoll"?/i); + expect(inhalt).not.toMatch(/DISABLE\s+TRIGGER/i); + }); +});