feat(rotation): nach Abbruch erneut rotieren (#033)
Bisher war vorgaengerId eindeutig: nach einer abgebrochenen Rotation passte kein zweiter Nachfolger in die Kette, und der Schlüssel ließ sich nur über Neuanlage, Zuweisen und Widerruf ersetzen. Jetzt ist die Relation 1:n. Ein partieller Index erlaubt je Vorgänger nur einen Nachfolger, der weder widerrufen ist noch gerade widerrufen wird. Maßgeblich ist der jüngste. Erst wenn der Abbruch abgeschlossen ist, beginnt die Rotation wieder bei Schritt 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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)
|
||||
- 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)
|
||||
- Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (#033)
|
||||
|
||||
### Geändert
|
||||
- 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
|
||||
(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“.
|
||||
→ Behoben in #033 (partieller Index statt 1:1).
|
||||
- **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
|
||||
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
|
||||
/// auf WIDERRUFEN/ROTIERT, wenn alle Entfernungen erfolgreich waren.
|
||||
widerrufEingeleitetAm DateTime?
|
||||
/// Rotationskette: der Schluessel, den dieser abgeloest hat.
|
||||
vorgaengerId String? @unique
|
||||
/// Rotationskette: der Schluessel, den dieser abgeloest hat. Nach einer abgebrochenen Rotation
|
||||
/// 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)
|
||||
nachfolger Schluessel? @relation("Rotation")
|
||||
/// Alle Nachfolger, auch abgebrochene. Massgeblich ist der juengste (`nachfolgerVon`).
|
||||
nachfolgerListe 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).
|
||||
@@ -334,6 +337,8 @@ model Schluessel {
|
||||
hostsAlsDeployment Host[] @relation("HostDeploymentSchluessel")
|
||||
gruppenAlsDeployment Hostgruppe[] @relation("GruppeDeploymentSchluessel")
|
||||
vorgaenge Vorgang[]
|
||||
|
||||
@@index([vorgaengerId])
|
||||
}
|
||||
|
||||
/// 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 abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
|
||||
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
|
||||
// Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter
|
||||
// passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr.
|
||||
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger));
|
||||
// Nach einem abgeschlossenen Abbruch (#032) darf erneut rotiert werden (#033); solange der
|
||||
// abgebrochene Nachfolger noch entfernt wird, nicht.
|
||||
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.
|
||||
const darfAbbrechen =
|
||||
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
|
||||
@@ -543,8 +546,10 @@ export default async function SchluesselSeite({
|
||||
<p className="text-muted mt-3 text-[14px]">
|
||||
Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen
|
||||
{s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"};
|
||||
dieser Schlüssel bleibt aktiv. Ein zweiter Nachfolger ist nicht vorgesehen: für eine neue
|
||||
Rotation einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.
|
||||
dieser Schlüssel bleibt aktiv
|
||||
{s.nachfolger.status === "WIDERRUFEN"
|
||||
? " und lässt sich erneut rotieren."
|
||||
: ". Danach lässt er sich erneut rotieren."}
|
||||
</p>
|
||||
)}
|
||||
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
|
||||
|
||||
@@ -80,6 +80,10 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
|
||||
tests: stand.neu ? stand.tests : [],
|
||||
widerruf,
|
||||
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"),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -53,6 +53,8 @@ export type RotationsDaten = {
|
||||
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
|
||||
*/
|
||||
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 }) {
|
||||
@@ -115,8 +117,8 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
|
||||
</>
|
||||
);
|
||||
|
||||
if (daten.abbruch && neu) {
|
||||
return daten.abbruch.offen ? (
|
||||
if (daten.abbruch?.offen && neu) {
|
||||
return (
|
||||
<Karte
|
||||
titel="Rotation wird abgebrochen"
|
||||
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
|
||||
/>
|
||||
</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 (
|
||||
<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" />
|
||||
|
||||
{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}`;
|
||||
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 { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { JUENGSTER, mitNachfolger } from "@/lib/rotation";
|
||||
|
||||
/**
|
||||
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
|
||||
@@ -60,7 +61,10 @@ export const SCHLUESSEL_DETAIL_SELECT = {
|
||||
erstelltVon: { select: { name: true } },
|
||||
vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
|
||||
// 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: {
|
||||
where: { zustand: { not: "ENTFERNT" } },
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
@@ -164,7 +168,7 @@ export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>
|
||||
export async function schluesselDetail(id: string) {
|
||||
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
|
||||
if (!schluessel) return null;
|
||||
const { masterKeyVersion, ...rest } = schluessel;
|
||||
const { masterKeyVersion, ...rest } = mitNachfolger(schluessel);
|
||||
return { ...rest, privatGespeichert: masterKeyVersion !== null };
|
||||
}
|
||||
|
||||
@@ -210,7 +214,7 @@ export async function rotationsKette(id: string, maximal = 25): Promise<KettenGl
|
||||
name: true,
|
||||
status: true,
|
||||
vorgaengerId: true,
|
||||
nachfolger: { select: { id: true } },
|
||||
nachfolgerListe: { select: { id: true }, ...JUENGSTER },
|
||||
},
|
||||
});
|
||||
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 });
|
||||
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);
|
||||
if (!g) break;
|
||||
gesehen.add(g.id);
|
||||
kette.push({ id: g.id, name: g.name, status: g.status });
|
||||
danach = g.nachfolger?.id;
|
||||
danach = g.nachfolgerListe[0]?.id;
|
||||
}
|
||||
return kette;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
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 { eindeutigerSlug } from "@/server/services/schluessel";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
@@ -45,11 +52,15 @@ const ALT_SELECT = {
|
||||
vorlageId: true,
|
||||
masterKeyVersion: 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] } } } } },
|
||||
} 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
|
||||
@@ -77,7 +88,7 @@ export function rotationGesperrt(
|
||||
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;
|
||||
return mitNachfolger(alt);
|
||||
}
|
||||
|
||||
function gesperrtPruefen(alt: Alt) {
|
||||
@@ -92,14 +103,15 @@ function gesperrtPruefen(alt: Alt) {
|
||||
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
|
||||
const alt = await altLaden(altId);
|
||||
gesperrtPruefen(alt);
|
||||
if (alt.nachfolger && rotationAbgebrochen(alt.nachfolger)) {
|
||||
// vorgaengerId ist eindeutig und bleibt fuer die Historie stehen (#032): ein zweiter
|
||||
// Nachfolger passt nicht in die Kette.
|
||||
// Ein vollstaendig abgebrochener (widerrufener) Nachfolger bleibt in der Historie; danach darf
|
||||
// ein neuer folgen (#033). Der partielle Index haelt die Kette trotzdem eindeutig.
|
||||
if (alt.nachfolger && alt.nachfolger.status !== "WIDERRUFEN") {
|
||||
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 slug = await eindeutigerSlug(null, name);
|
||||
// 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) {
|
||||
const alt = await altLaden(altId);
|
||||
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 };
|
||||
const abgebrochen = rotationAbgebrochen(neu);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
import { JUENGSTER, type MitNachfolger, mitNachfolger } from "@/lib/rotation";
|
||||
|
||||
/**
|
||||
* Widerruf und Massenwiderruf (#024).
|
||||
@@ -37,10 +38,11 @@ const SCHLUESSEL_KOPF = {
|
||||
status: true,
|
||||
fingerprint: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } },
|
||||
nachfolgerListe: { select: { id: true, status: true, widerrufEingeleitetAm: true }, ...JUENGSTER },
|
||||
} 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. */
|
||||
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" },
|
||||
});
|
||||
if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht.");
|
||||
return liste;
|
||||
return liste.map(mitNachfolger);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zielkonten
|
||||
|
||||
@@ -110,7 +110,8 @@ export const schluesselErzeugenHandler: Handler = async ({ vorgang, geheim }) =>
|
||||
});
|
||||
} catch (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.");
|
||||
throw new VorgangsFehler(
|
||||
`Der Kurzname „${eingabe.slug}“ wurde inzwischen vergeben. Bitte erneut erzeugen.`,
|
||||
|
||||
@@ -26,6 +26,7 @@ const REGELN = [
|
||||
|
||||
const INDIZES = [
|
||||
"schluessel_ein_instanzstandard",
|
||||
"schluessel_ein_offener_nachfolger",
|
||||
"publicverwendung_eindeutig_host",
|
||||
"publicverwendung_eindeutig_gruppe",
|
||||
"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", "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);
|
||||
const kette = page.getByRole("list", { name: "Rotationskette" });
|
||||
await expect(kette.getByText("widerrufen", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen/)).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Rotieren" })).toHaveCount(0);
|
||||
await expect(page.getByText(/Die Rotation zu „.*“ wurde abgebrochen.*erneut rotieren/)).toBeVisible();
|
||||
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, hostB);
|
||||
|
||||
@@ -174,6 +174,7 @@ describe("Rotation", () => {
|
||||
...(await prisma.privateVerwendung.findMany({ where: { schluesselId: alt.id } })),
|
||||
].map((v) => v.zustand);
|
||||
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> {
|
||||
const vorgangId = await rotationStarten(altId, admin);
|
||||
await vorgangImWorker(vorgangId);
|
||||
// Nach einem Abbruch gibt es mehrere Nachfolger; der juengste ist der gerade erzeugte (#033).
|
||||
const neu = await prisma.schluessel.findFirstOrThrow({
|
||||
where: { vorgaengerId: altId },
|
||||
orderBy: { angelegtAm: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
return neu.id;
|
||||
@@ -311,6 +313,9 @@ describe("Rotation abbrechen (#032)", () => {
|
||||
});
|
||||
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.
|
||||
await prisma.publicVerwendung.updateMany({
|
||||
where: { schluesselId: neuId },
|
||||
@@ -329,7 +334,37 @@ describe("Rotation abbrechen (#032)", () => {
|
||||
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
|
||||
.vorgaengerId,
|
||||
).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 () => {
|
||||
|
||||
Reference in New Issue
Block a user