Merge branch 'phase/033-erneut-rotieren'

This commit is contained in:
2026-10-12 20:09:48 +02:00
17 changed files with 183 additions and 48 deletions
+1
View File
@@ -11,6 +11,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
- Geführte Rotation des Deployment-Keys in fünf Schritten (erzeugen, verteilen, prüfen, umstellen, aufräumen) – kein Host steht dabei ohne funktionierenden Key da; nicht erreichbare Hosts lassen sich bewusst übergehen (#031) - Geführte Rotation des Deployment-Keys in fünf Schritten (erzeugen, verteilen, prüfen, umstellen, aufräumen) – kein Host steht dabei ohne funktionierenden Key da; nicht erreichbare Hosts lassen sich bewusst übergehen (#031)
- Laufende Schlüsselrotation abbrechen – der Nachfolger wird per Dry-Run wieder entfernt (#032) - Laufende Schlüsselrotation abbrechen – der Nachfolger wird per Dry-Run wieder entfernt (#032)
- „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032) - „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032)
- Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (#033)
### Geändert ### Geändert
- Protokoll-IDs werden lückenlos vergeben (#032) - Protokoll-IDs werden lückenlos vergeben (#032)
+1
View File
@@ -42,6 +42,7 @@ Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
derselbe Schlüssel deshalb nicht erneut über sshkv rotieren; Seite und Fehlermeldung sagen das derselbe Schlüssel deshalb nicht erneut über sshkv rotieren; Seite und Fehlermeldung sagen das
(neuen Schlüssel anlegen, zuweisen, alten widerrufen). Eine erneute Rotation bräuchte eine (neuen Schlüssel anlegen, zuweisen, alten widerrufen). Eine erneute Rotation bräuchte eine
1:n-Relation mit eindeutigem Teilindex „ein nicht widerrufener Nachfolger je Vorgänger“. 1:n-Relation mit eindeutigem Teilindex „ein nicht widerrufener Nachfolger je Vorgänger“.
→ Behoben in #033 (partieller Index statt 1:1).
- **Protokoll-IDs:** Migration `20261012173000_protokoll_id_lueckenlos` setzt den Spalten-Default auf den - **Protokoll-IDs:** Migration `20261012173000_protokoll_id_lueckenlos` setzt den Spalten-Default auf den
Platzhalter `0` (Prisma: `@default(0)`, `create` ohne ID geht weiter); die ID zieht nur noch der Platzhalter `0` (Prisma: `@default(0)`, `create` ohne ID geht weiter); die ID zieht nur noch der
Trigger unter der Advisory-Sperre aus der weiterhin zugeordneten Sequenz. Bestehende Zeilen bleiben Trigger unter der Advisory-Sperre aus der weiterhin zugeordneten Sequenz. Bestehende Zeilen bleiben
+27
View File
@@ -0,0 +1,27 @@
# #033 Erneut rotieren nach abgebrochener Rotation
**Status:** erledigt
**Meilenstein:** v1.0.0
**Labels:** backend, datenbank
**Geschätzt:** 1 h **Tatsächlich:** 0,5 h
## Ziel
Nach einer abgebrochenen Rotation (#032) lässt sich derselbe Schlüssel erneut rotieren, statt dass ein neuer Schlüssel angelegt, zugewiesen und der alte widerrufen werden muss.
## Aufgaben
- [x] `vorgaengerId` nicht mehr eindeutig; stattdessen partieller Index: je Vorgänger höchstens ein Nachfolger, der weder widerrufen ist noch gerade widerrufen wird
- [x] Relation 1:n (`nachfolgerListe`); maßgeblich ist der jüngste Nachfolger (`JUENGSTER`, `mitNachfolger`)
- [x] Neue Rotation erst, wenn der Abbruch abgeschlossen ist (Nachfolger WIDERRUFEN)
- [x] Schlüsselseite: „Rotieren“ wieder anbieten, Hinweistext angepasst
## Akzeptanzkriterien
- [x] DB-Test: Abbruch → während des Abbruchs keine neue Rotation → danach zweiter Nachfolger, Kette zeigt ihn
- [x] DB-Test: die Datenbank lehnt einen zweiten offenen Nachfolger ab
- [x] E2E: nach dem Abbruch führt „Rotieren“ wieder zu Schritt 1
## Abhängigkeiten
#023, #032
## Notizen / Entscheidungen
- **Abgebrochene Nachfolger bleiben** mit `vorgaengerId` in der Historie; die Rotationskette folgt dem jüngsten Nachfolger.
- **Kein neuer Rotations-Zustand:** Ist der jüngste Nachfolger vollständig widerrufen und der alte nicht ROTIERT, steht die Rotation wieder bei Schritt 0.
- Prisma kennt partielle Indizes nicht; `migrate diff` bleibt trotzdem leer, weil der Index nur in der Migration steht (wie beim Instanz-Standard).
@@ -0,0 +1,11 @@
-- Nach einer abgebrochenen Rotation darf derselbe Schluessel erneut rotiert werden (#033).
-- Die Kette bleibt eindeutig: je Vorgaenger hoechstens ein Nachfolger, der nicht widerrufen
-- ist oder gerade widerrufen wird.
DROP INDEX "Schluessel_vorgaengerId_key";
CREATE INDEX "Schluessel_vorgaengerId_idx" ON "Schluessel"("vorgaengerId");
CREATE UNIQUE INDEX "schluessel_ein_offener_nachfolger" ON "Schluessel" ("vorgaengerId")
WHERE "vorgaengerId" IS NOT NULL
AND "widerrufEingeleitetAm" IS NULL
AND "status" <> 'WIDERRUFEN';
+8 -3
View File
@@ -318,10 +318,13 @@ model Schluessel {
/// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst /// Widerruf eingeleitet (#024): Verwendungen stehen auf ENTFERNEN, der Status wechselt erst
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren. /// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
widerrufEingeleitetAm DateTime? widerrufEingeleitetAm DateTime?
/// Rotationskette: der Schluessel, den dieser abgeloest hat. /// Rotationskette: der Schluessel, den dieser abgeloest hat. Nach einer abgebrochenen Rotation
vorgaengerId String? @unique /// darf ein weiterer Nachfolger folgen; je Vorgaenger hoechstens ein nicht widerrufener
/// (partieller Index, #033).
vorgaengerId String?
vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull) vorgaenger Schluessel? @relation("Rotation", fields: [vorgaengerId], references: [id], onDelete: SetNull)
nachfolger Schluessel? @relation("Rotation") /// Alle Nachfolger, auch abgebrochene. Massgeblich ist der juengste (`nachfolgerVon`).
nachfolgerListe Schluessel[] @relation("Rotation")
erstelltVonId String? erstelltVonId String?
erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull) erstelltVon Benutzer? @relation("SchluesselErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
/// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service). /// true, wenn eine Person den Key selbst hinterlegt hat (Self-Service).
@@ -334,6 +337,8 @@ model Schluessel {
hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel") hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel")
gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel") gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel")
vorgaenge Vorgang[] vorgaenge Vorgang[]
@@index([vorgaengerId])
} }
/// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe. /// Soll: Public Key in authorized_keys eines Benutzers auf einem Host oder einer Hostgruppe.
+10 -5
View File
@@ -246,9 +246,12 @@ export default async function SchluesselSeite({
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT"; const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger); const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat); const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
// Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter // Nach einem abgeschlossenen Abbruch (#032) darf erneut rotiert werden (#033); solange der
// passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr. // abgebrochene Nachfolger noch entfernt wird, nicht.
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger)); const rotierbar =
darfRotieren &&
(rotationLaeuft ||
(!rotationsSperre && !imWiderruf && (!s.nachfolger || s.nachfolger.status === "WIDERRUFEN")));
// Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht. // Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
const darfAbbrechen = const darfAbbrechen =
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) && Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
@@ -543,8 +546,10 @@ export default async function SchluesselSeite({
<p className="text-muted mt-3 text-[14px]"> <p className="text-muted mt-3 text-[14px]">
Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen
{s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"}; {s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"};
dieser Schlüssel bleibt aktiv. Ein zweiter Nachfolger ist nicht vorgesehen: für eine neue dieser Schlüssel bleibt aktiv
Rotation einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen. {s.nachfolger.status === "WIDERRUFEN"
? " und lässt sich erneut rotieren."
: ". Danach lässt er sich erneut rotieren."}
</p> </p>
)} )}
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && ( {rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
@@ -80,6 +80,10 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
tests: stand.neu ? stand.tests : [], tests: stand.neu ? stand.tests : [],
widerruf, widerruf,
abbruch, abbruch,
letzterAbbruch:
!stand.neu && stand.alt.nachfolger?.status === "WIDERRUFEN"
? { id: stand.alt.nachfolger.id, name: stand.alt.nachfolger.name }
: null,
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"), darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
}} }}
/> />
+19 -16
View File
@@ -53,6 +53,8 @@ export type RotationsDaten = {
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist. * solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
*/ */
abbruch: { offen: OffeneEntfernung[] | null } | null; abbruch: { offen: OffeneEntfernung[] | null } | null;
/** Zuletzt abgebrochener Nachfolger, wenn die Rotation danach wieder am Anfang steht (#033). */
letzterAbbruch: { id: string; name: string } | null;
}; };
export function Rotation({ daten }: { daten: RotationsDaten }) { export function Rotation({ daten }: { daten: RotationsDaten }) {
@@ -115,8 +117,8 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
</> </>
); );
if (daten.abbruch && neu) { if (daten.abbruch?.offen && neu) {
return daten.abbruch.offen ? ( return (
<Karte <Karte
titel="Rotation wird abgebrochen" titel="Rotation wird abgebrochen"
untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`} untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`}
@@ -132,25 +134,26 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
ohneZielkonten ohneZielkonten
/> />
</Karte> </Karte>
) : (
<Karte titel="Rotation abgebrochen">
<p className="text-[14px]">
Der neue Schlüssel{" "}
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
{neu.name}
</Link>{" "}
ist widerrufen und von allen Hosts entfernt.{" "}
<Link href={`/schluessel/${alt.id}`} className="text-accent hover:underline">
{alt.name}
</Link>{" "}
bleibt aktiv.
</p>
</Karte>
); );
} }
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{daten.letzterAbbruch && schritt === 0 && (
<Karte titel="Rotation abgebrochen">
<p className="text-[14px]">
Der neue Schlüssel{" "}
<Link href={`/schluessel/${daten.letzterAbbruch.id}`} className="text-accent hover:underline">
{daten.letzterAbbruch.name}
</Link>{" "}
ist widerrufen und von allen Hosts entfernt.{" "}
<Link href={`/schluessel/${alt.id}`} className="text-accent hover:underline">
{alt.name}
</Link>{" "}
bleibt aktiv und lässt sich erneut rotieren.
</p>
</Karte>
)}
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" /> <Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
{schritt <= 1 && ( {schritt <= 1 && (
+17
View File
@@ -35,3 +35,20 @@ export function rotationsPfad(pfad: string, datumKompakt: string, belegt: Readon
for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`; for (let nummer = 2; belegt.has(kandidat); nummer += 1) kandidat = `${basis}-r${datumKompakt}-${nummer}`;
return kandidat; return kandidat;
} }
/** Laedt nur den juengsten Nachfolger – nach einem Abbruch kann es mehrere geben (#033). */
export const JUENGSTER = { orderBy: { angelegtAm: "desc" }, take: 1 } as const;
/**
* Macht aus `nachfolgerListe` (hoechstens ein Element, siehe JUENGSTER) wieder `nachfolger`.
* Massgeblich ist immer der juengste: ein laufender oder – wenn keiner laeuft – der zuletzt
* abgebrochene.
*/
export type MitNachfolger<T extends { nachfolgerListe: readonly unknown[] }> = Omit<T, "nachfolgerListe"> & {
nachfolger: T["nachfolgerListe"][number] | null;
};
export function mitNachfolger<T extends { nachfolgerListe: readonly unknown[] }>(s: T): MitNachfolger<T> {
const { nachfolgerListe, ...rest } = s;
return { ...rest, nachfolger: nachfolgerListe[0] ?? null };
}
+9 -5
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums"; import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { JUENGSTER, mitNachfolger } from "@/lib/rotation";
/** /**
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen. * Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
@@ -60,7 +61,10 @@ export const SCHLUESSEL_DETAIL_SELECT = {
erstelltVon: { select: { name: true } }, erstelltVon: { select: { name: true } },
vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
// Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032). // Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032).
nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, nachfolgerListe: {
select: { id: true, name: true, status: true, widerrufEingeleitetAm: true },
...JUENGSTER,
},
publicVerwendungen: { publicVerwendungen: {
where: { zustand: { not: "ENTFERNT" } }, where: { zustand: { not: "ENTFERNT" } },
orderBy: { angelegtAm: "asc" }, orderBy: { angelegtAm: "asc" },
@@ -164,7 +168,7 @@ export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>
export async function schluesselDetail(id: string) { export async function schluesselDetail(id: string) {
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT }); const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
if (!schluessel) return null; if (!schluessel) return null;
const { masterKeyVersion, ...rest } = schluessel; const { masterKeyVersion, ...rest } = mitNachfolger(schluessel);
return { ...rest, privatGespeichert: masterKeyVersion !== null }; return { ...rest, privatGespeichert: masterKeyVersion !== null };
} }
@@ -210,7 +214,7 @@ export async function rotationsKette(id: string, maximal = 25): Promise<KettenGl
name: true, name: true,
status: true, status: true,
vorgaengerId: true, vorgaengerId: true,
nachfolger: { select: { id: true } }, nachfolgerListe: { select: { id: true }, ...JUENGSTER },
}, },
}); });
const start = await glied(id); const start = await glied(id);
@@ -224,12 +228,12 @@ export async function rotationsKette(id: string, maximal = 25): Promise<KettenGl
kette.unshift({ id: g.id, name: g.name, status: g.status }); kette.unshift({ id: g.id, name: g.name, status: g.status });
davor = g.vorgaengerId; davor = g.vorgaengerId;
} }
for (let danach = start.nachfolger?.id; danach && kette.length < maximal && !gesehen.has(danach);) { for (let danach = start.nachfolgerListe[0]?.id; danach && kette.length < maximal && !gesehen.has(danach);) {
const g = await glied(danach); const g = await glied(danach);
if (!g) break; if (!g) break;
gesehen.add(g.id); gesehen.add(g.id);
kette.push({ id: g.id, name: g.name, status: g.status }); kette.push({ id: g.id, name: g.name, status: g.status });
danach = g.nachfolger?.id; danach = g.nachfolgerListe[0]?.id;
} }
return kette; return kette;
} }
+23 -10
View File
@@ -1,6 +1,13 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation"; import {
JUENGSTER,
type MitNachfolger,
mitNachfolger,
rotationsDatum,
rotationsName,
rotationsPfad,
} from "@/lib/rotation";
import { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel"; import { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel";
import { eindeutigerSlug } from "@/server/services/schluessel"; import { eindeutigerSlug } from "@/server/services/schluessel";
import { protokollieren } from "@/server/services/protokoll"; import { protokollieren } from "@/server/services/protokoll";
@@ -45,11 +52,15 @@ const ALT_SELECT = {
vorlageId: true, vorlageId: true,
masterKeyVersion: true, masterKeyVersion: true,
widerrufEingeleitetAm: true, widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } }, nachfolgerListe: {
select: { id: true, name: true, status: true, widerrufEingeleitetAm: true },
...JUENGSTER,
},
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } }, _count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
} satisfies Prisma.SchluesselSelect; } satisfies Prisma.SchluesselSelect;
type Alt = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>; type AltRoh = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>;
type Alt = MitNachfolger<AltRoh>;
/** /**
* Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder * Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder
@@ -77,7 +88,7 @@ export function rotationGesperrt(
async function altLaden(id: string): Promise<Alt> { async function altLaden(id: string): Promise<Alt> {
const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT }); const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT });
if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht."); if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht.");
return alt; return mitNachfolger(alt);
} }
function gesperrtPruefen(alt: Alt) { function gesperrtPruefen(alt: Alt) {
@@ -92,14 +103,15 @@ function gesperrtPruefen(alt: Alt) {
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> { export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
const alt = await altLaden(altId); const alt = await altLaden(altId);
gesperrtPruefen(alt); gesperrtPruefen(alt);
if (alt.nachfolger && rotationAbgebrochen(alt.nachfolger)) { // Ein vollstaendig abgebrochener (widerrufener) Nachfolger bleibt in der Historie; danach darf
// vorgaengerId ist eindeutig und bleibt fuer die Historie stehen (#032): ein zweiter // ein neuer folgen (#033). Der partielle Index haelt die Kette trotzdem eindeutig.
// Nachfolger passt nicht in die Kette. if (alt.nachfolger && alt.nachfolger.status !== "WIDERRUFEN") {
throw new RotationsFehler( throw new RotationsFehler(
"Die Rotation dieses Schlüssels wurde abgebrochen; ein zweiter Nachfolger ist nicht vorgesehen. Bitte einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.", rotationAbgebrochen(alt.nachfolger)
? "Der Abbruch der letzten Rotation läuft noch – bitte erst den neuen Schlüssel fertig entfernen (Dry-Run und Anwenden)."
: "Für diesen Schlüssel gibt es schon einen Nachfolger.",
); );
} }
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 name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX);
const slug = await eindeutigerSlug(null, name); const slug = await eindeutigerSlug(null, name);
// Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen. // Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen.
@@ -272,7 +284,8 @@ export type RotationsStand = Awaited<ReturnType<typeof rotationsStand>>;
export async function rotationsStand(altId: string) { export async function rotationsStand(altId: string) {
const alt = await altLaden(altId); const alt = await altLaden(altId);
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen }); const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
const neu = alt.nachfolger; // Nach einem abgeschlossenen Abbruch beginnt die Rotation von vorn (#033).
const neu = alt.nachfolger?.status === "WIDERRUFEN" && alt.status !== "ROTIERT" ? null : alt.nachfolger;
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const, abgebrochen: false }; if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const, abgebrochen: false };
const abgebrochen = rotationAbgebrochen(neu); const abgebrochen = rotationAbgebrochen(neu);
+5 -3
View File
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
import { protokollieren } from "@/server/services/protokoll"; import { protokollieren } from "@/server/services/protokoll";
import { VerwendungsFehler } from "@/server/services/verwendungen"; import { VerwendungsFehler } from "@/server/services/verwendungen";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge"; import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
import { JUENGSTER, type MitNachfolger, mitNachfolger } from "@/lib/rotation";
/** /**
* Widerruf und Massenwiderruf (#024). * Widerruf und Massenwiderruf (#024).
@@ -37,10 +38,11 @@ const SCHLUESSEL_KOPF = {
status: true, status: true,
fingerprint: true, fingerprint: true,
widerrufEingeleitetAm: true, widerrufEingeleitetAm: true,
nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } }, nachfolgerListe: { select: { id: true, status: true, widerrufEingeleitetAm: true }, ...JUENGSTER },
} satisfies Prisma.SchluesselSelect; } satisfies Prisma.SchluesselSelect;
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>; type KopfRoh = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
type SchluesselKopf = MitNachfolger<KopfRoh>;
/** Warum ein Schluessel nicht widerrufen werden kann – oder null. */ /** Warum ein Schluessel nicht widerrufen werden kann – oder null. */
export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | null { export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | null {
@@ -61,7 +63,7 @@ async function schluesselLaden(ids: readonly string[]): Promise<SchluesselKopf[]
orderBy: { name: "asc" }, orderBy: { name: "asc" },
}); });
if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht."); if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht.");
return liste; return liste.map(mitNachfolger);
} }
// ---------------------------------------------------------------- Zielkonten // ---------------------------------------------------------------- Zielkonten
+2 -1
View File
@@ -110,7 +110,8 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
}); });
} catch (fehler) { } catch (fehler) {
if (istEindeutigkeitsFehler(fehler)) { if (istEindeutigkeitsFehler(fehler)) {
// vorgaengerId ist eindeutig: zwei Rotationen desselben Schluessels gleichzeitig (#023). // Je Vorgaenger nur ein offener Nachfolger (partieller Index, #023/#033): zwei Rotationen
// desselben Schluessels gleichzeitig.
if (eingabe.vorgaengerId) throw new VorgangsFehler("Für diesen Schlüssel läuft bereits eine Rotation."); if (eingabe.vorgaengerId) throw new VorgangsFehler("Für diesen Schlüssel läuft bereits eine Rotation.");
throw new VorgangsFehler( throw new VorgangsFehler(
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`, `Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
+1
View File
@@ -26,6 +26,7 @@ const REGELN = [
const INDIZES = [ const INDIZES = [
"schluessel_ein_instanzstandard", "schluessel_ein_instanzstandard",
"schluessel_ein_offener_nachfolger",
"publicverwendung_eindeutig_host", "publicverwendung_eindeutig_host",
"publicverwendung_eindeutig_gruppe", "publicverwendung_eindeutig_gruppe",
"privateverwendung_eindeutig", "privateverwendung_eindeutig",
+7 -3
View File
@@ -105,12 +105,16 @@ test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", as
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).not.toMatch(/-r\d{8}/); expect(aufZiel("ziel-a", "cat /root/.ssh/config")).not.toMatch(/-r\d{8}/);
expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root"); expect(aufZiel("ziel-a", "ssh -o BatchMode=yes zu-b id -un").trim()).toBe("root");
// Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger, kein „Rotieren“ mehr. // Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger – und er laesst sich
// erneut rotieren (#033).
await page.goto(schluesselSeite); await page.goto(schluesselSeite);
const kette = page.getByRole("list", { name: "Rotationskette" }); const kette = page.getByRole("list", { name: "Rotationskette" });
await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible(); await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible();
await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen/)).toBeVisible(); await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen.*erneut rotieren/)).toBeVisible();
await expect(page.getByRole("link", { name: "Rotieren" })).toHaveCount(0); await page.getByRole("link", { name: "Rotieren" }).click();
await expect(
page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }),
).toBeVisible();
await archivieren(page, hostA); await archivieren(page, hostA);
await archivieren(page, hostB); await archivieren(page, hostB);
+2 -1
View File
@@ -174,6 +174,7 @@ describe("Rotation", () => {
...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })), ...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })),
].map((v) => v.zustand); ].map((v) => v.zustand);
expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]); expect(zustaende).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
expect((await rotationsStand(alt.id)).abgebrochen).toBe(true); // Abbruch abgeschlossen: die Rotation steht wieder am Anfang (#033).
expect(await rotationsStand(alt.id)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
}); });
}); });
+36 -1
View File
@@ -97,8 +97,10 @@ async function alterSchluessel() {
async function nachfolgerErzeugen(altId: string): Promise<string> { async function nachfolgerErzeugen(altId: string): Promise<string> {
const vorgangId = await rotationStarten(altId, admin); const vorgangId = await rotationStarten(altId, admin);
await vorgangImWorker(vorgangId); await vorgangImWorker(vorgangId);
// Nach einem Abbruch gibt es mehrere Nachfolger; der juengste ist der gerade erzeugte (#033).
const neu = await prisma.schluessel.findFirstOrThrow({ const neu = await prisma.schluessel.findFirstOrThrow({
where: { vorgaengerId: altId }, where: { vorgaengerId: altId },
orderBy: { angelegtAm: "desc" },
select: { id: true }, select: { id: true },
}); });
return neu.id; return neu.id;
@@ -311,6 +313,9 @@ describe("Rotation abbrechen (#032)", () => {
}); });
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } }); expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } });
// Solange der Abbruch laeuft, keine neue Rotation (#033).
await expect(rotationStarten(altId, admin)).rejects.toThrow(/läuft noch/);
// Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt. // Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
await prisma.publicVerwendung.updateMany({ await prisma.publicVerwendung.updateMany({
where: { schluesselId: neuId }, where: { schluesselId: neuId },
@@ -329,7 +334,37 @@ describe("Rotation abbrechen (#032)", () => {
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } })) (await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
.vorgaengerId, .vorgaengerId,
).toBe(altId); ).toBe(altId);
await expect(rotationStarten(altId, admin)).rejects.toThrow(/abgebrochen/);
// Danach laesst sich derselbe Schluessel erneut rotieren (#033).
expect(await rotationsStand(altId)).toMatchObject({ neu: null, schritt: 0, abgebrochen: false });
const neu2 = await nachfolgerErzeugen(altId);
expect(neu2).not.toBe(neuId);
expect(await rotationsStand(altId)).toMatchObject({ neu: { id: neu2 }, schritt: 1, abgebrochen: false });
expect((await rotationsKette(altId)).map((g) => g.id)).toEqual([altId, neu2]);
await expect(rotationStarten(altId, admin)).rejects.toThrow(/schon einen Nachfolger/);
});
it("erlaubt in der Datenbank nur einen offenen Nachfolger je Schluessel (#033)", async () => {
const altId = await alterSchluessel();
const nachfolger = (n: string, daten: object = {}) =>
prisma.schluessel.create({
data: {
name: n,
slug: n,
typ: "ED25519",
kategorie: "DIENST",
publicKey: zeile("ssh-ed25519", ed25519Blob(), n),
fingerprint: `SHA256:${n}`,
kommentar: n,
vorgaengerId: altId,
...daten,
},
select: { id: true },
});
await nachfolger("abgebrochen", { status: "WIDERRUFEN", widerrufEingeleitetAm: new Date() });
await nachfolger("im-abbruch", { widerrufEingeleitetAm: new Date() });
await nachfolger("offen");
await expect(nachfolger("zweiter-offen")).rejects.toThrow();
}); });
it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => { it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {