import type { AbweichungsArt } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db"; import { aktionText } from "@/lib/protokoll-aktionen"; /** * Lesemodell der Übersicht (#026). Alle Zahlen werden berechnet, nichts ist hinterlegt – * Kacheln und Listen können so nie einander widersprechen. */ const TAG_MS = 86_400_000; const BEFUND_TEXT: Record = { UNBEKANNTER_SCHLUESSEL: ["unbekannter Schlüssel", "unbekannte Schlüssel"], FEHLENDER_SCHLUESSEL: ["Schlüssel fehlt", "Schlüssel fehlen"], ZUSAETZLICH_IM_BLOCK: ["Block von Hand geändert", "Block von Hand geändert"], DATEI_GEAENDERT: ["Block beschädigt", "Blöcke beschädigt"], RECHTE: ["Rechte falsch", "Rechte falsch"], HOSTKEY_GEAENDERT: ["Host-Key geändert", "Host-Key geändert"], PRIVATE_KEY_FEHLT: ["Private Key fehlt", "Private Keys fehlen"], }; /** „2 unbekannte Schlüssel · Rechte falsch“ aus den Befunden eines Host/Benutzer-Paars. */ export function befundZusammenfassen(arten: readonly AbweichungsArt[]): string { const anzahl = new Map(); for (const art of arten) anzahl.set(art, (anzahl.get(art) ?? 0) + 1); // Feste Reihenfolge (die von BEFUND_TEXT), damit die Zeile nicht je nach Datenbank springt. return (Object.keys(BEFUND_TEXT) as AbweichungsArt[]) .filter((art) => anzahl.has(art)) .map((art) => [art, anzahl.get(art)!] as const) .map(([art, n]) => { const [einzahl, mehrzahl] = BEFUND_TEXT[art]; return n === 1 ? (art === "UNBEKANNTER_SCHLUESSEL" ? `1 ${einzahl}` : einzahl) : `${n} ${mehrzahl}`; }) .join(" · "); } export type AktionsArt = | "ausgerollt" | "widerrufen" | "erstellt" | "abgleich" | "rotiert" | "anmeldung" | "sonstiges" | "verweigert"; /** Grobe Einordnung fuer die Farbe in „Letzte Aktionen“; die Ansicht im Audit-Log ist genauer (#027). */ export function aktionEinordnen(aktion: string, ergebnis: string): AktionsArt { if (ergebnis !== "ERFOLG") return "verweigert"; if (aktion.startsWith("authorized_keys.") || aktion.startsWith("privatekey.ausgerollt")) return "ausgerollt"; if (aktion.includes("widerruf")) return "widerrufen"; if (aktion.includes("rotation") || aktion === "deploymentkey.umgestellt") return "rotiert"; if (aktion === "abgleich") return "abgleich"; if (aktion.startsWith("anmeldung") || aktion.startsWith("bestaetigung")) return "anmeldung"; if ( aktion.endsWith(".erzeugt") || aktion.endsWith(".angelegt") || aktion.endsWith(".importiert") || aktion.endsWith(".uebernommen") ) { return "erstellt"; } return "sonstiges"; } export async function uebersicht(jetzt = new Date()) { const in30Tagen = new Date(jetzt.getTime() + 30 * TAG_MS); const [hosts, schluesselGesamt, schluesselPrivat, offen, ablaufend, aktionen] = await Promise.all([ prisma.host.findMany({ where: { archiviertAm: null }, select: { id: true, name: true, erreichbar: true, driftZustand: true, letzterAbgleichAm: true, bootstrapAm: true, }, }), prisma.schluessel.count({ where: { status: "AKTIV", kategorie: { not: "DEPLOYMENT" } } }), prisma.schluessel.count({ where: { status: "AKTIV", kategorie: { not: "DEPLOYMENT" }, masterKeyVersion: { not: null } }, }), prisma.abweichung.findMany({ where: { behobenAm: null, host: { archiviertAm: null } }, select: { art: true, zielbenutzer: true, hostId: true, host: { select: { name: true } } }, }), prisma.schluessel.findMany({ where: { status: "AKTIV", ablaufAm: { not: null, lte: in30Tagen } }, select: { id: true, name: true, typ: true, kategorie: true, ablaufAm: true, person: { select: { name: true } }, _count: { select: { publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } } } }, }, orderBy: { ablaufAm: "asc" }, }), // Anmeldungen sind im Audit-Log zu sehen; auf der Uebersicht verdraengten sie sonst alles. prisma.protokoll.findMany({ where: { NOT: [{ aktion: { startsWith: "anmeldung" } }, { aktion: { startsWith: "bestaetigung" } }] }, orderBy: { id: "desc" }, take: 8, select: { id: true, zeitpunkt: true, akteurName: true, aktion: true, ergebnis: true, hostName: true, zielbenutzer: true, }, }), ]); // Befunde je Host und Benutzer zusammenfassen; nicht erreichbare Hosts zaehlen mit. const gruppen = new Map< string, { hostId: string; host: string; benutzer: string; arten: AbweichungsArt[] } >(); for (const a of offen) { const schluessel = `${a.hostId}\u0000${a.zielbenutzer ?? ""}`; const gruppe = gruppen.get(schluessel) ?? { hostId: a.hostId, host: a.host.name, benutzer: a.zielbenutzer ?? "–", arten: [], }; gruppe.arten.push(a.art); gruppen.set(schluessel, gruppe); } const mitAbweichung = [ ...[...gruppen.values()].map((g) => ({ hostId: g.hostId, host: g.host, benutzer: g.benutzer, status: "drift" as const, befund: befundZusammenfassen(g.arten), })), ...hosts .filter((h) => h.erreichbar === false) .map((h) => ({ hostId: h.id, host: h.name, benutzer: "–", status: "unerreichbar" as const, befund: "nicht erreichbar", })), ].sort((a, b) => a.host.localeCompare(b.host)); const letzterAbgleich = hosts.reduce( (spaetester, h) => h.letzterAbgleichAm && (!spaetester || h.letzterAbgleichAm > spaetester) ? h.letzterAbgleichAm : spaetester, null, ); const abgelaufen = ablaufend.filter((s) => s.ablaufAm! < jetzt).length; return { letzterAbgleich, kennzahlen: { hosts: { wert: hosts.length, hinweis: `${hosts.filter((h) => h.driftZustand === "SYNCHRON").length} synchron · ${hosts.filter((h) => h.erreichbar === false).length} nicht erreichbar`, }, schluessel: { wert: schluesselGesamt, hinweis: `davon ${schluesselPrivat} mit Private Key` }, abweichungen: { wert: offen.length, hinweis: `auf ${new Set(offen.map((a) => a.hostId)).size} Host(s)`, }, ablaufend: { wert: ablaufend.length - abgelaufen, hinweis: abgelaufen ? `${abgelaufen} bereits abgelaufen` : "in den nächsten 30 Tagen", }, }, mitAbweichung: mitAbweichung.slice(0, 8), weitereAbweichungen: Math.max(0, mitAbweichung.length - 8), baldAblaufend: ablaufend.slice(0, 6).map((s) => ({ id: s.id, zweck: s.name, person: s.person?.name ?? (s.kategorie === "DIENST" ? "Dienst" : "Person"), typ: s.typ === "RSA4096" ? "rsa-4096" : s.typ === "ED25519" ? "ed25519" : "anderer", ablauf: s.ablaufAm!, ausgerollt: s._count.publicVerwendungen > 0, })), letzteAktionen: aktionen.map((a) => ({ id: a.id.toString(), zeit: a.zeitpunkt, art: aktionEinordnen(a.aktion, a.ergebnis), aktion: a.aktion, // Lesbarer Text aus dem Audit-Log (#027) statt des technischen Aktionsnamens. beschreibung: [ aktionText(a.aktion), a.zielbenutzer && a.hostName ? `${a.zielbenutzer}@${a.hostName}` : a.hostName, ] .filter(Boolean) .join(" · "), benutzer: a.akteurName, })), }; } export type Uebersicht = Awaited>;