feat(widerruf): Widerruf und Massenwiderruf mit Dry-Run (#024)

Ein Schlüssel soll nach Projektende mit einem Klick von allen Hosts
verschwinden – aber nie als „widerrufen“ gelten, solange er irgendwo noch
liegt. Deshalb ein Zwischenzustand (widerrufEingeleitetAm): Verwendungen
werden zum Entfernen vorgemerkt, der bestehende Dry-Run mit Hash-Sperre
zeigt und schreibt die Änderungen, und erst wenn keine Verwendung mehr
offen ist, setzt der Abschluss WIDERRUFEN (bzw. ROTIERT für #023).
Scheitert ein Host, bleibt der Schlüssel im Zwischenzustand und die Seite
nennt die offenen Ziele.

Zielkonten lassen sich optional mit entfernen, aber erst nach den
Schlüsseln und nie, wenn ein anderer aktiver Schlüssel sie noch nutzt.
Massenwiderruf (Admin, frische Bestätigung) nutzt einen gemeinsamen
Dry-Run und protokolliert je Schlüssel. Abgelaufene Schlüssel erscheinen
im Filter „abgelaufen“ und bieten den Widerruf an – entfernt wird nie
automatisch.

Migration 20261012130000_widerruf_eingeleitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:14:01 +02:00
co-authored by Claude Opus 5.5
parent 2dad8e5631
commit c6361ff3c6
26 changed files with 2334 additions and 91 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+34 -4
View File
@@ -1,19 +1,49 @@
# #024 Widerruf und Massenwiderruf
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.4.0
**Labels:** backend, ui, sicherheit
**Geschätzt:** 3 h **Tatsächlich:** –
**Geschätzt:** 3 h **Tatsächlich:** 0 h 25 min
## Ziel
Ein-Klick-Widerruf inkl. Benutzer/sudoers; Massenwiderruf mit Re-Auth.
## Aufgaben
- [ ] wird zu Beginn der Phase verfeinert
- [x] „Widerrufen“ auf der Schlüsselseite mit Dialog, der jede Entfernung auflistet
- [x] Public- und Private-Verwendungen → ENTFERNEN, Dry-Run, anwenden, erst dann `WIDERRUFEN`
- [x] Zielkonten optional entfernen (nur `vonSshkvAngelegt`, nie ein noch genutztes)
- [x] Massenwiderruf auf der Schlüsselliste, ein gemeinsamer Dry-Run, Protokoll je Schlüssel
- [x] Abgelaufene Schlüssel erkennbar (Filter) und Widerruf anbieten – ohne automatisches Entfernen
## Akzeptanzkriterien
- [ ] wird zu Beginn der Phase verfeinert
- [x] DB-Tests: Zustände, Rechte, Zielkonten, Protokoll ohne Geheimnisse (`tests/widerruf.test.ts`)
- [x] Integrationstest gegen zwei Hosts inkl. „Host nicht erreichbar“ (`tests/integration/widerruf.test.ts`)
- [x] E2E im Browser (`tests/e2e/widerruf.spec.ts`), Screenshot `widerruf-desktop.png`
## Abhängigkeiten
#018
## Notizen / Entscheidungen
- **Zwischenzustand statt Sofort-Status:** neues Feld `Schluessel.widerrufEingeleitetAm` (Migration
`20261012130000_widerruf_eingeleitet`). Der Status bleibt AKTIV/ABGELAUFEN, bis keine Verwendung mehr
offen ist; erst `widerrufAbschliessen` setzt `WIDERRUFEN` (bzw. `ROTIERT`, wenn ein aktiver Nachfolger
existiert, #023). Schlüsselseite und Liste zeigen „Widerruf unvollständig“ und die offenen Ziele.
- **Reihenfolge:** vormerken → Dry-Run/Anwenden (bestehende Hash-Sperre aus #016/#017) → optional
Zielkonten (`ZIELKONTO_ENTFERNEN`, erst wenn die Schlüssel überall weg sind) → abschließen.
Ein Ziel, das im Dry-Run scheitert (nicht erreichbar), wird nicht angewendet; seine Verwendung bleibt
ENTFERNEN, ein neuer Dry-Run versucht es erneut. Der Abschluss läuft nach erfolgreichem Anwenden
automatisch und meldet, was noch fehlt.
- **Nichts zu schreiben:** Steht der Schlüssel schon nirgends mehr, bietet das Dry-Run-Panel
„Zustand übernehmen“ an (sonst bliebe der Widerruf hängen).
- **Archivierte Hosts:** sshkv verbindet sich dort nicht mehr – deren Verwendungen gelten sofort als
entfernt; der Dialog sagt das, das Protokoll nennt die Hosts.
- **Zielkonten:** Ein Zielkonto kennt seinen Schlüssel nicht; Kandidat ist ein von sshkv angelegtes Konto
auf (Host, Benutzer) einer Zuweisung der widerrufenen Schlüssel. Nutzt ein anderer, nicht im Widerruf
befindlicher Schlüssel das Konto noch, bleibt es (Dialog nennt den Schlüssel).
- **Rechte:** nur Public Keys → `publicKeyAusrollen`; Private Keys → `privateKeyAusrollen` (Admin);
mehrere Schlüssel → `massenwiderruf`, Zielkonten → `zielkontenVerwalten` (jeweils Admin mit frischer
Bestätigung). Der Deployment-Key ist nicht widerrufbar. Ein Schlüssel im Widerruf lässt sich nicht
mehr zuweisen.
- **Ablauf:** Status-Filter „abgelaufen“ umfasst aktive Schlüssel mit vergangenem Ablaufdatum; die
Schlüsselseite bietet dann „Jetzt widerrufen“. Entfernt wird nie automatisch.
- Massenwiderruf als GET-Formular `/schluessel/widerruf?id=…` – die Auswahl der Zielkonten reist als
`?zielkonten=` mit und übersteht Neuladen und erneute Bestätigung.
@@ -0,0 +1,3 @@
-- #024: Zwischenzustand „Widerruf eingeleitet“. Der Schluessel bleibt AKTIV (bzw. ABGELAUFEN),
-- bis alle Verwendungen nachweislich entfernt sind; erst dann wird er WIDERRUFEN bzw. ROTIERT.
ALTER TABLE "Schluessel" ADD COLUMN "widerrufEingeleitetAm" TIMESTAMP(3);
+33 -30
View File
@@ -274,44 +274,47 @@ model HostSchluessel {
// ---------------------------------------------------------------- Schluessel
model Schluessel {
id String @id @default(cuid())
id String @id @default(cuid())
/// Zweck, z. B. „Backup srv-backup01 → srv-pve01“.
name String
name String
/// Fuer Dateinamen und Kommentare: backup-srv-backup01-srv-pve01.
slug String @unique
typ SchluesselTyp
kategorie SchluesselKategorie
status SchluesselStatus @default(AKTIV)
slug String @unique
typ SchluesselTyp
kategorie SchluesselKategorie
status SchluesselStatus @default(AKTIV)
/// OpenSSH-Zeile ohne Optionen: "ssh-ed25519 AAAA… kommentar".
publicKey String
fingerprint String @unique
kommentar String
publicKey String
fingerprint String @unique
kommentar String
/// Envelope-Chiffrat des Private Keys. Liest ausschliesslich src/worker/geheimnisse.ts.
privatVerschluesselt Bytes?
privatVerschluesselt Bytes?
/// Data-Key, verschluesselt mit dem Master-Key.
dekVerschluesselt Bytes?
masterKeyVersion Int?
privatGeloeschtAm DateTime?
passphraseGeschuetzt Boolean @default(false)
dekVerschluesselt Bytes?
masterKeyVersion Int?
privatGeloeschtAm DateTime?
passphraseGeschuetzt Boolean @default(false)
/// Genau ein Deployment-Key ist Instanz-Standard (partieller Index).
instanzStandard Boolean @default(false)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
vorlageId String?
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
tags String[]
ablaufAm DateTime?
zuletztVerwendetAm DateTime?
instanzStandard Boolean @default(false)
personId String?
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
vorlageId String?
vorlage Vorlage? @relation(fields: [vorlageId], references: [id], onDelete: SetNull)
tags String[]
ablaufAm DateTime?
zuletztVerwendetAm DateTime?
/// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
widerrufEingeleitetAm DateTime?
/// Rotationskette: der Schluessel, den dieser abgeloest hat.
vorgaengerId String? @unique
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
nachfolger Schluessel? @relation("Rotation")
erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
vorgaengerId String? @unique
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
nachfolger Schluessel? @relation("Rotation")
erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
selbstHinterlegt Boolean @default(false)
angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt
selbstHinterlegt Boolean @default(false)
angelegtAm DateTime @default(now())
geaendertAm DateTime @updatedAt
publicVerwendungen PublicVerwendung[]
privateVerwendungen PrivateVerwendung[]
+99 -7
View File
@@ -19,6 +19,10 @@ import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schlue
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 { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
@@ -187,18 +191,41 @@ function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNo
}
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) {
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;
const aktiv = s.status === "AKTIV";
// 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 widerrufbar =
!imWiderruf &&
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;
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");
@@ -206,7 +233,6 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
const jetzt = new Date();
return (
<div className="flex flex-col gap-6 sm:gap-7">
@@ -228,6 +254,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
<TypBadge typ={s.typ} publicKey={s.publicKey} />
<KategorieBadge kategorie={s.kategorie} />
<StatusBadge status={s.status} />
{imWiderruf && <Badge variante="gefahr">Widerruf unvollständig</Badge>}
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
@@ -240,14 +267,78 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
</>
}
aktionen={
verwalten && (
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
Bearbeiten
</Button>
(verwalten || (widerrufbar && widerruf)) && (
<>
{verwalten && (
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
Bearbeiten
</Button>
)}
{widerrufbar && widerruf && (
<WiderrufKnopf uebersicht={widerruf} ruecksprung={`/schluessel/${s.id}`} />
)}
</>
)
}
/>
{imWiderruf && widerruf && (
<Karte
titel="Widerruf läuft"
untertitel="Der Schlüssel gilt erst als widerrufen, wenn er von allen Hosts entfernt ist."
className="border-danger-border"
>
<WiderrufAblauf
schluesselIds={[s.id]}
offen={widerruf.offen}
zielkonten={widerruf.zielkonten}
vorauswahl={vorauswahl}
ruecksprung={`/schluessel/${s.id}`}
darfPrivat={rechte.privat}
/>
</Karte>
)}
{abgelaufen && widerrufbar && widerruf && (
<Karte titel="Abgelaufen" className="border-danger-border">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted max-w-xl text-[14px]">
{s.ablaufAm ? (
<>
Das Ablaufdatum (<time dateTime={s.ablaufAm.toISOString()}>{datum(s.ablaufAm)}</time>) 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.
</p>
<WiderrufKnopf
uebersicht={widerruf}
ruecksprung={`/schluessel/${s.id}`}
beschriftung="Jetzt widerrufen"
/>
</div>
</Karte>
)}
{(s.status === "WIDERRUFEN" || s.status === "ROTIERT") && (
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
<p className="text-muted text-[14px]">
Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen.
{s.nachfolger && (
<>
{" "}
Ersetzt durch{" "}
<Link href={`/schluessel/${s.nachfolger.id}`} className="text-accent hover:underline">
{s.nachfolger.name}
</Link>
.
</>
)}
</p>
</Karte>
)}
<Karte as="div" innen="ohne">
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
<InfoFeld titel="Fingerprint">
@@ -321,6 +412,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
</div>
{rechte.public &&
!imWiderruf &&
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
<Karte
titel="Dry-Run-Vorschau"
+54 -3
View File
@@ -1,8 +1,10 @@
import { KeyRound, Lock, Plus, Search } from "lucide-react";
import { Ban, KeyRound, Lock, Plus, Search } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { AlleAuswaehlen } from "@/components/widerruf/AlleAuswaehlen";
import { Auswahlliste, Feld } from "@/components/ui/Feld";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
@@ -50,6 +52,31 @@ function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) {
);
}
const MASSEN_FORMULAR = "massenwiderruf";
/** Nur Schluessel, die sich widerrufen lassen, sind waehlbar (#024). */
function Auswahl({ s }: { s: SchluesselListenEintrag }) {
const gesperrt = s.kategorie === "DEPLOYMENT" || s.status === "WIDERRUFEN" || s.status === "ROTIERT";
return (
<label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
<input
type="checkbox"
name="id"
value={s.id}
form={MASSEN_FORMULAR}
disabled={gesperrt}
aria-label={`${s.name} für den Massenwiderruf auswählen`}
className="accent-accent size-[18px] cursor-pointer disabled:cursor-not-allowed"
/>
</label>
);
}
function WiderrufBadge({ s }: { s: SchluesselListenEintrag }) {
if (!s.widerrufEingeleitetAm || (s.status !== "AKTIV" && s.status !== "ABGELAUFEN")) return null;
return <Badge variante="gefahr">Widerruf unvollständig</Badge>;
}
/** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */
export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
@@ -60,6 +87,8 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
const status = einzeln(parameter["status"]);
const liste = await schluesselListe({ q, kategorie, status });
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
// Massenwiderruf (#024): Auswahl nur fuer Admins; die frische Bestaetigung prueft die Aktion.
const massen = darf(benutzer.rolle, "massenwiderruf");
const gefiltert = q !== "" || kategorie !== "" || status !== "";
const jetzt = new Date();
@@ -142,13 +171,27 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
)
) : (
<Karte>
{massen && (
<form
id={MASSEN_FORMULAR}
method="get"
action="/schluessel/widerruf"
className="border-border mb-3 flex flex-wrap items-center justify-between gap-2 border-b pb-3"
>
<AlleAuswaehlen formular={MASSEN_FORMULAR} />
<Button type="submit" variante="gefahr" klein icon={<Ban className="size-4" />}>
Ausgewählte widerrufen
</Button>
</form>
)}
{/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */}
<ul className="flex flex-col md:hidden">
{liste.map((s) => (
<li key={s.id} className="border-border border-b last:border-b-0">
<li key={s.id} className="border-border flex items-start gap-1 border-b last:border-b-0">
{massen && <Auswahl s={s} />}
<Link
href={`/schluessel/${s.id}`}
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-2 px-2 py-3 transition-colors"
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 min-w-0 flex-1 flex-col gap-2 px-2 py-3 transition-colors"
>
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
<span className="truncate">{s.name}</span>
@@ -161,6 +204,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
<TypBadge typ={s.typ} publicKey={s.publicKey} />
<KategorieBadge kategorie={s.kategorie} />
<StatusBadge status={s.status} />
<WiderrufBadge s={s} />
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
</span>
<span className="text-muted text-[13px]">
@@ -175,6 +219,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
className="hidden md:block"
beschriftung="Schlüssel"
spalten={[
...(massen ? [{ titel: "Auswahl", versteckt: true, className: "w-10" }] : []),
{ titel: "Name" },
{ titel: "Typ" },
{ titel: "Kategorie" },
@@ -185,6 +230,11 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
>
{liste.map((s) => (
<TabellenZeile key={s.id}>
{massen && (
<TabellenZelle className="w-10">
<Auswahl s={s} />
</TabellenZelle>
)}
<TabellenZelle>
<span className="flex min-w-0 items-center gap-2">
<Link
@@ -195,6 +245,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
</Link>
<PrivatHinweis s={s} />
<StatusBadge status={s.status} />
<WiderrufBadge s={s} />
</span>
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
</TabellenZelle>
+118
View File
@@ -0,0 +1,118 @@
import { Ban } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { StatusBadge } from "@/components/schluessel/Anzeige";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Button } from "@/components/ui/Button";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular";
import { darf } from "@/server/auth/rechte";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { widerrufUebersicht } from "@/server/queries/widerruf";
export const metadata: Metadata = { title: "Massenwiderruf" };
type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>;
function liste(wert: string | string[] | undefined): string[] {
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
return [...new Set(roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9]{1,64}$/i.test(w)))].slice(
0,
200,
);
}
/**
* Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem
* Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische
* Bestaetigung (Recht `massenwiderruf`). Protokolliert wird je Schluessel.
*/
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
const parameter = await searchParams;
const ids = liste(parameter.id);
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
const kopf = (
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: "Massenwiderruf" }]} />}
titel="Massenwiderruf"
untertitel="Mehrere Schlüssel gemeinsam von allen Hosts entfernen – ein Dry-Run für alle."
/>
);
if (ids.length === 0) {
return (
<div className="flex flex-col gap-6 sm:gap-7">
{kopf}
<Leerzustand
icon={<Ban className="size-5" />}
titel="Keine Schlüssel gewählt"
text="Wählen Sie in der Schlüsselliste die Schlüssel aus, die widerrufen werden sollen."
aktion={<Button href="/schluessel">Zur Schlüsselliste</Button>}
/>
</div>
);
}
const uebersicht = await widerrufUebersicht(ids);
const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN";
const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet);
const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet);
const vorauswahl = liste(parameter.zielkonten);
return (
<div className="flex flex-col gap-6 sm:gap-7">
{kopf}
{nochNichtEingeleitet.length > 0 || uebersicht.schluessel.some((s) => s.gesperrt && !s.eingeleitet) ? (
<Karte
titel={`${uebersicht.schluessel.length} Schlüssel widerrufen?`}
untertitel="Genau das passiert – erst nach Dry-Run und „Anwenden“ auf den Hosts."
>
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
<p className="text-accent mb-4 text-[13.5px]">
Der Massenwiderruf verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
Vormerken danach.
</p>
)}
<WiderrufFormular uebersicht={uebersicht} ruecksprung={selbst} />
</Karte>
) : laufend.length > 0 ? (
<Karte
titel="Widerruf läuft"
untertitel="Ein gemeinsamer Dry-Run für alle Schlüssel. Widerrufen ist ein Schlüssel erst, wenn er überall entfernt ist."
className="border-danger-border"
>
<WiderrufAblauf
schluesselIds={laufend.map((s) => s.id)}
offen={uebersicht.offen}
zielkonten={uebersicht.zielkonten}
vorauswahl={vorauswahl}
ruecksprung={selbst}
darfPrivat={darf(benutzer.rolle, "privateKeyAusrollen")}
/>
</Karte>
) : null}
<Karte titel="Gewählte Schlüssel">
<ul className="flex flex-col gap-2">
{uebersicht.schluessel.map((s) => (
<li key={s.id} className="flex flex-wrap items-center gap-2 text-[14px]">
<Link href={`/schluessel/${s.id}`} className="text-accent hover:underline">
{s.name}
</Link>
{s.person && <span className="text-muted">· {s.person}</span>}
<StatusBadge status={s.status} />
{s.eingeleitet && offeneStati(s) && (
<span className="text-danger text-[13px]">Widerruf unvollständig</span>
)}
</li>
))}
</ul>
</Karte>
</div>
);
}
+4 -36
View File
@@ -1,12 +1,13 @@
"use client";
import { ArrowLeft, ArrowRight, Check, ExternalLink, Play } from "lucide-react";
import { ArrowLeft, ArrowRight, ExternalLink, Play } from "lucide-react";
import { useState } from "react";
import { clsx } from "clsx";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { warten } from "@/components/vorgang/warten";
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
import {
@@ -91,19 +92,6 @@ function kurz(text: string): string {
);
}
/** Wartet auf das Ende eines Vorgangs (fuer die Kette im Klick, nicht als Hook). */
async function warten(
vorgangId: string,
): Promise<{ status: string; fehler: string | null; ergebnis: unknown }> {
for (;;) {
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
const stand = (await antwort.json()) as { status: string; fehler: string | null; ergebnis: unknown };
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
await new Promise((weiter) => setTimeout(weiter, 800));
}
}
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
const [schritt, setSchritt] = useState(0);
const [w, setW] = useState<Werte>(LEER);
@@ -249,27 +237,7 @@ export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestae
return (
<div className="flex flex-col gap-6">
<ol className="flex flex-wrap gap-2" aria-label="Schritte">
{SCHRITTE.map((titel, index) => (
<li
key={titel}
aria-current={index === schritt ? "step" : undefined}
className={clsx(
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
index === schritt
? "border-accent text-accent bg-raised font-medium"
: index < schritt
? "border-border text-text"
: "border-border text-muted",
)}
>
<span className="font-mono">
{index < schritt ? <Check className="size-4" aria-hidden /> : index + 1}
</span>
{titel}
</li>
))}
</ol>
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} />
{schritt === 0 && (
<Karte
+29 -6
View File
@@ -2,7 +2,7 @@
import { Play, ScanSearch } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useEffect, useRef, useState, useTransition } from "react";
import { Button } from "@/components/ui/Button";
import { DiffBox } from "@/components/ui/DiffBox";
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
@@ -34,7 +34,16 @@ type PlanZiel = {
fehler?: string;
};
export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; offen: number }) {
export function AusrollenPanel({
auswahl,
offen,
nachAusrollen,
}: {
auswahl: AusrollAuswahl;
offen: number;
/** Nach erfolgreichem Ausrollen – etwa um einen Widerruf abzuschliessen (#024). */
nachAusrollen?: () => void;
}) {
const router = useRouter();
const [trockenlaufId, setTrockenlaufId] = useState<string>();
const [ausrollenId, setAusrollenId] = useState<string>();
@@ -43,12 +52,22 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
const trockenlauf = useVorgang(trockenlaufId);
const ausrollen = useVorgang(ausrollenId);
// Ueber eine Ref, damit der Rueckruf genau einmal je Lauf feuert und nicht bei jedem Render.
const rueckruf = useRef(nachAusrollen);
useEffect(() => {
if (ausrollen.stand && !laeuftNoch(ausrollen.stand)) router.refresh();
rueckruf.current = nachAusrollen;
});
useEffect(() => {
if (!ausrollen.stand || laeuftNoch(ausrollen.stand)) return;
router.refresh();
if (ausrollen.stand.status === "ERLEDIGT") rueckruf.current?.();
}, [ausrollen.stand, router]);
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0;
// Steht schon alles so auf dem Host (etwa ein Key, den jemand von Hand entfernt hat), gibt es
// nichts zu schreiben – der Zustand der Zuweisungen muss trotzdem nachgezogen werden (#024).
const nurZustand = aenderungen === 0 && offen > 0 && (plan?.some((z) => !z.fehler) ?? false);
const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand);
const fertig = ausrollen.stand?.status === "ERLEDIGT";
@@ -103,7 +122,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
</div>
))}
{plan && aenderungen > 0 && !fertig && (
{plan && (aenderungen > 0 || nurZustand) && !fertig && (
<div className="border-border flex flex-col gap-3 border-t pt-4">
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
<input type="checkbox" checked disabled className="accent-accent size-5" />
@@ -124,7 +143,11 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
})
}
>
{laeuftNoch(ausrollen.stand) ? "Wird ausgerollt …" : `Änderung anwenden (${aenderungen})`}
{laeuftNoch(ausrollen.stand)
? "Wird ausgerollt …"
: nurZustand
? "Zustand übernehmen"
: `Änderung anwenden (${aenderungen})`}
</Button>
<Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
Verwerfen
@@ -132,7 +155,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
</div>
</div>
)}
{plan && aenderungen === 0 && (
{plan && aenderungen === 0 && !fertig && (
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
)}
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && (
+41
View File
@@ -0,0 +1,41 @@
import { Check } from "lucide-react";
import { clsx } from "clsx";
/**
* Schrittanzeige fuer gefuehrte Ablaeufe (Assistent #020, Widerruf #024, Rotation #023):
* erledigte Schritte mit Haken, der aktuelle in Akzentfarbe und als `aria-current="step"`.
*/
export function Schrittleiste({
schritte,
aktuell,
beschriftung = "Schritte",
}: {
schritte: readonly string[];
/** Index des aktuellen Schritts; `schritte.length` heisst: alles erledigt. */
aktuell: number;
beschriftung?: string;
}) {
return (
<ol className="flex flex-wrap gap-2" aria-label={beschriftung}>
{schritte.map((titel, index) => (
<li
key={titel}
aria-current={index === aktuell ? "step" : undefined}
className={clsx(
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
index === aktuell
? "border-accent text-accent bg-raised font-medium"
: index < aktuell
? "border-border text-text"
: "border-border text-muted",
)}
>
<span className="font-mono">
{index < aktuell ? <Check className="size-4" aria-hidden /> : index + 1}
</span>
{titel}
</li>
))}
</ol>
);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Wartet auf das Ende eines Vorgangs – fuer Ketten im Klick (Assistent #020, Widerruf #024,
* Rotation #023), nicht als Hook.
*/
export type VorgangsEnde = { status: string; fehler: string | null; ergebnis: unknown };
export async function warten(vorgangId: string): Promise<VorgangsEnde> {
for (;;) {
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
const stand = (await antwort.json()) as VorgangsEnde;
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
await new Promise((weiter) => setTimeout(weiter, 800));
}
}
@@ -0,0 +1,24 @@
"use client";
/**
* „Alle auswählen“ fuer den Massenwiderruf (#024): setzt alle aktivierbaren Kaestchen, die zum
* Formular gehoeren. Die Auswahl selbst bleibt ein schlichtes GET-Formular – ohne JavaScript
* waehlt man eben einzeln.
*/
export function AlleAuswaehlen({ formular }: { formular: string }) {
return (
<label className="hover:bg-raised/60 rounded-box flex min-h-11 cursor-pointer items-center gap-3 px-2 text-[14px]">
<input
type="checkbox"
className="accent-accent size-[18px] cursor-pointer"
onChange={(e) => {
const kaestchen = document.querySelectorAll<HTMLInputElement>(
`input[type="checkbox"][form="${formular}"]:not(:disabled)`,
);
for (const k of kaestchen) k.checked = e.target.checked;
}}
/>
Alle auswählen
</label>
);
}
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { CheckCheck, ExternalLink } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { warten } from "@/components/vorgang/warten";
import { widerrufAbschliessenAktion, widerrufZielkontenAktion } from "@/server/actions/widerruf";
import type { OffeneEntfernung, ZielkontoKandidat } from "@/server/services/widerruf";
/**
* Ein eingeleiteter Widerruf (#024), wiederaufnehmbar wie der Assistent (#020): Dry-Run und
* Anwenden fuer alle offenen Entfernungen, dann optional Zielkonten, dann Abschluss.
*
* Was noch fehlt, kommt immer frisch vom Server (`offen`) – nach einem Neuladen, einem
* Fehlschlag auf einem Host oder einer erneuten Bestaetigung geht es an derselben Stelle weiter.
*/
const SCHRITTE = ["Vorgemerkt", "Dry-Run & Anwenden", "Zielkonten", "Abgeschlossen"] as const;
const ZUSTAND_TEXT: Record<string, string> = {
ENTFERNEN: "steht aus",
FEHLGESCHLAGEN: "letzter Versuch fehlgeschlagen",
};
export function WiderrufAblauf({
schluesselIds,
offen,
zielkonten,
vorauswahl,
ruecksprung,
darfPrivat,
ohneZielkonten = false,
nachAbschluss,
}: {
schluesselIds: string[];
offen: OffeneEntfernung[];
zielkonten: ZielkontoKandidat[];
vorauswahl: string[];
/** Diese Seite – Ziel der erneuten Bestaetigung. */
ruecksprung: string;
darfPrivat: boolean;
/** Rotation (#023): das Zielkonto nutzt der Nachfolger weiter. */
ohneZielkonten?: boolean;
nachAbschluss?: () => void;
}) {
const router = useRouter();
const waehlbar = ohneZielkonten ? [] : zielkonten.filter((k) => k.nochGenutztVon.length === 0);
// Ohne waehlbares Zielkonto entfaellt der Schritt ganz – ein leerer Schritt verwirrt nur.
const ohneSchritt = waehlbar.length === 0;
const [auswahl, setAuswahl] = useState<string[]>(() =>
vorauswahl.filter((id) => waehlbar.some((k) => k.id === id)),
);
const [zielkontenFertig, setZielkontenFertig] = useState(false);
const [laeuft, setLaeuft] = useState<string>();
const [fehler, setFehler] = useState<string>();
const [bestaetigen, setBestaetigen] = useState(false);
const [noch, setNoch] = useState<OffeneEntfernung[]>();
const aktuell = offen.length > 0 ? 1 : auswahl.length > 0 && !zielkontenFertig ? 2 : 3;
const privatOhneRecht = !darfPrivat && offen.some((o) => o.art === "privat");
async function abschliessen() {
setFehler(undefined);
setBestaetigen(false);
setNoch(undefined);
try {
if (auswahl.length > 0 && !zielkontenFertig) {
setLaeuft("Zielkonten werden entfernt …");
const antwort = await widerrufZielkontenAktion(schluesselIds, auswahl);
if (antwort.bestaetigen) setBestaetigen(true);
if (antwort.fehler || !antwort.vorgaenge) {
setFehler(antwort.fehler);
return;
}
const fehlschlaege: string[] = [];
for (const vorgang of antwort.vorgaenge) {
const ende = await warten(vorgang.id);
if (ende.status !== "ERLEDIGT")
fehlschlaege.push(`${vorgang.beschriftung}: ${ende.fehler ?? "fehlgeschlagen"}`);
}
if (fehlschlaege.length > 0) {
setFehler(`Zielkonto nicht entfernt – ${fehlschlaege.join(" · ")}`);
return;
}
setZielkontenFertig(true);
}
setLaeuft("Widerruf wird abgeschlossen …");
const antwort = await widerrufAbschliessenAktion(schluesselIds);
if (antwort.fehler) {
setFehler(antwort.fehler);
return;
}
const offeneSchluessel = (antwort.ergebnisse ?? []).filter((e) => e.status === "OFFEN");
if (offeneSchluessel.length > 0) {
setNoch(offeneSchluessel.flatMap((e) => e.offen));
} else {
nachAbschluss?.();
}
router.refresh();
} catch (fehler) {
setFehler((fehler as Error).message);
} finally {
setLaeuft(undefined);
}
}
const bestaetigenZiel = (() => {
const url = new URL(ruecksprung, "http://x");
if (auswahl.length > 0) url.searchParams.set("zielkonten", auswahl.join(","));
else url.searchParams.delete("zielkonten");
return `${url.pathname}${url.search}`;
})();
return (
<div className="flex flex-col gap-5">
<Schrittleiste
schritte={ohneSchritt ? SCHRITTE.filter((s) => s !== "Zielkonten") : SCHRITTE}
aktuell={ohneSchritt && aktuell >= 2 ? aktuell - 1 : aktuell}
beschriftung="Schritte des Widerrufs"
/>
{offen.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-[14px] font-medium">Noch nicht entfernt ({offen.length}):</p>
<ul className="flex flex-col gap-1 font-mono text-[13px]">
{offen.map((o) => (
<li key={o.id} className="break-all">
<span className={o.zustand === "FEHLGESCHLAGEN" ? "text-danger" : "text-accent"}>●</span>{" "}
{o.ziel}
<span className="text-muted font-sans">
{" "}
· {ZUSTAND_TEXT[o.zustand] ?? o.zustand.toLowerCase()}
</span>
</li>
))}
</ul>
{privatOhneRecht ? (
<p className="text-danger text-[14px]">Private Keys entfernt nur ein Admin.</p>
) : (
<>
<p className="text-muted text-[13.5px]">
Der Dry-Run zeigt je Host, was entfernt wird – und warum ein Host gerade nicht geht (etwa
nicht erreichbar). Fehlgeschlagene Ziele bleiben offen; ein neuer Dry-Run versucht es erneut.
</p>
<AusrollenPanel
auswahl={schluesselIds.length === 1 ? { schluesselId: schluesselIds[0]! } : { schluesselIds }}
offen={offen.length}
nachAusrollen={() => void abschliessen()}
/>
</>
)}
</div>
)}
{waehlbar.length > 0 && (
<fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-[14px] font-medium">Zielkonten entfernen (optional)</legend>
{zielkonten.map((k) => {
const genutzt = k.nochGenutztVon.length > 0;
return (
<Ankreuzfeld
key={k.id}
disabled={genutzt || zielkontenFertig || Boolean(laeuft)}
checked={auswahl.includes(k.id)}
onChange={(e) =>
setAuswahl((alt) => (e.target.checked ? [...alt, k.id] : alt.filter((id) => id !== k.id)))
}
beschriftung={
<>
Benutzer <span className="font-mono">{k.benutzername}</span> auf{" "}
<span className="font-mono">{k.hostName}</span> entfernen
</>
}
erklaerung={
genutzt
? `Bleibt: wird noch von „${k.nochGenutztVon.join("“, „")}“ genutzt.`
: "Nach dem Entfernen der Schlüssel; verlangt eine frische Bestätigung."
}
/>
);
})}
</fieldset>
)}
{noch && noch.length > 0 && (
<p
role="status"
className="border-danger-border bg-danger-soft text-danger rounded-box border px-4 py-3 text-[14px]"
>
Noch nicht widerrufen – offen: {noch.map((o) => o.ziel).join(", ")}. Bitte einen neuen Dry-Run
starten.
</p>
)}
<Formularfehler text={fehler} />
{bestaetigen && (
<a
href={`/bestaetigen?ziel=${encodeURIComponent(bestaetigenZiel)}`}
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
>
<ExternalLink className="size-4" aria-hidden /> Anmeldung bestätigen, danach hier fortsetzen
</a>
)}
{laeuft && (
<p role="status" className="text-muted text-[14px]">
{laeuft}
</p>
)}
{offen.length === 0 && (
<div>
<Button
variante="gefahr"
icon={<CheckCheck className="size-4" />}
disabled={Boolean(laeuft)}
onClick={() => void abschliessen()}
>
Widerruf abschließen
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,209 @@
"use client";
import { Ban } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
import { widerrufEinleitenAktion } from "@/server/actions/widerruf";
import type { WiderrufUebersicht } from "@/server/queries/widerruf";
/**
* Bestaetigung eines Widerrufs (#024): listet genau auf, was auf welchem Host passiert, und
* laesst Zielkonten (Standard: aus) zum Entfernen auswaehlen. Der Klick merkt nur vor – auf den
* Hosts aendert sich erst nach Dry-Run und „Anwenden“ etwas.
*
* Die Auswahl der Zielkonten reist als `?zielkonten=` in der Adresse weiter: so uebersteht sie
* ein Neuladen und die erneute Bestaetigung der Anmeldung.
*/
export function WiderrufFormular({
uebersicht,
ruecksprung,
abbrechen,
}: {
uebersicht: WiderrufUebersicht;
/** Wohin nach dem Vormerken (Schluesselseite bzw. Massenwiderruf-Seite). */
ruecksprung: string;
abbrechen?: () => void;
}) {
const router = useRouter();
const [auswahl, setAuswahl] = useState<string[]>([]);
const [fehler, setFehler] = useState<string>();
const [bestaetigen, setBestaetigen] = useState(false);
const [laeuft, starten] = useTransition();
const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt);
const mehrere = uebersicht.schluessel.length > 1;
const ziel = (() => {
const url = new URL(ruecksprung, "http://x");
if (auswahl.length > 0) url.searchParams.set("zielkonten", auswahl.join(","));
return `${url.pathname}${url.search}`;
})();
return (
<div className="flex flex-col gap-5">
<WiderrufInhalt uebersicht={uebersicht} mehrere={mehrere} />
{uebersicht.zielkonten.length > 0 && (
<fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-[14px] font-medium">Zielkonten zusätzlich entfernen (optional)</legend>
{uebersicht.zielkonten.map((k) => {
const genutzt = k.nochGenutztVon.length > 0;
return (
<Ankreuzfeld
key={k.id}
name="zielkonten"
value={k.id}
disabled={genutzt}
checked={auswahl.includes(k.id)}
onChange={(e) =>
setAuswahl((alt) => (e.target.checked ? [...alt, k.id] : alt.filter((id) => id !== k.id)))
}
beschriftung={
<>
Benutzer <span className="font-mono">{k.benutzername}</span> auf{" "}
<span className="font-mono">{k.hostName}</span> entfernen
</>
}
erklaerung={
genutzt
? `Bleibt: wird noch von „${k.nochGenutztVon.join("“, „")}“ genutzt.`
: `Von sshkv angelegt. Benutzer samt Home${k.sudoModus !== "KEINER" ? " und sudoers-Datei" : ""} werden gelöscht – verlangt eine frische Bestätigung.`
}
/>
);
})}
</fieldset>
)}
<div className="border-border bg-bg rounded-box border px-4 py-3 text-[13.5px]">
<p className="mb-1.5 font-medium">So geht es weiter</p>
<ol className="text-muted list-decimal space-y-1 pl-5">
<li>
Die Zuweisungen werden zum Entfernen vorgemerkt; der Schlüssel lässt sich nicht mehr zuweisen.
</li>
<li>Der Dry-Run zeigt jede Änderung. Erst „Anwenden“ ändert etwas auf den Hosts.</li>
<li>
Erst wenn alle Entfernungen erfolgreich waren, gilt der Schlüssel als widerrufen. Scheitert ein
Host, bleibt er im Zwischenzustand und die Seite zeigt, was fehlt.
</li>
</ol>
</div>
{gesperrt.map((s) => (
<p key={s.id} className="text-danger text-[14px]">
{s.gesperrt}
</p>
))}
<Formularfehler text={fehler} />
{bestaetigen && (
<a
href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`}
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium"
>
Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen
</a>
)}
<div className="flex flex-wrap justify-end gap-2.5">
{abbrechen && <Button onClick={abbrechen}>Abbrechen</Button>}
<Button
variante="gefahr"
icon={<Ban className="size-4" />}
disabled={laeuft || gesperrt.length > 0}
onClick={() =>
starten(async () => {
setFehler(undefined);
const antwort = await widerrufEinleitenAktion(uebersicht.schluessel.map((s) => s.id));
if (antwort.bestaetigen) setBestaetigen(true);
if (antwort.fehler) {
setFehler(antwort.fehler);
return;
}
abbrechen?.();
router.replace(ziel);
router.refresh();
})
}
>
{laeuft
? "Wird vorgemerkt …"
: mehrere
? `${uebersicht.schluessel.length} Schlüssel widerrufen`
: "Widerruf vorbereiten"}
</Button>
</div>
</div>
);
}
/** Die Liste „was passiert“ – im Dialog und auf der Massenwiderruf-Seite. */
export function WiderrufInhalt({
uebersicht,
mehrere,
}: {
uebersicht: WiderrufUebersicht;
mehrere: boolean;
}) {
return (
<div className="flex flex-col gap-4">
{uebersicht.schluessel.map((s) => {
const oeffentlich = uebersicht.public.filter((v) => v.schluesselId === s.id);
const privat = uebersicht.privat.filter((v) => v.schluesselId === s.id);
return (
<section key={s.id} className="flex flex-col gap-2" aria-label={`Widerruf von ${s.name}`}>
{mehrere && (
<h3 className="text-[14.5px] font-semibold break-words">
{s.name}
{s.person && <span className="text-muted font-normal"> · {s.person}</span>}
</h3>
)}
{oeffentlich.length === 0 && privat.length === 0 && (
<p className="text-muted text-[13.5px]">
Keine Zuweisungen – der Schlüssel wird ohne Änderung auf einem Host widerrufen.
</p>
)}
{oeffentlich.length > 0 && (
<div>
<p className="text-muted mb-1 text-[13px]">Public Key wird aus authorized_keys entfernt:</p>
<ul className="flex flex-col gap-1 font-mono text-[13px]">
{oeffentlich.map((v) => (
<li key={v.id} className="break-all">
<span className="text-danger">−</span> {v.ziel}
{v.archiviert && (
<span className="text-muted font-sans">
{" "}
· Host archiviert – sshkv ändert dort nichts
</span>
)}
</li>
))}
</ul>
</div>
)}
{privat.length > 0 && (
<div>
<p className="text-muted mb-1 text-[13px]">Private Key wird vom Quell-Host entfernt:</p>
<ul className="flex flex-col gap-1.5 font-mono text-[13px]">
{privat.map((v) => (
<li key={v.id}>
<span className="break-all">
<span className="text-danger">−</span> {v.ziel}:{v.pfad}
</span>
<span className="text-muted block pl-3.5 font-sans text-[12.5px]">
{v.beimWiderruf === "UMBENENNEN"
? `Wird umbenannt in …${v.pfad.split("/").pop()}.sshkv-widerrufen.<Zeit>`
: "Wird gelöscht"}
, dazu die .pub-Datei
{v.alias ? `, der config-Eintrag „Host ${v.alias}“ und sein known_hosts-Block` : ""}
{v.archiviert ? " · Host archiviert – sshkv ändert dort nichts" : ""}
</span>
</li>
))}
</ul>
</div>
)}
</section>
);
})}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { Ban, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/Button";
import type { WiderrufUebersicht } from "@/server/queries/widerruf";
import { WiderrufFormular } from "./WiderrufFormular";
/**
* „Widerrufen“ auf der Schluesselseite (#024): Gefahr-Knopf mit Bestaetigungsdialog. Offen/zu
* als Zustand wie im AusrollDialog – kein Render-Pfad liest die Ref.
*/
export function WiderrufKnopf({
uebersicht,
ruecksprung,
beschriftung = "Widerrufen",
}: {
uebersicht: WiderrufUebersicht;
ruecksprung: string;
beschriftung?: string;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [offen, setOffen] = useState(false);
const name = uebersicht.schluessel[0]?.name ?? "";
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (offen && !element.open) element.showModal();
if (!offen && element.open) element.close();
}, [offen]);
const schliessen = () => setOffen(false);
return (
<>
<Button variante="gefahr" icon={<Ban className="size-4" />} onClick={() => setOffen(true)}>
{beschriftung}
</Button>
<dialog
ref={dialog}
aria-labelledby="widerruf-titel"
onClose={schliessen}
className="border-border-strong bg-surface text-text rounded-karte m-auto max-h-[calc(100dvh-32px)] w-[calc(100%-32px)] max-w-2xl overflow-y-auto border p-5 backdrop:bg-black/70 sm:p-6"
>
<div className="mb-4 flex items-start justify-between gap-3">
<h2 id="widerruf-titel" className="font-display text-[18px] font-semibold break-words">
„{name}“ widerrufen?
</h2>
<button
type="button"
onClick={schliessen}
aria-label="Schließen"
className="text-muted hover:text-text hover:bg-raised rounded-feld -mt-2 -mr-2 flex size-11 shrink-0 items-center justify-center"
>
<X className="size-5" aria-hidden />
</button>
</div>
{offen && (
<WiderrufFormular uebersicht={uebersicht} ruecksprung={ruecksprung} abbrechen={schliessen} />
)}
</dialog>
</>
);
}
+8 -2
View File
@@ -74,7 +74,11 @@ export async function publicEntfernenAktion(
}
}
export type AusrollAuswahl = { readonly schluesselId: string } | { readonly hostId: string };
export type AusrollAuswahl =
| { readonly schluesselId: string }
| { readonly hostId: string }
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
| { readonly schluesselIds: readonly string[] };
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
try {
@@ -84,7 +88,9 @@ export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<Vorgan
vorgangId: await trockenlaufStarten(
"schluesselId" in auswahl
? { schluesselId: id.parse(auswahl.schluesselId) }
: { hostId: id.parse(auswahl.hostId) },
: "schluesselIds" in auswahl
? { schluesselIds: z.array(id).min(1).max(200).parse(auswahl.schluesselIds) }
: { hostId: id.parse(auswahl.hostId) },
akteurVon(benutzer),
// Private-Key-Ziele nur fuer Admins – ein Operator rollt so nie einen Private Key aus.
darf(benutzer.rolle, "privateKeyAusrollen"),
+85
View File
@@ -0,0 +1,85 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { akteurVon } from "@/server/actions/formular";
import { darf } from "@/server/auth/rechte";
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { VerwendungsFehler } from "@/server/services/verwendungen";
import {
widerrufAbschliessen,
widerrufEinleiten,
widerrufZielkontenEntfernen,
type AbschlussErgebnis,
} from "@/server/services/widerruf";
/**
* Widerruf und Massenwiderruf (#024). Duenn: Recht, Eingabe, Service.
*
* Rechte: einen Public Key entfernen darf, wer ausrollen darf; sobald Private Keys betroffen
* sind, nur ein Admin (prueft der Service mit `privatErlaubt`). Mehrere Schluessel auf einmal
* (Massenwiderruf) und das Entfernen von Zielkonten verlangen Admin mit frischer Bestaetigung.
*/
export type WiderrufAntwort = {
readonly fehler?: string;
/** Frische Bestaetigung noetig: die Oberflaeche verlinkt /bestaetigen. */
readonly bestaetigen?: boolean;
};
function alsAntwort(fehler: unknown): WiderrufAntwort {
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
if (fehler instanceof RechteFehler || fehler instanceof VerwendungsFehler)
return { fehler: fehler.message };
throw fehler;
}
const idListe = z.array(z.string().min(1).max(64)).min(1).max(200);
function pfadeNeuLaden(ids: readonly string[]) {
revalidatePath("/schluessel");
for (const id of ids) revalidatePath(`/schluessel/${id}`);
}
export async function widerrufEinleitenAktion(schluesselIds: string[]): Promise<WiderrufAntwort> {
try {
const ids = idListe.parse(schluesselIds);
const { benutzer } = await rechtErforderlich(ids.length > 1 ? "massenwiderruf" : "publicKeyAusrollen");
await widerrufEinleiten(ids, akteurVon(benutzer), {
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
});
pfadeNeuLaden(ids);
return {};
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function widerrufZielkontenAktion(
schluesselIds: string[],
zielkontoIds: string[],
): Promise<WiderrufAntwort & { vorgaenge?: { id: string; beschriftung: string }[] }> {
try {
const ids = idListe.parse(schluesselIds);
const konten = idListe.parse(zielkontoIds);
// Ein Benutzer samt Home verschwindet: Admin mit frischer Bestaetigung (#018).
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
return { vorgaenge: await widerrufZielkontenEntfernen(ids, konten, akteurVon(benutzer)) };
} catch (fehler) {
return alsAntwort(fehler);
}
}
export async function widerrufAbschliessenAktion(
schluesselIds: string[],
): Promise<WiderrufAntwort & { ergebnisse?: AbschlussErgebnis[] }> {
try {
const ids = idListe.parse(schluesselIds);
// Abschliessen setzt nur den Status nach dem, was die Datenbank belegt – kein Host-Zugriff.
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
const ergebnisse = await widerrufAbschliessen(ids, akteurVon(benutzer));
pfadeNeuLaden(ids);
return { ergebnisse };
} catch (fehler) {
return alsAntwort(fehler);
}
}
+26 -1
View File
@@ -23,6 +23,7 @@ export const SCHLUESSEL_LISTE_SELECT = {
ablaufAm: true,
masterKeyVersion: true,
passphraseGeschuetzt: true,
widerrufEingeleitetAm: true,
person: { select: { id: true, name: true } },
_count: {
select: {
@@ -50,12 +51,15 @@ export const SCHLUESSEL_DETAIL_SELECT = {
tags: true,
ablaufAm: true,
zuletztVerwendetAm: true,
widerrufEingeleitetAm: true,
angelegtAm: true,
personId: true,
vorlageId: true,
person: { select: { id: true, name: true } },
vorlage: { select: { id: true, name: true } },
erstelltVon: { select: { name: true } },
vorgaenger: { select: { id: true, name: true, status: true } },
nachfolger: { select: { id: true, name: true, status: true } },
publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" },
@@ -101,13 +105,34 @@ function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
return STATI.find((s) => s === wert);
}
/**
* Heute 00:00 UTC nach dem Kalender in Berlin – Ablaufdaten liegen als UTC-Mitternacht ihres
* Tages in der Datenbank, und `ablaufZustand()` rechnet ebenfalls mit dem Berliner Datum.
*/
function heute(jetzt = new Date()): Date {
const tag = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" }).format(jetzt);
return new Date(`${tag}T00:00:00Z`);
}
/**
* Status als Filter (#024): ein aktiver Schluessel mit Ablaufdatum in der Vergangenheit gilt als
* abgelaufen, auch wenn niemand den Status von Hand gesetzt hat. Automatisch entfernt wird nichts.
*/
function statusFilter(status: SchluesselStatus | undefined): Prisma.SchluesselWhereInput {
if (status === "AKTIV") return { status, OR: [{ ablaufAm: null }, { ablaufAm: { gte: heute() } }] };
if (status === "ABGELAUFEN") {
return { OR: [{ status }, { status: "AKTIV", ablaufAm: { lt: heute() } }] };
}
return status ? { status } : {};
}
export async function schluesselListe(filter: SchluesselFilter = {}) {
const q = filter.q?.trim().slice(0, 100);
const kategorie = alsKategorie(filter.kategorie);
const status = alsStatus(filter.status);
const where: Prisma.SchluesselWhereInput = {
...(kategorie ? { kategorie } : {}),
...(status ? { status } : {}),
AND: [statusFilter(status)],
...(q
? {
OR: [
+121
View File
@@ -0,0 +1,121 @@
import { prisma } from "@/lib/db";
import {
offeneEntfernungen,
widerrufGesperrt,
zielkontoKandidaten,
type OffeneEntfernung,
type ZielkontoKandidat,
} from "@/server/services/widerruf";
/**
* Was ein Widerruf bewirkt (#024) – fuer den Bestaetigungsdialog, die Massenwiderruf-Seite und
* die Fortsetzen-Ansicht. Nur Namen, Ziele und Pfade; nie Schluesselinhalte.
*/
export type WiderrufSchluessel = {
readonly id: string;
readonly name: string;
readonly kategorie: "PERSON" | "DIENST" | "DEPLOYMENT";
readonly status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
readonly eingeleitet: boolean;
readonly person: string | null;
readonly gesperrt: string | null;
};
export type WiderrufZiel = {
readonly id: string;
readonly schluesselId: string;
readonly ziel: string;
readonly archiviert: boolean;
};
export type WiderrufPrivatZiel = WiderrufZiel & {
readonly pfad: string;
readonly alias: string | null;
readonly beimWiderruf: "LOESCHEN" | "UMBENENNEN";
};
export type WiderrufUebersicht = {
readonly schluessel: WiderrufSchluessel[];
readonly public: WiderrufZiel[];
readonly privat: WiderrufPrivatZiel[];
readonly zielkonten: ZielkontoKandidat[];
readonly offen: OffeneEntfernung[];
};
export async function widerrufUebersicht(schluesselIds: readonly string[]): Promise<WiderrufUebersicht> {
const ids = [...new Set(schluesselIds)];
const schluessel = await prisma.schluessel.findMany({
where: { id: { in: ids } },
select: {
id: true,
name: true,
kategorie: true,
status: true,
widerrufEingeleitetAm: true,
person: { select: { name: true } },
},
orderBy: { name: "asc" },
});
const [oeffentlich, privat, zielkonten, offen] = await Promise.all([
prisma.publicVerwendung.findMany({
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
select: {
id: true,
schluesselId: true,
zielbenutzer: true,
host: { select: { name: true, archiviertAm: true } },
hostgruppe: { select: { name: true } },
},
orderBy: { angelegtAm: "asc" },
}),
prisma.privateVerwendung.findMany({
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
select: {
id: true,
schluesselId: true,
zielbenutzer: true,
pfad: true,
sshConfig: true,
beimWiderruf: true,
host: { select: { name: true, archiviertAm: true } },
},
orderBy: { angelegtAm: "asc" },
}),
zielkontoKandidaten(ids),
offeneEntfernungen(ids),
]);
return {
schluessel: schluessel.map((s) => ({
id: s.id,
name: s.name,
kategorie: s.kategorie,
status: s.status,
eingeleitet: Boolean(s.widerrufEingeleitetAm),
person: s.person?.name ?? null,
gesperrt: widerrufGesperrt(s),
})),
public: oeffentlich.map((v) => ({
id: v.id,
schluesselId: v.schluesselId,
ziel: v.host
? `${v.zielbenutzer}@${v.host.name}`
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
archiviert: Boolean(v.host?.archiviertAm),
})),
privat: privat.map((v) => {
const config =
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
return {
id: v.id,
schluesselId: v.schluesselId,
ziel: `${v.zielbenutzer}@${v.host.name}`,
pfad: v.pfad,
alias: typeof config?.alias === "string" ? config.alias : null,
beimWiderruf: v.beimWiderruf,
archiviert: Boolean(v.host.archiviertAm),
};
}),
zielkonten,
offen,
};
}
+42 -2
View File
@@ -20,6 +20,9 @@ export class VerwendungsFehler extends Error {
type Akteur = { readonly id: string; readonly name: string };
/** Ein Schluessel im Widerruf (#024) bekommt keine neuen Ziele – sonst wuerde er nie fertig. */
const WIDERRUF_LAEUFT = "Dieser Schlüssel wird gerade widerrufen und lässt sich nicht mehr zuweisen.";
export type PublicZuweisung = {
readonly schluesselId: string;
readonly hostId?: string | null;
@@ -46,11 +49,12 @@ export async function publicVerwendungAnlegen(eingabe: PublicZuweisung, akteur:
}
const schluessel = await prisma.schluessel.findUnique({
where: { id: eingabe.schluesselId },
select: { status: true, fingerprint: true, kategorie: true },
select: { status: true, fingerprint: true, kategorie: true, widerrufEingeleitetAm: true },
});
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
if (schluessel.status !== "AKTIV")
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich zuweisen.");
if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
if (schluessel.kategorie === "DEPLOYMENT") {
throw new VerwendungsFehler("Der Deployment-Key wird von sshkv selbst verwaltet.");
}
@@ -116,13 +120,47 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro
});
}
/** Ziele mehrerer Schluessel ohne Doppelte – ein gemeinsamer Dry-Run beim Massenwiderruf (#024). */
async function zieleMehrererSchluessel(
schluesselIds: readonly string[],
mitPrivat: boolean,
): Promise<Ziel[]> {
const ziele = new Map<string, Ziel>();
for (const schluesselId of schluesselIds) {
const liste = [
...(await zieleVonVerwendungen({ schluesselId })),
...(mitPrivat ? await zieleVonPrivatVerwendungen({ schluesselId }) : []),
];
for (const ziel of liste)
ziele.set(`${ziel.hostId}\u0000${ziel.zielbenutzer}\u0000${ziel.art ?? "public"}`, ziel);
}
return [...ziele.values()];
}
/** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */
export async function trockenlaufStarten(
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
auswahl: {
schluesselId?: string;
/** Mehrere Schluessel in einem Dry-Run (Massenwiderruf, #024). */
schluesselIds?: readonly string[];
hostId?: string;
verwendungIds?: string[];
ziele?: Ziel[];
},
akteur: Akteur,
/** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */
mitPrivat = false,
): Promise<string> {
if (auswahl.schluesselIds && !auswahl.ziele) {
return trockenlaufStarten(
{
ziele: await zieleMehrererSchluessel(auswahl.schluesselIds, mitPrivat),
...(auswahl.schluesselIds.length === 1 ? { schluesselId: auswahl.schluesselIds[0] } : {}),
},
akteur,
mitPrivat,
);
}
const ziele = auswahl.ziele ?? [
...(await zieleVonVerwendungen({
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
@@ -197,11 +235,13 @@ export async function privateVerwendungAnlegen(eingabe: PrivateZuweisung, akteur
kategorie: true,
privatGeloeschtAm: true,
masterKeyVersion: true,
widerrufEingeleitetAm: true,
},
});
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
if (schluessel.status !== "AKTIV")
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich ausrollen.");
if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
if (schluessel.kategorie !== "DIENST")
throw new VerwendungsFehler("Private Keys werden nur für Service-Schlüssel ausgerollt.");
// masterKeyVersion ist genau dann gesetzt, wenn ein Private Key gespeichert ist (DB-CHECK).
+433
View File
@@ -0,0 +1,433 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { protokollieren } from "@/server/services/protokoll";
import { VerwendungsFehler } from "@/server/services/verwendungen";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
* Widerruf und Massenwiderruf (#024).
*
* Ablauf in drei Schritten, damit der Status nie mehr verspricht, als auf den Hosts passiert ist:
*
* 1. `widerrufEinleiten`: alle Verwendungen auf ENTFERNEN, `widerrufEingeleitetAm` setzen.
* Der Schluessel bleibt AKTIV, laesst sich aber nicht mehr zuweisen.
* 2. Dry-Run und Ausrollen wie sonst auch (#016/#017). Was gelingt, wird ENTFERNT; was auf einem
* Host scheitert (nicht erreichbar, Datei geaendert), bleibt ENTFERNEN.
* 3. `widerrufAbschliessen`: erst wenn keine Verwendung mehr offen ist, wird der Status
* WIDERRUFEN (bzw. ROTIERT, wenn ein Nachfolger aus einer Rotation existiert, #023).
*
* Optional dazwischen: Zielkonten entfernen, die fuer diese Schluessel angelegt wurden.
*/
export class WiderrufFehler extends VerwendungsFehler {
constructor(nachricht: string) {
super(nachricht);
this.name = "WiderrufFehler";
}
}
type Akteur = { readonly id: string; readonly name: string };
/** Verwendungen in diesen Zustaenden zaehlen als „noch in Gebrauch“ (fuer Zielkonten). */
const IN_GEBRAUCH = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
const SCHLUESSEL_KOPF = {
id: true,
name: true,
kategorie: true,
status: true,
fingerprint: true,
widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, status: true } },
} satisfies Prisma.SchluesselSelect;
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
/** Warum ein Schluessel nicht widerrufen werden kann – oder null. */
export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | null {
// Der Deployment-Key ist sshkvs eigener Zugang; ohne ihn ist kein Host mehr erreichbar.
if (s.kategorie === "DEPLOYMENT")
return "Der Deployment-Key lässt sich nicht widerrufen – er wird über eine eigene Rotation ersetzt.";
if (s.status === "WIDERRUFEN") return `„${s.name}“ ist bereits widerrufen.`;
if (s.status === "ROTIERT") return `„${s.name}“ ist bereits rotiert und von allen Hosts entfernt.`;
return null;
}
async function schluesselLaden(ids: readonly string[]): Promise<SchluesselKopf[]> {
const eindeutig = [...new Set(ids)];
if (eindeutig.length === 0) throw new WiderrufFehler("Bitte mindestens einen Schlüssel wählen.");
const liste = await prisma.schluessel.findMany({
where: { id: { in: eindeutig } },
select: SCHLUESSEL_KOPF,
orderBy: { name: "asc" },
});
if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht.");
return liste;
}
// ---------------------------------------------------------------- Zielkonten
export type ZielkontoKandidat = {
readonly id: string;
readonly hostId: string;
readonly hostName: string;
readonly benutzername: string;
readonly sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
/** Namen anderer Schluessel, die das Konto noch nutzen – dann bleibt es. */
readonly nochGenutztVon: string[];
};
/**
* Zielkonten, die sshkv fuer diese Schluessel angelegt hat: ein von sshkv angelegtes, noch
* vorhandenes Konto auf einem Host, auf dem einer der Schluessel (in irgendeinem Zustand) fuer
* genau diesen Benutzer zugewiesen war. Ein Zielkonto kennt seinen Schluessel nicht; die
* Zuweisung ist die einzige belastbare Verbindung.
*/
export async function zielkontoKandidaten(schluesselIds: readonly string[]): Promise<ZielkontoKandidat[]> {
const ids = [...new Set(schluesselIds)];
const [oeffentlich, privat] = await Promise.all([
prisma.publicVerwendung.findMany({
where: { schluesselId: { in: ids } },
select: {
zielbenutzer: true,
hostId: true,
hostgruppe: { select: { mitglieder: { select: { hostId: true } } } },
},
}),
prisma.privateVerwendung.findMany({
where: { schluesselId: { in: ids } },
select: { zielbenutzer: true, hostId: true },
}),
]);
const paare = new Set<string>();
for (const v of oeffentlich) {
const hosts = v.hostId ? [v.hostId] : (v.hostgruppe?.mitglieder.map((m) => m.hostId) ?? []);
for (const hostId of hosts) paare.add(`${hostId}\u0000${v.zielbenutzer}`);
}
for (const v of privat) paare.add(`${v.hostId}\u0000${v.zielbenutzer}`);
if (paare.size === 0) return [];
const konten = await prisma.zielkonto.findMany({
where: {
entferntAm: null,
vonSshkvAngelegt: true,
host: { archiviertAm: null },
OR: [...paare].map((p) => {
const [hostId, benutzername] = p.split("\u0000") as [string, string];
return { hostId, benutzername };
}),
},
select: {
id: true,
hostId: true,
benutzername: true,
sudoModus: true,
host: { select: { name: true } },
},
orderBy: [{ host: { name: "asc" } }, { benutzername: "asc" }],
});
const ergebnis: ZielkontoKandidat[] = [];
for (const konto of konten) {
const [andere, anderePrivat] = await Promise.all([
prisma.publicVerwendung.findMany({
where: {
schluesselId: { notIn: ids },
zielbenutzer: konto.benutzername,
zustand: { in: [...IN_GEBRAUCH] },
schluessel: { widerrufEingeleitetAm: null },
OR: [{ hostId: konto.hostId }, { hostgruppe: { mitglieder: { some: { hostId: konto.hostId } } } }],
},
select: { schluessel: { select: { name: true } } },
}),
prisma.privateVerwendung.findMany({
where: {
schluesselId: { notIn: ids },
hostId: konto.hostId,
zielbenutzer: konto.benutzername,
zustand: { in: [...IN_GEBRAUCH] },
schluessel: { widerrufEingeleitetAm: null },
},
select: { schluessel: { select: { name: true } } },
}),
]);
ergebnis.push({
id: konto.id,
hostId: konto.hostId,
hostName: konto.host.name,
benutzername: konto.benutzername,
sudoModus: konto.sudoModus,
nochGenutztVon: [...new Set([...andere, ...anderePrivat].map((v) => v.schluessel.name))].sort(),
});
}
return ergebnis;
}
// ---------------------------------------------------------------- Einleiten
export type EinleitenOptionen = {
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
readonly privatErlaubt: boolean;
};
/**
* Schritt 1: Verwendungen zum Entfernen vormerken. Idempotent – ein zweiter Aufruf (etwa beim
* Fortsetzen) aendert an bereits vorgemerkten Verwendungen nichts.
*/
export async function widerrufEinleiten(
schluesselIds: readonly string[],
akteur: Akteur,
optionen: EinleitenOptionen,
): Promise<void> {
const liste = await schluesselLaden(schluesselIds);
for (const s of liste) {
const grund = widerrufGesperrt(s);
if (grund) throw new WiderrufFehler(grund);
}
const ids = liste.map((s) => s.id);
const privatOffen = await prisma.privateVerwendung.count({
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
});
if (privatOffen > 0 && !optionen.privatErlaubt) {
throw new WiderrufFehler(
"Private Keys auf Hosts entfernt nur ein Admin. Bitte einen Admin widerrufen lassen.",
);
}
await prisma.$transaction(async (tx) => {
const jetzt = new Date();
for (const s of liste) {
const [oeffentlich, privat] = await Promise.all([
tx.publicVerwendung.findMany({
where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } },
select: {
id: true,
zustand: true,
zuletztAngewendetAm: true,
host: { select: { name: true, archiviertAm: true } },
},
}),
tx.privateVerwendung.findMany({
where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } },
select: {
id: true,
zustand: true,
zuletztAngewendetAm: true,
host: { select: { name: true, archiviertAm: true } },
},
}),
]);
// Nie ausgerollt: auf dem Host steht nichts. Archivierter Host: sshkv verbindet sich dort
// nicht mehr – das Protokoll haelt fest, dass dort nichts entfernt wurde.
const sofort = (v: (typeof oeffentlich)[number]) =>
(v.zustand === "GEPLANT" && !v.zuletztAngewendetAm) || Boolean(v.host?.archiviertAm);
const archiviert = [...oeffentlich, ...privat]
.filter((v) => v.host?.archiviertAm)
.map((v) => v.host!.name);
await tx.publicVerwendung.updateMany({
where: { id: { in: oeffentlich.filter(sofort).map((v) => v.id) } },
data: { zustand: "ENTFERNT" },
});
await tx.publicVerwendung.updateMany({
where: { id: { in: oeffentlich.filter((v) => !sofort(v)).map((v) => v.id) } },
data: { zustand: "ENTFERNEN" },
});
await tx.privateVerwendung.updateMany({
where: { id: { in: privat.filter(sofort).map((v) => v.id) } },
data: { zustand: "ENTFERNT" },
});
await tx.privateVerwendung.updateMany({
where: { id: { in: privat.filter((v) => !sofort(v)).map((v) => v.id) } },
data: { zustand: "ENTFERNEN" },
});
if (!s.widerrufEingeleitetAm) {
await tx.schluessel.update({
where: { id: s.id },
data: { widerrufEingeleitetAm: jetzt },
select: { id: true },
});
}
await protokollieren(
{
akteur,
aktion: "schluessel.widerruf.eingeleitet",
ergebnis: "ERFOLG",
fingerprint: s.fingerprint,
details: {
name: s.name,
public: oeffentlich.length,
privat: privat.length,
ohneHostZugriff: oeffentlich.filter(sofort).length + privat.filter(sofort).length,
archivierteHosts: [...new Set(archiviert)].sort(),
massenwiderruf: liste.length > 1,
},
},
tx,
);
}
});
}
// ---------------------------------------------------------------- Offene Entfernungen
export type OffeneEntfernung = {
readonly id: string;
readonly schluesselId: string;
readonly art: "public" | "privat";
readonly ziel: string;
readonly zustand: string;
};
/** Was fuer diese Schluessel noch nicht entfernt ist – fuer Seite und Abschluss. */
export async function offeneEntfernungen(schluesselIds: readonly string[]): Promise<OffeneEntfernung[]> {
const ids = [...new Set(schluesselIds)];
const [oeffentlich, privat] = await Promise.all([
prisma.publicVerwendung.findMany({
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
select: {
id: true,
schluesselId: true,
zielbenutzer: true,
zustand: true,
host: { select: { name: true } },
hostgruppe: { select: { name: true } },
},
orderBy: { angelegtAm: "asc" },
}),
prisma.privateVerwendung.findMany({
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
select: {
id: true,
schluesselId: true,
zielbenutzer: true,
pfad: true,
zustand: true,
host: { select: { name: true } },
},
orderBy: { angelegtAm: "asc" },
}),
]);
return [
...oeffentlich.map((v) => ({
id: v.id,
schluesselId: v.schluesselId,
art: "public" as const,
ziel: v.host
? `${v.zielbenutzer}@${v.host.name}:~/.ssh/authorized_keys`
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe):~/.ssh/authorized_keys`,
zustand: v.zustand,
})),
...privat.map((v) => ({
id: v.id,
schluesselId: v.schluesselId,
art: "privat" as const,
ziel: `${v.zielbenutzer}@${v.host.name}:${v.pfad}`,
zustand: v.zustand,
})),
];
}
// ---------------------------------------------------------------- Zielkonten entfernen
/**
* Optionaler Schritt nach dem Ausrollen: ausgewaehlte Zielkonten entfernen. Erst wenn die
* Schluessel von den Hosts verschwunden sind – sonst haette ein Fehlschlag beim Ausrollen einen
* halb geloeschten Benutzer mit noch gueltigem Schluessel zur Folge.
*/
export async function widerrufZielkontenEntfernen(
schluesselIds: readonly string[],
zielkontoIds: readonly string[],
akteur: Akteur,
): Promise<{ id: string; beschriftung: string }[]> {
const liste = await schluesselLaden(schluesselIds);
if (liste.some((s) => !s.widerrufEingeleitetAm))
throw new WiderrufFehler("Bitte zuerst den Widerruf einleiten.");
const offen = await offeneEntfernungen(liste.map((s) => s.id));
if (offen.length > 0) {
throw new WiderrufFehler(
"Zielkonten werden erst entfernt, wenn die Schlüssel von allen Hosts entfernt sind.",
);
}
const kandidaten = new Map((await zielkontoKandidaten(liste.map((s) => s.id))).map((k) => [k.id, k]));
const vorgaenge: { id: string; beschriftung: string }[] = [];
const gewaehlt = [...new Set(zielkontoIds)];
for (const id of gewaehlt) {
const konto = kandidaten.get(id);
if (!konto) throw new WiderrufFehler("Dieses Zielkonto gehört nicht zu den gewählten Schlüsseln.");
if (konto.nochGenutztVon.length > 0) {
throw new WiderrufFehler(
`${konto.benutzername}@${konto.hostName} wird noch von „${konto.nochGenutztVon.join("“, „")}“ genutzt und bleibt.`,
);
}
}
for (const id of gewaehlt) {
const konto = kandidaten.get(id)!;
vorgaenge.push({
id: await vorgangEinreihen({
art: "ZIELKONTO_ENTFERNEN",
angefordertVonId: akteur.id,
hostId: konto.hostId,
eingabe: { benutzer: konto.benutzername },
}),
beschriftung: `${konto.benutzername}@${konto.hostName}`,
});
}
return vorgaenge;
}
// ---------------------------------------------------------------- Abschliessen
export type AbschlussErgebnis = {
readonly schluesselId: string;
readonly name: string;
readonly status: "WIDERRUFEN" | "ROTIERT" | "OFFEN";
readonly offen: OffeneEntfernung[];
};
/**
* Schritt 3: Status setzen – aber nur fuer Schluessel ohne offene Entfernung. Die anderen
* bleiben im Zwischenzustand; die Oberflaeche zeigt, was fehlt.
*/
export async function widerrufAbschliessen(
schluesselIds: readonly string[],
akteur: Akteur,
): Promise<AbschlussErgebnis[]> {
const liste = await schluesselLaden(schluesselIds);
const offen = await offeneEntfernungen(liste.map((s) => s.id));
const ergebnisse: AbschlussErgebnis[] = [];
for (const s of liste) {
if (s.status === "WIDERRUFEN" || s.status === "ROTIERT") {
ergebnisse.push({ schluesselId: s.id, name: s.name, status: s.status, offen: [] });
continue;
}
if (!s.widerrufEingeleitetAm) throw new WiderrufFehler(`Für „${s.name}“ ist kein Widerruf eingeleitet.`);
const eigene = offen.filter((o) => o.schluesselId === s.id);
if (eigene.length > 0) {
ergebnisse.push({ schluesselId: s.id, name: s.name, status: "OFFEN", offen: eigene });
continue;
}
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN";
await prisma.$transaction(async (tx) => {
const geaendert = await tx.schluessel.updateMany({
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
data: { status },
});
if (geaendert.count === 0) return;
await protokollieren(
{
akteur,
aktion: status === "ROTIERT" ? "schluessel.rotiert" : "schluessel.widerrufen",
ergebnis: "ERFOLG",
fingerprint: s.fingerprint,
details: {
name: s.name,
...(s.nachfolger && status === "ROTIERT" ? { nachfolgerId: s.nachfolger.id } : {}),
},
},
tx,
);
});
ergebnisse.push({ schluesselId: s.id, name: s.name, status, offen: [] });
}
return ergebnisse;
}
+90
View File
@@ -0,0 +1,90 @@
import { execFileSync } from "node:child_process";
import { expect, type Page } from "@playwright/test";
import "dotenv/config";
/**
* Gemeinsame Schritte fuer Widerruf (#024) und Rotation (#023): anmelden, Hosts einrichten und
* der Kern-Anwendungsfall aus kern-anwendungsfall.spec.ts als Ausgangslage.
*/
const ROOT_PASSWORT = process.env["SSHKV_TEST_ROOT_PASSWORT"] || "sshkv-test";
export const PORT = {
a: (process.env["SSHKV_TEST_ZIEL_A"] ?? "127.0.0.1:2221").split(":")[1] ?? "2221",
b: (process.env["SSHKV_TEST_ZIEL_B"] ?? "127.0.0.1:2222").split(":")[1] ?? "2222",
};
export function aufZiel(ziel: "ziel-a" | "ziel-b", befehl: string): string {
return execFileSync(
"docker",
["compose", "-f", "docker-compose.test.yml", "exec", "-T", ziel, "sh", "-c", befehl],
{ encoding: "utf8" },
);
}
export function zieleZuruecksetzen(): void {
const reset =
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
aufZiel("ziel-a", reset);
aufZiel("ziel-b", reset);
}
export async function anmelden(page: Page): Promise<void> {
await page.goto("/anmelden");
await page.getByRole("link", { name: /Mit Mock-OIDC anmelden/ }).click();
await page.locator('button[data-rolle="admin"]').click();
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
}
export async function hostEinrichten(page: Page, name: string, port: string): Promise<void> {
await page.goto("/hosts/neu");
await page.getByLabel("Name").fill(name);
await page.getByLabel("Adresse").fill("127.0.0.1");
await page.getByLabel("Port").fill(port);
await page.getByRole("button", { name: "Host anlegen" }).click();
await page.getByRole("button", { name: "Host-Key abrufen" }).click();
await page.getByRole("button", { name: "Fingerprint stimmt – vertrauen" }).click({ timeout: 30_000 });
await page.getByLabel("Passwort", { exact: true }).fill(ROOT_PASSWORT);
await page.getByRole("button", { name: "Zugang einrichten" }).click();
await expect(page.getByRole("button", { name: "Verbindung prüfen" })).toBeVisible({ timeout: 60_000 });
}
export async function archivieren(page: Page, name: string): Promise<void> {
await page.goto("/hosts");
await page.getByRole("link", { name }).first().click();
await page.getByRole("button", { name: "Host archivieren" }).click();
await page.getByRole("dialog").getByRole("button", { name: "Archivieren", exact: true }).click();
await expect(page).toHaveURL(/\/hosts$/);
}
/**
* Kern-Anwendungsfall: Schluessel erzeugen, Private Key auf A (Alias zu-b), Public Key auf B,
* Dry-Run, anwenden. Endet auf der Schluesselseite; liefert deren Adresse.
*/
export async function kernAufbauen(page: Page, zweck: string, hostA: string, hostB: string): Promise<string> {
await page.goto("/schluessel/neu");
await page.getByLabel("Zweck").fill(zweck);
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: "Ausrollen" }).first().click();
const privat = page.getByRole("dialog", { name: "Private Key ausrollen" });
await privat.getByLabel("Quell-Host (bekommt den Private Key)").selectOption({ label: hostA });
await privat.getByLabel("Ziel-Host (für known_hosts)").selectOption({ label: hostB });
await privat.getByRole("textbox", { name: "Alias" }).fill("zu-b");
await privat.getByRole("textbox", { name: "HostName" }).fill("ziel-b");
await privat.getByRole("spinbutton", { name: "Port" }).fill("22");
await privat.getByRole("button", { name: "Zuweisen" }).click();
await expect(privat).toBeHidden();
await page.getByRole("button", { name: "Ausrollen" }).nth(1).click();
const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" });
await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: hostB });
await oeffentlich.getByRole("button", { name: "Zuweisen" }).click();
await expect(oeffentlich).toBeHidden();
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
return page.url();
}
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "@playwright/test";
import {
anmelden,
archivieren,
aufZiel,
hostEinrichten,
kernAufbauen,
PORT,
zieleZuruecksetzen,
} from "./hilfen";
/**
* Widerruf im Browser (#024), gebauter Stand + Worker: Ausgangslage ist der Kern-Anwendungsfall
* (Private Key auf A, Public Key auf B). „Widerrufen“ → Dialog → Dry-Run → anwenden → Status
* „widerrufen“; danach kommt A nicht mehr nach B.
*/
test.setTimeout(240_000);
test.beforeAll(() => zieleZuruecksetzen());
test("Widerruf mit einem Klick", async ({ page }) => {
const kennung = Date.now().toString(36);
const hostA = `zz-e2e-a-${kennung}`;
const hostB = `zz-e2e-b-${kennung}`;
const zweck = `zz-e2e Widerruf ${kennung}`;
await anmelden(page);
await hostEinrichten(page, hostA, PORT.a);
await hostEinrichten(page, hostB, PORT.b);
await kernAufbauen(page, zweck, hostA, hostB);
await page.reload();
// Dialog: zeigt genau, was passiert.
await page.getByRole("button", { name: "Widerrufen" }).click();
const dialog = page.getByRole("dialog", { name: `„${zweck}“ widerrufen?` });
await expect(dialog.getByText(`root@${hostB}`)).toBeVisible();
await expect(dialog.getByText(`root@${hostA}:~/.ssh/`)).toBeVisible();
await expect(dialog.getByText(/config-Eintrag „Host zu-b“/)).toBeVisible();
await page.screenshot({ path: "docs/anleitung/bilder/widerruf-desktop.png" });
await dialog.getByRole("button", { name: "Widerruf vorbereiten" }).click();
// Zwischenzustand: noch nicht widerrufen, nichts mehr zuweisbar.
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible();
await expect(page.getByText("Widerruf unvollständig")).toBeVisible();
await expect(page.getByRole("button", { name: "Ausrollen" })).toHaveCount(0);
await page.getByRole("button", { name: "Dry-Run starten" }).click();
await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: "docs/anleitung/bilder/widerruf-dry-run-desktop.png", fullPage: true });
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
await expect(page.getByRole("heading", { name: "Widerrufen", exact: true })).toBeVisible({
timeout: 60_000,
});
await expect(page.getByText("Widerruf unvollständig")).toHaveCount(0);
expect(aufZiel("ziel-a", "ls /root/.ssh")).not.toContain("id_ed25519");
expect(() => aufZiel("ziel-a", "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b true")).toThrow();
await archivieren(page, hostA);
await archivieren(page, hostB);
});
+171
View File
@@ -0,0 +1,171 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
import {
widerrufAbschliessen,
widerrufEinleiten,
widerrufZielkontenEntfernen,
} from "@/server/services/widerruf";
import type { Plan } from "@/worker/ablaeufe/ausrollen";
import { vorgangAusfuehren } from "@/worker/ausfuehren";
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
import { HANDLER } from "@/worker/handler";
import { datenbankLeeren } from "../hilfen/db";
import { warteschlangeStoppen } from "../hilfen/vorgang";
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
/**
* Widerruf gegen echte Hosts (#024): Public Key aus authorized_keys von B, Private Key samt
* config- und known_hosts-Eintrag von A, optional das Zielkonto deploy auf B. Danach scheitert
* `ssh <alias>` von A. Scheitert ein Host, bleibt der Schluessel im Zwischenzustand.
*/
const AKTEUR = { id: "", name: "Test-Admin" };
let aufraeumen: () => void;
beforeAll(async () => {
await Promise.all([zielBereit(zielA), zielBereit(zielB)]);
aufraeumen = masterKeyAnlegen();
});
afterAll(async () => {
aufraeumen();
await warteschlangeStoppen();
});
beforeEach(async () => {
await datenbankLeeren();
await deploymentKeySicherstellen();
const reset =
"userdel -r sshkv 2>/dev/null; userdel -r deploy 2>/dev/null; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
const admin = await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } });
AKTEUR.id = admin.id;
});
async function dienstSchluessel(name: string) {
const neu = await schluesselpaarVersiegeltErzeugen("ED25519", `sshkv:${name}`);
return prisma.schluessel.create({
data: {
id: neu.id,
name,
slug: name,
typ: "ED25519",
kategorie: "DIENST",
publicKey: neu.publicKeyZeile,
fingerprint: neu.fingerprint,
kommentar: `sshkv:${name}`,
...neu.versiegelt,
},
select: { id: true, fingerprint: true, publicKey: true },
});
}
async function trockenlaufUndAusrollen(schluesselId: string) {
const ziele = [
...(await zieleVonVerwendungen({ schluesselId })),
...(await zieleVonPrivatVerwendungen({ schluesselId })),
];
const trockenlauf = await vorgangLaufen("TROCKENLAUF", { eingabe: { ziele }, angefordertVonId: AKTEUR.id });
expect(trockenlauf.status).toBe("ERLEDIGT");
const ausrollen = await vorgangLaufen("AUSROLLEN", {
eingabe: { trockenlaufId: trockenlauf.id },
angefordertVonId: AKTEUR.id,
});
return { plan: trockenlauf.plan as unknown as Plan, ausrollen };
}
/** Deployment-Zugang wie im Assistenten: deploy auf B, Private Key auf A, Alias zu-b. */
async function deploymentZugang() {
const a = await eingerichteterHost(zielA, "ziel-a");
const b = await eingerichteterHost(zielB, "ziel-b");
const konto = await vorgangLaufen("ZIELKONTO_ANLEGEN", {
hostId: b.id,
eingabe: { benutzer: "deploy", modus: "KEINER", befehle: [] },
});
expect(konto.fehler).toBeNull();
const schluessel = await dienstSchluessel("deploy-a-b");
await privateVerwendungAnlegen(
{
schluesselId: schluessel.id,
hostId: a.id,
zielbenutzer: "root",
pfad: "~/.ssh/id_ed25519_deploy",
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "deploy", port: 22 },
knownHostsFuer: [b.id],
},
AKTEUR,
);
await publicVerwendungAnlegen(
{ schluesselId: schluessel.id, hostId: b.id, zielbenutzer: "deploy" },
AKTEUR,
);
const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(ausrollen.fehler).toBeNull();
const vorher = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
expect(vorher.stdout.trim()).toBe("deploy");
return { a, b, schluessel };
}
describe("Widerruf", () => {
it("entfernt Public Key, Private Key, config, known_hosts und auf Wunsch das Zielkonto", async () => {
const { schluessel } = await deploymentZugang();
const blob = schluessel.publicKey.split(" ")[1]!;
await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true });
const { plan, ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(plan.ziele.every((z) => !z.fehler)).toBe(true);
expect(ausrollen.fehler).toBeNull();
expect((await befehlAlsRoot(zielB, "cat /home/deploy/.ssh/authorized_keys")).stdout).not.toContain(blob);
const ssh = await befehlAlsRoot(zielA, "ls -a /root/.ssh; cat /root/.ssh/config /root/.ssh/known_hosts");
expect(ssh.stdout).not.toContain("id_ed25519_deploy");
expect(ssh.stdout).not.toContain("zu-b");
expect(ssh.stdout).not.toContain("ssh-ed25519");
const danach = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o ConnectTimeout=5 zu-b id -un");
expect(danach.code).not.toBe(0);
// Zielkonto: erst nach den Schluesseln, dann Abschluss.
const konto = await prisma.zielkonto.findFirstOrThrow({ where: { benutzername: "deploy" } });
const vorgaenge = await widerrufZielkontenEntfernen([schluessel.id], [konto.id], AKTEUR);
for (const v of vorgaenge) await vorgangAusfuehren(v.id, HANDLER);
expect((await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgaenge[0]!.id } })).fehler).toBeNull();
expect((await befehlAlsRoot(zielB, "id deploy")).code).not.toBe(0);
const [ergebnis] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(ergebnis?.status).toBe("WIDERRUFEN");
const protokoll = await prisma.protokoll.findMany({ where: { fingerprint: schluessel.fingerprint } });
expect(protokoll.map((p) => p.aktion)).toEqual(
expect.arrayContaining(["schluessel.widerruf.eingeleitet", "schluessel.widerrufen"]),
);
expect(JSON.stringify(protokoll.map((p) => [p.details, p.diff]))).not.toContain("PRIVATE KEY");
});
it("bleibt im Zwischenzustand, wenn ein Host nicht erreichbar ist", async () => {
const { b, schluessel } = await deploymentZugang();
await widerrufEinleiten([schluessel.id], AKTEUR, { privatErlaubt: true });
// B „faellt aus“: ein Port, auf dem niemand lauscht.
await prisma.host.update({ where: { id: b.id }, data: { port: 1 } });
const { plan } = await trockenlaufUndAusrollen(schluessel.id);
expect(plan.ziele.find((z) => z.hostName === "ziel-b")?.fehler).toBeTruthy();
expect(plan.ziele.find((z) => z.hostName === "ziel-a")?.fehler).toBeUndefined();
const [offen] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(offen?.status).toBe("OFFEN");
expect(offen?.offen.map((o) => o.ziel)).toEqual(["deploy@ziel-b:~/.ssh/authorized_keys"]);
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: schluessel.id } })).status).toBe(
"AKTIV",
);
// Was ging, ist trotzdem weg: der Private Key auf A.
expect((await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout).not.toContain("id_ed25519_deploy");
// B ist zurueck: ein neuer Dry-Run erledigt den Rest.
await prisma.host.update({ where: { id: b.id }, data: { port: zielB.port } });
const { ausrollen } = await trockenlaufUndAusrollen(schluessel.id);
expect(ausrollen.fehler).toBeNull();
const [fertig] = await widerrufAbschliessen([schluessel.id], AKTEUR);
expect(fertig?.status).toBe("WIDERRUFEN");
});
});
+346
View File
@@ -0,0 +1,346 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { BESTAETIGUNG_NOETIG, RECHTE } from "@/server/auth/rechte";
import { schluesselListe } from "@/server/queries/schluessel";
import { widerrufUebersicht } from "@/server/queries/widerruf";
import {
VerwendungsFehler,
publicVerwendungAnlegen,
trockenlaufStarten,
} from "@/server/services/verwendungen";
import {
WiderrufFehler,
widerrufAbschliessen,
widerrufEinleiten,
widerrufZielkontenEntfernen,
zielkontoKandidaten,
} from "@/server/services/widerruf";
import { datenbankLeeren } from "./hilfen/db";
import { datenbankAlsText, warteschlangeStoppen } from "./hilfen/vorgang";
import { ed25519Blob, zeile } from "./ssh/hilfen";
/** Widerruf und Massenwiderruf (#024): Zustaende, Rechte, Protokoll. */
let admin: { id: string; name: string };
let a: { id: string; name: string };
let b: { id: string; name: string };
let zaehler = 0;
afterAll(async () => {
await warteschlangeStoppen();
});
beforeEach(async () => {
await datenbankLeeren();
admin = await prisma.benutzer.create({
data: { name: "Ada Admin", rolle: "ADMIN" },
select: { id: true, name: true },
});
a = await prisma.host.create({
data: { name: "srv-a", adresse: "10.0.0.1" },
select: { id: true, name: true },
});
b = await prisma.host.create({
data: { name: "srv-b", adresse: "10.0.0.2" },
select: { id: true, name: true },
});
});
async function schluessel(name: string, daten: { kategorie?: "PERSON" | "DIENST" | "DEPLOYMENT" } = {}) {
zaehler += 1;
return prisma.schluessel.create({
data: {
name,
slug: `k-${zaehler}`,
typ: "ED25519",
kategorie: daten.kategorie ?? "DIENST",
publicKey: zeile("ssh-ed25519", ed25519Blob(), `k-${zaehler}`),
fingerprint: `SHA256:test${zaehler}`,
kommentar: `k-${zaehler}`,
},
select: { id: true, name: true, fingerprint: true },
});
}
/** Public auf B (ausgerollt) und Private auf A (ausgerollt) – wie nach dem Assistenten. */
async function ausgerollt(schluesselId: string, benutzerB = "deploy") {
const jetzt = new Date();
const oeffentlich = await prisma.publicVerwendung.create({
data: {
schluesselId,
hostId: b.id,
zielbenutzer: benutzerB,
zustand: "AUSGEROLLT",
zuletztAngewendetAm: jetzt,
},
});
const privat = await prisma.privateVerwendung.create({
data: {
schluesselId,
hostId: a.id,
zielbenutzer: "root",
pfad: `~/.ssh/id_${schluesselId}`,
sshConfig: { alias: "zu-b", hostName: "srv-b", benutzer: benutzerB, port: 22 },
zustand: "AUSGEROLLT",
zuletztAngewendetAm: jetzt,
},
});
return { oeffentlich, privat };
}
async function fehlerVon(versuch: Promise<unknown>): Promise<Error> {
const fehler = await versuch.then(
() => undefined,
(f: unknown) => f,
);
expect(fehler).toBeInstanceOf(Error);
return fehler as Error;
}
describe("Widerruf einleiten", () => {
it("merkt alle Verwendungen zum Entfernen vor und laesst den Status noch AKTIV", async () => {
const s = await schluessel("Backup");
const { oeffentlich, privat } = await ausgerollt(s.id);
// Nie ausgerollt: auf dem Host steht nichts, die Verwendung ist sofort erledigt.
const geplant = await prisma.publicVerwendung.create({
data: { schluesselId: s.id, hostId: a.id, zielbenutzer: "root" },
});
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
const nachher = await prisma.schluessel.findUniqueOrThrow({
where: { id: s.id },
select: { status: true, widerrufEingeleitetAm: true },
});
expect(nachher.status).toBe("AKTIV");
expect(nachher.widerrufEingeleitetAm).not.toBeNull();
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: oeffentlich.id } })).zustand).toBe(
"ENTFERNEN",
);
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: geplant.id } })).zustand).toBe(
"ENTFERNT",
);
expect((await prisma.privateVerwendung.findUniqueOrThrow({ where: { id: privat.id } })).zustand).toBe(
"ENTFERNEN",
);
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.widerruf.eingeleitet" },
});
expect(eintrag.fingerprint).toBe(s.fingerprint);
expect(eintrag.akteurName).toBe("Ada Admin");
expect(eintrag.details).toMatchObject({ public: 2, privat: 1, ohneHostZugriff: 1 });
expect(await datenbankAlsText()).not.toContain("PRIVATE KEY");
});
it("ist idempotent und sperrt neue Zuweisungen", async () => {
const s = await schluessel("Backup");
await ausgerollt(s.id);
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
const erstes = (await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).widerrufEingeleitetAm;
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
expect(
(await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).widerrufEingeleitetAm,
).toEqual(erstes);
const fehler = await fehlerVon(
publicVerwendungAnlegen({ schluesselId: s.id, hostId: a.id, zielbenutzer: "backup" }, admin),
);
expect(fehler).toBeInstanceOf(VerwendungsFehler);
expect(fehler.message).toMatch(/widerrufen/);
});
it("verlangt fuer Private Keys das Admin-Recht", async () => {
const s = await schluessel("Backup");
await ausgerollt(s.id);
const fehler = await fehlerVon(widerrufEinleiten([s.id], admin, { privatErlaubt: false }));
expect(fehler).toBeInstanceOf(WiderrufFehler);
expect(fehler.message).toMatch(/nur ein Admin/);
// Nichts vorgemerkt.
expect(await prisma.publicVerwendung.count({ where: { zustand: "ENTFERNEN" } })).toBe(0);
// Ein reiner Public-Key-Widerruf geht auch ohne.
const person = await schluessel("Laptop Alice", { kategorie: "PERSON" });
await prisma.publicVerwendung.create({
data: { schluesselId: person.id, hostId: b.id, zielbenutzer: "alice", zustand: "AUSGEROLLT" },
});
await widerrufEinleiten([person.id], admin, { privatErlaubt: false });
});
it("weist den Deployment-Key und bereits widerrufene Schluessel ab", async () => {
const deployment = await schluessel("Deployment", { kategorie: "DEPLOYMENT" });
expect(
(await fehlerVon(widerrufEinleiten([deployment.id], admin, { privatErlaubt: true }))).message,
).toMatch(/Deployment-Key/);
const alt = await schluessel("Alt");
await prisma.schluessel.update({ where: { id: alt.id }, data: { status: "WIDERRUFEN" } });
expect((await fehlerVon(widerrufEinleiten([alt.id], admin, { privatErlaubt: true }))).message).toMatch(
/bereits widerrufen/,
);
});
it("erledigt Verwendungen auf archivierten Hosts sofort", async () => {
const s = await schluessel("Backup");
const v = await prisma.publicVerwendung.create({
data: { schluesselId: s.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
await prisma.host.update({ where: { id: b.id }, data: { archiviertAm: new Date() } });
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
expect((await prisma.publicVerwendung.findUniqueOrThrow({ where: { id: v.id } })).zustand).toBe(
"ENTFERNT",
);
const eintrag = await prisma.protokoll.findFirstOrThrow({
where: { aktion: "schluessel.widerruf.eingeleitet" },
});
expect(eintrag.details).toMatchObject({ archivierteHosts: ["srv-b"] });
});
});
describe("Dry-Run fuer den Widerruf", () => {
it("nimmt die zum Entfernen vorgemerkten Ziele mehrerer Schluessel in einen Trockenlauf", async () => {
const eins = await schluessel("Eins");
const zwei = await schluessel("Zwei");
await ausgerollt(eins.id);
await prisma.publicVerwendung.create({
data: { schluesselId: zwei.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
await widerrufEinleiten([eins.id, zwei.id], admin, { privatErlaubt: true });
const id = await trockenlaufStarten({ schluesselIds: [eins.id, zwei.id] }, admin, true);
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id } });
expect(vorgang.art).toBe("TROCKENLAUF");
expect(vorgang.schluesselId).toBeNull();
const ziele = (vorgang.eingabe as { ziele: { hostId: string; zielbenutzer: string; art?: string }[] })
.ziele;
// Beide Schluessel auf B/deploy ergeben ein Ziel, dazu der Private Key auf A.
expect(ziele).toHaveLength(2);
expect(ziele).toContainEqual({ hostId: b.id, zielbenutzer: "deploy" });
expect(ziele).toContainEqual({ hostId: a.id, zielbenutzer: "root", art: "privat" });
});
});
describe("Widerruf abschliessen", () => {
it("bleibt im Zwischenzustand, solange eine Entfernung offen ist", async () => {
const s = await schluessel("Backup");
const { oeffentlich, privat } = await ausgerollt(s.id);
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
// B war nicht erreichbar: der Private Key auf A ist weg, der Public Key auf B noch nicht.
await prisma.privateVerwendung.update({ where: { id: privat.id }, data: { zustand: "ENTFERNT" } });
const [ergebnis] = await widerrufAbschliessen([s.id], admin);
expect(ergebnis?.status).toBe("OFFEN");
expect(ergebnis?.offen.map((o) => o.ziel)).toEqual(["deploy@srv-b:~/.ssh/authorized_keys"]);
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("AKTIV");
expect((await widerrufUebersicht([s.id])).offen).toHaveLength(1);
await prisma.publicVerwendung.update({ where: { id: oeffentlich.id }, data: { zustand: "ENTFERNT" } });
const [fertig] = await widerrufAbschliessen([s.id], admin);
expect(fertig?.status).toBe("WIDERRUFEN");
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("WIDERRUFEN");
const eintrag = await prisma.protokoll.findFirstOrThrow({ where: { aktion: "schluessel.widerrufen" } });
expect(eintrag.fingerprint).toBe(s.fingerprint);
// Ein zweiter Abschluss aendert nichts und protokolliert nicht doppelt.
await widerrufAbschliessen([s.id], admin);
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.widerrufen" } })).toBe(1);
});
it("schliesst ohne eingeleiteten Widerruf nicht ab", async () => {
const s = await schluessel("Backup");
expect((await fehlerVon(widerrufAbschliessen([s.id], admin))).message).toMatch(
/kein Widerruf eingeleitet/,
);
});
it("setzt ROTIERT, wenn ein aktiver Nachfolger existiert (#023)", async () => {
const alt = await schluessel("Alt");
const neu = await schluessel("Neu");
await prisma.schluessel.update({ where: { id: neu.id }, data: { vorgaengerId: alt.id } });
await widerrufEinleiten([alt.id], admin, { privatErlaubt: true });
const [ergebnis] = await widerrufAbschliessen([alt.id], admin);
expect(ergebnis?.status).toBe("ROTIERT");
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotiert" } })).toBe(1);
});
});
describe("Zielkonten beim Widerruf", () => {
it("bietet nur von sshkv angelegte Konten an und nennt weitere Nutzer", async () => {
const s = await schluessel("Deploy A");
const andere = await schluessel("Deploy C");
await ausgerollt(s.id);
const konto = await prisma.zielkonto.create({ data: { hostId: b.id, benutzername: "deploy" } });
// Ein vorhandenes, nicht von sshkv angelegtes Konto taucht nicht auf.
await prisma.zielkonto.create({ data: { hostId: a.id, benutzername: "root2", vonSshkvAngelegt: false } });
expect(await zielkontoKandidaten([s.id])).toEqual([
expect.objectContaining({
id: konto.id,
benutzername: "deploy",
hostName: "srv-b",
nochGenutztVon: [],
}),
]);
await prisma.publicVerwendung.create({
data: { schluesselId: andere.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
expect((await zielkontoKandidaten([s.id]))[0]?.nochGenutztVon).toEqual(["Deploy C"]);
// Beide gemeinsam widerrufen: niemand nutzt das Konto weiter.
expect((await zielkontoKandidaten([s.id, andere.id]))[0]?.nochGenutztVon).toEqual([]);
});
it("entfernt Zielkonten erst nach den Schluesseln und nie ein noch genutztes", async () => {
const s = await schluessel("Deploy A");
const { oeffentlich, privat } = await ausgerollt(s.id);
const konto = await prisma.zielkonto.create({ data: { hostId: b.id, benutzername: "deploy" } });
await widerrufEinleiten([s.id], admin, { privatErlaubt: true });
expect((await fehlerVon(widerrufZielkontenEntfernen([s.id], [konto.id], admin))).message).toMatch(
/erst entfernt, wenn die Schlüssel/,
);
await prisma.publicVerwendung.update({ where: { id: oeffentlich.id }, data: { zustand: "ENTFERNT" } });
await prisma.privateVerwendung.update({ where: { id: privat.id }, data: { zustand: "ENTFERNT" } });
const andere = await schluessel("Deploy C");
const fremd = await prisma.publicVerwendung.create({
data: { schluesselId: andere.id, hostId: b.id, zielbenutzer: "deploy", zustand: "AUSGEROLLT" },
});
expect((await fehlerVon(widerrufZielkontenEntfernen([s.id], [konto.id], admin))).message).toMatch(
/noch von „Deploy C“ genutzt/,
);
await prisma.publicVerwendung.delete({ where: { id: fremd.id } });
const vorgaenge = await widerrufZielkontenEntfernen([s.id], [konto.id], admin);
expect(vorgaenge).toEqual([{ id: expect.any(String), beschriftung: "deploy@srv-b" }]);
const vorgang = await prisma.vorgang.findUniqueOrThrow({ where: { id: vorgaenge[0]!.id } });
expect(vorgang).toMatchObject({
art: "ZIELKONTO_ENTFERNEN",
hostId: b.id,
eingabe: { benutzer: "deploy" },
});
});
});
describe("Rechte und Ablauf", () => {
it("Massenwiderruf und Zielkonten nur fuer Admins mit frischer Bestaetigung", () => {
expect(RECHTE.massenwiderruf).toEqual(["ADMIN"]);
expect(BESTAETIGUNG_NOETIG.has("massenwiderruf")).toBe(true);
expect(BESTAETIGUNG_NOETIG.has("zielkontenVerwalten")).toBe(true);
});
it("zeigt aktive Schluessel mit vergangenem Ablaufdatum unter „abgelaufen“", async () => {
const s = await schluessel("Projekt X");
const frisch = await schluessel("Projekt Y");
await prisma.schluessel.update({
where: { id: s.id },
data: { ablaufAm: new Date("2020-01-01T00:00:00Z") },
});
await prisma.schluessel.update({
where: { id: frisch.id },
data: { ablaufAm: new Date("2999-01-01T00:00:00Z") },
});
expect((await schluesselListe({ status: "ABGELAUFEN" })).map((k) => k.name)).toEqual(["Projekt X"]);
expect((await schluesselListe({ status: "AKTIV" })).map((k) => k.name)).toEqual(["Projekt Y"]);
// Automatisch entfernt wird nichts: der Status in der Datenbank bleibt AKTIV.
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: s.id } })).status).toBe("AKTIV");
});
});