Merge branch 'phase/023-024' (Rotation, Widerruf)
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 292 KiB |
@@ -1,19 +1,46 @@
|
||||
# #023 Geführte Rotation
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.4.0
|
||||
**Labels:** backend, ui
|
||||
**Geschätzt:** 3 h **Tatsächlich:** –
|
||||
**Geschätzt:** 3 h **Tatsächlich:** 0 h 20 min
|
||||
|
||||
## Ziel
|
||||
Neuer Schlüssel an allen Verwendungen, Test, alter entfernt.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] „Rotieren“ auf der Schlüsselseite, Schrittfolge `/schluessel/[id]/rotieren` (wiederaufnehmbar)
|
||||
- [x] Neuer Schlüssel gleichen Typs, Name „… (rotiert TT.MM.JJJJ)“, `vorgaengerId` = alter
|
||||
- [x] Zuweisungen übernehmen: Public gleiche Ziele/Optionen, Private gleicher Host/Benutzer/Alias mit neuem Pfad
|
||||
- [x] Dry-Run + Ausrollen, Verbindungstests, erst dann den alten widerrufen (#024) → `ROTIERT`
|
||||
- [x] Personen-Key ohne Private Key: nur Hinweis „neuer Public Key nötig“
|
||||
- [x] Rotationskette auf der Schlüsselseite (Vorgänger/Nachfolger verlinkt)
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] wird zu Beginn der Phase verfeinert
|
||||
- [x] DB-Tests: Schritte, Reihenfolge, Rechte, Namen/Pfade (`tests/rotation.test.ts`)
|
||||
- [x] Integrationstest: neuer Schlüssel funktioniert, alter von beiden Hosts weg, `ssh <alias>` geht weiter
|
||||
- [x] E2E im Browser (`tests/e2e/rotation.spec.ts`), Screenshot `rotation-desktop.png`
|
||||
|
||||
## Abhängigkeiten
|
||||
#020
|
||||
#020, #024
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- **Fünf Schritte:** Neuer Schlüssel → Zuweisungen übernehmen → Dry-Run & Ausrollen → Verbindungstests →
|
||||
Alten widerrufen. Der aktuelle Schritt wird aus der Datenbank errechnet (`rotationsStand`), deshalb
|
||||
lässt sich die Rotation jederzeit neu laden und fortsetzen; jeder Schritt ist idempotent.
|
||||
- **Kette sofort:** Der Worker setzt `vorgaengerId` schon beim Erzeugen (neues Feld in der
|
||||
Vorgangs-Eingabe) – auch ein abgebrochener Browser hinterlässt keinen verwaisten Schlüssel.
|
||||
`vorgaengerId` ist eindeutig: zwei gleichzeitige Rotationen scheitern verständlich.
|
||||
- **Gleicher Alias, zwei Zuweisungen:** `privat.ts` schreibt je Alias genau einen config-Eintrag, die
|
||||
jüngste Zuweisung gewinnt (an der Position der ersten). So zeigt `ssh <alias>` nach dem Ausrollen auf
|
||||
die neue Datei, während die alte bis zum Widerruf daneben liegt.
|
||||
- **Neuer Pfad:** `<alter Pfad ohne -rJJJJMMTT>-rJJJJMMTT`, bei Kollision mit laufender Nummer –
|
||||
auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index über alle Zustände).
|
||||
- **Grün heißt aktuell:** Der alte Schlüssel wird nur widerrufen, wenn für jede Private-Zuweisung mit
|
||||
Alias ein erfolgreicher `VERBINDUNGSTEST` nach dem letzten Ausrollen vorliegt (Server prüft das).
|
||||
Ohne Private-Zuweisung mit Alias entfällt der Test.
|
||||
- **Abschluss:** Widerruf aus #024 ohne Zielkonten (der Nachfolger nutzt sie weiter); ein aktiver
|
||||
Nachfolger macht aus `WIDERRUFEN` den Status `ROTIERT`.
|
||||
- Ablaufdatum: Der Nachfolger erbt es nur, wenn es in der Zukunft liegt. Eine Passphrase wird nicht
|
||||
übernommen (sshkv erzeugt ohne).
|
||||
- Rechte: `schluesselVerwalten` + `publicKeyAusrollen`, bei Private-Zuweisungen `privateKeyAusrollen`.
|
||||
Deployment-Key: eigene Rotation (später).
|
||||
|
||||
@@ -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
@@ -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[]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Download, Lock, LockOpen, Pencil } from "lucide-react";
|
||||
import { Download, Lock, LockOpen, Pencil, RefreshCw } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -15,10 +15,15 @@ import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
|
||||
import { datum, datumZeit } from "@/lib/format";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||
import { rotationsKette, schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||
import { zielAuswahl } from "@/server/queries/ziele";
|
||||
import { widerrufUebersicht } from "@/server/queries/widerruf";
|
||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
|
||||
import { ablaufZustand } from "@/lib/schluessel";
|
||||
import { rotationGesperrt } from "@/server/services/rotation";
|
||||
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||
@@ -187,18 +192,61 @@ 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 verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
|
||||
const widerrufbar =
|
||||
!imWiderruf &&
|
||||
!(s.nachfolger?.status === "AKTIV") &&
|
||||
s.kategorie !== "DEPLOYMENT" &&
|
||||
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
|
||||
rechte.public &&
|
||||
(s.privateVerwendungen.length === 0 || rechte.privat);
|
||||
const widerruf = widerrufbar || imWiderruf ? await widerrufUebersicht([s.id]) : undefined;
|
||||
// Rotation (#023): Kette, Knopf bzw. Hinweis fuer Schluessel ohne Private Key in sshkv.
|
||||
const kette = await rotationsKette(s.id);
|
||||
const aktivePrivat = s.privateVerwendungen.filter((v) => v.zustand !== "ENTFERNEN").length;
|
||||
const rotationsSperre = rotationGesperrt({
|
||||
kategorie: s.kategorie,
|
||||
status: s.status,
|
||||
masterKeyVersion: s.privatGespeichert ? 1 : null,
|
||||
widerrufEingeleitetAm: s.widerrufEingeleitetAm,
|
||||
privatZuweisungen: aktivePrivat,
|
||||
});
|
||||
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
|
||||
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
|
||||
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf));
|
||||
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
|
||||
const rotationDesVorgaengers =
|
||||
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
|
||||
? s.vorgaenger
|
||||
: null;
|
||||
const 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 +254,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 +275,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 +288,99 @@ 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 || rotierbar || (widerrufbar && widerruf)) && (
|
||||
<>
|
||||
{verwalten && (
|
||||
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
{rotierbar && (
|
||||
<Button href={`/schluessel/${s.id}/rotieren`} icon={<RefreshCw className="size-4" />}>
|
||||
{rotationLaeuft ? "Rotation fortsetzen" : "Rotieren"}
|
||||
</Button>
|
||||
)}
|
||||
{widerrufbar && widerruf && (
|
||||
<WiderrufKnopf uebersicht={widerruf} ruecksprung={`/schluessel/${s.id}`} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{rotationDesVorgaengers && (
|
||||
<Karte titel="Rotation läuft" className="border-accent">
|
||||
<p className="text-muted text-[14px]">
|
||||
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
|
||||
neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "}
|
||||
<Link
|
||||
href={`/schluessel/${rotationDesVorgaengers.id}/rotieren`}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Rotation fortsetzen
|
||||
</Link>
|
||||
</p>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{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}
|
||||
ohneZielkonten={rotationLaeuft}
|
||||
/>
|
||||
</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 +454,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"
|
||||
@@ -336,6 +470,42 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{(kette.length > 1 || (rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf)) && (
|
||||
<Karte
|
||||
titel="Rotation"
|
||||
untertitel={kette.length > 1 ? "Vorgänger und Nachfolger dieses Schlüssels" : undefined}
|
||||
>
|
||||
{kette.length > 1 && (
|
||||
<ol className="flex flex-wrap items-center gap-2 text-[14px]" aria-label="Rotationskette">
|
||||
{kette.map((glied, index) => (
|
||||
<li key={glied.id} className="flex items-center gap-2">
|
||||
{index > 0 && (
|
||||
<span className="text-muted" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
)}
|
||||
{glied.id === s.id ? (
|
||||
<span className="font-medium" aria-current="true">
|
||||
{glied.name}
|
||||
</span>
|
||||
) : (
|
||||
<Link href={`/schluessel/${glied.id}`} className="text-accent hover:underline">
|
||||
{glied.name}
|
||||
</Link>
|
||||
)}
|
||||
<StatusBadge status={glied.status} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
|
||||
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
|
||||
{rotationsSperre.grund}
|
||||
</p>
|
||||
)}
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
<Karte
|
||||
titel="Public Key"
|
||||
untertitel={
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { KeyRound } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Rotation } from "@/components/rotation/Rotation";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { RotationsFehler, rotationsStand } from "@/server/services/rotation";
|
||||
import { offeneEntfernungen } from "@/server/services/widerruf";
|
||||
|
||||
export const metadata: Metadata = { title: "Schlüssel rotieren" };
|
||||
|
||||
/** Gefuehrte Rotation (#023): Schrittfolge, wiederaufnehmbar. */
|
||||
export default async function RotierenSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { benutzer } = await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/rotieren`);
|
||||
if (!darf(benutzer.rolle, "publicKeyAusrollen")) notFound();
|
||||
let stand;
|
||||
try {
|
||||
stand = await rotationsStand(id);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RotationsFehler) notFound();
|
||||
throw fehler;
|
||||
}
|
||||
const { alt } = stand;
|
||||
|
||||
const kopf = (
|
||||
<Seitenkopf
|
||||
oben={
|
||||
<Breadcrumb
|
||||
pfad={[
|
||||
{ titel: "Schlüssel", href: "/schluessel" },
|
||||
{ titel: alt.name, href: `/schluessel/${alt.id}` },
|
||||
{ titel: "Rotieren" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
titel={`„${alt.name}“ rotieren`}
|
||||
untertitel="Neuer Schlüssel an allen Stellen, testen, erst dann den alten entfernen."
|
||||
/>
|
||||
);
|
||||
|
||||
// Ohne Nachfolger und nicht rotierbar: nur der Hinweis, keine halbe Rotation.
|
||||
if (!stand.neu && stand.gesperrt) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
{kopf}
|
||||
<Leerzustand
|
||||
icon={<KeyRound className="size-5" />}
|
||||
titel={stand.gesperrt.neuerPublicKeyNoetig ? "Neuer Public Key nötig" : "Nicht rotierbar"}
|
||||
text={stand.gesperrt.grund}
|
||||
aktion={<Button href={`/schluessel/${alt.id}`}>Zurück zum Schlüssel</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const widerruf =
|
||||
stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
|
||||
? await offeneEntfernungen([alt.id])
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
{kopf}
|
||||
<Rotation
|
||||
daten={{
|
||||
alt: { id: alt.id, name: alt.name },
|
||||
neu: stand.neu ? { id: stand.neu.id, name: stand.neu.name } : null,
|
||||
schritt: stand.schritt,
|
||||
neuOffen: stand.neu ? stand.neuOffen : 0,
|
||||
tests: stand.neu ? stand.tests : [],
|
||||
widerruf,
|
||||
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 disabled:opacity-30"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { Ban, CircleCheck, CircleX, KeyRound, PlugZap } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { Button, Formularfehler, Karte } from "@/components/ui";
|
||||
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||
import { warten } from "@/components/vorgang/warten";
|
||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||
import {
|
||||
rotationAltWiderrufenAktion,
|
||||
rotationStartenAktion,
|
||||
rotationUebernehmenAktion,
|
||||
} from "@/server/actions/rotation";
|
||||
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
|
||||
import type { OffeneEntfernung } from "@/server/services/widerruf";
|
||||
|
||||
/**
|
||||
* Gefuehrte Rotation (#023) als Schrittfolge wie der Assistent (#020). Der Schritt kommt vom
|
||||
* Server (aus der Datenbank errechnet) – nach Neuladen, Abbruch oder Fehlschlag geht es an
|
||||
* derselben Stelle weiter.
|
||||
*/
|
||||
const SCHRITTE = [
|
||||
"Neuer Schlüssel",
|
||||
"Zuweisungen übernehmen",
|
||||
"Dry-Run & Ausrollen",
|
||||
"Verbindungstests",
|
||||
"Alten widerrufen",
|
||||
] as const;
|
||||
|
||||
export type RotationsTest = {
|
||||
id: string;
|
||||
ziel: string;
|
||||
ausgerollt: boolean;
|
||||
status: string | null;
|
||||
fehler: string | null;
|
||||
};
|
||||
|
||||
export type RotationsDaten = {
|
||||
alt: { id: string; name: string };
|
||||
neu: { id: string; name: string } | null;
|
||||
schritt: 0 | 1 | 2 | 3 | 4 | 5;
|
||||
neuOffen: number;
|
||||
tests: RotationsTest[];
|
||||
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
|
||||
widerruf: OffeneEntfernung[] | null;
|
||||
darfPrivat: boolean;
|
||||
};
|
||||
|
||||
export function Rotation({ daten }: { daten: RotationsDaten }) {
|
||||
const router = useRouter();
|
||||
const [laeuft, setLaeuft] = useState<string>();
|
||||
const [fehler, setFehler] = useState<string>();
|
||||
const { alt, neu, schritt } = daten;
|
||||
|
||||
async function kette(arbeit: () => Promise<string | undefined>) {
|
||||
setFehler(undefined);
|
||||
try {
|
||||
const meldung = await arbeit();
|
||||
if (meldung) setFehler(meldung);
|
||||
} catch (f) {
|
||||
setFehler((f as Error).message);
|
||||
} finally {
|
||||
setLaeuft(undefined);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const vorbereiten = () =>
|
||||
kette(async () => {
|
||||
if (!neu) {
|
||||
setLaeuft("Neues Schlüsselpaar wird erzeugt …");
|
||||
const antwort = await rotationStartenAktion(alt.id);
|
||||
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
|
||||
const ende = await warten(antwort.vorgangId);
|
||||
if (ende.status !== "ERLEDIGT") return ende.fehler ?? "Erzeugen fehlgeschlagen.";
|
||||
}
|
||||
setLaeuft("Zuweisungen werden übernommen …");
|
||||
return (await rotationUebernehmenAktion(alt.id)).fehler;
|
||||
});
|
||||
|
||||
const testen = () =>
|
||||
kette(async () => {
|
||||
for (const test of daten.tests) {
|
||||
setLaeuft(`Teste ${test.ziel} …`);
|
||||
const antwort = await verbindungTestenAktion(test.id);
|
||||
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
|
||||
await warten(antwort.vorgangId);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const altWiderrufen = () =>
|
||||
kette(async () => {
|
||||
setLaeuft("Alter Schlüssel wird zum Entfernen vorgemerkt …");
|
||||
return (await rotationAltWiderrufenAktion(alt.id)).fehler;
|
||||
});
|
||||
|
||||
const status = (
|
||||
<>
|
||||
<Formularfehler text={fehler} />
|
||||
{laeuft && (
|
||||
<p role="status" className="text-muted text-[14px]">
|
||||
{laeuft}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
|
||||
|
||||
{schritt <= 1 && (
|
||||
<Karte
|
||||
titel={schritt === 0 ? "Neuen Schlüssel erzeugen" : "Zuweisungen übernehmen"}
|
||||
untertitel="Noch ändert sich auf keinem Host etwas."
|
||||
>
|
||||
<ul className="text-muted mb-4 list-disc space-y-1 pl-5 text-[14px]">
|
||||
<li>Neues Schlüsselpaar gleichen Typs, Name mit „(rotiert …)“, verknüpft mit dem alten.</li>
|
||||
<li>Public Key: dieselben Ziele und Einschränkungen wie bisher.</li>
|
||||
<li>
|
||||
Private Key: derselbe Host und Benutzer, neue Datei mit Suffix <code>-rJJJJMMTT</code>; der
|
||||
config-Eintrag behält seinen Alias und zeigt danach auf die neue Datei.
|
||||
</li>
|
||||
<li>Der alte Schlüssel bleibt gültig, bis der neue getestet ist.</li>
|
||||
</ul>
|
||||
{status}
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<KeyRound className="size-4" />}
|
||||
disabled={Boolean(laeuft)}
|
||||
onClick={() => void vorbereiten()}
|
||||
>
|
||||
{neu ? "Zuweisungen übernehmen" : "Neuen Schlüssel erzeugen und Zuweisungen übernehmen"}
|
||||
</Button>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 2 && neu && (
|
||||
<Karte
|
||||
titel="Neuen Schlüssel ausrollen"
|
||||
untertitel="Dry-Run prüfen, dann anwenden – der alte Schlüssel bleibt vorerst daneben bestehen."
|
||||
>
|
||||
<AusrollenPanel auswahl={{ schluesselId: neu.id }} offen={daten.neuOffen} />
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 3 && (
|
||||
<Karte
|
||||
titel="Verbindungen mit dem neuen Schlüssel testen"
|
||||
untertitel="Erst wenn alle Tests grün sind, wird der alte Schlüssel entfernt."
|
||||
>
|
||||
<ul className="mb-4 flex flex-col gap-2">
|
||||
{daten.tests.map((t) => (
|
||||
<li key={t.id} className="flex flex-col gap-0.5 text-[14px]">
|
||||
<span className="flex items-center gap-2 font-mono text-[13px] break-all">
|
||||
{t.status === "ERLEDIGT" ? (
|
||||
<CircleCheck className="text-ok size-4 shrink-0" aria-label="grün" />
|
||||
) : t.status === "FEHLGESCHLAGEN" ? (
|
||||
<CircleX className="text-danger size-4 shrink-0" aria-label="fehlgeschlagen" />
|
||||
) : (
|
||||
<PlugZap className="text-muted size-4 shrink-0" aria-label="noch nicht getestet" />
|
||||
)}
|
||||
{t.ziel}
|
||||
</span>
|
||||
{t.fehler && <span className="text-danger pl-6 text-[13px]">{t.fehler}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{status}
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<PlugZap className="size-4" />}
|
||||
disabled={Boolean(laeuft)}
|
||||
onClick={() => void testen()}
|
||||
>
|
||||
Verbindungen testen
|
||||
</Button>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 4 && !daten.widerruf && (
|
||||
<Karte
|
||||
titel="Alten Schlüssel entfernen"
|
||||
untertitel={
|
||||
daten.tests.length > 0
|
||||
? "Alle Verbindungstests sind grün."
|
||||
: "Keine Private-Key-Zuweisung mit Alias – es gibt nichts zu testen."
|
||||
}
|
||||
>
|
||||
<p className="text-muted mb-4 text-[14px]">
|
||||
Der alte Schlüssel „{alt.name}“ wird wie bei einem Widerruf von allen Hosts entfernt (Dry-Run
|
||||
folgt). Danach gilt er als rotiert.
|
||||
</p>
|
||||
{status}
|
||||
<Button
|
||||
variante="gefahr"
|
||||
icon={<Ban className="size-4" />}
|
||||
disabled={Boolean(laeuft)}
|
||||
onClick={() => void altWiderrufen()}
|
||||
>
|
||||
Alten Schlüssel widerrufen
|
||||
</Button>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 4 && daten.widerruf && (
|
||||
<Karte
|
||||
titel="Alten Schlüssel entfernen"
|
||||
untertitel="Der Dry-Run zeigt, was vom alten Schlüssel verschwindet."
|
||||
className="border-danger-border"
|
||||
>
|
||||
<WiderrufAblauf
|
||||
schluesselIds={[alt.id]}
|
||||
offen={daten.widerruf}
|
||||
zielkonten={[]}
|
||||
vorauswahl={[]}
|
||||
ruecksprung={`/schluessel/${alt.id}/rotieren`}
|
||||
darfPrivat={daten.darfPrivat}
|
||||
ohneZielkonten
|
||||
/>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 5 && neu && (
|
||||
<Karte titel="Rotation abgeschlossen">
|
||||
<p className="text-[14px]">
|
||||
Der alte Schlüssel ist von allen Hosts entfernt. Weiter mit{" "}
|
||||
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
|
||||
{neu.name}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</Karte>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Namens- und Pfadregeln der gefuehrten Rotation (#023). Rein, damit testbar.
|
||||
*/
|
||||
|
||||
const NAME_SUFFIX = /\s*\(rotiert \d{2}\.\d{2}\.\d{4}\)$/;
|
||||
const PFAD_SUFFIX = /-r\d{8}(?:-\d+)?$/;
|
||||
|
||||
/** TT.MM.JJJJ und JJJJMMTT nach dem Kalender in Berlin. */
|
||||
export function rotationsDatum(jetzt: Date = new Date()): { anzeige: string; kompakt: string } {
|
||||
const [jahr, monat, tag] = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" })
|
||||
.format(jetzt)
|
||||
.split("-");
|
||||
return { anzeige: `${tag}.${monat}.${jahr}`, kompakt: `${jahr}${monat}${tag}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* „Backup A → B (rotiert 12.10.2026)“ – ein frueheres Rotations-Suffix wird ersetzt, damit
|
||||
* die Namen bei jeder Rotation nicht laenger werden.
|
||||
*/
|
||||
export function rotationsName(name: string, datum: string, maxLaenge = 200): string {
|
||||
const suffix = ` (rotiert ${datum})`;
|
||||
const basis = name.replace(NAME_SUFFIX, "").trim();
|
||||
return `${basis.slice(0, Math.max(1, maxLaenge - suffix.length))}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuer Pfad fuer den Private Key des Nachfolgers: gleicher Name mit `-rJJJJMMTT`. Ein alter
|
||||
* Suffix wird ersetzt; ist der Pfad (auch durch eine fruehere, entfernte Zuweisung) belegt,
|
||||
* kommt eine laufende Nummer dazu. Zwei Dateien nebeneinander sind gewollt: bis der Nachfolger
|
||||
* getestet ist, bleibt der alte Schluessel benutzbar.
|
||||
*/
|
||||
export function rotationsPfad(pfad: string, datumKompakt: string, belegt: ReadonlySet<string>): string {
|
||||
const basis = pfad.replace(PFAD_SUFFIX, "");
|
||||
let kandidat = `${basis}-r${datumKompakt}`;
|
||||
for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`;
|
||||
return kandidat;
|
||||
}
|
||||
@@ -200,6 +200,8 @@ export type SchluesselVorgangEingabe = {
|
||||
readonly typ?: "ED25519" | "RSA4096";
|
||||
/** Nur Import: soll der Worker eine Passphrase erwarten? Die Passphrase selbst reist im Job. */
|
||||
readonly mitPassphrase?: boolean;
|
||||
/** Rotation (#023): der abgeloeste Schluessel – so ist die Kette sofort da, auch ohne Browser. */
|
||||
readonly vorgaengerId?: string | null;
|
||||
};
|
||||
|
||||
/** Prueft `Vorgang.eingabe` im Worker – sie lag zwischendurch in der Datenbank. */
|
||||
@@ -217,6 +219,7 @@ export const vorgangEingabeSchema = z.object({
|
||||
.nullable(),
|
||||
typ: z.enum(["ED25519", "RSA4096"]).optional(),
|
||||
mitPassphrase: z.boolean().optional(),
|
||||
vorgaengerId: z.string().max(64).nullish(),
|
||||
});
|
||||
|
||||
/** Was im verschluesselten `geheim` eines Erzeugen- oder Import-Jobs steht. */
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"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 { FachFehler } from "@/server/services/fachfehler";
|
||||
import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation";
|
||||
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||
|
||||
/**
|
||||
* Gefuehrte Rotation (#023): je Schritt eine duenne Aktion, die Oberflaeche verkettet sie.
|
||||
* Rotieren heisst einen Schluessel erzeugen und ausrollen: `schluesselVerwalten` und
|
||||
* `publicKeyAusrollen`; fuer Private-Key-Zuweisungen zusaetzlich `privateKeyAusrollen` (Service).
|
||||
*/
|
||||
export type RotationsAntwort = { readonly vorgangId?: string; readonly fehler?: string };
|
||||
|
||||
function alsAntwort(fehler: unknown): RotationsAntwort {
|
||||
if (
|
||||
fehler instanceof RechteFehler ||
|
||||
fehler instanceof BestaetigungFehler ||
|
||||
fehler instanceof VerwendungsFehler ||
|
||||
fehler instanceof FachFehler
|
||||
) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
|
||||
async function berechtigt() {
|
||||
await rechtErforderlich("schluesselVerwalten");
|
||||
return rechtErforderlich("publicKeyAusrollen");
|
||||
}
|
||||
|
||||
export async function rotationStartenAktion(schluesselId: string): Promise<RotationsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await berechtigt();
|
||||
return { vorgangId: await rotationStarten(id.parse(schluesselId), akteurVon(benutzer)) };
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotationUebernehmenAktion(schluesselId: string): Promise<RotationsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await berechtigt();
|
||||
await rotationUebernehmen(id.parse(schluesselId), akteurVon(benutzer), {
|
||||
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
});
|
||||
revalidatePath(`/schluessel/${schluesselId}`);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotationAltWiderrufenAktion(schluesselId: string): Promise<RotationsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await berechtigt();
|
||||
await rotationAltWiderrufen(id.parse(schluesselId), akteurVon(benutzer), {
|
||||
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
});
|
||||
revalidatePath(`/schluessel/${schluesselId}`);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
@@ -166,3 +191,44 @@ export async function vorlagenAuswahl() {
|
||||
select: { id: true, name: true, standardLaufzeitTage: true },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Rotationskette (#023)
|
||||
|
||||
export type KettenGlied = { readonly id: string; readonly name: string; readonly status: SchluesselStatus };
|
||||
|
||||
/**
|
||||
* Die ganze Rotationskette eines Schluessels, aeltester zuerst. Begrenzt, damit eine (durch
|
||||
* Fehler entstandene) Schleife die Seite nicht aufhaengt.
|
||||
*/
|
||||
export async function rotationsKette(id: string, maximal = 25): Promise<KettenGlied[]> {
|
||||
const glied = (schluesselId: string) =>
|
||||
prisma.schluessel.findUnique({
|
||||
where: { id: schluesselId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
vorgaengerId: true,
|
||||
nachfolger: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
const start = await glied(id);
|
||||
if (!start) return [];
|
||||
const kette: KettenGlied[] = [{ id: start.id, name: start.name, status: start.status }];
|
||||
const gesehen = new Set([start.id]);
|
||||
for (let davor = start.vorgaengerId; davor && kette.length < maximal && !gesehen.has(davor);) {
|
||||
const g = await glied(davor);
|
||||
if (!g) break;
|
||||
gesehen.add(g.id);
|
||||
kette.unshift({ id: g.id, name: g.name, status: g.status });
|
||||
davor = g.vorgaengerId;
|
||||
}
|
||||
for (let danach = start.nachfolger?.id; danach && kette.length < maximal && !gesehen.has(danach);) {
|
||||
const g = await glied(danach);
|
||||
if (!g) break;
|
||||
gesehen.add(g.id);
|
||||
kette.push({ id: g.id, name: g.name, status: g.status });
|
||||
danach = g.nachfolger?.id;
|
||||
}
|
||||
return kette;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
|
||||
import { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel";
|
||||
import { eindeutigerSlug } from "@/server/services/schluessel";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import {
|
||||
VerwendungsFehler,
|
||||
privateVerwendungAnlegen,
|
||||
publicVerwendungAnlegen,
|
||||
} from "@/server/services/verwendungen";
|
||||
import { widerrufEinleiten } from "@/server/services/widerruf";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Gefuehrte Rotation (#023): neuer Schluessel gleichen Typs, alle aktiven Zuweisungen
|
||||
* uebernehmen, ausrollen, Verbindungen testen – und erst dann den alten Schluessel ueber den
|
||||
* Widerruf (#024) entfernen. Der alte endet als ROTIERT, der neue zeigt per `vorgaengerId` auf ihn.
|
||||
*
|
||||
* Jeder Schritt ist idempotent und liest seinen Stand aus der Datenbank: die Oberflaeche kann
|
||||
* jederzeit neu laden und an derselben Stelle weitermachen.
|
||||
*/
|
||||
export class RotationsFehler extends VerwendungsFehler {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "RotationsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
/** Zustaende, in denen eine Verwendung mitgenommen wird. */
|
||||
const AKTIVE = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
|
||||
|
||||
const ALT_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
typ: true,
|
||||
kategorie: true,
|
||||
status: true,
|
||||
fingerprint: true,
|
||||
tags: true,
|
||||
ablaufAm: true,
|
||||
personId: true,
|
||||
vorlageId: true,
|
||||
masterKeyVersion: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
nachfolger: { select: { id: true, name: true, status: true } },
|
||||
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
|
||||
} satisfies Prisma.SchluesselSelect;
|
||||
|
||||
type Alt = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>;
|
||||
|
||||
/**
|
||||
* Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder
|
||||
* ein Dienst-Key, dessen Private Key woanders liegt) braucht einen neuen Public Key von aussen.
|
||||
*/
|
||||
export function rotationGesperrt(
|
||||
s: Pick<Alt, "kategorie" | "status" | "masterKeyVersion" | "widerrufEingeleitetAm"> & {
|
||||
readonly privatZuweisungen: number;
|
||||
},
|
||||
): { grund: string; neuerPublicKeyNoetig?: boolean } | null {
|
||||
if (s.kategorie === "DEPLOYMENT")
|
||||
return { grund: "Der Deployment-Key wird über eine eigene Rotation ersetzt." };
|
||||
if (s.status !== "AKTIV" && s.status !== "ABGELAUFEN")
|
||||
return { grund: "Nur aktive oder abgelaufene Schlüssel lassen sich rotieren." };
|
||||
if (s.kategorie === "PERSON" || (s.masterKeyVersion === null && s.privatZuweisungen === 0)) {
|
||||
return {
|
||||
grund:
|
||||
"Den Private Key hat sshkv nicht – rotieren heißt hier: Die Person erzeugt ein neues Schlüsselpaar und liefert den neuen Public Key. Diesen importieren, genauso zuweisen und danach diesen Schlüssel widerrufen.",
|
||||
neuerPublicKeyNoetig: true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function altLaden(id: string): Promise<Alt> {
|
||||
const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT });
|
||||
if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht.");
|
||||
return alt;
|
||||
}
|
||||
|
||||
function gesperrtPruefen(alt: Alt) {
|
||||
// Ein laufender Widerruf gehoert zur Rotation, wenn es schon einen Nachfolger gibt.
|
||||
if (alt.widerrufEingeleitetAm && !alt.nachfolger)
|
||||
throw new RotationsFehler("Dieser Schlüssel wird gerade widerrufen.");
|
||||
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
|
||||
if (gesperrt) throw new RotationsFehler(gesperrt.grund);
|
||||
}
|
||||
|
||||
/** Schritt 1: Nachfolger im Worker erzeugen lassen. Liefert die Vorgangs-ID. */
|
||||
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
|
||||
const alt = await altLaden(altId);
|
||||
gesperrtPruefen(alt);
|
||||
if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger.");
|
||||
const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX);
|
||||
const slug = await eindeutigerSlug(null, name);
|
||||
// Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen.
|
||||
const ablaufAm = alt.ablaufAm && alt.ablaufAm > new Date() ? alt.ablaufAm.toISOString() : null;
|
||||
const eingabe: SchluesselVorgangEingabe = {
|
||||
name,
|
||||
slug,
|
||||
tags: alt.tags,
|
||||
ablaufAm,
|
||||
personId: alt.personId,
|
||||
vorlageId: alt.vorlageId,
|
||||
kommentar: null,
|
||||
typ: alt.typ === "RSA4096" ? "RSA4096" : "ED25519",
|
||||
vorgaengerId: alt.id,
|
||||
};
|
||||
return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe });
|
||||
}
|
||||
|
||||
async function nachfolgerVon(alt: Alt) {
|
||||
if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
||||
if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv.");
|
||||
return alt.nachfolger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schritt 2: aktive Zuweisungen fuer den Nachfolger uebernehmen. Public: gleiche Ziele und
|
||||
* Optionen. Private: gleicher Host/Benutzer und Alias, neuer Pfad – der config-Block zeigt nach
|
||||
* dem Ausrollen auf die neue Datei (juengste Zuweisung gewinnt je Alias, src/worker/ablaeufe/privat.ts).
|
||||
*/
|
||||
export async function rotationUebernehmen(
|
||||
altId: string,
|
||||
akteur: Akteur,
|
||||
optionen: { readonly privatErlaubt: boolean },
|
||||
): Promise<{ public: number; privat: number }> {
|
||||
const alt = await altLaden(altId);
|
||||
gesperrtPruefen(alt);
|
||||
const neu = await nachfolgerVon(alt);
|
||||
const [altPublic, altPrivat, neuPublic, neuPrivat] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } },
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } },
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
prisma.publicVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }),
|
||||
prisma.privateVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }),
|
||||
]);
|
||||
if (altPrivat.length > 0 && !optionen.privatErlaubt)
|
||||
throw new RotationsFehler("Private Keys rollt nur ein Admin aus.");
|
||||
|
||||
let anzahlPublic = 0;
|
||||
for (const v of altPublic) {
|
||||
const schon = neuPublic.some(
|
||||
(n) => n.hostId === v.hostId && n.hostgruppeId === v.hostgruppeId && n.zielbenutzer === v.zielbenutzer,
|
||||
);
|
||||
if (schon) continue;
|
||||
await publicVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: neu.id,
|
||||
hostId: v.hostId,
|
||||
hostgruppeId: v.hostgruppeId,
|
||||
zielbenutzer: v.zielbenutzer,
|
||||
optionen: v.optionen,
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
anzahlPublic += 1;
|
||||
}
|
||||
|
||||
const datum = rotationsDatum().kompakt;
|
||||
let anzahlPrivat = 0;
|
||||
for (const v of altPrivat) {
|
||||
const config = v.sshConfig as { alias: string; hostName: string; benutzer: string; port: number } | null;
|
||||
const schon = neuPrivat.some(
|
||||
(n) =>
|
||||
n.hostId === v.hostId &&
|
||||
n.zielbenutzer === v.zielbenutzer &&
|
||||
((n.sshConfig as { alias?: string } | null)?.alias ?? null) === (config?.alias ?? null),
|
||||
);
|
||||
if (schon) continue;
|
||||
// Auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index ueber alle Zustaende).
|
||||
const belegt = new Set(
|
||||
(
|
||||
await prisma.privateVerwendung.findMany({
|
||||
where: { hostId: v.hostId, zielbenutzer: v.zielbenutzer },
|
||||
select: { pfad: true },
|
||||
})
|
||||
).map((p) => p.pfad),
|
||||
);
|
||||
await privateVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: neu.id,
|
||||
hostId: v.hostId,
|
||||
zielbenutzer: v.zielbenutzer,
|
||||
pfad: rotationsPfad(v.pfad, datum, belegt),
|
||||
sshConfig: config,
|
||||
knownHostsFuer: v.knownHostsFuer,
|
||||
beimWiderruf: v.beimWiderruf,
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
anzahlPrivat += 1;
|
||||
}
|
||||
if (anzahlPublic + anzahlPrivat > 0) {
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "schluessel.rotation.uebernommen",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: alt.fingerprint,
|
||||
details: { nachfolgerId: neu.id, public: anzahlPublic, privat: anzahlPrivat },
|
||||
});
|
||||
}
|
||||
return { public: anzahlPublic, privat: anzahlPrivat };
|
||||
}
|
||||
|
||||
/** Verbindungstests, die der Nachfolger bestehen muss: jede Private-Zuweisung mit Alias. */
|
||||
async function testZiele(neuId: string) {
|
||||
const verwendungen = await prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: neuId, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
zielbenutzer: true,
|
||||
sshConfig: true,
|
||||
zustand: true,
|
||||
zuletztAngewendetAm: true,
|
||||
host: { select: { name: true } },
|
||||
},
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
});
|
||||
const mitAlias = verwendungen.filter((v) => (v.sshConfig as { alias?: string } | null)?.alias);
|
||||
return Promise.all(
|
||||
mitAlias.map(async (v) => {
|
||||
// Gruen ist nur ein Test nach dem letzten Ausrollen – ein aelterer bewiese nichts.
|
||||
const test = await prisma.vorgang.findFirst({
|
||||
where: {
|
||||
art: "VERBINDUNGSTEST",
|
||||
eingabe: { equals: { privatVerwendungId: v.id } },
|
||||
...(v.zuletztAngewendetAm ? { angelegtAm: { gte: v.zuletztAngewendetAm } } : {}),
|
||||
},
|
||||
orderBy: { angelegtAm: "desc" },
|
||||
select: { status: true, fehler: true },
|
||||
});
|
||||
return {
|
||||
id: v.id,
|
||||
ziel: `${v.zielbenutzer}@${v.host.name} → ${(v.sshConfig as { alias: string }).alias}`,
|
||||
ausgerollt: v.zustand === "AUSGEROLLT",
|
||||
status: test?.status ?? null,
|
||||
fehler: test?.fehler ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export type RotationsStand = Awaited<ReturnType<typeof rotationsStand>>;
|
||||
|
||||
/** Wo steht die Rotation? Fuer die Schrittanzeige – alles aus der Datenbank. */
|
||||
export async function rotationsStand(altId: string) {
|
||||
const alt = await altLaden(altId);
|
||||
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
|
||||
const neu = alt.nachfolger;
|
||||
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const };
|
||||
|
||||
const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([
|
||||
prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
|
||||
prisma.privateVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } },
|
||||
select: { zustand: true },
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } },
|
||||
select: { zustand: true },
|
||||
}),
|
||||
testZiele(neu.id),
|
||||
]);
|
||||
const neuOffen = [...neuPublic, ...neuPrivat].filter((v) => v.zustand !== "AUSGEROLLT").length;
|
||||
// Uebernommen, sobald der Neue mindestens so viele Zuweisungen hat wie der Alte (noch) aktive.
|
||||
const uebernommen =
|
||||
alt.widerrufEingeleitetAm !== null || (neuPublic.length >= altPublic && neuPrivat.length >= altPrivat);
|
||||
const testsGruen = tests.every((t) => t.ausgerollt && t.status === "ERLEDIGT");
|
||||
const schritt =
|
||||
alt.status === "ROTIERT"
|
||||
? (5 as const)
|
||||
: !uebernommen
|
||||
? (1 as const)
|
||||
: alt.widerrufEingeleitetAm
|
||||
? (4 as const)
|
||||
: neuOffen > 0
|
||||
? (2 as const)
|
||||
: !testsGruen
|
||||
? (3 as const)
|
||||
: (4 as const);
|
||||
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schritt 5: den alten Schluessel widerrufen – nur wenn der Nachfolger ueberall ausgerollt ist
|
||||
* und jeder Verbindungstest seit dem letzten Ausrollen gruen war.
|
||||
*/
|
||||
export async function rotationAltWiderrufen(
|
||||
altId: string,
|
||||
akteur: Akteur,
|
||||
optionen: { readonly privatErlaubt: boolean },
|
||||
): Promise<void> {
|
||||
const stand = await rotationsStand(altId);
|
||||
if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
||||
if (stand.schritt === 5) return;
|
||||
if (stand.schritt < 4) {
|
||||
throw new RotationsFehler(
|
||||
stand.schritt === 3
|
||||
? "Erst wenn alle Verbindungstests mit dem neuen Schlüssel grün sind, wird der alte entfernt."
|
||||
: "Der neue Schlüssel ist noch nicht überall ausgerollt.",
|
||||
);
|
||||
}
|
||||
await widerrufEinleiten([altId], akteur, optionen);
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -60,7 +60,10 @@ async function gewollteDateien(
|
||||
});
|
||||
|
||||
const dateien: Gewollt[] = [];
|
||||
const config: SshConfigEintrag[] = [];
|
||||
// Je Alias genau ein Eintrag: bei einer Rotation (#023) tragen alte und neue Zuweisung denselben
|
||||
// Alias. Die juengere gewinnt (Reihenfolge nach angelegtAm) – ssh nimmt sonst den ersten Treffer,
|
||||
// und der Alias zeigte weiter auf den alten Schluessel. Die Position bleibt die des ersten.
|
||||
const config = new Map<string, SshConfigEintrag>();
|
||||
const knownHosts: string[] = [];
|
||||
for (const verwendung of verwendungen) {
|
||||
const pfad = relativenPfadPruefen(verwendung.pfad);
|
||||
@@ -100,7 +103,7 @@ async function gewollteDateien(
|
||||
port?: number;
|
||||
} | null;
|
||||
if (sshConfig?.alias && sshConfig.hostName && sshConfig.benutzer) {
|
||||
config.push({
|
||||
config.set(sshConfig.alias, {
|
||||
alias: sshConfig.alias,
|
||||
hostName: sshConfig.hostName,
|
||||
benutzer: sshConfig.benutzer,
|
||||
@@ -127,7 +130,7 @@ async function gewollteDateien(
|
||||
dateien.push({
|
||||
art: "config",
|
||||
pfad: ".ssh/config",
|
||||
inhalt: Buffer.from(sshConfigZeilen(config).join("\n")),
|
||||
inhalt: Buffer.from(sshConfigZeilen([...config.values()]).join("\n")),
|
||||
modus: "0600",
|
||||
});
|
||||
dateien.push({
|
||||
|
||||
@@ -103,12 +103,15 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
|
||||
vorlageId: eingabe.vorlageId,
|
||||
erstelltVonId: akteur === "System" ? null : akteur.id,
|
||||
passphraseGeschuetzt: Boolean(passphrase),
|
||||
vorgaengerId: eingabe.vorgaengerId ?? null,
|
||||
...neu.versiegelt,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
} catch (fehler) {
|
||||
if (istEindeutigkeitsFehler(fehler)) {
|
||||
// vorgaengerId ist eindeutig: zwei Rotationen desselben Schluessels gleichzeitig (#023).
|
||||
if (eingabe.vorgaengerId) throw new VorgangsFehler("Für diesen Schlüssel läuft bereits eine Rotation.");
|
||||
throw new VorgangsFehler(
|
||||
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
|
||||
);
|
||||
@@ -127,6 +130,7 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
|
||||
typ,
|
||||
kategorie: "DIENST",
|
||||
passphraseGeschuetzt: Boolean(passphrase),
|
||||
...(eingabe.vorgaengerId ? { vorgaengerId: eingabe.vorgaengerId } : {}),
|
||||
},
|
||||
});
|
||||
return { ergebnis: { schluesselId: neu.id, fingerprint: neu.fingerprint } };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import {
|
||||
anmelden,
|
||||
archivieren,
|
||||
aufZiel,
|
||||
hostEinrichten,
|
||||
kernAufbauen,
|
||||
PORT,
|
||||
zieleZuruecksetzen,
|
||||
} from "./hilfen";
|
||||
|
||||
/**
|
||||
* Gefuehrte Rotation im Browser (#023), gebauter Stand + Worker: Ausgangslage ist der
|
||||
* Kern-Anwendungsfall. Neuer Schluessel → Zuweisungen uebernehmen → ausrollen → Verbindung
|
||||
* testen → alten widerrufen. `ssh zu-b` von A funktioniert vorher und nachher.
|
||||
*/
|
||||
test.setTimeout(300_000);
|
||||
|
||||
test.beforeAll(() => zieleZuruecksetzen());
|
||||
|
||||
test("Rotation mit Schrittanzeige", async ({ page }) => {
|
||||
const kennung = Date.now().toString(36);
|
||||
const hostA = `zz-e2e-a-${kennung}`;
|
||||
const hostB = `zz-e2e-b-${kennung}`;
|
||||
const zweck = `zz-e2e Rotation ${kennung}`;
|
||||
|
||||
await anmelden(page);
|
||||
await hostEinrichten(page, hostA, PORT.a);
|
||||
await hostEinrichten(page, hostB, PORT.b);
|
||||
await kernAufbauen(page, zweck, hostA, hostB);
|
||||
const altDateien = aufZiel("ziel-a", "ls /root/.ssh");
|
||||
await page.reload();
|
||||
|
||||
await page.getByRole("link", { name: "Rotieren" }).click();
|
||||
await expect(page.getByRole("heading", { name: `„${zweck}“ rotieren` })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click();
|
||||
|
||||
// Schritt 3: neuen Schluessel ausrollen.
|
||||
await expect(page.getByRole("button", { name: "Dry-Run starten" })).toBeVisible({ timeout: 60_000 });
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||
// Derselbe Alias, neue Datei: nur die IdentityFile-Zeile aendert sich.
|
||||
await expect(page.getByText(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/)).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({ path: "docs/anleitung/bilder/rotation-desktop.png", fullPage: true });
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||
|
||||
// Schritt 4: Verbindungstest mit dem neuen Schluessel.
|
||||
await page.getByRole("button", { name: "Verbindungen testen" }).click({ timeout: 60_000 });
|
||||
|
||||
// Schritt 5: alten Schluessel widerrufen.
|
||||
await page.getByRole("button", { name: "Alten Schlüssel widerrufen" }).click({ timeout: 60_000 });
|
||||
await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 30_000 });
|
||||
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||
await expect(page.getByRole("heading", { name: "Rotation abgeschlossen" })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
|
||||
const neueDateien = aufZiel("ziel-a", "ls /root/.ssh");
|
||||
const alterKey = altDateien.split("\n").find((d) => d.startsWith("id_ed25519") && !d.endsWith(".pub"))!;
|
||||
expect(neueDateien.split("\n")).not.toContain(alterKey);
|
||||
expect(neueDateien).toMatch(/-r\d{8}\n/);
|
||||
|
||||
// Kette auf der Seite des neuen Schluessels.
|
||||
await page.getByRole("link", { name: /\(rotiert / }).click();
|
||||
const kette = page.getByRole("list", { name: "Rotationskette" });
|
||||
await expect(kette.getByRole("link", { name: zweck })).toBeVisible();
|
||||
await expect(kette.getByText("rotiert", { exact: true })).toBeVisible();
|
||||
|
||||
await archivieren(page, hostA);
|
||||
await archivieren(page, hostB);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rotationsDatum } from "@/lib/rotation";
|
||||
import {
|
||||
rotationAltWiderrufen,
|
||||
rotationStarten,
|
||||
rotationUebernehmen,
|
||||
rotationsStand,
|
||||
} from "@/server/services/rotation";
|
||||
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen } from "@/server/services/soll";
|
||||
import { privateVerwendungAnlegen, publicVerwendungAnlegen } from "@/server/services/verwendungen";
|
||||
import { widerrufAbschliessen } from "@/server/services/widerruf";
|
||||
import { deploymentKeySicherstellen, schluesselpaarVersiegeltErzeugen } from "@/worker/geheimnisse";
|
||||
import { datenbankLeeren } from "../hilfen/db";
|
||||
import { vorgangImWorker, warteschlangeStoppen } from "../hilfen/vorgang";
|
||||
import { eingerichteterHost, masterKeyAnlegen, vorgangLaufen } from "./hilfen/host";
|
||||
import { befehlAlsRoot, zielA, zielB, zielBereit } from "./hilfen/ziele";
|
||||
|
||||
/**
|
||||
* Rotation gegen echte Hosts (#023): der neue Schluessel funktioniert (Verbindungstest gruen),
|
||||
* der alte ist danach von beiden Hosts verschwunden, und `ssh <alias>` von A geht weiter –
|
||||
* derselbe Alias zeigt jetzt auf die neue Datei.
|
||||
*/
|
||||
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; rm -f /etc/sudoers.d/sshkv*; rm -rf /root/.ssh; true";
|
||||
await Promise.all([befehlAlsRoot(zielA, reset), befehlAlsRoot(zielB, reset)]);
|
||||
AKTEUR.id = (await prisma.benutzer.create({ data: { name: "Test-Admin", rolle: "ADMIN" } })).id;
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
expect(ausrollen.fehler).toBeNull();
|
||||
}
|
||||
|
||||
describe("Rotation", () => {
|
||||
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
|
||||
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||
const b = await eingerichteterHost(zielB, "ziel-b");
|
||||
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
|
||||
const alt = await prisma.schluessel.create({
|
||||
data: {
|
||||
id: paar.id,
|
||||
name: "Backup",
|
||||
slug: "backup",
|
||||
typ: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
publicKey: paar.publicKeyZeile,
|
||||
fingerprint: paar.fingerprint,
|
||||
kommentar: "sshkv:backup",
|
||||
...paar.versiegelt,
|
||||
},
|
||||
select: { id: true, publicKey: true },
|
||||
});
|
||||
await privateVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: alt.id,
|
||||
hostId: a.id,
|
||||
zielbenutzer: "root",
|
||||
pfad: "~/.ssh/id_backup",
|
||||
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
|
||||
knownHostsFuer: [b.id],
|
||||
},
|
||||
AKTEUR,
|
||||
);
|
||||
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR);
|
||||
await trockenlaufUndAusrollen(alt.id);
|
||||
|
||||
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
|
||||
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
|
||||
await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true });
|
||||
const neu = await prisma.schluessel.findFirstOrThrow({
|
||||
where: { vorgaengerId: alt.id },
|
||||
select: { id: true, publicKey: true },
|
||||
});
|
||||
|
||||
// 3: ausrollen – beide Schluessel liegen jetzt nebeneinander, der Alias zeigt auf den neuen.
|
||||
await trockenlaufUndAusrollen(neu.id);
|
||||
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
|
||||
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
|
||||
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
|
||||
expect(config).toContain(`IdentityFile ~/.ssh/${neuerPfad}`);
|
||||
|
||||
// 4: Verbindungstest mit dem neuen Schluessel.
|
||||
const verwendung = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neu.id } });
|
||||
const test = await vorgangLaufen("VERBINDUNGSTEST", { eingabe: { privatVerwendungId: verwendung.id } });
|
||||
expect(test.fehler).toBeNull();
|
||||
expect((await rotationsStand(alt.id)).schritt).toBe(4);
|
||||
|
||||
// 5: alten widerrufen.
|
||||
await rotationAltWiderrufen(alt.id, AKTEUR, { privatErlaubt: true });
|
||||
await trockenlaufUndAusrollen(alt.id);
|
||||
expect((await widerrufAbschliessen([alt.id], AKTEUR))[0]?.status).toBe("ROTIERT");
|
||||
|
||||
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
|
||||
expect(authorized).not.toContain(alt.publicKey.split(" ")[1]!);
|
||||
expect(authorized).toContain(neu.publicKey.split(" ")[1]!);
|
||||
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
|
||||
expect(dateien).not.toContain("id_backup");
|
||||
expect(dateien).toContain(neuerPfad);
|
||||
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
|
||||
expect(ssh.stdout.trim()).toBe("root");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
|
||||
import { rotationsKette } from "@/server/queries/schluessel";
|
||||
import {
|
||||
RotationsFehler,
|
||||
rotationAltWiderrufen,
|
||||
rotationGesperrt,
|
||||
rotationStarten,
|
||||
rotationUebernehmen,
|
||||
rotationsStand,
|
||||
} from "@/server/services/rotation";
|
||||
import { widerrufAbschliessen } from "@/server/services/widerruf";
|
||||
import { masterKeyVergessen } from "@/worker/geheimnisse";
|
||||
import { datenbankLeeren } from "./hilfen/db";
|
||||
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
|
||||
import { ed25519Blob, zeile } from "./ssh/hilfen";
|
||||
|
||||
/** Gefuehrte Rotation (#023): Regeln, Schritte, Rechte, Protokoll. */
|
||||
|
||||
let verzeichnis: string;
|
||||
let admin: { id: string; name: string };
|
||||
let a: { id: string };
|
||||
let b: { id: string };
|
||||
|
||||
beforeAll(() => {
|
||||
verzeichnis = mkdtempSync(path.join(tmpdir(), "sshkv-master-"));
|
||||
const datei = path.join(verzeichnis, "master.key");
|
||||
writeFileSync(datei, randomBytes(32), { mode: 0o600 });
|
||||
process.env["SSHKV_MASTER_KEY_FILE"] = datei;
|
||||
masterKeyVergessen();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
rmSync(verzeichnis, { recursive: true, force: true });
|
||||
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 } });
|
||||
b = await prisma.host.create({ data: { name: "srv-b", adresse: "10.0.0.2" }, select: { id: true } });
|
||||
});
|
||||
|
||||
/** Ein ausgerollter Dienst-Key wie nach dem Assistenten: Private auf A (Alias), Public auf B. */
|
||||
async function alterSchluessel() {
|
||||
const alt = await prisma.schluessel.create({
|
||||
data: {
|
||||
name: "Backup A → B",
|
||||
slug: "backup-a-b",
|
||||
typ: "ED25519",
|
||||
kategorie: "DIENST",
|
||||
publicKey: zeile("ssh-ed25519", ed25519Blob(), "alt"),
|
||||
fingerprint: "SHA256:alt",
|
||||
kommentar: "alt",
|
||||
tags: ["backup"],
|
||||
ablaufAm: new Date("2020-01-01T00:00:00Z"),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const jetzt = new Date();
|
||||
await prisma.publicVerwendung.create({
|
||||
data: {
|
||||
schluesselId: alt.id,
|
||||
hostId: b.id,
|
||||
zielbenutzer: "backup",
|
||||
optionen: 'from="10.0.0.1"',
|
||||
zustand: "AUSGEROLLT",
|
||||
zuletztAngewendetAm: jetzt,
|
||||
},
|
||||
});
|
||||
await prisma.privateVerwendung.create({
|
||||
data: {
|
||||
schluesselId: alt.id,
|
||||
hostId: a.id,
|
||||
zielbenutzer: "root",
|
||||
pfad: "~/.ssh/id_backup",
|
||||
sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 },
|
||||
knownHostsFuer: [b.id],
|
||||
beimWiderruf: "UMBENENNEN",
|
||||
zustand: "AUSGEROLLT",
|
||||
zuletztAngewendetAm: jetzt,
|
||||
},
|
||||
});
|
||||
return alt.id;
|
||||
}
|
||||
|
||||
async function nachfolgerErzeugen(altId: string): Promise<string> {
|
||||
const vorgangId = await rotationStarten(altId, admin);
|
||||
await vorgangImWorker(vorgangId);
|
||||
const neu = await prisma.schluessel.findFirstOrThrow({
|
||||
where: { vorgaengerId: altId },
|
||||
select: { id: true },
|
||||
});
|
||||
return neu.id;
|
||||
}
|
||||
|
||||
describe("Namen und Pfade", () => {
|
||||
it("ersetzt ein frueheres Rotations-Suffix", () => {
|
||||
expect(rotationsName("Backup", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)");
|
||||
expect(rotationsName("Backup (rotiert 01.01.2026)", "12.10.2026")).toBe("Backup (rotiert 12.10.2026)");
|
||||
expect(rotationsName("x".repeat(200), "12.10.2026", 120)).toHaveLength(120);
|
||||
});
|
||||
|
||||
it("haengt -rJJJJMMTT an und weicht belegten Pfaden aus", () => {
|
||||
expect(rotationsPfad("~/.ssh/id_backup", "20261012", new Set())).toBe("~/.ssh/id_backup-r20261012");
|
||||
expect(rotationsPfad("~/.ssh/id_backup-r20260101", "20261012", new Set())).toBe(
|
||||
"~/.ssh/id_backup-r20261012",
|
||||
);
|
||||
expect(
|
||||
rotationsPfad("~/.ssh/id_backup-r20261012", "20261012", new Set(["~/.ssh/id_backup-r20261012"])),
|
||||
).toBe("~/.ssh/id_backup-r20261012-2");
|
||||
});
|
||||
|
||||
it("rechnet das Datum in Berlin", () => {
|
||||
expect(rotationsDatum(new Date("2026-10-11T23:30:00Z"))).toEqual({
|
||||
anzeige: "12.10.2026",
|
||||
kompakt: "20261012",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Was sich rotieren laesst", () => {
|
||||
const basis = {
|
||||
status: "AKTIV" as const,
|
||||
masterKeyVersion: 1,
|
||||
widerrufEingeleitetAm: null,
|
||||
privatZuweisungen: 0,
|
||||
};
|
||||
it("verlangt bei Personen-Keys einen neuen Public Key von aussen", () => {
|
||||
expect(rotationGesperrt({ ...basis, kategorie: "PERSON", masterKeyVersion: null })).toMatchObject({
|
||||
neuerPublicKeyNoetig: true,
|
||||
});
|
||||
expect(rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null })).toMatchObject({
|
||||
neuerPublicKeyNoetig: true,
|
||||
});
|
||||
// Private Key aus sshkv geloescht, aber ausgerollt: sshkv erzeugt und verteilt den neuen selbst.
|
||||
expect(
|
||||
rotationGesperrt({ ...basis, kategorie: "DIENST", masterKeyVersion: null, privatZuweisungen: 1 }),
|
||||
).toBeNull();
|
||||
expect(rotationGesperrt({ ...basis, kategorie: "DEPLOYMENT" })?.grund).toMatch(/eigene Rotation/);
|
||||
expect(rotationGesperrt({ ...basis, kategorie: "DIENST", status: "WIDERRUFEN" })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rotation Schritt fuer Schritt", () => {
|
||||
it("fuehrt vom neuen Schluessel bis ROTIERT und haelt die Reihenfolge ein", async () => {
|
||||
const altId = await alterSchluessel();
|
||||
expect((await rotationsStand(altId)).schritt).toBe(0);
|
||||
|
||||
const neuId = await nachfolgerErzeugen(altId);
|
||||
const neu = await prisma.schluessel.findUniqueOrThrow({
|
||||
where: { id: neuId },
|
||||
select: { name: true, typ: true, tags: true, ablaufAm: true, kategorie: true },
|
||||
});
|
||||
expect(neu.name).toBe(`Backup A → B (rotiert ${rotationsDatum().anzeige})`);
|
||||
expect(neu).toMatchObject({ typ: "ED25519", kategorie: "DIENST", tags: ["backup"], ablaufAm: null });
|
||||
expect((await rotationsStand(altId)).schritt).toBe(1);
|
||||
await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/);
|
||||
|
||||
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: false })).rejects.toBeInstanceOf(
|
||||
RotationsFehler,
|
||||
);
|
||||
expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({
|
||||
public: 1,
|
||||
privat: 1,
|
||||
});
|
||||
expect(await rotationUebernehmen(altId, admin, { privatErlaubt: true })).toEqual({
|
||||
public: 0,
|
||||
privat: 0,
|
||||
});
|
||||
|
||||
const oeffentlich = await prisma.publicVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } });
|
||||
expect(oeffentlich).toMatchObject({ hostId: b.id, zielbenutzer: "backup", optionen: 'from="10.0.0.1"' });
|
||||
const privat = await prisma.privateVerwendung.findFirstOrThrow({ where: { schluesselId: neuId } });
|
||||
expect(privat).toMatchObject({
|
||||
hostId: a.id,
|
||||
zielbenutzer: "root",
|
||||
pfad: `~/.ssh/id_backup-r${rotationsDatum().kompakt}`,
|
||||
sshConfig: { alias: "zu-b", hostName: "10.0.0.2", benutzer: "backup", port: 22 },
|
||||
knownHostsFuer: [b.id],
|
||||
beimWiderruf: "UMBENENNEN",
|
||||
});
|
||||
expect((await rotationsStand(altId)).schritt).toBe(2);
|
||||
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
|
||||
/noch nicht überall/,
|
||||
);
|
||||
|
||||
// Ausgerollt, aber noch nicht getestet.
|
||||
const jetzt = new Date();
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "AUSGEROLLT" },
|
||||
});
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
||||
});
|
||||
expect((await rotationsStand(altId)).schritt).toBe(3);
|
||||
await prisma.vorgang.create({
|
||||
data: { art: "VERBINDUNGSTEST", status: "FEHLGESCHLAGEN", eingabe: { privatVerwendungId: privat.id } },
|
||||
});
|
||||
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/grün/);
|
||||
|
||||
await prisma.vorgang.create({
|
||||
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
|
||||
});
|
||||
expect((await rotationsStand(altId)).schritt).toBe(4);
|
||||
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
|
||||
expect(
|
||||
await prisma.publicVerwendung.count({ where: { schluesselId: altId, zustand: "ENTFERNEN" } }),
|
||||
).toBe(1);
|
||||
// Der Nachfolger bleibt unberuehrt.
|
||||
expect(
|
||||
await prisma.publicVerwendung.count({ where: { schluesselId: neuId, zustand: "AUSGEROLLT" } }),
|
||||
).toBe(1);
|
||||
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { schluesselId: altId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
await prisma.privateVerwendung.updateMany({
|
||||
where: { schluesselId: altId },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("ROTIERT");
|
||||
expect((await rotationsStand(altId)).schritt).toBe(5);
|
||||
|
||||
expect((await rotationsKette(neuId)).map((g) => [g.id, g.status])).toEqual([
|
||||
[altId, "ROTIERT"],
|
||||
[neuId, "AKTIV"],
|
||||
]);
|
||||
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.uebernommen" } })).toBe(1);
|
||||
expect(await datenbankAlsText()).not.toContain("PRIVATE KEY");
|
||||
});
|
||||
|
||||
it("verweigert Personen-Keys und den Deployment-Key", async () => {
|
||||
const person = await prisma.schluessel.create({
|
||||
data: {
|
||||
name: "Laptop",
|
||||
slug: "laptop",
|
||||
typ: "ED25519",
|
||||
kategorie: "PERSON",
|
||||
publicKey: zeile("ssh-ed25519", ed25519Blob(), "p"),
|
||||
fingerprint: "SHA256:p",
|
||||
kommentar: "p",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user