import { Download, Lock, LockOpen, Pencil, RefreshCw } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; import type { ReactNode } from "react"; import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige"; import { Badge } from "@/components/ui/Badge"; import { Breadcrumb } from "@/components/ui/Breadcrumb"; import { Button } from "@/components/ui/Button"; import { InnereBox, Karte } from "@/components/ui/Karte"; import { MonoBlock } from "@/components/ui/MonoBlock"; import { MonoKopieren } from "@/components/ui/MonoKopieren"; import { Seitenkopf } from "@/components/ui/Seitenkopf"; import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt"; import { datum, datumZeit } from "@/lib/format"; import { darf } from "@/server/auth/rechte"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { rotationsKette, schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel"; import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; import { EintragAktionen } from "@/components/ausrollen/EintragAktionen"; import { zielAuswahl } from "@/server/queries/ziele"; import { widerrufUebersicht } from "@/server/queries/widerruf"; import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf"; import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf"; import { ablaufZustand } from "@/lib/schluessel"; import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen"; import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation"; import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion"; import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen"; import { PublicAusrollenAktion } from "./PublicAusrollenAktion"; export const metadata: Metadata = { title: "Schlüssel" }; function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) { return (
{titel}
{children}
); } /** Soll-Zustand einer Verwendung als Statuspunkt – Form und Text tragen die Information mit. */ const ZUSTAND: Record = { GEPLANT: { status: "unbekannt", text: "geplant" }, AUSGEROLLT: { status: "synchron", text: "ausgerollt" }, ABWEICHUNG: { status: "drift", text: "Abweichung" }, ENTFERNEN: { status: "unbekannt", text: "wird entfernt" }, FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" }, }; type Eintrag = { id: string; ziel: string; href?: string; pfad: string; zusatz?: string; zustand: string; aktionen?: ReactNode; }; function AusrollKarte({ titel, untertitel, eintraege, leer, aktion, }: { titel: string; untertitel: string; eintraege: Eintrag[]; leer: string; aktion: ReactNode; }) { return ( {eintraege.length === 0 ? (

{leer}

) : ( )}
); } function privateEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] { return s.privateVerwendungen.map((v) => { const config = v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null; return { id: v.id, ziel: `${v.zielbenutzer}@${v.host.name}`, href: `/hosts/${v.host.id}`, pfad: v.pfad, ...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}), zustand: v.zustand, aktionen: ( ), }; }); } type Rechte = { readonly public: boolean; readonly privat: boolean; readonly testen: boolean }; function publicEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] { return s.publicVerwendungen.map((v) => ({ id: v.id, ziel: v.host ? `${v.zielbenutzer}@${v.host.name}` : `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`, ...(v.host ? { href: `/hosts/${v.host.id}` } : v.hostgruppe ? { href: `/hostgruppen/${v.hostgruppe.id}` } : {}), pfad: "~/.ssh/authorized_keys", ...(v.optionen ? { zusatz: v.optionen } : {}), zustand: v.zustand, aktionen: ( ), })); } /** Was mit dem Private Key ist – ohne ihn je zu lesen (masterKeyVersion verraet es). */ function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNode }) { if (s.privatGespeichert) { return ( verschlüsselt gespeichert {s.passphraseGeschuetzt && · mit Passphrase} {anzeigen} ); } if (s.privatGeloeschtAm) { return ( gelöscht am ); } return ( nicht gespeichert{s.kategorie === "PERSON" ? " (Personen-Key)" : ""} ); } /** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */ export default async function SchluesselSeite({ params, searchParams, }: { params: Promise<{ id: string }>; searchParams: Promise<{ zielkonten?: string | string[] }>; }) { const { id } = await params; const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`); const s = await schluesselDetail(id); if (!s) notFound(); const zielkontenParameter = (await searchParams).zielkonten; // Im Widerruf (#024) bleibt der Status AKTIV, bis alles entfernt ist – zuweisen geht aber nicht mehr. const imWiderruf = Boolean(s.widerrufEingeleitetAm) && (s.status === "AKTIV" || s.status === "ABGELAUFEN"); const aktiv = s.status === "AKTIV" && !imWiderruf; const rechte: Rechte = { public: darf(benutzer.rolle, "publicKeyAusrollen"), privat: darf(benutzer.rolle, "privateKeyAusrollen"), testen: darf(benutzer.rolle, "hostsVerwalten"), }; const jetzt = new Date(); const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen"; const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten"); // Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger. const rotationDesVorgaengers = s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN") ? s.vorgaenger : null; const widerrufbar = !imWiderruf && !(s.nachfolger?.status === "AKTIV") && // Ein Nachfolger mitten in der Rotation wird ueber „Rotation abbrechen“ widerrufen (#032) – // so steht der Abbruch im Protokoll und die Rotationsseite kennt den Stand. !rotationDesVorgaengers && s.kategorie !== "DEPLOYMENT" && (s.status === "AKTIV" || s.status === "ABGELAUFEN") && rechte.public && (s.privateVerwendungen.length === 0 || rechte.privat); const widerruf = widerrufbar || imWiderruf ? await widerrufUebersicht([s.id]) : undefined; // Rotation (#023): Kette, Knopf bzw. Hinweis fuer Schluessel ohne Private Key in sshkv. const kette = await rotationsKette(s.id); const aktivePrivat = s.privateVerwendungen.filter((v) => v.zustand !== "ENTFERNEN").length; const rotationsSperre = rotationGesperrt({ kategorie: s.kategorie, status: s.status, masterKeyVersion: s.privatGespeichert ? 1 : null, widerrufEingeleitetAm: s.widerrufEingeleitetAm, privatZuweisungen: aktivePrivat, }); const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT"; const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger); const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat); // Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter // passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr. const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger)); // Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht. const darfAbbrechen = Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) && !imWiderruf && s.status === "AKTIV" && verwaltenRecht && rechte.public && (s.privateVerwendungen.length === 0 || rechte.privat); const vorauswahl = ( Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "") ) .split(",") .filter(Boolean); const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined; const personenKey = s.kategorie === "PERSON"; const verwalten = darf(benutzer.rolle, "schluesselVerwalten"); // Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst. const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT"; const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen"); const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen"); return (
} titel={s.name} untertitel={ s.person ? ( <> Person:{" "} {s.person.name} ) : undefined } badges={ <> {imWiderruf && Widerruf unvollständig} {s.instanzStandard && Instanz-Standard} {s.selbstHinterlegt && selbst hinterlegt} {s.vorlage && Vorlage: {s.vorlage.name}} {s.tags.map((t) => ( #{t} ))} } aktionen={ (verwalten || rotierbar || (widerrufbar && widerruf)) && ( <> {verwalten && ( )} {rotierbar && ( )} {widerrufbar && widerruf && ( )} ) } /> {rotationDesVorgaengers && (imWiderruf || s.status === "WIDERRUFEN" ? (

Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen. {imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "} {rotationDesVorgaengers.name} {" "} bleibt aktiv.

) : (

Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "} Rotation fortsetzen

{darfAbbrechen && (
)}
))} {imWiderruf && widerruf && ( )} {abgelaufen && widerrufbar && widerruf && (

{s.ablaufAm ? ( <> Das Ablaufdatum () ist überschritten.{" "} ) : null} Nach Projektende bzw. Ablaufdatum: Widerruf mit einem Klick. sshkv entfernt abgelaufene Schlüssel nie von selbst – erst nach Ihrer Bestätigung und dem Dry-Run.

)} {(s.status === "WIDERRUFEN" || s.status === "ROTIERT") && (

Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen. {/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */} {s.nachfolger && s.status === "ROTIERT" && ( <> {" "} Ersetzt durch{" "} {s.nachfolger.name} . )}

)}
{s.erstelltVon ? `von ${s.erstelltVon.name}` : "von sshkv"} · ) } />
{/* Personen-Keys haben nie einen gespeicherten Private Key (DB-CHECK) – die Karte dafuer waere immer leer. */}
{!personenKey && ( } /> )} } />
{rechte.public && !imWiderruf && (s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && ( v.zustand !== "AUSGEROLLT").length + (rechte.privat ? s.privateVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length : 0) } /> )} {(kette.length > 1 || (rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf)) && ( 1 ? "Vorgänger und Nachfolger dieses Schlüssels" : undefined} > {kette.length > 1 && (
    {kette.map((glied, index) => (
  1. {index > 0 && ( → )} {glied.id === s.id ? ( {glied.name} ) : ( {glied.name} )}
  2. ))}
)} {abgebrocheneRotation && s.nachfolger && (

Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen {s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"}; dieser Schlüssel bleibt aktiv. Ein zweiter Nachfolger ist nicht vorgesehen: für eine neue Rotation einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.

)} {rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (

1 ? "mt-3" : ""}`}> {rotationsSperre.grund}

)}
)} Kommentar: {s.kommentar || "–"} } aktionen={ {s.slug}.pub } > {darfLoeschen && (

Sinnvoll, sobald der Private Key ausgerollt ist und sshkv ihn nicht mehr braucht. Danach kann ihn niemand mehr aus SSHKeyVault holen – auch kein Admin.

)}
); }