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:
@@ -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<Record<string, string | string[] | undefined>>;
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Platzhalterseite
|
||||
titel="Audit-Log"
|
||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
|
||||
phase={5}
|
||||
issue="#027"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||
<Seitenkopf
|
||||
titel="Audit-Log"
|
||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen – nur lesbar, nicht änderbar"
|
||||
aktionen={
|
||||
<a href={`/api/protokoll.csv${query}`} download className={buttonKlassen("sekundaer")}>
|
||||
<Download className="size-4" aria-hidden />
|
||||
CSV exportieren
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
|
||||
<Karte innen="ohne" className="divide-border divide-y">
|
||||
<div className="flex flex-wrap items-baseline gap-x-6 gap-y-1 px-5 py-4 sm:px-6">
|
||||
<p className="text-[14px]">
|
||||
<span className="font-display text-text text-[22px] font-semibold tabular-nums">
|
||||
{zaehler.gesamt.toLocaleString("de-DE")}
|
||||
</span>{" "}
|
||||
<span className="text-muted">Einträge insgesamt</span>
|
||||
</p>
|
||||
{gefiltert && (
|
||||
<p className="text-muted text-[14px]">
|
||||
<span className="text-text font-medium tabular-nums">
|
||||
{zaehler.treffer.toLocaleString("de-DE")}
|
||||
</span>{" "}
|
||||
passen zum Filter
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted text-[13px] sm:ml-auto">
|
||||
Syslog:{" "}
|
||||
{syslog ? (
|
||||
<span className="text-text font-mono">{syslogZielText(syslog)}</span>
|
||||
) : (
|
||||
<span>nicht eingerichtet</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-5 py-4 sm:px-6">
|
||||
<KettenPruefung
|
||||
anfang={
|
||||
kette
|
||||
? {
|
||||
fehlerhafteId: kette.fehlerhafteId,
|
||||
eintraege: kette.eintraege,
|
||||
geprueftAm: kette.geprueftAm.toISOString(),
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<form method="get" role="search" className="grid grid-cols-2 items-end gap-3 lg:grid-cols-4">
|
||||
<Feld
|
||||
beschriftung="Suche"
|
||||
name="q"
|
||||
type="search"
|
||||
defaultValue={filter.q}
|
||||
placeholder="Fingerprint, Host oder Benutzer"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="col-span-2"
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Host"
|
||||
name="host"
|
||||
defaultValue={filter.host}
|
||||
placeholder="z. B. srv-pve01"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
mono
|
||||
className="col-span-2 sm:col-span-1"
|
||||
/>
|
||||
<Auswahlliste
|
||||
beschriftung="Akteur"
|
||||
name="akteur"
|
||||
defaultValue={filter.akteur}
|
||||
className="col-span-2 sm:col-span-1"
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
{/* Ein gefilterter Name, der nicht mehr unter den haeufigsten ist, bleibt waehlbar. */}
|
||||
{[...new Set([...akteure, ...(filter.akteur ? [filter.akteur] : [])])].map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Auswahlliste beschriftung="Aktion" name="gruppe" defaultValue={filter.gruppe}>
|
||||
<option value="">Alle Aktionen</option>
|
||||
{Object.entries(AKTIONSGRUPPEN).map(([wert, text]) => (
|
||||
<option key={wert} value={wert}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Auswahlliste beschriftung="Ergebnis" name="ergebnis" defaultValue={filter.ergebnis}>
|
||||
<option value="">Alle</option>
|
||||
{Object.entries(ERGEBNIS_TEXT).map(([wert, text]) => (
|
||||
<option key={wert} value={wert}>
|
||||
{text}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Feld beschriftung="Von" name="von" type="date" defaultValue={filter.von} />
|
||||
<Feld beschriftung="Bis" name="bis" type="date" defaultValue={filter.bis} />
|
||||
<div className="col-span-2 flex gap-2.5 sm:justify-end lg:col-span-4">
|
||||
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
|
||||
Filtern
|
||||
</Button>
|
||||
{gefiltert && (
|
||||
<Button href="/protokoll" className="flex-1 sm:flex-none">
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{vor !== undefined && (
|
||||
<p className="text-muted text-[14px]">
|
||||
Einträge älter als #{String(vor)}.{" "}
|
||||
<a href={`/protokoll${query}`} className="text-accent underline-offset-2 hover:underline">
|
||||
Zu den neuesten
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{seite.zeilen.length === 0 ? (
|
||||
gefiltert ? (
|
||||
<Leerzustand
|
||||
icon={<Search className="size-5" />}
|
||||
titel="Keine Treffer"
|
||||
text="Kein Eintrag passt zu Suche und Filter."
|
||||
aktion={<Button href="/protokoll">Filter zurücksetzen</Button>}
|
||||
/>
|
||||
) : (
|
||||
<Leerzustand
|
||||
icon={<ScrollText className="size-5" />}
|
||||
titel="Noch keine Einträge"
|
||||
text="Sobald jemand sich anmeldet oder etwas ändert, steht es hier."
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Karte>
|
||||
{/* key: neue Filter oder ein neuer Cursor beginnen eine frische Liste. */}
|
||||
<ProtokollListe
|
||||
key={`${query}|${String(vor ?? "")}`}
|
||||
seite={seite}
|
||||
query={query.replace(/^\?/, "")}
|
||||
/>
|
||||
</Karte>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user