Merge branch 'phase/033-erneut-rotieren'
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user