Compare commits
8
Commits
3e0176dc0c
...
c91e882159
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c91e882159 | ||
|
|
d34f8b6f54 | ||
|
|
844161dc1b | ||
|
|
021e415fde | ||
|
|
f18fd4ec06 | ||
|
|
fb56fd36c5 | ||
|
|
be9837ba50 | ||
|
|
b4804390c4 |
@@ -9,10 +9,15 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
- 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)
|
||||||
|
- „Zum Dry-Run“ in der Zuweisungsmatrix springt direkt zur Dry-Run-Karte (#032)
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
- Protokoll-IDs werden lückenlos vergeben (#032)
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
- Massenwiderruf lehnte im Browser erzeugte Schlüssel ab (IDs mit Bindestrich) (#032)
|
||||||
|
- Hinweis nach verweigerter Bestätigung nannte beim Massenwiderruf den falschen Knopf (#032)
|
||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt (Wandzeit):** 4 h 25 min
|
**Gesamt (Wandzeit):** 5 h 20 min
|
||||||
**Summe der Issue-Zeiten:** 13 h 18 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027 und #028.
|
**Summe der Issue-Zeiten:** 15 h 13 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032.
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -37,7 +37,9 @@
|
|||||||
| #027 Protokollansicht, CSV, Syslog | 0 h 20 min |
|
| #027 Protokollansicht, CSV, Syslog | 0 h 20 min |
|
||||||
| #028 Benachrichtigungen | 0 h 30 min |
|
| #028 Benachrichtigungen | 0 h 30 min |
|
||||||
| #029 Handbuch und Betriebsdokumentation | 0 h 50 min |
|
| #029 Handbuch und Betriebsdokumentation | 0 h 50 min |
|
||||||
| #030 Gitea-Abgleich (vorbereitet) | 0 h 05 min |
|
| #030 Gitea-Abgleich | 0 h 05 min |
|
||||||
|
| #031 Rotation des Deployment-Keys | 1 h 10 min |
|
||||||
|
| #032 Nacharbeiten Phase 4/5 | 0 h 45 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
||||||
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
| v0.4.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||||
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
| v0.5.0 | 0 h 45 min (Wandzeit) | **abgeschlossen**
|
||||||
|
| v1.0.0 | 0 h 55 min (Wandzeit) | in Arbeit – Abnahme steht aus
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -149,3 +152,20 @@
|
|||||||
- **Master-Key nicht in die Sicherung,** nur ein Prüfwert; Worker verweigert den Start mit falschem Master-Key.
|
- **Master-Key nicht in die Sicherung,** nur ein Prüfwert; Worker verweigert den Start mit falschem Master-Key.
|
||||||
- **Benachrichtigungen lesen ihre Anlässe aus der Datenbank** statt sich in den Abgleich einzuhängen; Doppelmeldungen verhindert ein Versandprotokoll mit Fingerabdruck je Anlass.
|
- **Benachrichtigungen lesen ihre Anlässe aus der Datenbank** statt sich in den Abgleich einzuhängen; Doppelmeldungen verhindert ein Versandprotokoll mit Fingerabdruck je Anlass.
|
||||||
- **Keine 1.0.0 ohne Abnahme:** 0.5.0 schließt die fünf Phasen; authentik, Gitea und ein Lauf auf echten Hosts stehen noch aus.
|
- **Keine 1.0.0 ohne Abnahme:** 0.5.0 schließt die fünf Phasen; authentik, Gitea und ein Lauf auf echten Hosts stehen noch aus.
|
||||||
|
|
||||||
|
## 2026-10-12 · Sitzung 6 · 17:12–18:07
|
||||||
|
**Issue:** #030–#032
|
||||||
|
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
|
||||||
|
|
||||||
|
### Erledigt
|
||||||
|
- Remote-Repo in Gitea: `main` und Tags v0.1.0–v0.5.0 gepusht, Issues abgeglichen (Basic-Auth, weil die hinterlegten Zugangsdaten ein Passwort sind)
|
||||||
|
- #031 Geführte Rotation des Deployment-Keys: Service, Worker-Schritte, Seite mit Schrittleiste, Integrationstest gegen beide Testziele, E2E
|
||||||
|
- #032 Nacharbeiten (Sub-Agent): lückenlose Protokoll-IDs, Rotation abbrechen, Anker „Zum Dry-Run“, Massenwiderruf-E2E – der fand, dass der Massenwiderruf UUIDs mit Bindestrich abwies
|
||||||
|
- Stand: 876 Unit-/DB-Tests, 46 Integrationstests, 27 E2E-Abläufe grün
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
- **Der Nachfolger des Deployment-Keys ist ein eigener Schlüssel,** der während der Rotation über `sollFuer()` in Block und Pflichtliste steht – so verteilt ihn der normale Dry-Run, und der Aussperrschutz deckt beide Keys.
|
||||||
|
- **Umstellen erst nach Prüfung** auf allen Hosts; ein Host lässt sich nur bewusst und protokolliert übergehen.
|
||||||
|
- **Rotation abbrechen nutzt den vorhandenen Widerruf** statt eines neuen Zustands (#032).
|
||||||
|
- **Protokoll-ID nur noch aus dem Trigger** unter der Advisory-Sperre – keine Lücken mehr durch verworfene Sequenzwerte.
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,52 @@
|
|||||||
# #032 Nacharbeiten aus Phase 4/5
|
# #032 Nacharbeiten aus Phase 4/5
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v1.0.0
|
**Meilenstein:** v1.0.0
|
||||||
**Labels:** test, chore
|
**Labels:** test, chore
|
||||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
**Geschätzt:** 2 h **Tatsächlich:** 0,75 h
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
|
Kleinere offene Punkte aus den Berichten der Phasen 4 und 5 erledigen.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC)
|
- [x] Massenwiderruf im Browser testen (E2E mit frischer Bestätigung über den Mock-OIDC)
|
||||||
- [ ] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv)
|
- [x] Laufende Rotation abbrechen können (Nachfolger widerrufen, Vorgänger wieder aktiv)
|
||||||
- [ ] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`)
|
- [x] Protokoll-IDs ohne Zweierschritte (Spalten-Default und Trigger ziehen beide `nextval`)
|
||||||
- [ ] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker)
|
- [x] „Zum Dry-Run“ aus der Zuweisungsmatrix direkt zur Dry-Run-Karte springen (Anker)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] Alle Suiten grün
|
- [x] Alle Suiten grün
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#023, #024, #025, #027
|
#023, #024, #025, #027
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
|
- **Massenwiderruf:** Der neue E2E-Test (`widerruf.spec.ts`) fand einen echten Fehler: Die Seite
|
||||||
|
`/schluessel/widerruf` filterte `?id=` mit `/^[a-z0-9]{1,64}$/`. Vom Worker erzeugte Schlüssel tragen
|
||||||
|
eine UUID mit Bindestrichen – jeder erzeugte Schlüssel fiel still heraus („Keine Schlüssel gewählt“).
|
||||||
|
Jetzt `idsAusSuchparameter` in `src/lib/schluessel.ts` (mit Test). Außerdem nannte der Hinweis nach
|
||||||
|
verweigerter Bestätigung den falschen Knopf („Widerruf vorbereiten“ statt „2 Schlüssel widerrufen“).
|
||||||
|
`datenbank()` und `oidcBestaetigen()` liegen jetzt in `tests/e2e/hilfen.ts`.
|
||||||
|
- **Rotation abbrechen:** kein neues Feld. Abbrechen = Widerruf des Nachfolgers über den Ablauf aus #024
|
||||||
|
(Dry-Run, er kann schon ausgerollt sein); ein Nachfolger im Widerruf bzw. widerrufen *ist* die
|
||||||
|
abgebrochene Rotation (`rotationAbgebrochen`). `vorgaengerId` bleibt für die Historie, der Abbruch
|
||||||
|
steht als `schluessel.rotation.abgebrochen` im Protokoll – in derselben Transaktion wie das Vormerken
|
||||||
|
(`widerrufEinleiten` nimmt dafür optional `zusaetzlich` an). Der alte Schlüssel bleibt unverändert; bei
|
||||||
|
gleichem Alias zeigt die config nach dem Anwenden wieder auf die alte Datei (nur verbleibende
|
||||||
|
Zuweisungen landen im Block).
|
||||||
|
- Abbrechen geht nur, solange der alte Schlüssel noch nicht entfernt wird (Schritt 5) – sonst wären am
|
||||||
|
Ende beide halb entfernt. Auf dem Nachfolger ersetzt „Rotation abbrechen“ den normalen
|
||||||
|
„Widerrufen“-Knopf, damit der Abbruch immer im Protokoll steht.
|
||||||
|
- `widerrufAbschliessen` setzt `ROTIERT` nur noch bei einem aktiven Nachfolger, der nicht selbst im
|
||||||
|
Widerruf ist; die Seite schreibt „Ersetzt durch …“ nur bei `ROTIERT`.
|
||||||
|
- **Kein zweiter Nachfolger:** `vorgaengerId` ist eindeutig (1:1-Relation). Nach einem Abbruch lässt sich
|
||||||
|
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“.
|
||||||
|
- **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
|
||||||
|
unverändert (die ID steckt in der Hash-Eingabe), alte Lücken bleiben also stehen. Eine mitgegebene ID
|
||||||
|
überschreibt der Trigger weiterhin.
|
||||||
|
- **Anker:** Die Dry-Run-Karte trägt `id="dry-run"` (`Karte` nimmt eine optionale `id`) und
|
||||||
|
`scroll-mt-20` wegen der festen Kopfleiste der schmalen Ansicht; „Zum Dry-Run“ zeigt auf
|
||||||
|
`/schluessel/<id>#dry-run`.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- #032: Protokoll-IDs ohne Zweierschritte.
|
||||||
|
--
|
||||||
|
-- Bisher zog der Spalten-Default (BIGSERIAL) eine Nummer aus der Sequenz und der Trigger
|
||||||
|
-- `protokoll_verketten` gleich noch eine – jede Zeile verbrauchte zwei IDs. Die ID muss aber
|
||||||
|
-- im Trigger unter der Advisory-Sperre vergeben werden: Eine vorher gezogene Nummer koennte
|
||||||
|
-- kleiner sein als die einer parallelen Transaktion, die zuerst verkettet, und die Kette liesse
|
||||||
|
-- sich in ID-Reihenfolge nicht mehr pruefen. Deshalb verzichtet der Default auf die Sequenz.
|
||||||
|
--
|
||||||
|
-- 0 ist nur ein Platzhalter, damit `prisma.protokoll.create` ohne ID auskommt; der Trigger
|
||||||
|
-- ueberschreibt jede ID (auch eine mitgegebene). Die Sequenz bleibt der Spalte zugeordnet
|
||||||
|
-- (OWNED BY), deshalb findet `pg_get_serial_sequence` sie im Trigger weiterhin.
|
||||||
|
--
|
||||||
|
-- Bestehende Zeilen bleiben, wie sie sind: Die ID ist Teil der Hash-Eingabe, Umnummerieren
|
||||||
|
-- wuerde die Kette brechen. Die alten Luecken bleiben also stehen.
|
||||||
|
ALTER TABLE "Protokoll" ALTER COLUMN "id" SET DEFAULT 0;
|
||||||
@@ -483,7 +483,9 @@ model Vorgang {
|
|||||||
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
|
/// Revisionssicheres Protokoll (#005). Append-only per Trigger; `hash` verkettet jede Zeile
|
||||||
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
|
/// mit ihrer Vorgaengerin und wird in der Datenbank berechnet.
|
||||||
model Protokoll {
|
model Protokoll {
|
||||||
id BigInt @id @default(autoincrement())
|
/// Vergibt der Trigger `protokoll_verketten` unter der Advisory-Sperre aus der Sequenz; der
|
||||||
|
/// Default 0 ist nur ein Platzhalter, damit `create` ohne ID auskommt (#032).
|
||||||
|
id BigInt @id @default(0)
|
||||||
zeitpunkt DateTime @default(now())
|
zeitpunkt DateTime @default(now())
|
||||||
akteurId String?
|
akteurId String?
|
||||||
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
|
/// Name zum Zeitpunkt der Aktion – bleibt lesbar, auch wenn das Konto spaeter umbenannt wird.
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ import { widerrufUebersicht } from "@/server/queries/widerruf";
|
|||||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||||
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
|
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
|
||||||
import { ablaufZustand } from "@/lib/schluessel";
|
import { ablaufZustand } from "@/lib/schluessel";
|
||||||
import { rotationGesperrt } from "@/server/services/rotation";
|
import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen";
|
||||||
|
import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation";
|
||||||
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||||
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||||
@@ -216,9 +217,17 @@ export default async function SchluesselSeite({
|
|||||||
const jetzt = new Date();
|
const jetzt = new Date();
|
||||||
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
|
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
|
||||||
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
|
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
|
||||||
|
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
|
||||||
|
const rotationDesVorgaengers =
|
||||||
|
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
|
||||||
|
? s.vorgaenger
|
||||||
|
: null;
|
||||||
const widerrufbar =
|
const widerrufbar =
|
||||||
!imWiderruf &&
|
!imWiderruf &&
|
||||||
!(s.nachfolger?.status === "AKTIV") &&
|
!(s.nachfolger?.status === "AKTIV") &&
|
||||||
|
// Ein Nachfolger mitten in der Rotation wird ueber „Rotation abbrechen“ widerrufen (#032) –
|
||||||
|
// so steht der Abbruch im Protokoll und die Rotationsseite kennt den Stand.
|
||||||
|
!rotationDesVorgaengers &&
|
||||||
s.kategorie !== "DEPLOYMENT" &&
|
s.kategorie !== "DEPLOYMENT" &&
|
||||||
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
|
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
|
||||||
rechte.public &&
|
rechte.public &&
|
||||||
@@ -235,13 +244,19 @@ export default async function SchluesselSeite({
|
|||||||
privatZuweisungen: aktivePrivat,
|
privatZuweisungen: aktivePrivat,
|
||||||
});
|
});
|
||||||
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 darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
|
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
|
||||||
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf));
|
// Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter
|
||||||
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
|
// passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr.
|
||||||
const rotationDesVorgaengers =
|
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger));
|
||||||
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
|
// Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
|
||||||
? s.vorgaenger
|
const darfAbbrechen =
|
||||||
: null;
|
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
|
||||||
|
!imWiderruf &&
|
||||||
|
s.status === "AKTIV" &&
|
||||||
|
verwaltenRecht &&
|
||||||
|
rechte.public &&
|
||||||
|
(s.privateVerwendungen.length === 0 || rechte.privat);
|
||||||
const vorauswahl = (
|
const vorauswahl = (
|
||||||
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
|
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
|
||||||
)
|
)
|
||||||
@@ -308,7 +323,19 @@ export default async function SchluesselSeite({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{rotationDesVorgaengers && (
|
{rotationDesVorgaengers &&
|
||||||
|
(imWiderruf || s.status === "WIDERRUFEN" ? (
|
||||||
|
<Karte titel="Rotation abgebrochen">
|
||||||
|
<p className="text-muted text-[14px]">
|
||||||
|
Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen.
|
||||||
|
{imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "}
|
||||||
|
<Link href={`/schluessel/${rotationDesVorgaengers.id}`} className="text-accent hover:underline">
|
||||||
|
{rotationDesVorgaengers.name}
|
||||||
|
</Link>{" "}
|
||||||
|
bleibt aktiv.
|
||||||
|
</p>
|
||||||
|
</Karte>
|
||||||
|
) : (
|
||||||
<Karte titel="Rotation läuft" className="border-accent">
|
<Karte titel="Rotation läuft" className="border-accent">
|
||||||
<p className="text-muted text-[14px]">
|
<p className="text-muted text-[14px]">
|
||||||
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
|
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
|
||||||
@@ -320,8 +347,17 @@ export default async function SchluesselSeite({
|
|||||||
Rotation fortsetzen
|
Rotation fortsetzen
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</Karte>
|
{darfAbbrechen && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<RotationAbbrechen
|
||||||
|
altId={rotationDesVorgaengers.id}
|
||||||
|
altName={rotationDesVorgaengers.name}
|
||||||
|
neuName={s.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</Karte>
|
||||||
|
))}
|
||||||
|
|
||||||
{imWiderruf && widerruf && (
|
{imWiderruf && widerruf && (
|
||||||
<Karte
|
<Karte
|
||||||
@@ -336,7 +372,7 @@ export default async function SchluesselSeite({
|
|||||||
vorauswahl={vorauswahl}
|
vorauswahl={vorauswahl}
|
||||||
ruecksprung={`/schluessel/${s.id}`}
|
ruecksprung={`/schluessel/${s.id}`}
|
||||||
darfPrivat={rechte.privat}
|
darfPrivat={rechte.privat}
|
||||||
ohneZielkonten={rotationLaeuft}
|
ohneZielkonten={rotationLaeuft || Boolean(rotationDesVorgaengers)}
|
||||||
/>
|
/>
|
||||||
</Karte>
|
</Karte>
|
||||||
)}
|
)}
|
||||||
@@ -367,7 +403,8 @@ export default async function SchluesselSeite({
|
|||||||
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
|
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
|
||||||
<p className="text-muted text-[14px]">
|
<p className="text-muted text-[14px]">
|
||||||
Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen.
|
Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen.
|
||||||
{s.nachfolger && (
|
{/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */}
|
||||||
|
{s.nachfolger && s.status === "ROTIERT" && (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
Ersetzt durch{" "}
|
Ersetzt durch{" "}
|
||||||
@@ -457,6 +494,10 @@ export default async function SchluesselSeite({
|
|||||||
!imWiderruf &&
|
!imWiderruf &&
|
||||||
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
|
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
|
||||||
<Karte
|
<Karte
|
||||||
|
// Sprungziel fuer „Zum Dry-Run“ aus der Zuweisungsmatrix; scroll-mt haelt die Karte
|
||||||
|
// unter der festen Kopfleiste der schmalen Ansicht (#032).
|
||||||
|
id="dry-run"
|
||||||
|
className="scroll-mt-20"
|
||||||
titel="Dry-Run-Vorschau"
|
titel="Dry-Run-Vorschau"
|
||||||
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
|
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
|
||||||
>
|
>
|
||||||
@@ -498,6 +539,14 @@ export default async function SchluesselSeite({
|
|||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
|
{abgebrocheneRotation && s.nachfolger && (
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
|
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
|
||||||
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
|
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
|
||||||
{rotationsSperre.grund}
|
{rotationsSperre.grund}
|
||||||
|
|||||||
@@ -59,9 +59,14 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
const widerruf =
|
const widerruf =
|
||||||
stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
|
stand.neu && !stand.abgebrochen && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
|
||||||
? await offeneEntfernungen([alt.id])
|
? await offeneEntfernungen([alt.id])
|
||||||
: null;
|
: null;
|
||||||
|
// Abbruch (#032): solange der neue Schluessel nicht widerrufen ist, laeuft sein Widerruf hier weiter.
|
||||||
|
const abbruch =
|
||||||
|
stand.neu && stand.abgebrochen
|
||||||
|
? { offen: stand.neu.status === "WIDERRUFEN" ? null : await offeneEntfernungen([stand.neu.id]) }
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 sm:gap-7">
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
@@ -74,6 +79,7 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
|
|||||||
neuOffen: stand.neu ? stand.neuOffen : 0,
|
neuOffen: stand.neu ? stand.neuOffen : 0,
|
||||||
tests: stand.neu ? stand.tests : [],
|
tests: stand.neu ? stand.tests : [],
|
||||||
widerruf,
|
widerruf,
|
||||||
|
abbruch,
|
||||||
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Leerzustand } from "@/components/ui/Leerzustand";
|
|||||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||||
import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular";
|
import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular";
|
||||||
|
import { idsAusSuchparameter } from "@/lib/schluessel";
|
||||||
import { darf } from "@/server/auth/rechte";
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { widerrufUebersicht } from "@/server/queries/widerruf";
|
import { widerrufUebersicht } from "@/server/queries/widerruf";
|
||||||
@@ -17,14 +18,6 @@ export const metadata: Metadata = { title: "Massenwiderruf" };
|
|||||||
|
|
||||||
type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>;
|
type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>;
|
||||||
|
|
||||||
function liste(wert: string | string[] | undefined): string[] {
|
|
||||||
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
|
|
||||||
return [...new Set(roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9]{1,64}$/i.test(w)))].slice(
|
|
||||||
0,
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem
|
* Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem
|
||||||
* Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische
|
* Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische
|
||||||
@@ -32,7 +25,7 @@ function liste(wert: string | string[] | undefined): string[] {
|
|||||||
*/
|
*/
|
||||||
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
|
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
|
||||||
const parameter = await searchParams;
|
const parameter = await searchParams;
|
||||||
const ids = liste(parameter.id);
|
const ids = idsAusSuchparameter(parameter.id);
|
||||||
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
|
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
|
||||||
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
|
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
|
||||||
|
|
||||||
@@ -61,7 +54,7 @@ export default async function MassenwiderrufSeite({ searchParams }: { searchPara
|
|||||||
const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN";
|
const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN";
|
||||||
const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet);
|
const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet);
|
||||||
const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet);
|
const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet);
|
||||||
const vorauswahl = liste(parameter.zielkonten);
|
const vorauswahl = idsAusSuchparameter(parameter.zielkonten);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 sm:gap-7">
|
<div className="flex flex-col gap-6 sm:gap-7">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Button, Formularfehler, Karte } from "@/components/ui";
|
|||||||
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||||||
import { warten } from "@/components/vorgang/warten";
|
import { warten } from "@/components/vorgang/warten";
|
||||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||||
|
import { RotationAbbrechen } from "./RotationAbbrechen";
|
||||||
import {
|
import {
|
||||||
rotationAltWiderrufenAktion,
|
rotationAltWiderrufenAktion,
|
||||||
rotationStartenAktion,
|
rotationStartenAktion,
|
||||||
@@ -47,6 +48,11 @@ export type RotationsDaten = {
|
|||||||
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
|
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
|
||||||
widerruf: OffeneEntfernung[] | null;
|
widerruf: OffeneEntfernung[] | null;
|
||||||
darfPrivat: boolean;
|
darfPrivat: boolean;
|
||||||
|
/**
|
||||||
|
* Abgebrochene Rotation (#032): `offen` sind die offenen Entfernungen des neuen Schluessels,
|
||||||
|
* solange sein Widerruf laeuft; null, wenn er abgeschlossen ist.
|
||||||
|
*/
|
||||||
|
abbruch: { offen: OffeneEntfernung[] | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Rotation({ daten }: { daten: RotationsDaten }) {
|
export function Rotation({ daten }: { daten: RotationsDaten }) {
|
||||||
@@ -109,6 +115,40 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (daten.abbruch && neu) {
|
||||||
|
return daten.abbruch.offen ? (
|
||||||
|
<Karte
|
||||||
|
titel="Rotation wird abgebrochen"
|
||||||
|
untertitel={`Der neue Schlüssel wird entfernt – „${alt.name}“ bleibt aktiv und unverändert.`}
|
||||||
|
className="border-danger-border"
|
||||||
|
>
|
||||||
|
<WiderrufAblauf
|
||||||
|
schluesselIds={[neu.id]}
|
||||||
|
offen={daten.abbruch.offen}
|
||||||
|
zielkonten={[]}
|
||||||
|
vorauswahl={[]}
|
||||||
|
ruecksprung={`/schluessel/${alt.id}/rotieren`}
|
||||||
|
darfPrivat={daten.darfPrivat}
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
|
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
|
||||||
@@ -225,6 +265,11 @@ export function Rotation({ daten }: { daten: RotationsDaten }) {
|
|||||||
</Karte>
|
</Karte>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Abbrechen geht, solange der alte Schluessel noch nicht entfernt wird (#032). */}
|
||||||
|
{neu && schritt >= 1 && schritt <= 4 && !daten.widerruf && (
|
||||||
|
<RotationAbbrechen altId={alt.id} altName={alt.name} neuName={neu.name} />
|
||||||
|
)}
|
||||||
|
|
||||||
{schritt === 5 && neu && (
|
{schritt === 5 && neu && (
|
||||||
<Karte titel="Rotation abgeschlossen">
|
<Karte titel="Rotation abgeschlossen">
|
||||||
<p className="text-[14px]">
|
<p className="text-[14px]">
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Undo2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Button, Formularfehler } from "@/components/ui";
|
||||||
|
import { rotationAbbrechenAktion } from "@/server/actions/rotation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Rotation abbrechen“ (#032) – auf der Rotationsseite und auf der Seite des Nachfolgers. Der
|
||||||
|
* Klick merkt den neuen Schluessel nur zum Widerruf vor; entfernt wird er erst nach Dry-Run und
|
||||||
|
* „Anwenden“. Deshalb erst eine Rueckfrage, die genau das sagt.
|
||||||
|
*/
|
||||||
|
export function RotationAbbrechen({
|
||||||
|
altId,
|
||||||
|
altName,
|
||||||
|
neuName,
|
||||||
|
}: {
|
||||||
|
altId: string;
|
||||||
|
altName: string;
|
||||||
|
neuName: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [fehler, setFehler] = useState<string>();
|
||||||
|
const [laeuft, starten] = useTransition();
|
||||||
|
|
||||||
|
if (!offen) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Button icon={<Undo2 className="size-4" />} onClick={() => setOffen(true)}>
|
||||||
|
Rotation abbrechen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Rotation abbrechen?"
|
||||||
|
className="border-danger-border bg-bg rounded-box flex flex-col gap-3 border px-4 py-3 text-[14px]"
|
||||||
|
>
|
||||||
|
<p className="font-medium">Rotation abbrechen?</p>
|
||||||
|
<ul className="text-muted list-disc space-y-1 pl-5 text-[13.5px]">
|
||||||
|
<li>
|
||||||
|
Der neue Schlüssel „{neuName}“ wird widerrufen – wo er schon ausgerollt ist, zeigt der Dry-Run, was
|
||||||
|
entfernt wird.
|
||||||
|
</li>
|
||||||
|
<li>„{altName}“ bleibt aktiv und unverändert; ein config-Alias zeigt danach wieder auf ihn.</li>
|
||||||
|
<li>Die Verknüpfung bleibt in der Rotationskette sichtbar, der Abbruch steht im Protokoll.</li>
|
||||||
|
</ul>
|
||||||
|
<Formularfehler text={fehler} />
|
||||||
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
|
<Button disabled={laeuft} onClick={() => setOffen(false)}>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variante="gefahr"
|
||||||
|
icon={<Undo2 className="size-4" />}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() =>
|
||||||
|
starten(async () => {
|
||||||
|
setFehler(undefined);
|
||||||
|
const antwort = await rotationAbbrechenAktion(altId);
|
||||||
|
if (antwort.fehler) {
|
||||||
|
setFehler(antwort.fehler);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOffen(false);
|
||||||
|
router.refresh();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{laeuft ? "Wird vorgemerkt …" : "Neuen Schlüssel widerrufen"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export function Karte({
|
|||||||
className,
|
className,
|
||||||
innen = "normal",
|
innen = "normal",
|
||||||
as: Element = "section",
|
as: Element = "section",
|
||||||
|
id,
|
||||||
}: {
|
}: {
|
||||||
titel?: ReactNode;
|
titel?: ReactNode;
|
||||||
untertitel?: ReactNode;
|
untertitel?: ReactNode;
|
||||||
@@ -22,9 +23,12 @@ export function Karte({
|
|||||||
/** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */
|
/** „ohne“ für Inhalte, die bis an den Rand reichen (z. B. Tabellen mit eigenem Abstand). */
|
||||||
innen?: "normal" | "ohne";
|
innen?: "normal" | "ohne";
|
||||||
as?: "section" | "div" | "article";
|
as?: "section" | "div" | "article";
|
||||||
|
/** Sprungziel für Links von anderen Seiten (z. B. `#dry-run`, #032). */
|
||||||
|
id?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Element
|
<Element
|
||||||
|
id={id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"border-border bg-surface min-w-0 rounded-karte border",
|
"border-border bg-surface min-w-0 rounded-karte border",
|
||||||
innen === "normal" && "p-5 sm:p-6",
|
innen === "normal" && "p-5 sm:p-6",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function WiderrufFormular({
|
|||||||
const [laeuft, starten] = useTransition();
|
const [laeuft, starten] = useTransition();
|
||||||
const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt);
|
const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt);
|
||||||
const mehrere = uebersicht.schluessel.length > 1;
|
const mehrere = uebersicht.schluessel.length > 1;
|
||||||
|
const knopfText = mehrere ? `${uebersicht.schluessel.length} Schlüssel widerrufen` : "Widerruf vorbereiten";
|
||||||
|
|
||||||
const ziel = (() => {
|
const ziel = (() => {
|
||||||
const url = new URL(ruecksprung, "http://x");
|
const url = new URL(ruecksprung, "http://x");
|
||||||
@@ -100,7 +101,8 @@ export function WiderrufFormular({
|
|||||||
href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`}
|
href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`}
|
||||||
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium"
|
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium"
|
||||||
>
|
>
|
||||||
Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen
|
{/* Nennt den Knopf, den es hier wirklich gibt – beim Massenwiderruf heisst er anders (#032). */}
|
||||||
|
Anmeldung bestätigen und danach erneut „{knopfText}“ wählen
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap justify-end gap-2.5">
|
<div className="flex flex-wrap justify-end gap-2.5">
|
||||||
@@ -124,11 +126,7 @@ export function WiderrufFormular({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{laeuft
|
{laeuft ? "Wird vorgemerkt …" : knopfText}
|
||||||
? "Wird vorgemerkt …"
|
|
||||||
: mehrere
|
|
||||||
? `${uebersicht.schluessel.length} Schlüssel widerrufen`
|
|
||||||
: "Widerruf vorbereiten"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export function ZellenDialog({
|
|||||||
zeigt und schreibt die Änderung.
|
zeigt und schreibt die Änderung.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
href={`/schluessel/${schluessel.id}`}
|
href={`/schluessel/${schluessel.id}#dry-run`}
|
||||||
klein
|
klein
|
||||||
className="self-start"
|
className="self-start"
|
||||||
icon={<ArrowRight className="size-4" />}
|
icon={<ArrowRight className="size-4" />}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const AKTIONEN: Readonly<Record<string, Beschreibung>> = {
|
|||||||
"schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"),
|
"schluessel.widerrufen": a("Schlüssel widerrufen", "widerruf"),
|
||||||
"schluessel.rotiert": a("Schlüssel rotiert", "widerruf"),
|
"schluessel.rotiert": a("Schlüssel rotiert", "widerruf"),
|
||||||
"schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"),
|
"schluessel.rotation.uebernommen": a("Rotation: Zuweisungen übernommen", "widerruf"),
|
||||||
|
"schluessel.rotation.abgebrochen": a("Rotation abgebrochen", "widerruf"),
|
||||||
|
|
||||||
// Zielkonten (#018)
|
// Zielkonten (#018)
|
||||||
"zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"),
|
"zielkonto.angelegt": a("Zielkonto angelegt", "zielkonto"),
|
||||||
|
|||||||
@@ -293,3 +293,14 @@ export function publicKeyZeileMitKommentar(zeile: string, kommentar: string): st
|
|||||||
const [typ, base64] = zeile.trim().split(/\s+/);
|
const [typ, base64] = zeile.trim().split(/\s+/);
|
||||||
return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
|
return kommentar ? `${typ} ${base64} ${kommentar}` : `${typ} ${base64}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs aus einem Suchparameter (`?id=a&id=b` oder `?id=a,b`), etwa fuer den Massenwiderruf (#024).
|
||||||
|
* Erlaubt Bindestriche: Vom Worker erzeugte Schluessel tragen eine UUID, nur Prisma-Defaults sind
|
||||||
|
* cuids – ohne Bindestrich fielen alle erzeugten Schluessel still aus der Auswahl (#032).
|
||||||
|
*/
|
||||||
|
export function idsAusSuchparameter(wert: string | string[] | undefined, hoechstens = 200): string[] {
|
||||||
|
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
|
||||||
|
const gueltig = roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9-]{1,64}$/i.test(w));
|
||||||
|
return [...new Set(gueltig)].slice(0, hoechstens);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { akteurVon } from "@/server/actions/formular";
|
|||||||
import { darf } from "@/server/auth/rechte";
|
import { darf } from "@/server/auth/rechte";
|
||||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
import { FachFehler } from "@/server/services/fachfehler";
|
import { FachFehler } from "@/server/services/fachfehler";
|
||||||
import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation";
|
import {
|
||||||
|
rotationAbbrechen,
|
||||||
|
rotationAltWiderrufen,
|
||||||
|
rotationStarten,
|
||||||
|
rotationUebernehmen,
|
||||||
|
} from "@/server/services/rotation";
|
||||||
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,3 +74,22 @@ export async function rotationAltWiderrufenAktion(schluesselId: string): Promise
|
|||||||
return alsAntwort(fehler);
|
return alsAntwort(fehler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laufende Rotation abbrechen (#032): merkt den neuen Schluessel zum Widerruf vor. Gleiche Rechte
|
||||||
|
* wie die Rotation selbst; Private Keys entfernt nur, wer sie ausrollen darf (Service).
|
||||||
|
*/
|
||||||
|
export async function rotationAbbrechenAktion(schluesselId: string): Promise<RotationsAntwort> {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await berechtigt();
|
||||||
|
const altId = id.parse(schluesselId);
|
||||||
|
await rotationAbbrechen(altId, akteurVon(benutzer), {
|
||||||
|
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||||
|
});
|
||||||
|
revalidatePath(`/schluessel/${altId}`);
|
||||||
|
revalidatePath(`/schluessel/${altId}/rotieren`);
|
||||||
|
return {};
|
||||||
|
} catch (fehler) {
|
||||||
|
return alsAntwort(fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ export const SCHLUESSEL_DETAIL_SELECT = {
|
|||||||
person: { select: { id: true, name: true } },
|
person: { select: { id: true, name: true } },
|
||||||
vorlage: { select: { id: true, name: true } },
|
vorlage: { select: { id: true, name: true } },
|
||||||
erstelltVon: { select: { name: true } },
|
erstelltVon: { select: { name: true } },
|
||||||
vorgaenger: { select: { id: true, name: true, status: true } },
|
vorgaenger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
|
||||||
nachfolger: { select: { id: true, name: true, status: true } },
|
// Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032).
|
||||||
|
nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
|
||||||
publicVerwendungen: {
|
publicVerwendungen: {
|
||||||
where: { zustand: { not: "ENTFERNT" } },
|
where: { zustand: { not: "ENTFERNT" } },
|
||||||
orderBy: { angelegtAm: "asc" },
|
orderBy: { angelegtAm: "asc" },
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const ALT_SELECT = {
|
|||||||
vorlageId: true,
|
vorlageId: true,
|
||||||
masterKeyVersion: true,
|
masterKeyVersion: true,
|
||||||
widerrufEingeleitetAm: true,
|
widerrufEingeleitetAm: true,
|
||||||
nachfolger: { select: { id: true, name: true, status: true } },
|
nachfolger: { select: { id: true, name: true, status: true, widerrufEingeleitetAm: true } },
|
||||||
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
|
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
|
||||||
} satisfies Prisma.SchluesselSelect;
|
} satisfies Prisma.SchluesselSelect;
|
||||||
|
|
||||||
@@ -92,6 +92,13 @@ 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)) {
|
||||||
|
// vorgaengerId ist eindeutig und bleibt fuer die Historie stehen (#032): ein zweiter
|
||||||
|
// Nachfolger passt nicht in die Kette.
|
||||||
|
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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
if (alt.nachfolger) throw new RotationsFehler("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);
|
||||||
@@ -111,8 +118,19 @@ export async function rotationStarten(altId: string, akteur: Akteur): Promise<st
|
|||||||
return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe });
|
return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Nachfolger im Widerruf (oder widerrufen) heisst: die Rotation wurde abgebrochen (#032).
|
||||||
|
* Ein eigenes Feld braucht es dafuer nicht – der Widerruf des Nachfolgers ist der Abbruch.
|
||||||
|
*/
|
||||||
|
export function rotationAbgebrochen(
|
||||||
|
neu: { readonly status: string; readonly widerrufEingeleitetAm: Date | null } | null,
|
||||||
|
): boolean {
|
||||||
|
return neu !== null && (neu.status === "WIDERRUFEN" || neu.widerrufEingeleitetAm !== null);
|
||||||
|
}
|
||||||
|
|
||||||
async function nachfolgerVon(alt: Alt) {
|
async function nachfolgerVon(alt: Alt) {
|
||||||
if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
||||||
|
if (rotationAbgebrochen(alt.nachfolger)) throw new RotationsFehler("Die Rotation wurde abgebrochen.");
|
||||||
if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv.");
|
if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv.");
|
||||||
return alt.nachfolger;
|
return alt.nachfolger;
|
||||||
}
|
}
|
||||||
@@ -255,7 +273,8 @@ 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;
|
const neu = alt.nachfolger;
|
||||||
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const };
|
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const, abgebrochen: false };
|
||||||
|
const abgebrochen = rotationAbgebrochen(neu);
|
||||||
|
|
||||||
const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([
|
const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([
|
||||||
prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
|
prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
|
||||||
@@ -287,7 +306,7 @@ export async function rotationsStand(altId: string) {
|
|||||||
: !testsGruen
|
: !testsGruen
|
||||||
? (3 as const)
|
? (3 as const)
|
||||||
: (4 as const);
|
: (4 as const);
|
||||||
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen };
|
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen, abgebrochen };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -302,6 +321,7 @@ export async function rotationAltWiderrufen(
|
|||||||
const stand = await rotationsStand(altId);
|
const stand = await rotationsStand(altId);
|
||||||
if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
||||||
if (stand.schritt === 5) return;
|
if (stand.schritt === 5) return;
|
||||||
|
if (stand.abgebrochen) throw new RotationsFehler("Die Rotation wurde abgebrochen.");
|
||||||
if (stand.schritt < 4) {
|
if (stand.schritt < 4) {
|
||||||
throw new RotationsFehler(
|
throw new RotationsFehler(
|
||||||
stand.schritt === 3
|
stand.schritt === 3
|
||||||
@@ -311,3 +331,46 @@ export async function rotationAltWiderrufen(
|
|||||||
}
|
}
|
||||||
await widerrufEinleiten([altId], akteur, optionen);
|
await widerrufEinleiten([altId], akteur, optionen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laufende Rotation abbrechen (#032): der neue Schluessel wird ueber den normalen Widerruf (#024)
|
||||||
|
* entfernt – mit Dry-Run, denn er kann schon ausgerollt sein. Der alte bleibt aktiv und
|
||||||
|
* unveraendert; wo beide denselben Alias tragen, zeigt die config nach dem Anwenden wieder auf
|
||||||
|
* die alte Datei (juengste *verbleibende* Zuweisung gewinnt, src/worker/ablaeufe/privat.ts).
|
||||||
|
*
|
||||||
|
* `vorgaengerId` des neuen Schluessels bleibt fuer die Historie stehen; dass abgebrochen wurde,
|
||||||
|
* erkennt man am Widerruf des Nachfolgers und am Protokolleintrag. Ist der alte Schluessel schon
|
||||||
|
* im Widerruf (Schritt 5), gibt es kein Zurueck mehr – sonst waeren am Ende beide halb entfernt.
|
||||||
|
*/
|
||||||
|
export async function rotationAbbrechen(
|
||||||
|
altId: string,
|
||||||
|
akteur: Akteur,
|
||||||
|
optionen: { readonly privatErlaubt: boolean },
|
||||||
|
): Promise<void> {
|
||||||
|
const alt = await altLaden(altId);
|
||||||
|
const neu = alt.nachfolger;
|
||||||
|
if (!neu) throw new RotationsFehler("Für diesen Schlüssel läuft keine Rotation.");
|
||||||
|
if (alt.status === "ROTIERT") throw new RotationsFehler("Die Rotation ist bereits abgeschlossen.");
|
||||||
|
// Idempotent: ein zweiter Klick (oder ein Neuladen) setzt den laufenden Abbruch nur fort.
|
||||||
|
if (rotationAbgebrochen(neu)) return;
|
||||||
|
if (alt.widerrufEingeleitetAm) {
|
||||||
|
throw new RotationsFehler(
|
||||||
|
"Der alte Schlüssel wird bereits entfernt – die Rotation lässt sich nicht mehr abbrechen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await widerrufEinleiten([neu.id], akteur, {
|
||||||
|
privatErlaubt: optionen.privatErlaubt,
|
||||||
|
zusaetzlich: async (tx) => {
|
||||||
|
await protokollieren(
|
||||||
|
{
|
||||||
|
akteur,
|
||||||
|
aktion: "schluessel.rotation.abgebrochen",
|
||||||
|
ergebnis: "ERFOLG",
|
||||||
|
fingerprint: alt.fingerprint,
|
||||||
|
details: { name: alt.name, nachfolgerId: neu.id, nachfolger: neu.name },
|
||||||
|
},
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const SCHLUESSEL_KOPF = {
|
|||||||
status: true,
|
status: true,
|
||||||
fingerprint: true,
|
fingerprint: true,
|
||||||
widerrufEingeleitetAm: true,
|
widerrufEingeleitetAm: true,
|
||||||
nachfolger: { select: { id: true, status: true } },
|
nachfolger: { select: { id: true, status: true, widerrufEingeleitetAm: true } },
|
||||||
} satisfies Prisma.SchluesselSelect;
|
} satisfies Prisma.SchluesselSelect;
|
||||||
|
|
||||||
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
|
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
|
||||||
@@ -167,6 +167,11 @@ export async function zielkontoKandidaten(schluesselIds: readonly string[]): Pro
|
|||||||
export type EinleitenOptionen = {
|
export type EinleitenOptionen = {
|
||||||
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
|
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
|
||||||
readonly privatErlaubt: boolean;
|
readonly privatErlaubt: boolean;
|
||||||
|
/**
|
||||||
|
* Laeuft in derselben Transaktion wie das Vormerken – etwa der Protokolleintrag eines
|
||||||
|
* Rotationsabbruchs (#032), damit es keinen Abbruch ohne Eintrag gibt.
|
||||||
|
*/
|
||||||
|
readonly zusaetzlich?: (tx: Prisma.TransactionClient) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -264,6 +269,7 @@ export async function widerrufEinleiten(
|
|||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await optionen.zusaetzlich?.(tx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +412,9 @@ export async function widerrufAbschliessen(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
|
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
|
||||||
const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN";
|
// Ein Nachfolger im Widerruf ist eine abgebrochene Rotation (#032) – ersetzt ist dann nichts.
|
||||||
|
const status =
|
||||||
|
s.nachfolger?.status === "AKTIV" && !s.nachfolger.widerrufEingeleitetAm ? "ROTIERT" : "WIDERRUFEN";
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
const geaendert = await tx.schluessel.updateMany({
|
const geaendert = await tx.schluessel.updateMany({
|
||||||
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
|
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
|
||||||
|
|||||||
@@ -236,8 +236,30 @@ describe("Protokoll", () => {
|
|||||||
expect(pruefung?.ergebnis).toBe(zweiter.id);
|
expect(pruefung?.ergebnis).toBe(zweiter.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("vergibt IDs ohne Zweierschritte (#032)", async () => {
|
||||||
|
// Frueher zogen Spalten-Default und Trigger je eine Nummer aus der Sequenz.
|
||||||
|
const [a, b, c] = [await eintragen("eins"), await eintragen("zwei"), await eintragen("drei")];
|
||||||
|
expect(b.id).toBe(a.id + 1n);
|
||||||
|
expect(c.id).toBe(a.id + 2n);
|
||||||
|
const [pruefung] = await prisma.$queryRaw<
|
||||||
|
{ ergebnis: bigint | null }[]
|
||||||
|
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
||||||
|
expect(pruefung?.ergebnis).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ueberschreibt eine mitgegebene ID unter der Sperre (#032)", async () => {
|
||||||
|
const erster = await eintragen("eins");
|
||||||
|
const zweiter = await prisma.protokoll.create({
|
||||||
|
data: { id: 999n, akteurName: "Test", aktion: "zwei", ergebnis: "ERFOLG" },
|
||||||
|
});
|
||||||
|
expect(zweiter.id).toBe(erster.id + 1n);
|
||||||
|
expect(zweiter.vorgaengerHash).toBe(erster.hash);
|
||||||
|
});
|
||||||
|
|
||||||
it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => {
|
it("bleibt bei gleichzeitigem Schreiben eine lueckenlose Kette", async () => {
|
||||||
await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
|
const zeilen = await Promise.all(Array.from({ length: 20 }, (_, i) => eintragen(`parallel-${i}`)));
|
||||||
|
const ids = zeilen.map((z) => z.id).sort((x, y) => (x < y ? -1 : 1));
|
||||||
|
expect(ids.at(-1)! - ids[0]!).toBe(19n);
|
||||||
const [pruefung] = await prisma.$queryRaw<
|
const [pruefung] = await prisma.$queryRaw<
|
||||||
{ ergebnis: bigint | null }[]
|
{ ergebnis: bigint | null }[]
|
||||||
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
import pg from "pg";
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +36,30 @@ export async function anmelden(page: Page): Promise<void> {
|
|||||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Direkter Zugriff auf die Datenbank des gebauten Stands – fuer Vorbedingungen und Pruefungen. */
|
||||||
|
export async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
|
||||||
|
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
return await arbeit(client);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
|
||||||
|
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
|
||||||
|
*/
|
||||||
|
export async function oidcBestaetigen(page: Page): Promise<void> {
|
||||||
|
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
|
||||||
|
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
||||||
|
await page.locator('button[data-rolle="admin"]').click();
|
||||||
|
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
||||||
|
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
||||||
|
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
export async function hostEinrichten(page: Page, name: string, port: string): Promise<void> {
|
export async function hostEinrichten(page: Page, name: string, port: string): Promise<void> {
|
||||||
await page.goto("/hosts/neu");
|
await page.goto("/hosts/neu");
|
||||||
await page.getByLabel("Name").fill(name);
|
await page.getByLabel("Name").fill(name);
|
||||||
|
|||||||
@@ -69,3 +69,49 @@ test("Rotation mit Schrittanzeige", async ({ page }) => {
|
|||||||
await archivieren(page, hostA);
|
await archivieren(page, hostA);
|
||||||
await archivieren(page, hostB);
|
await archivieren(page, hostB);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Rotation abbrechen, nachdem der neue Schlüssel ausgerollt ist (#032)", async ({ page }) => {
|
||||||
|
const kennung = Date.now().toString(36);
|
||||||
|
const hostA = `zz-e2e-a-${kennung}`;
|
||||||
|
const hostB = `zz-e2e-b-${kennung}`;
|
||||||
|
const zweck = `zz-e2e Abbruch ${kennung}`;
|
||||||
|
|
||||||
|
zieleZuruecksetzen();
|
||||||
|
await anmelden(page);
|
||||||
|
await hostEinrichten(page, hostA, PORT.a);
|
||||||
|
await hostEinrichten(page, hostB, PORT.b);
|
||||||
|
const schluesselSeite = await kernAufbauen(page, zweck, hostA, hostB);
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "Rotieren" }).click();
|
||||||
|
await page.getByRole("button", { name: "Neuen Schlüssel erzeugen und Zuweisungen übernehmen" }).click();
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click({ timeout: 60_000 });
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||||
|
await expect(page.getByRole("button", { name: "Verbindungen testen" })).toBeVisible({ timeout: 60_000 });
|
||||||
|
expect(aufZiel("ziel-a", "cat /root/.ssh/config")).toMatch(/IdentityFile ~\/\.ssh\/.*-r\d{8}$/m);
|
||||||
|
|
||||||
|
// Abbrechen: Rueckfrage, dann Widerruf des neuen Schluessels mit Dry-Run.
|
||||||
|
await page.getByRole("button", { name: "Rotation abbrechen" }).click();
|
||||||
|
const rueckfrage = page.getByRole("group", { name: "Rotation abbrechen?" });
|
||||||
|
await expect(rueckfrage.getByText(`„${zweck}“ bleibt aktiv und unverändert`)).toBeVisible();
|
||||||
|
await rueckfrage.getByRole("button", { name: "Neuen Schlüssel widerrufen" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Rotation wird abgebrochen" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||||
|
await expect(page.getByRole("heading", { name: "Rotation abgebrochen" })).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
|
// Auf den Hosts: wie vorher. Der Alias geht wieder mit dem alten Schluessel.
|
||||||
|
expect(aufZiel("ziel-a", "ls /root/.ssh")).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");
|
||||||
|
|
||||||
|
// Der alte bleibt aktiv; die Kette zeigt den widerrufenen Nachfolger, kein „Rotieren“ mehr.
|
||||||
|
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 archivieren(page, hostA);
|
||||||
|
await archivieren(page, hostB);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import pg from "pg";
|
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
|
import { schluesselpaarErzeugen } from "../../src/lib/ssh/openssh";
|
||||||
|
import { datenbank, oidcBestaetigen } from "./hilfen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-…
|
* Schluessel im gebauten Stand mit laufendem Worker (#012–#014). Testdaten heissen zz-e2e-…
|
||||||
@@ -17,16 +17,6 @@ async function oidcAnmelden(page: Page, rolle: "admin" | "operator" | "reader")
|
|||||||
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Übersicht" })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function datenbank<T>(arbeit: (client: pg.Client) => Promise<T>): Promise<T> {
|
|
||||||
const client = new pg.Client({ connectionString: process.env["DATABASE_URL"] });
|
|
||||||
await client.connect();
|
|
||||||
try {
|
|
||||||
return await arbeit(client);
|
|
||||||
} finally {
|
|
||||||
await client.end();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function eindeutig(): string {
|
function eindeutig(): string {
|
||||||
return `zz-e2e-${Date.now().toString(36)}`;
|
return `zz-e2e-${Date.now().toString(36)}`;
|
||||||
}
|
}
|
||||||
@@ -104,19 +94,6 @@ test("Leser hinterlegt einen eigenen Public Key und entfernt ihn wieder", async
|
|||||||
await expect(page.locator("li", { hasText: name })).toHaveCount(0);
|
await expect(page.locator("li", { hasText: name })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Erneute Anmeldung beim Mock-OIDC (#014). Der Mock uebernimmt nur die Claims aus dem
|
|
||||||
* Formular; `auth_time` muss deshalb mitgeschickt werden, sonst lehnt sshkv die Bestaetigung ab.
|
|
||||||
*/
|
|
||||||
async function oidcBestaetigen(page: Page) {
|
|
||||||
await expect(page).toHaveURL(/\/bestaetigen\?ziel=/);
|
|
||||||
await page.getByRole("link", { name: /erneut anmelden/ }).click();
|
|
||||||
await page.locator('button[data-rolle="admin"]').click();
|
|
||||||
const claims = JSON.parse(await page.locator("#claims").inputValue()) as Record<string, unknown>;
|
|
||||||
await page.locator("#claims").fill(JSON.stringify({ ...claims, auth_time: Math.floor(Date.now() / 1000) }));
|
|
||||||
await page.getByRole("button", { name: "Anmelden", exact: true }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({
|
test("Admin zeigt den Private Key nach Bestätigung an, lädt ihn herunter und löscht ihn", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { expect, test, type Page } from "@playwright/test";
|
||||||
import {
|
import {
|
||||||
anmelden,
|
anmelden,
|
||||||
archivieren,
|
archivieren,
|
||||||
aufZiel,
|
aufZiel,
|
||||||
|
datenbank,
|
||||||
hostEinrichten,
|
hostEinrichten,
|
||||||
kernAufbauen,
|
kernAufbauen,
|
||||||
|
oidcBestaetigen,
|
||||||
PORT,
|
PORT,
|
||||||
zieleZuruecksetzen,
|
zieleZuruecksetzen,
|
||||||
} from "./hilfen";
|
} from "./hilfen";
|
||||||
@@ -59,3 +61,90 @@ test("Widerruf mit einem Klick", async ({ page }) => {
|
|||||||
await archivieren(page, hostA);
|
await archivieren(page, hostA);
|
||||||
await archivieren(page, hostB);
|
await archivieren(page, hostB);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Schluessel erzeugen, Public Key auf `host` zuweisen und ausrollen. Liefert den Key-Teil. */
|
||||||
|
async function publicKeyAusrollen(page: Page, zweck: string, host: string): Promise<string> {
|
||||||
|
await page.goto("/schluessel/neu");
|
||||||
|
await page.getByLabel("Zweck").fill(zweck);
|
||||||
|
await page.getByRole("button", { name: "Schlüssel erzeugen" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: zweck })).toBeVisible({ timeout: 30_000 });
|
||||||
|
const zeile = (await page.getByLabel("Public Key", { exact: true }).textContent()) ?? "";
|
||||||
|
const keyTeil = zeile.trim().split(/\s+/)[1] ?? "";
|
||||||
|
expect(keyTeil).toMatch(/^AAAA/);
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Ausrollen" }).nth(1).click();
|
||||||
|
const oeffentlich = page.getByRole("dialog", { name: "Public Key zuweisen" });
|
||||||
|
await oeffentlich.getByLabel("Ziel", { exact: true }).selectOption({ label: host });
|
||||||
|
await oeffentlich.getByRole("button", { name: "Zuweisen" }).click();
|
||||||
|
await expect(oeffentlich).toBeHidden();
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click({ timeout: 30_000 });
|
||||||
|
await expect(page.getByText("Ausgerollt.")).toBeVisible({ timeout: 60_000 });
|
||||||
|
return keyTeil;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Massenwiderruf mit frischer Bestätigung (#032)", async ({ page }) => {
|
||||||
|
const kennung = Date.now().toString(36);
|
||||||
|
const hostB = `zz-e2e-mb-${kennung}`;
|
||||||
|
const praefix = `zz-e2e Massen ${kennung}`;
|
||||||
|
const zwecke = [`${praefix} eins`, `${praefix} zwei`];
|
||||||
|
|
||||||
|
await anmelden(page);
|
||||||
|
// Eine Bestaetigung aus der Anmeldung eben oder einem frueheren Lauf soll nicht mitzaehlen.
|
||||||
|
await datenbank((client) =>
|
||||||
|
client.query(`UPDATE "Benutzer" SET "bestaetigtAm" = NULL WHERE "email" = 'admin@sshkv.test'`),
|
||||||
|
);
|
||||||
|
await hostEinrichten(page, hostB, PORT.b);
|
||||||
|
const keys = [];
|
||||||
|
for (const zweck of zwecke) keys.push(await publicKeyAusrollen(page, zweck, hostB));
|
||||||
|
const vorher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys");
|
||||||
|
for (const key of keys) expect(vorher).toContain(key);
|
||||||
|
|
||||||
|
// Auswahl in der Liste – ein GET-Formular auf die Massenwiderruf-Seite.
|
||||||
|
await page.goto(`/schluessel?q=${encodeURIComponent(praefix)}`);
|
||||||
|
for (const zweck of zwecke)
|
||||||
|
await page.getByRole("checkbox", { name: `${zweck} für den Massenwiderruf auswählen` }).check();
|
||||||
|
await page.getByRole("button", { name: "Ausgewählte widerrufen" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/);
|
||||||
|
await expect(page.getByRole("heading", { name: "2 Schlüssel widerrufen?" })).toBeVisible();
|
||||||
|
for (const zweck of zwecke)
|
||||||
|
await expect(
|
||||||
|
page.getByRole("region", { name: `Widerruf von ${zweck}` }).getByText(`root@${hostB}`),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Ohne frische Bestaetigung merkt sshkv nichts vor und schickt zur erneuten Anmeldung.
|
||||||
|
await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click();
|
||||||
|
await page.getByRole("link", { name: /Anmeldung bestätigen/ }).click();
|
||||||
|
await oidcBestaetigen(page);
|
||||||
|
await expect(page).toHaveURL(/\/schluessel\/widerruf\?id=/);
|
||||||
|
const eingeleitet = await datenbank((client) =>
|
||||||
|
client.query(
|
||||||
|
`SELECT count(*)::int AS n FROM "Schluessel" WHERE "name" = ANY($1) AND "widerrufEingeleitetAm" IS NOT NULL`,
|
||||||
|
[zwecke],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(eingeleitet.rows[0]).toEqual({ n: 0 });
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "2 Schlüssel widerrufen" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeVisible();
|
||||||
|
|
||||||
|
// Ein gemeinsamer Dry-Run fuer beide Schluessel, dann anwenden; der Abschluss laeuft danach.
|
||||||
|
await page.getByRole("button", { name: "Dry-Run starten" }).click();
|
||||||
|
await expect(page.getByText(`root@${hostB}:/root/.ssh/authorized_keys`)).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole("button", { name: /Änderung anwenden/ }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "Widerruf läuft" })).toBeHidden({ timeout: 60_000 });
|
||||||
|
const gewaehlt = page.locator("section", {
|
||||||
|
has: page.getByRole("heading", { name: "Gewählte Schlüssel" }),
|
||||||
|
});
|
||||||
|
await expect(gewaehlt.getByText("widerrufen", { exact: true })).toHaveCount(2);
|
||||||
|
await expect(gewaehlt.getByText("Widerruf unvollständig")).toHaveCount(0);
|
||||||
|
|
||||||
|
const nachher = aufZiel("ziel-b", "cat /root/.ssh/authorized_keys");
|
||||||
|
for (const key of keys) expect(nachher).not.toContain(key);
|
||||||
|
const status = await datenbank((client) =>
|
||||||
|
client.query(`SELECT "status" FROM "Schluessel" WHERE "name" = ANY($1)`, [zwecke]),
|
||||||
|
);
|
||||||
|
expect(status.rows).toEqual([{ status: "WIDERRUFEN" }, { status: "WIDERRUFEN" }]);
|
||||||
|
|
||||||
|
await archivieren(page, hostB);
|
||||||
|
});
|
||||||
|
|||||||
@@ -128,6 +128,14 @@ test("Matrix zeigt Gruppen-Zuweisung, filtert, weist zu und exportiert", async (
|
|||||||
`"${name} Backup";"SHA256:${name}";"${name}-h2";"root";"Public Key";"${name}-g";"restrict";"noch nicht ausgerollt"`,
|
`"${name} Backup";"SHA256:${name}";"${name}-h2";"root";"Public Key";"${name}-g";"restrict";"noch nicht ausgerollt"`,
|
||||||
);
|
);
|
||||||
expect(text).toContain(`"${name}-h3";"deploy";"Public Key";""`);
|
expect(text).toContain(`"${name}-h3";"deploy";"Public Key";""`);
|
||||||
|
|
||||||
|
// „Zum Dry-Run“ springt direkt zur Dry-Run-Karte der Schluesselseite (#032).
|
||||||
|
await matrix.getByRole("button", { name: new RegExp(`auf ${name}-h3: Public Key direkt`) }).click();
|
||||||
|
await dialog.getByRole("link", { name: "Zum Dry-Run" }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/schluessel/${ids.schluessel}#dry-run$`));
|
||||||
|
const karte = page.locator("#dry-run");
|
||||||
|
await expect(karte).toContainText("Dry-Run-Vorschau");
|
||||||
|
await expect(karte).toBeInViewport();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Leser sieht die Matrix, aber keinen Zuweisen-Knopf", async ({ page }) => {
|
test("Leser sieht die Matrix, aber keinen Zuweisen-Knopf", async ({ page }) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { rotationsDatum } from "@/lib/rotation";
|
import { rotationsDatum } from "@/lib/rotation";
|
||||||
import {
|
import {
|
||||||
|
rotationAbbrechen,
|
||||||
rotationAltWiderrufen,
|
rotationAltWiderrufen,
|
||||||
rotationStarten,
|
rotationStarten,
|
||||||
rotationUebernehmen,
|
rotationUebernehmen,
|
||||||
@@ -55,10 +56,8 @@ async function trockenlaufUndAusrollen(schluesselId: string) {
|
|||||||
expect(ausrollen.fehler).toBeNull();
|
expect(ausrollen.fehler).toBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Rotation", () => {
|
/** Dienst-Key wie nach dem Assistenten: Private Key auf A (Alias zu-b), Public Key auf B, ausgerollt. */
|
||||||
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
|
async function alterSchluesselAusgerollt(aId: string, bId: string) {
|
||||||
const a = await eingerichteterHost(zielA, "ziel-a");
|
|
||||||
const b = await eingerichteterHost(zielB, "ziel-b");
|
|
||||||
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
|
const paar = await schluesselpaarVersiegeltErzeugen("ED25519", "sshkv:backup");
|
||||||
const alt = await prisma.schluessel.create({
|
const alt = await prisma.schluessel.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -77,16 +76,24 @@ describe("Rotation", () => {
|
|||||||
await privateVerwendungAnlegen(
|
await privateVerwendungAnlegen(
|
||||||
{
|
{
|
||||||
schluesselId: alt.id,
|
schluesselId: alt.id,
|
||||||
hostId: a.id,
|
hostId: aId,
|
||||||
zielbenutzer: "root",
|
zielbenutzer: "root",
|
||||||
pfad: "~/.ssh/id_backup",
|
pfad: "~/.ssh/id_backup",
|
||||||
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
|
sshConfig: { alias: "zu-b", hostName: "ziel-b", benutzer: "root", port: 22 },
|
||||||
knownHostsFuer: [b.id],
|
knownHostsFuer: [bId],
|
||||||
},
|
},
|
||||||
AKTEUR,
|
AKTEUR,
|
||||||
);
|
);
|
||||||
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: b.id, zielbenutzer: "root" }, AKTEUR);
|
await publicVerwendungAnlegen({ schluesselId: alt.id, hostId: bId, zielbenutzer: "root" }, AKTEUR);
|
||||||
await trockenlaufUndAusrollen(alt.id);
|
await trockenlaufUndAusrollen(alt.id);
|
||||||
|
return alt;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Rotation", () => {
|
||||||
|
it("tauscht den Schluessel aus, ohne dass der Alias aufhoert zu funktionieren", async () => {
|
||||||
|
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||||
|
const b = await eingerichteterHost(zielB, "ziel-b");
|
||||||
|
const alt = await alterSchluesselAusgerollt(a.id, b.id);
|
||||||
|
|
||||||
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
|
// 1–2: Nachfolger erzeugen, Zuweisungen uebernehmen.
|
||||||
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
|
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
|
||||||
@@ -123,4 +130,50 @@ describe("Rotation", () => {
|
|||||||
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
|
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
|
||||||
expect(ssh.stdout.trim()).toBe("root");
|
expect(ssh.stdout.trim()).toBe("root");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("bricht ab: der ausgerollte neue Schluessel verschwindet, der alte arbeitet weiter (#032)", async () => {
|
||||||
|
const a = await eingerichteterHost(zielA, "ziel-a");
|
||||||
|
const b = await eingerichteterHost(zielB, "ziel-b");
|
||||||
|
const alt = await alterSchluesselAusgerollt(a.id, b.id);
|
||||||
|
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } });
|
||||||
|
|
||||||
|
await vorgangImWorker(await rotationStarten(alt.id, AKTEUR));
|
||||||
|
await rotationUebernehmen(alt.id, AKTEUR, { privatErlaubt: true });
|
||||||
|
const neu = await prisma.schluessel.findFirstOrThrow({
|
||||||
|
where: { vorgaengerId: alt.id },
|
||||||
|
select: { id: true, publicKey: true },
|
||||||
|
});
|
||||||
|
await trockenlaufUndAusrollen(neu.id);
|
||||||
|
const neuerPfad = `id_backup-r${rotationsDatum().kompakt}`;
|
||||||
|
expect((await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout).toContain(
|
||||||
|
`IdentityFile ~/.ssh/${neuerPfad}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Abbrechen = Widerruf des neuen Schluessels mit Dry-Run und Anwenden.
|
||||||
|
await rotationAbbrechen(alt.id, AKTEUR, { privatErlaubt: true });
|
||||||
|
await trockenlaufUndAusrollen(neu.id);
|
||||||
|
expect((await widerrufAbschliessen([neu.id], AKTEUR))[0]?.status).toBe("WIDERRUFEN");
|
||||||
|
|
||||||
|
const authorized = (await befehlAlsRoot(zielB, "cat /root/.ssh/authorized_keys")).stdout;
|
||||||
|
expect(authorized).not.toContain(neu.publicKey.split(" ")[1]!);
|
||||||
|
expect(authorized).toContain(alt.publicKey.split(" ")[1]!);
|
||||||
|
const dateien = (await befehlAlsRoot(zielA, "ls /root/.ssh")).stdout.split("\n");
|
||||||
|
expect(dateien).toContain("id_backup");
|
||||||
|
expect(dateien).not.toContain(neuerPfad);
|
||||||
|
// Der Alias zeigt wieder auf die alte Datei – und funktioniert.
|
||||||
|
const config = (await befehlAlsRoot(zielA, "cat /root/.ssh/config")).stdout;
|
||||||
|
expect(config.match(/^Host zu-b$/gm)).toHaveLength(1);
|
||||||
|
expect(config).toContain("IdentityFile ~/.ssh/id_backup\n");
|
||||||
|
const ssh = await befehlAlsRoot(zielA, "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes zu-b id -un");
|
||||||
|
expect(ssh.stdout.trim()).toBe("root");
|
||||||
|
|
||||||
|
// Der alte Schluessel ist unveraendert, seine Zuweisungen sind ausgerollt.
|
||||||
|
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: alt.id } })).toEqual(altVorher);
|
||||||
|
const zustaende = [
|
||||||
|
...(await prisma.publicVerwendung.findMany({ where: { schluesselId: alt.id } })),
|
||||||
|
...(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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ beforeEach(async () => {
|
|||||||
|
|
||||||
describe("protokollSeite", () => {
|
describe("protokollSeite", () => {
|
||||||
it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => {
|
it("blaettert ueber die ID, neueste zuerst, ohne Luecken und Doppelte", async () => {
|
||||||
// IDs sind nicht lueckenlos (der Trigger zieht die Sequenz selbst) – nur die Ordnung zaehlt.
|
// IDs koennen Luecken haben (Rollbacks, alte Zeilen vor #032) – nur die Ordnung zaehlt.
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (let i = 0; i < SEITENGROESSE + 7; i += 1)
|
for (let i = 0; i < SEITENGROESSE + 7; i += 1)
|
||||||
ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id));
|
ids.unshift(String((await eintragen({ aktion: "host.angelegt" })).id));
|
||||||
|
|||||||
+117
-1
@@ -8,13 +8,14 @@ import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
|
|||||||
import { rotationsKette } from "@/server/queries/schluessel";
|
import { rotationsKette } from "@/server/queries/schluessel";
|
||||||
import {
|
import {
|
||||||
RotationsFehler,
|
RotationsFehler,
|
||||||
|
rotationAbbrechen,
|
||||||
rotationAltWiderrufen,
|
rotationAltWiderrufen,
|
||||||
rotationGesperrt,
|
rotationGesperrt,
|
||||||
rotationStarten,
|
rotationStarten,
|
||||||
rotationUebernehmen,
|
rotationUebernehmen,
|
||||||
rotationsStand,
|
rotationsStand,
|
||||||
} from "@/server/services/rotation";
|
} from "@/server/services/rotation";
|
||||||
import { widerrufAbschliessen } from "@/server/services/widerruf";
|
import { widerrufAbschliessen, widerrufEinleiten } from "@/server/services/widerruf";
|
||||||
import { masterKeyVergessen } from "@/worker/geheimnisse";
|
import { masterKeyVergessen } from "@/worker/geheimnisse";
|
||||||
import { datenbankLeeren } from "./hilfen/db";
|
import { datenbankLeeren } from "./hilfen/db";
|
||||||
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
|
import { datenbankAlsText, vorgangImWorker, warteschlangeStoppen } from "./hilfen/vorgang";
|
||||||
@@ -258,3 +259,118 @@ describe("Rotation Schritt fuer Schritt", () => {
|
|||||||
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
|
await expect(rotationStarten(person.id, admin)).rejects.toThrow(/neuen Public Key/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Rotation abbrechen (#032)", () => {
|
||||||
|
/** Rotation bis Schritt 3: Nachfolger uebernommen und ausgerollt. */
|
||||||
|
async function laufendeRotation() {
|
||||||
|
const altId = await alterSchluessel();
|
||||||
|
const neuId = await nachfolgerErzeugen(altId);
|
||||||
|
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
|
||||||
|
const jetzt = new Date();
|
||||||
|
await prisma.publicVerwendung.updateMany({
|
||||||
|
where: { schluesselId: neuId },
|
||||||
|
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
||||||
|
});
|
||||||
|
await prisma.privateVerwendung.updateMany({
|
||||||
|
where: { schluesselId: neuId },
|
||||||
|
data: { zustand: "AUSGEROLLT", zuletztAngewendetAm: jetzt },
|
||||||
|
});
|
||||||
|
return { altId, neuId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verwendungen(schluesselId: string) {
|
||||||
|
const [oeffentlich, privat] = await Promise.all([
|
||||||
|
prisma.publicVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
|
||||||
|
prisma.privateVerwendung.findMany({ where: { schluesselId }, select: { zustand: true } }),
|
||||||
|
]);
|
||||||
|
return [...oeffentlich, ...privat].map((v) => v.zustand).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
it("widerruft den neuen Schluessel, der alte bleibt unveraendert", async () => {
|
||||||
|
const { altId, neuId } = await laufendeRotation();
|
||||||
|
const altVorher = await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } });
|
||||||
|
|
||||||
|
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: false })).rejects.toThrow(/Admin/);
|
||||||
|
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||||
|
|
||||||
|
// Ausgerollt heisst: erst Dry-Run und Anwenden entfernen ihn – vorgemerkt, nicht sofort weg.
|
||||||
|
expect(await verwendungen(neuId)).toEqual(["ENTFERNEN", "ENTFERNEN"]);
|
||||||
|
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
|
||||||
|
expect(await prisma.schluessel.findUniqueOrThrow({ where: { id: altId } })).toEqual(altVorher);
|
||||||
|
const stand = await rotationsStand(altId);
|
||||||
|
expect(stand.abgebrochen).toBe(true);
|
||||||
|
|
||||||
|
// Kein Weiterdrehen mehr, ein zweiter Abbruch aendert nichts.
|
||||||
|
await expect(rotationUebernehmen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
|
||||||
|
await expect(rotationAltWiderrufen(altId, admin, { privatErlaubt: true })).rejects.toThrow(/abgebrochen/);
|
||||||
|
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||||
|
expect(await prisma.protokoll.count({ where: { aktion: "schluessel.rotation.abgebrochen" } })).toBe(1);
|
||||||
|
const eintrag = await prisma.protokoll.findFirstOrThrow({
|
||||||
|
where: { aktion: "schluessel.rotation.abgebrochen" },
|
||||||
|
select: { fingerprint: true, details: true },
|
||||||
|
});
|
||||||
|
expect(eintrag).toMatchObject({ fingerprint: "SHA256:alt", details: { nachfolgerId: neuId } });
|
||||||
|
|
||||||
|
// Nach dem Anwenden: der Nachfolger ist WIDERRUFEN (nicht ROTIERT), die Kette bleibt.
|
||||||
|
await prisma.publicVerwendung.updateMany({
|
||||||
|
where: { schluesselId: neuId },
|
||||||
|
data: { zustand: "ENTFERNT" },
|
||||||
|
});
|
||||||
|
await prisma.privateVerwendung.updateMany({
|
||||||
|
where: { schluesselId: neuId },
|
||||||
|
data: { zustand: "ENTFERNT" },
|
||||||
|
});
|
||||||
|
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
|
||||||
|
expect((await rotationsKette(altId)).map((g) => [g.id, g.status])).toEqual([
|
||||||
|
[altId, "AKTIV"],
|
||||||
|
[neuId, "WIDERRUFEN"],
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
(await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId }, select: { vorgaengerId: true } }))
|
||||||
|
.vorgaengerId,
|
||||||
|
).toBe(altId);
|
||||||
|
await expect(rotationStarten(altId, admin)).rejects.toThrow(/abgebrochen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt nie ausgerollte Zuweisungen des neuen Schluessels sofort auf ENTFERNT", async () => {
|
||||||
|
const altId = await alterSchluessel();
|
||||||
|
const neuId = await nachfolgerErzeugen(altId);
|
||||||
|
await rotationUebernehmen(altId, admin, { privatErlaubt: true });
|
||||||
|
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||||
|
expect(await verwendungen(neuId)).toEqual(["ENTFERNT", "ENTFERNT"]);
|
||||||
|
expect((await widerrufAbschliessen([neuId], admin))[0]?.status).toBe("WIDERRUFEN");
|
||||||
|
expect(await verwendungen(altId)).toEqual(["AUSGEROLLT", "AUSGEROLLT"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("geht nicht mehr, sobald der alte Schluessel entfernt wird", async () => {
|
||||||
|
const { altId } = await laufendeRotation();
|
||||||
|
const privat = await prisma.privateVerwendung.findFirstOrThrow({
|
||||||
|
where: { schluessel: { vorgaengerId: altId } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await prisma.vorgang.create({
|
||||||
|
data: { art: "VERBINDUNGSTEST", status: "ERLEDIGT", eingabe: { privatVerwendungId: privat.id } },
|
||||||
|
});
|
||||||
|
await rotationAltWiderrufen(altId, admin, { privatErlaubt: true });
|
||||||
|
await expect(rotationAbbrechen(altId, admin, { privatErlaubt: true })).rejects.toThrow(
|
||||||
|
/nicht mehr abbrechen/,
|
||||||
|
);
|
||||||
|
await expect(rotationAbbrechen(b.id, admin, { privatErlaubt: true })).rejects.toThrow(/gibt es nicht/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("macht aus dem Widerruf des alten Schluessels kein ROTIERT, solange der Abbruch laeuft", async () => {
|
||||||
|
const { altId, neuId } = await laufendeRotation();
|
||||||
|
await rotationAbbrechen(altId, admin, { privatErlaubt: true });
|
||||||
|
await widerrufEinleiten([altId], admin, { privatErlaubt: true });
|
||||||
|
await prisma.publicVerwendung.updateMany({
|
||||||
|
where: { schluesselId: altId },
|
||||||
|
data: { zustand: "ENTFERNT" },
|
||||||
|
});
|
||||||
|
await prisma.privateVerwendung.updateMany({
|
||||||
|
where: { schluesselId: altId },
|
||||||
|
data: { zustand: "ENTFERNT" },
|
||||||
|
});
|
||||||
|
expect((await widerrufAbschliessen([altId], admin))[0]?.status).toBe("WIDERRUFEN");
|
||||||
|
expect((await prisma.schluessel.findUniqueOrThrow({ where: { id: neuId } })).status).toBe("AKTIV");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ablaufZustand,
|
ablaufZustand,
|
||||||
fingerprintKurz,
|
fingerprintKurz,
|
||||||
freierSlug,
|
freierSlug,
|
||||||
|
idsAusSuchparameter,
|
||||||
slugAus,
|
slugAus,
|
||||||
typAnzeige,
|
typAnzeige,
|
||||||
erzeugenSchema,
|
erzeugenSchema,
|
||||||
@@ -66,6 +67,15 @@ function kern(privateKey: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Regeln (rein)", () => {
|
describe("Regeln (rein)", () => {
|
||||||
|
it("liest IDs aus dem Suchparameter, auch UUIDs vom Worker (#032)", () => {
|
||||||
|
const uuid = "5f703ff1-3877-4af4-af08-84338237fcd8";
|
||||||
|
expect(idsAusSuchparameter([uuid, "cm1abc"])).toEqual([uuid, "cm1abc"]);
|
||||||
|
expect(idsAusSuchparameter(`a,b,a`)).toEqual(["a", "b"]);
|
||||||
|
expect(idsAusSuchparameter(["x y", "../etc", ""])).toEqual([]);
|
||||||
|
expect(idsAusSuchparameter(undefined)).toEqual([]);
|
||||||
|
expect(idsAusSuchparameter(["a", "b", "c"], 2)).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"],
|
["Backup srv-backup01 → srv-pve01", "backup-srv-backup01-srv-pve01"],
|
||||||
["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"],
|
["Größter Schlüssel für Ärzte", "groesster-schluessel-fuer-aerzte"],
|
||||||
|
|||||||
Reference in New Issue
Block a user