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:
Binary file not shown.
|
After Width: | Height: | Size: 478 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 481 KiB |
@@ -1,19 +1,40 @@
|
|||||||
# #027 Protokollansicht, CSV-Export, Syslog
|
# #027 Protokollansicht, CSV-Export, Syslog
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.5.0
|
**Meilenstein:** v0.5.0
|
||||||
**Labels:** ui, backend
|
**Labels:** ui, backend
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog.
|
Filterbare Ansicht, CSV, optionale Weiterleitung an Syslog.
|
||||||
|
|
||||||
## Aufgaben
|
## 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=<id>` 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
|
## 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
|
## Abhängigkeiten
|
||||||
#005
|
#005
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## 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`.
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const SEITEN: { name: string; pfad: string; oeffentlich?: boolean; folgen?: stri
|
|||||||
pfad: "/vorlagen",
|
pfad: "/vorlagen",
|
||||||
folgen: 'main a[aria-label="Deployment-Zugang bearbeiten"]',
|
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 = [
|
const ANSICHTEN = [
|
||||||
|
|||||||
@@ -1,18 +1,202 @@
|
|||||||
|
import { Download, ScrollText, Search } from "lucide-react";
|
||||||
import type { Metadata } from "next";
|
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 { 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 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).
|
// 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 (
|
return (
|
||||||
<Platzhalterseite
|
<div className="flex min-w-0 flex-col gap-6 sm:gap-7">
|
||||||
|
<Seitenkopf
|
||||||
titel="Audit-Log"
|
titel="Audit-Log"
|
||||||
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen"
|
untertitel="Lückenloses, verkettetes Protokoll aller Aktionen – nur lesbar, nicht änderbar"
|
||||||
phase={5}
|
aktionen={
|
||||||
issue="#027"
|
<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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<KettenAntwort | null>(anfang);
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
|
||||||
|
const pruefen = () =>
|
||||||
|
starten(async () => {
|
||||||
|
setErgebnis(await kettePruefenAktion());
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<Status ergebnis={ergebnis} />
|
||||||
|
<Button onClick={pruefen} disabled={laeuft} klein className="self-start sm:self-auto">
|
||||||
|
{laeuft ? "Prüft …" : "Kette prüfen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Status({ ergebnis }: { ergebnis: KettenAntwort | null }) {
|
||||||
|
if (!ergebnis) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted flex items-center gap-2 text-[14px]">
|
||||||
|
<ShieldQuestion aria-hidden className="size-4 shrink-0" />
|
||||||
|
Hash-Kette noch nicht geprüft
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ergebnis.fehler) {
|
||||||
|
return <p className="text-danger text-[14px]">{ergebnis.fehler}</p>;
|
||||||
|
}
|
||||||
|
const wann = ergebnis.geprueftAm ? ` um ${uhrzeit(ergebnis.geprueftAm)} Uhr` : "";
|
||||||
|
if (!ergebnis.fehlerhafteId) {
|
||||||
|
return (
|
||||||
|
<p className="text-ok flex items-center gap-2 text-[14px] font-medium">
|
||||||
|
<ShieldCheck aria-hidden className="size-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Hash-Kette unversehrt
|
||||||
|
<span className="text-muted font-normal">
|
||||||
|
{" "}
|
||||||
|
· {ergebnis.eintraege?.toLocaleString("de-DE")} Einträge geprüft{wann}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="border-danger-border bg-danger-soft text-danger rounded-box flex min-w-0 items-start gap-2.5 border px-3 py-2.5 text-[14px]"
|
||||||
|
>
|
||||||
|
<ShieldAlert aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<p className="min-w-0">
|
||||||
|
<strong className="font-semibold">Hash-Kette beschädigt.</strong> Der Eintrag{" "}
|
||||||
|
<a href={ziel} className="font-mono underline underline-offset-2">
|
||||||
|
#{id}
|
||||||
|
</a>{" "}
|
||||||
|
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}.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AktionsTon, string> = { ok: "text-ok", gefahr: "text-danger", neutral: "text-text" };
|
||||||
|
const ERGEBNIS_BADGE: Record<ProtokollZeile["ergebnis"], BadgeVariante> = {
|
||||||
|
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<ReadonlySet<string>>(new Set());
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
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 (
|
||||||
|
<div className="flex min-w-0 flex-col gap-4">
|
||||||
|
{/* Unter md Karten statt der Tabelle: acht Spalten passen nicht auf 390 px. */}
|
||||||
|
<ul className="flex flex-col md:hidden" aria-label="Protokolleinträge">
|
||||||
|
{zeilen.map((z) => (
|
||||||
|
<li key={z.id} className="border-border border-b py-3 last:border-b-0">
|
||||||
|
<ZeileKompakt z={z} />
|
||||||
|
<details className="group mt-1">
|
||||||
|
<summary className="text-muted hover:text-text -mx-2 flex min-h-11 cursor-pointer list-none items-center gap-1.5 rounded-box px-2 text-[13px]">
|
||||||
|
<ChevronDown aria-hidden className="size-4 transition-transform group-open:rotate-180" />
|
||||||
|
Einzelheiten
|
||||||
|
</summary>
|
||||||
|
<Einzelheiten z={z} />
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Tabelle
|
||||||
|
className="hidden md:block"
|
||||||
|
beschriftung="Protokolleinträge"
|
||||||
|
spalten={[
|
||||||
|
{ titel: "Zeitpunkt" },
|
||||||
|
{ titel: "Aktion" },
|
||||||
|
{ titel: "Akteur" },
|
||||||
|
{ titel: "Host" },
|
||||||
|
{ titel: "Zielbenutzer" },
|
||||||
|
{ titel: "Fingerprint" },
|
||||||
|
{ titel: "Ergebnis" },
|
||||||
|
{ titel: "Einzelheiten", versteckt: true, className: "w-12" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{zeilen.map((z) => {
|
||||||
|
const istOffen = offen.has(z.id);
|
||||||
|
return <ZeilenPaar key={z.id} z={z} offen={istOffen} umschalten={() => umschalten(z.id)} />;
|
||||||
|
})}
|
||||||
|
</Tabelle>
|
||||||
|
|
||||||
|
{fehler && <p className="text-danger text-[13px]">{fehler}</p>}
|
||||||
|
{weiterVor && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button onClick={aeltereLaden} disabled={laedt} icon={<History className="size-4" />}>
|
||||||
|
{laedt ? "Lädt …" : "Ältere laden"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AktionZelle({ z }: { z: ProtokollZeile }) {
|
||||||
|
return (
|
||||||
|
<span title={z.aktion} className={clsx("font-medium", TON_FARBE[aktionTon(z.aktion, z.ergebnis)])}>
|
||||||
|
{aktionText(z.aktion)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErgebnisBadge({ ergebnis }: { ergebnis: ProtokollZeile["ergebnis"] }) {
|
||||||
|
return <Badge variante={ERGEBNIS_BADGE[ergebnis]}>{ERGEBNIS_TEXT[ergebnis]}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZeilenPaar({ z, offen, umschalten }: { z: ProtokollZeile; offen: boolean; umschalten: () => void }) {
|
||||||
|
const detailId = `eintrag-${z.id}-einzelheiten`;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TabellenZeile className={clsx(offen && "border-b-0")}>
|
||||||
|
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||||
|
{/* Anker fuer den Sprung aus der Kettenpruefung zur ersten fehlerhaften ID. */}
|
||||||
|
<span
|
||||||
|
id={`eintrag-${z.id}`}
|
||||||
|
className="target:text-danger target:outline-danger scroll-mt-24 target:outline target:outline-offset-4"
|
||||||
|
>
|
||||||
|
{protokollZeit(z.zeitpunkt)}
|
||||||
|
</span>
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="min-w-[12rem]">
|
||||||
|
<AktionZelle z={z} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">{z.akteurName}</TabellenZelle>
|
||||||
|
<TabellenZelle mono className="whitespace-nowrap">
|
||||||
|
{z.hostName ?? <Leer />}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle mono className="whitespace-nowrap">
|
||||||
|
{z.zielbenutzer ?? <Leer />}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle mono className="text-muted whitespace-nowrap">
|
||||||
|
{z.fingerprint ? <span title={z.fingerprint}>{fingerprintKurz(z.fingerprint)}</span> : <Leer />}
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle className="whitespace-nowrap">
|
||||||
|
<ErgebnisBadge ergebnis={z.ergebnis} />
|
||||||
|
</TabellenZelle>
|
||||||
|
<TabellenZelle rechts className="w-12">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={umschalten}
|
||||||
|
aria-expanded={offen}
|
||||||
|
aria-controls={detailId}
|
||||||
|
aria-label={`Einzelheiten zu Eintrag ${z.id}`}
|
||||||
|
className="text-muted hover:text-text hover:bg-raised rounded-feld -my-2 inline-flex size-11 items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown aria-hidden className={clsx("size-4 transition-transform", offen && "rotate-180")} />
|
||||||
|
</button>
|
||||||
|
</TabellenZelle>
|
||||||
|
</TabellenZeile>
|
||||||
|
{offen && (
|
||||||
|
<tr id={detailId} className="border-border border-b last:border-b-0">
|
||||||
|
<td colSpan={8} className="pb-4">
|
||||||
|
<Einzelheiten z={z} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Leer() {
|
||||||
|
return <span className="text-muted">–</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kopf einer Karte in der mobilen Ansicht. */
|
||||||
|
function ZeileKompakt({ z }: { z: ProtokollZeile }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted font-mono text-[12.5px]">{protokollZeit(z.zeitpunkt)}</span>
|
||||||
|
<ErgebnisBadge ergebnis={z.ergebnis} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[14.5px] leading-snug">
|
||||||
|
<AktionZelle z={z} />
|
||||||
|
</span>
|
||||||
|
<span className="text-muted text-[13px] break-words">{z.akteurName}</span>
|
||||||
|
{(z.hostName || z.zielbenutzer) && (
|
||||||
|
<span className="text-text font-mono text-[12.5px] break-all">
|
||||||
|
{z.zielbenutzer ? `${z.zielbenutzer}@` : ""}
|
||||||
|
{z.hostName ?? "–"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{z.fingerprint && (
|
||||||
|
<span className="text-muted font-mono text-[12.5px] break-all">{fingerprintKurz(z.fingerprint)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Einzelheiten({ z }: { z: ProtokollZeile }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-sidebar border-border rounded-box flex min-w-0 flex-col gap-4 border p-4">
|
||||||
|
<dl className="grid min-w-0 gap-x-4 gap-y-2 text-[13px] sm:grid-cols-[9rem_minmax(0,1fr)]">
|
||||||
|
<dt className="text-muted">ID</dt>
|
||||||
|
<dd className="font-mono">{z.id}</dd>
|
||||||
|
<dt className="text-muted">Aktion (technisch)</dt>
|
||||||
|
<dd className="font-mono break-all">{z.aktion}</dd>
|
||||||
|
{z.fingerprint && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted">Fingerprint</dt>
|
||||||
|
<dd className="font-mono break-all">{z.fingerprint}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt className="text-muted">Hash</dt>
|
||||||
|
<dd className="font-mono break-all" data-feld="hash">
|
||||||
|
{z.hash}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted">Vorgänger-Hash</dt>
|
||||||
|
<dd className="font-mono break-all">{z.vorgaengerHash || "– (erster Eintrag)"}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{z.diff && (
|
||||||
|
<section className="flex min-w-0 flex-col gap-2" aria-label="Diff">
|
||||||
|
<h3 className="text-muted text-[13px] font-medium">Diff</h3>
|
||||||
|
<DiffBox zeilen={diffZeilen(z.diff)} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{z.details && (
|
||||||
|
<section className="flex min-w-0 flex-col gap-2" aria-label="Details">
|
||||||
|
<h3 className="text-muted text-[13px] font-medium">Details</h3>
|
||||||
|
<dl className="border-border bg-bg rounded-box grid min-w-0 gap-x-4 gap-y-1.5 border p-3 text-[12.5px] sm:grid-cols-[minmax(0,12rem)_minmax(0,1fr)]">
|
||||||
|
{z.details.map((d) => (
|
||||||
|
<div key={d.name} className="contents">
|
||||||
|
<dt className="text-muted font-mono break-all">{d.name}</dt>
|
||||||
|
<dd className="text-text mb-1.5 font-mono break-all whitespace-pre-wrap sm:mb-0">{d.wert}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Record<string, Beschreibung>> = {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
@@ -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<ProtokollErgebnisWert, string> = {
|
||||||
|
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<string, string | string[] | undefined>,
|
||||||
|
): 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, string> = {}): 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<ProtokollFilter, "von" | "bis">): { 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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 type { ProtokollErgebnis } from "@/generated/prisma/enums";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { schwaerzen } from "@/lib/protokoll-schwaerzen";
|
import { schwaerzen } from "@/lib/protokoll-schwaerzen";
|
||||||
|
import { syslogKonfigAus, syslogSender, type SyslogKonfig, type SyslogSender } from "@/lib/syslog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
|
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
|
||||||
@@ -26,7 +27,7 @@ export async function protokollieren(
|
|||||||
client: Prisma.TransactionClient = prisma,
|
client: Prisma.TransactionClient = prisma,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur;
|
const akteur = eintrag.akteur === "System" ? { id: null, name: "System" } : eintrag.akteur;
|
||||||
await client.protokoll.create({
|
const zeile = await client.protokoll.create({
|
||||||
data: {
|
data: {
|
||||||
akteurId: akteur.id,
|
akteurId: akteur.id,
|
||||||
akteurName: akteur.name,
|
akteurName: akteur.name,
|
||||||
@@ -40,13 +41,119 @@ export async function protokollieren(
|
|||||||
diff: eintrag.diff ? (schwaerzen(eintrag.diff) as string) : null,
|
diff: eintrag.diff ? (schwaerzen(eintrag.diff) as string) : null,
|
||||||
details: eintrag.details ? (schwaerzen(eintrag.details) as Prisma.InputJsonValue) : undefined,
|
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> {
|
export async function protokollKettePruefen(): Promise<bigint | null> {
|
||||||
const [zeile] = await prisma.$queryRaw<
|
const [zeile] = await prisma.$queryRaw<
|
||||||
{ ergebnis: bigint | null }[]
|
{ ergebnis: bigint | null }[]
|
||||||
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
||||||
return zeile?.ergebnis ?? null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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<string, string> {
|
||||||
|
const gefunden = new Map<string, string>();
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("@/server/auth/sitzungspflicht")>();
|
||||||
|
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>): 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<ProtokollFilter>) =>
|
||||||
|
(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<ProtokollFilter>) =>
|
||||||
|
(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<string>((fertig) =>
|
||||||
|
empfaenger.once("message", (d) => fertig(d.toString("utf8"))),
|
||||||
|
);
|
||||||
|
await new Promise<void>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user