From 426d3ec95a82628e2b6ca90387fd7d814e3f0206 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 14:28:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(schluessel):=20SSH-Schl=C3=BCssel=20hinter?= =?UTF-8?q?legen=20statt=20in=20jedes=20Formular?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benutzer hinterlegen ihre öffentlichen Schlüssel einmal, Admins zusätzlich Team-Schlüssel. Sie kommen in jeden Gast: im Assistenten vorausgewählt und abwählbar, per API und CLI automatisch, wenn die Anfrage keine nennt. Die Schlüssel kommen über die Schnittstelle SchluesselQuelle – dort lässt sich später eine externe Verwaltung anbinden. Vorgezogen aus #015 auf die Frage, ob Schlüssel ins Template gehören: nein, in die Verwaltung. Refs #015 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + docs/issues/015-rollen-audit.md | 5 +- .../migration.sql | 18 ++ prisma/schema.prisma | 17 ++ src/app/(app)/ausrollen/assistent.tsx | 86 ++++++---- src/app/(app)/ausrollen/page.tsx | 7 + src/app/(app)/einstellungen/page.tsx | 24 +++ .../einstellungen/schluessel-verwaltung.tsx | 147 ++++++++++++++++ src/app/api/v1/auftraege/plan/route.ts | 4 +- src/app/api/v1/schluessel/[id]/route.ts | 13 ++ src/app/api/v1/schluessel/route.ts | 20 +++ src/lib/schemas/ausrollen.ts | 6 +- src/server/schluessel/lokal.ts | 21 +++ src/server/schluessel/quelle.ts | 33 ++++ src/server/services/ausrollen.ts | 21 ++- src/server/services/schluessel.ts | 100 +++++++++++ tests/e2e/einstellungen.spec.ts | 49 ++++++ tests/schluessel.test.ts | 157 ++++++++++++++++++ 18 files changed, 690 insertions(+), 39 deletions(-) create mode 100644 prisma/migrations/20261012122118_ssh_schluessel/migration.sql create mode 100644 src/app/(app)/einstellungen/page.tsx create mode 100644 src/app/(app)/einstellungen/schluessel-verwaltung.tsx create mode 100644 src/app/api/v1/schluessel/[id]/route.ts create mode 100644 src/app/api/v1/schluessel/route.ts create mode 100644 src/server/schluessel/lokal.ts create mode 100644 src/server/schluessel/quelle.ts create mode 100644 src/server/services/schluessel.ts create mode 100644 tests/e2e/einstellungen.spec.ts create mode 100644 tests/schluessel.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7843276..f9a0a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unveröffentlicht] ### Hinzugefügt +- SSH-Schlüssel in den Einstellungen hinterlegen (persönlich und, für Admins, Team-Schlüssel); sie kommen automatisch in jeden Gast – auch per API/CLI ohne Angabe – und sind im Assistenten abwählbar (#015) - Templates mit eingebackenen Profilen: beim Template-Bau Profile wählen; Ausrollen nimmt das passende Template und überspringt, was schon drin ist (#022) - Profil-Editor: eigene Profile als YAML mit Live-Prüfung und Skriptvorschau, Katalogprofile als Kopie bearbeiten (#011) - Vorlagen (Presets): im Assistenten wählen und speichern, Vorlagenseite, `vorlage` in der API; Beispiel „Docker-Host klein“ (#012) diff --git a/docs/issues/015-rollen-audit.md b/docs/issues/015-rollen-audit.md index 651bec2..917e2f7 100644 --- a/docs/issues/015-rollen-audit.md +++ b/docs/issues/015-rollen-audit.md @@ -9,7 +9,8 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert. ## Aufgaben - [ ] Rechte-Tabelle, strukturelle Prüfung aller Route Handler (Test) -- [ ] Benutzerverwaltung, CLI-Tokens, SSH-Keys über `SchluesselQuelle` +- [ ] Benutzerverwaltung, CLI-Tokens +- [x] SSH-Keys über `SchluesselQuelle` – vorgezogen in v0.3.0: eigene und Team-Schlüssel, automatisch in jedem Gast, im Assistenten abwählbar - [ ] Audit-Log-Ansicht mit Filter ## Akzeptanzkriterien @@ -19,4 +20,4 @@ Admin/Operator serverseitig erzwungen, jede Aktion protokolliert. #001 ## Notizen / Entscheidungen -– +- SSH-Schlüssel auf Florians Frage nach Schlüsseln im Template vorgezogen (2026-10-12): Schlüssel gehören in die Verwaltung, nicht ins Template. `SchluesselQuelle` ist die Andockstelle für eine externe Verwaltung (z. B. sshkeyvault). diff --git a/prisma/migrations/20261012122118_ssh_schluessel/migration.sql b/prisma/migrations/20261012122118_ssh_schluessel/migration.sql new file mode 100644 index 0000000..8acbb10 --- /dev/null +++ b/prisma/migrations/20261012122118_ssh_schluessel/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "SshSchluessel" ( + "id" TEXT NOT NULL, + "benutzerId" TEXT, + "name" TEXT NOT NULL, + "oeffentlich" TEXT NOT NULL, + "fingerabdruck" TEXT NOT NULL, + "quelle" TEXT NOT NULL DEFAULT 'lokal', + "erstelltAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SshSchluessel_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SshSchluessel_benutzerId_fingerabdruck_key" ON "SshSchluessel"("benutzerId", "fingerabdruck"); + +-- AddForeignKey +ALTER TABLE "SshSchluessel" ADD CONSTRAINT "SshSchluessel_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "Benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e8eb052..27c1ddb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -39,6 +39,7 @@ model Benutzer { geaendertAm DateTime @updatedAt protokoll Protokoll[] auftraege Auftrag[] + schluessel SshSchluessel[] } /// Audit-Log. Nur anhängen – ein Trigger verweigert UPDATE und DELETE. @@ -292,3 +293,19 @@ model Vorlage { erstelltAm DateTime @default(now()) geaendertAm DateTime @updatedAt } + +/// Öffentliche SSH-Schlüssel, die in Gäste kommen. benutzerId leer = Team-Schlüssel (in jeden Gast). +model SshSchluessel { + id String @id @default(cuid()) + benutzerId String? + benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Cascade) + name String + oeffentlich String + /// SHA256:… wie ssh-keygen -lf + fingerabdruck String + /// Kennung der SchluesselQuelle („lokal“; später z. B. eine externe Schlüsselverwaltung). + quelle String @default("lokal") + erstelltAm DateTime @default(now()) + + @@unique([benutzerId, fingerabdruck]) +} diff --git a/src/app/(app)/ausrollen/assistent.tsx b/src/app/(app)/ausrollen/assistent.tsx index f2919f6..f6d3d21 100644 --- a/src/app/(app)/ausrollen/assistent.tsx +++ b/src/app/(app)/ausrollen/assistent.tsx @@ -49,6 +49,12 @@ export type AssistentDaten = { benutzer: string; version: string; vorlagen: Array<{ name: string; titel: string; beschreibung: string | null; parameter: VorlagenParameter }>; + schluessel: Array<{ + name: string; + oeffentlich: string; + fingerabdruck: string; + bereich: "persoenlich" | "team"; + }>; }; type Plan = { @@ -94,6 +100,8 @@ type Werte = { suchdomain: string; benutzer: string; schluessel: string; + /** Fingerprints hinterlegter Schlüssel, die für diesen Gast abgewählt sind. */ + ohneSchluessel: string[]; profile: string[]; variablen: Record>; templateBauen: boolean; @@ -128,9 +136,7 @@ function dekodieren(text: string | null): Partial { } } -const SCHLUESSEL_SPEICHER = "proxdeploy.ssh-schluessel"; - -function anfrageAus(w: Werte): AusrollEingabe { +function anfrageAus(w: Werte, hinterlegt: AssistentDaten["schluessel"] = []): AusrollEingabe { const zahl = (text: string) => (text.trim() === "" ? undefined : Number(text)); return { typ: w.typ, @@ -158,10 +164,13 @@ function anfrageAus(w: Werte): AusrollEingabe { }, zugang: { benutzer: w.benutzer, - sshSchluessel: w.schluessel - .split("\n") - .map((z) => z.trim()) - .filter(Boolean), + sshSchluessel: [ + ...hinterlegt.filter((s) => !w.ohneSchluessel.includes(s.fingerabdruck)).map((s) => s.oeffentlich), + ...w.schluessel + .split("\n") + .map((z) => z.trim()) + .filter(Boolean), + ], }, profile: w.profile, variablen: Object.fromEntries(Object.entries(w.variablen).filter(([p]) => w.profile.includes(p))), @@ -196,7 +205,7 @@ function vorlageUebernehmen(w: Werte, name: string, p: VorlagenParameter, daten: } function werteZuVorlage(w: Werte): VorlagenParameter { - const a = anfrageAus(w); + const a = anfrageAus(w, []); return { typ: a.typ, server: a.server, @@ -256,6 +265,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus suchdomain: "", benutzer: daten.benutzer.replace(/[^a-z0-9_-]/g, "") || "admin", schluessel: "", + ohneSchluessel: [], profile: ["base"], variablen: {}, templateBauen: false, @@ -305,19 +315,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus }; const gehe = (ziel: number) => { - let naechste = werte; - // Gespeicherte Schlüssel als Bequemlichkeit vorbelegen (bis die Schlüsselverwaltung kommt, #015). - if (SCHRITTE[ziel] === "Zugang" && !werte.schluessel) { - try { - const gemerkt = localStorage.getItem(SCHLUESSEL_SPEICHER); - if (gemerkt) { - naechste = { ...werte, schluessel: gemerkt }; - setWerte(naechste); - } - } catch { - // kein Speicher verfügbar – dann eben nicht - } - } + const naechste = werte; if (SCHRITTE[ziel] === "Zusammenfassung") void planen(naechste); const neu = new URLSearchParams({ s: String(ziel), d: kodieren(naechste) }); router.replace(`${pfad}?${neu.toString()}`, { scroll: false }); @@ -357,7 +355,9 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus .filter(Boolean); if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(werte.benutzer) || werte.benutzer === "root") return "Gültigen Benutzernamen angeben (nicht root)."; - if (schluessel.length === 0) return "Mindestens einen öffentlichen SSH-Schlüssel angeben."; + const hinterlegt = daten.schluessel.filter((s) => !werte.ohneSchluessel.includes(s.fingerabdruck)); + if (schluessel.length + hinterlegt.length === 0) + return "Mindestens einen öffentlichen SSH-Schlüssel wählen oder angeben."; if (schluessel.some((z) => !SSH_SCHLUESSEL.test(z))) return "Eine Zeile ist kein öffentlicher SSH-Schlüssel."; return null; @@ -372,7 +372,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus setLaeuft(true); setFehler(null); try { - setPlan(await apiAufruf("POST", "/api/v1/auftraege/plan", anfrageAus(stand))); + setPlan(await apiAufruf("POST", "/api/v1/auftraege/plan", anfrageAus(stand, daten.schluessel))); } catch (ursache) { setFehler( ursache instanceof ApiFehler @@ -388,12 +388,11 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus setLaeuft(true); setFehler(null); try { - try { - localStorage.setItem(SCHLUESSEL_SPEICHER, werte.schluessel); - } catch { - // egal - } - const { kurzId } = await apiAufruf<{ kurzId: string }>("POST", "/api/v1/auftraege", anfrageAus(werte)); + const { kurzId } = await apiAufruf<{ kurzId: string }>( + "POST", + "/api/v1/auftraege", + anfrageAus(werte, daten.schluessel), + ); router.push(`/auftraege/${kurzId}`); } catch (ursache) { setFehler( @@ -871,10 +870,35 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus autoComplete="off" /> + {daten.schluessel.length > 0 ? ( +
+ Hinterlegte Schlüssel + {daten.schluessel.map((s) => ( + + setze({ + ohneSchluessel: an + ? werte.ohneSchluessel.filter((f) => f !== s.fingerabdruck) + : [...werte.ohneSchluessel, s.fingerabdruck], + }) + } + /> + ))} +
+ ) : null} 0 + ? "Weitere öffentliche SSH-Schlüssel" + : "Öffentliche SSH-Schlüssel" + } + hilfe="Einer je Zeile, z. B. aus ~/.ssh/id_ed25519.pub. Dauerhaft hinterlegen: Einstellungen." >