Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9eec3d991 | ||
|
|
b3787f514c | ||
|
|
91c4880e40 | ||
|
|
dc202594d3 | ||
|
|
525eddb091 | ||
|
|
c06c051637 |
@@ -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)
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt (Wandzeit):** 5 h 20 min
|
||||
**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.
|
||||
**Gesamt (Wandzeit):** 5 h 50 min
|
||||
**Summe der Issue-Zeiten:** 15 h 43 min – Sub-Agenten arbeiteten parallel an #002, #006, #008, #011, #012–#014, #019 (Oberfläche), #023–#025, #027, #028 und #032.
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -40,6 +40,8 @@
|
||||
| #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 |
|
||||
| #033 Erneut rotieren nach Abbruch | 0 h 20 min |
|
||||
| #034 Abnahme-Checkliste | 0 h 10 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
@@ -48,7 +50,7 @@
|
||||
| v0.3.0 | 0 h 30 min (Wandzeit) | **abgeschlossen**
|
||||
| v0.4.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
|
||||
| v1.0.0 | 1 h 25 min (Wandzeit) | in Arbeit – Abnahme steht aus
|
||||
|
||||
---
|
||||
|
||||
@@ -169,3 +171,17 @@
|
||||
- **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.
|
||||
|
||||
## 2026-10-12 · Sitzung 7 · 19:48–20:18
|
||||
**Issue:** #033, #034
|
||||
**Version:** unveröffentlicht (auf dem Weg zu 1.0.0)
|
||||
|
||||
### Erledigt
|
||||
- #033 Nach einer abgebrochenen Rotation lässt sich derselbe Schlüssel erneut rotieren (1:n-Relation, partieller Index)
|
||||
- #034 Abnahme-Checkliste `docs/betrieb/abnahme.md` für authentik, echte Hosts und Betrieb
|
||||
- Stand: 877 Unit-/DB-Tests, 46 Integrationstests grün; E2E Rotation, Widerruf, Schlüssel grün
|
||||
|
||||
### Entscheidungen
|
||||
- **Je Vorgänger höchstens ein offener Nachfolger** statt genau einem: abgebrochene bleiben in der Historie, maßgeblich ist der jüngste.
|
||||
- **Neustart erst nach abgeschlossenem Abbruch** – sonst stünden zwei neue Schlüssel gleichzeitig auf den Hosts.
|
||||
- **Bestätigung „Rotation abgebrochen“ bleibt sichtbar** über dem Neustart, statt kommentarlos auf Schritt 1 zu springen (fand der E2E-Test).
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ Notfallplan) und [`docs/betrieb/sicherung.md`](docs/betrieb/sicherung.md).
|
||||
- [Installation und Betrieb](docs/betrieb/installation.md) – Docker Compose, authentik, Reverse Proxy
|
||||
- [Sicherung und Wiederherstellung](docs/betrieb/sicherung.md) – `deploy/sichern.sh`, `deploy/wiederherstellen.sh`
|
||||
- [Sicherheit](docs/betrieb/sicherheit.md) – Aufbau, Bedrohungsanalyse, Notfallplan
|
||||
- [Abnahme vor 1.0.0](docs/betrieb/abnahme.md) – Prüfliste für authentik, echte Hosts und Betrieb
|
||||
|
||||
## Installation (Docker Compose)
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Abnahme vor Version 1.0.0
|
||||
|
||||
Was die automatischen Tests **nicht** abdecken können: der echte Anbieter (authentik), echte Hosts
|
||||
und der Betrieb auf dem Zielserver. Diese Liste geht genau das durch. Jeder Punkt nennt das
|
||||
erwartete Ergebnis; was abweicht, kommt als Issue in `docs/issues/` (oder direkt als Notiz hier
|
||||
darunter).
|
||||
|
||||
Voraussetzung: Installation nach [installation.md](installation.md) ist durch, `docker compose ps`
|
||||
zeigt `db`, `web` und `worker` als *healthy*.
|
||||
|
||||
**Abnahme durch:** ____________________ **Datum:** __________ **Version/Commit:** __________
|
||||
|
||||
---
|
||||
|
||||
## A · Installation und Grundschutz
|
||||
|
||||
- [ ] `https://<sshkv>/api/gesundheit` antwortet mit `{"status":"ok","version":…}`; über `http://` gibt es keine Anmeldung
|
||||
(Secure-Cookie).
|
||||
- [ ] Von außerhalb des internen Netzes ist die Oberfläche **nicht** erreichbar (Reverse Proxy /
|
||||
Firewall liefert 403 oder Timeout).
|
||||
- [ ] Der Web-Container sieht den Master-Key nicht:
|
||||
`docker compose exec web ls /run/secrets` → leer bzw. Fehler.
|
||||
- [ ] Worker-Log beim Start: Master-Key passt, Deployment-Key vorhanden
|
||||
(`docker compose logs worker | head`).
|
||||
- [ ] Antwort-Header prüfen (`curl -sI https://<sshkv>/anmelden`): `Content-Security-Policy`,
|
||||
`Strict-Transport-Security`, `X-Frame-Options`/`frame-ancestors`, `Referrer-Policy`.
|
||||
|
||||
## B · Anmeldung mit authentik
|
||||
|
||||
- [ ] Anmelden mit einem Konto in `sshkv-admins` → Rolle **Admin** (unten links in der Navigation).
|
||||
- [ ] Konto in `sshkv-operators` → **Operator**; Konto in `sshkv-readers` → **Leser**.
|
||||
- [ ] Konto in **keiner** dieser Gruppen → kein Zugang, verständliche Meldung, Eintrag
|
||||
`anmeldung.verweigert` im Audit-Log.
|
||||
- [ ] Gruppe in authentik entziehen → bei der nächsten Anfrage gilt die neue Rolle bzw. kein
|
||||
Zugang (die Rolle kommt aus der Datenbank, nicht aus dem alten Token – spätestens nach
|
||||
erneuter Anmeldung).
|
||||
- [ ] **Erneute Bestätigung:** als Admin „Private Key anzeigen“ wählen → authentik fragt das
|
||||
Passwort (und ggf. MFA) **wirklich neu** ab; danach geht die Aktion fünf Minuten lang ohne
|
||||
erneute Frage. *Kritischer Punkt:* Kommt authentik ohne neue Eingabe zurück, meldet sshkv
|
||||
„auth_time fehlt oder ist zu alt“ – dann die Flow-Einstellung in authentik prüfen.
|
||||
- [ ] Abmelden beendet die Sitzung (Zurück-Knopf zeigt keine Daten mehr).
|
||||
|
||||
## C · Notfall-Admin
|
||||
|
||||
- [ ] `/anmelden` → „Notfallzugang“: Benutzer `notfall` + Passwort aus `.env` → TOTP-Einrichtung
|
||||
erzwungen (QR-Code mit Authenticator-App scannen).
|
||||
- [ ] Erneute Anmeldung verlangt Passwort **und** TOTP-Code; falscher Code wird abgelehnt.
|
||||
- [ ] Nach fünf Fehlversuchen in 15 Minuten → „Zu viele Fehlversuche“, auch mit richtigem Passwort.
|
||||
- [ ] `SSHKV_BREAKGLASS_PASSWORD` danach aus der `.env` entfernen (der Hash steht in der
|
||||
Datenbank) und das Passwort im Passwortmanager ablegen.
|
||||
|
||||
## D · Echte Hosts aufnehmen
|
||||
|
||||
Mindestens zwei Hosts, möglichst unterschiedlich (z. B. Debian 12 und Ubuntu 24.04), einer mit
|
||||
Anmeldeart *Dienstbenutzer*, einer mit *root*.
|
||||
|
||||
- [ ] Host anlegen → „Host-Key abrufen“ → Fingerprint mit dem Host vergleichen
|
||||
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` **auf dem Host**) → vertrauen.
|
||||
- [ ] Bootstrap per Einmal-Passwort: danach existiert `sshkv` mit `/etc/sudoers.d/sshkv`, das
|
||||
Passwort steht **nirgends** (Audit-Log, `docker compose logs`, Datenbank:
|
||||
`grep -r` im Dump findet es nicht).
|
||||
- [ ] Bootstrap per Kopier-Befehl auf dem zweiten Host: Befehl in die Root-Shell einfügen, danach
|
||||
„Einrichtung erneut prüfen“ → eingerichtet.
|
||||
- [ ] „Verbindung prüfen“ → erreichbar; sshd-Befund wird angezeigt (z. B. PasswordAuthentication).
|
||||
- [ ] Host-Key auf einem Host absichtlich tauschen (`ssh-keygen -A` nach Löschen der Keys,
|
||||
sshd neu starten) → nächster Abgleich meldet **Host-Key geändert**, sshkv verbindet sich
|
||||
nicht mehr, bis ein Admin den neuen Key bestätigt. Danach zurückdrehen.
|
||||
|
||||
## E · Schlüssel ausrollen (Kern-Anwendungsfall)
|
||||
|
||||
- [ ] Vorhandene `authorized_keys` eines Hosts mit eigenen Zeilen (Kommentare, Optionen, leere
|
||||
Zeilen) vorher sichern: `cp ~/.ssh/authorized_keys /tmp/vorher`.
|
||||
- [ ] Public Key eines Admins auf beide Hosts ausrollen → Dry-Run zeigt **nur** den verwalteten
|
||||
Block; nach „Anwenden“ ist alles außerhalb des Blocks **byte-gleich**
|
||||
(`diff` gegen `/tmp/vorher` zeigt nur den Block).
|
||||
- [ ] Backup-Datei `authorized_keys.sshkv.bak.*` liegt daneben; Rechte 0600, Verzeichnis 0700.
|
||||
- [ ] Host-zu-Host-Assistent: Vorlage „Deployment-Zugang“ von Host A nach Host B → Verbindungstest
|
||||
grün; auf A: `ssh <alias> id -un` funktioniert ohne Rückfrage (known_hosts gesetzt).
|
||||
- [ ] Datei `~/.ssh/config` auf A: eigener Inhalt außerhalb des sshkv-Blocks unverändert.
|
||||
- [ ] Zielkonto anlegen (z. B. `deploy` mit sudo) → Benutzer und sudoers-Datei entstehen,
|
||||
`visudo -c` auf dem Host ist sauber.
|
||||
|
||||
## F · Drift, Rotation, Widerruf
|
||||
|
||||
- [ ] Im verwalteten Block auf einem Host von Hand eine Zeile ändern → Abgleich meldet
|
||||
Abweichung; Übersicht zeigt den Host; Dry-Run stellt den Soll-Stand wieder her.
|
||||
- [ ] Fremden Key außerhalb des Blocks eintragen → Abgleich meldet **unbekannten Schlüssel**;
|
||||
„übernehmen“ funktioniert.
|
||||
- [ ] Geführte Rotation des Assistenten-Schlüssels: Alias auf A funktioniert vorher, während und
|
||||
nachher; alte Datei ist weg.
|
||||
- [ ] Rotation einmal **abbrechen** und danach erneut rotieren (#032, #033).
|
||||
- [ ] Widerruf eines Schlüssels mit einem nicht erreichbaren Host (Host kurz stoppen) →
|
||||
„Widerruf unvollständig“; nach dem Wiederanlauf abschließbar.
|
||||
- [ ] **Deployment-Key rotieren** (Einstellungen → Instanz) über alle echten Hosts – danach
|
||||
klappen Abgleich und Ausrollen weiter; der alte Fingerprint steht auf keinem Host mehr.
|
||||
- [ ] Aussperrschutz: Den Deployment-Key auf einem Host per Hand aus dem Block löschen und
|
||||
ausrollen → sshkv schreibt ihn zurück bzw. verweigert eine Änderung, die ihn entfernen würde.
|
||||
|
||||
## G · Audit-Log und Benachrichtigungen
|
||||
|
||||
- [ ] Audit-Log zeigt alle Aktionen aus D–F mit Akteur; „Kette prüfen“ → intakt.
|
||||
- [ ] CSV-Export öffnet sich sauber in LibreOffice/Excel (Umlaute, Trennzeichen).
|
||||
- [ ] Syslog-Kopie kommt beim Zielserver an (falls `SSHKV_SYSLOG_*` gesetzt).
|
||||
- [ ] Testversand E-Mail (Microsoft 365) und Webhook aus den Einstellungen kommt an.
|
||||
- [ ] Am nächsten Morgen: tägliche Zusammenfassung ist da und nennt die offenen Befunde.
|
||||
|
||||
## H · Sicherung und Wiederherstellung
|
||||
|
||||
- [ ] `sudo bash deploy/sichern.sh` läuft durch; Sicherung landet am vorgesehenen Ort.
|
||||
- [ ] Master-Key **getrennt** gesichert (Passwortmanager/Tresor), Wiederauffinden geprobt.
|
||||
- [ ] Wiederherstellung auf einer **Wegwerf-Instanz** nach [sicherung.md](sicherung.md): danach
|
||||
Anmeldung, Private Key anzeigen und ein Abgleich funktionieren.
|
||||
- [ ] Mit falschem Master-Key verweigert der Worker den Start.
|
||||
|
||||
## I · Oberfläche
|
||||
|
||||
- [ ] Smartphone (schmale Ansicht): Navigation, Übersicht, Hostliste und Dry-Run bedienbar.
|
||||
- [ ] Hell/dunkel: Die App ist bewusst dunkel (Tresor-Design); Kontrast in heller Umgebung
|
||||
ausreichend lesbar.
|
||||
- [ ] Handbuch (`docs/anleitung/README.md`) passt zu dem, was die Oberfläche zeigt.
|
||||
|
||||
---
|
||||
|
||||
## Ergebnis
|
||||
|
||||
- [ ] Alle Punkte erledigt oder als Issue erfasst und als *nicht blockierend* eingestuft.
|
||||
- [ ] **Freigabe für 1.0.0** – danach: Version in `package.json`, CHANGELOG-Abschnitt `[1.0.0]`,
|
||||
Tag `v1.0.0`, Push.
|
||||
|
||||
### Notizen
|
||||
@@ -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 h 20 min
|
||||
|
||||
## 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,23 @@
|
||||
# #034 Abnahme-Checkliste für 1.0.0
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v1.0.0
|
||||
**Labels:** docs
|
||||
**Geschätzt:** 0,5 h **Tatsächlich:** 0 h 10 min
|
||||
|
||||
## Ziel
|
||||
Was die automatischen Tests nicht abdecken – echter authentik, echte Hosts, Betrieb auf dem Zielserver – als abhakbare Liste mit erwartetem Ergebnis je Punkt, damit die Abnahme von 1.0.0 vollständig und nachvollziehbar ist.
|
||||
|
||||
## Aufgaben
|
||||
- [x] `docs/betrieb/abnahme.md`: Installation, authentik, Notfall-Admin, Hosts, Ausrollen, Drift/Rotation/Widerruf, Audit-Log/Benachrichtigungen, Sicherung, Oberfläche
|
||||
- [x] Aussagen gegen den Code geprüft (Knopfnamen, Healthcheck, Ratenbegrenzung, Header)
|
||||
- [x] Verlinkt im README
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [x] Jeder Punkt nennt ein prüfbares Ergebnis
|
||||
- [x] Die kritischen offenen Punkte (authentik-Neuanmeldung mit `auth_time`, Byte-Gleichheit außerhalb des Blocks auf echten Hosts) sind ausdrücklich enthalten
|
||||
|
||||
## Abhängigkeiten
|
||||
#029
|
||||
|
||||
## Notizen / Entscheidungen
|
||||
- Die Abnahme selbst macht der Betreiber; 1.0.0 wird erst danach getaggt.
|
||||
@@ -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