From 30f271b23a1e7c834f9e1e19bac7b3e0d85e0f26 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 10:19:27 +0200 Subject: [PATCH] feat(hosts): Schluessel auf dem Host anzeigen und ausrollen (#016) Die Hostseite listet, welche Public und Private Keys sshkv dort verwaltet - direkt oder ueber eine Hostgruppe - mit Zustand als Punkt und Text. Ein Dry-Run je Host zeigt das Diff aller Benutzer dieses Hosts und rollt nach Bestaetigung aus. Co-Authored-By: Claude Opus 5.5 --- src/app/(app)/hosts/[id]/page.tsx | 70 +++++++++++++++++-- src/components/ausrollen/AusrollenPanel.tsx | 6 +- .../ausrollen/VerwendungsZustand.tsx | 28 ++++++++ src/server/actions/verwendungen.ts | 9 ++- src/server/queries/ziele.ts | 33 +++++++++ src/server/services/soll.ts | 13 +++- src/server/services/verwendungen.ts | 5 +- 7 files changed, 151 insertions(+), 13 deletions(-) create mode 100644 src/components/ausrollen/VerwendungsZustand.tsx diff --git a/src/app/(app)/hosts/[id]/page.tsx b/src/app/(app)/hosts/[id]/page.tsx index 90fa9f8..8ddf26d 100644 --- a/src/app/(app)/hosts/[id]/page.tsx +++ b/src/app/(app)/hosts/[id]/page.tsx @@ -18,6 +18,9 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { hostDetail, type HostDetail } from "@/server/queries/hosts"; import { BootstrapAktionen } from "./BootstrapAktionen"; import { sshdHinweise } from "@/lib/sshd-hinweise"; +import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel"; +import { VerwendungsZustand } from "@/components/ausrollen/VerwendungsZustand"; +import { verwendungenAufHost } from "@/server/queries/ziele"; import { HostKeyAktionen } from "./HostKeyAktionen"; import { HostKeyEntscheidung } from "./HostKeyEntscheidung"; import { bootstrapBefehlFuer } from "@/server/queries/bootstrap"; @@ -95,6 +98,8 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri const archiviert = host.archiviertAm !== null; const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert; const darfEntscheiden = darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert; + const darfAusrollen = darf(benutzer.rolle, "publicKeyAusrollen") && !archiviert; + const verwendungen = await verwendungenAufHost(host.id); const jetzt = new Date(); return ( @@ -217,7 +222,7 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri {host.zielkonten.length > 0 && (

@@ -230,11 +235,64 @@ export default async function HostSeite({ params }: { params: Promise<{ id: stri ))}

)} - } - titel="Folgt mit #016" - text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet." - /> + {verwendungen.oeffentlich.length === 0 && verwendungen.privat.length === 0 ? ( + } + titel="Noch keine Schlüssel zugewiesen" + text="Zuweisen geht über die Seite eines Schlüssels („+ Ausrollen“)." + /> + ) : ( +
    + {verwendungen.oeffentlich.map((v) => ( +
  • + + + {v.schluessel.name} + + + Public · {v.zielbenutzer}@{host.name} + {v.hostgruppe ? ` · über ${v.hostgruppe.name}` : ""} + {v.optionen ? ` · ${v.optionen}` : ""} + + + +
  • + ))} + {verwendungen.privat.map((v) => ( +
  • + + + {v.schluessel.name} + + + Private · {v.zielbenutzer}@{host.name}:{v.pfad} + + + +
  • + ))} +
+ )} + {darfAusrollen && verwendungen.oeffentlich.length > 0 && host.bootstrapAm && ( +
+ v.zustand !== "AUSGEROLLT").length} + /> +
+ )}
{verwalten && ( diff --git a/src/components/ausrollen/AusrollenPanel.tsx b/src/components/ausrollen/AusrollenPanel.tsx index fc4a82b..96d236e 100644 --- a/src/components/ausrollen/AusrollenPanel.tsx +++ b/src/components/ausrollen/AusrollenPanel.tsx @@ -6,7 +6,7 @@ import { useEffect, useState, useTransition } from "react"; import { Button } from "@/components/ui/Button"; import { DiffBox } from "@/components/ui/DiffBox"; import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang"; -import { ausrollenAktion, trockenlaufAktion } from "@/server/actions/verwendungen"; +import { ausrollenAktion, trockenlaufAktion, type AusrollAuswahl } from "@/server/actions/verwendungen"; import type { DiffZeile } from "@/lib/ssh/zeilen-diff"; /** @@ -24,7 +24,7 @@ type PlanZiel = { fehler?: string; }; -export function AusrollenPanel({ schluesselId, offen }: { schluesselId: string; offen: number }) { +export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; offen: number }) { const router = useRouter(); const [trockenlaufId, setTrockenlaufId] = useState(); const [ausrollenId, setAusrollenId] = useState(); @@ -52,7 +52,7 @@ export function AusrollenPanel({ schluesselId, offen }: { schluesselId: string; starten(async () => { setFehler(undefined); setAusrollenId(undefined); - const antwort = await trockenlaufAktion(schluesselId); + const antwort = await trockenlaufAktion(auswahl); if (antwort.fehler) setFehler(antwort.fehler); else setTrockenlaufId(antwort.vorgangId); }) diff --git a/src/components/ausrollen/VerwendungsZustand.tsx b/src/components/ausrollen/VerwendungsZustand.tsx new file mode 100644 index 0000000..16d6b10 --- /dev/null +++ b/src/components/ausrollen/VerwendungsZustand.tsx @@ -0,0 +1,28 @@ +import type { VerwendungsZustand as Zustand } from "@/generated/prisma/enums"; +import { clsx } from "clsx"; + +/** Zustand einer Verwendung – immer Punkt und Text, nie nur Farbe (#016). */ +const ANZEIGE: Record = { + GEPLANT: { text: "noch nicht ausgerollt", klasse: "text-accent", punkt: "○" }, + AUSGEROLLT: { text: "synchron", klasse: "text-ok", punkt: "●" }, + ABWEICHUNG: { text: "Drift", klasse: "text-danger", punkt: "●" }, + ENTFERNEN: { text: "wird entfernt", klasse: "text-accent", punkt: "○" }, + ENTFERNT: { text: "entfernt", klasse: "text-muted", punkt: "○" }, + FEHLGESCHLAGEN: { text: "fehlgeschlagen", klasse: "text-danger", punkt: "●" }, +}; + +export function VerwendungsZustand({ zustand, className }: { zustand: Zustand; className?: string }) { + const anzeige = ANZEIGE[zustand]; + return ( + + {anzeige.punkt} + {anzeige.text} + + ); +} diff --git a/src/server/actions/verwendungen.ts b/src/server/actions/verwendungen.ts index a88ac82..0a4e944 100644 --- a/src/server/actions/verwendungen.ts +++ b/src/server/actions/verwendungen.ts @@ -70,12 +70,17 @@ export async function publicEntfernenAktion( } } -export async function trockenlaufAktion(schluesselId: string): Promise { +export type AusrollAuswahl = { readonly schluesselId: string } | { readonly hostId: string }; + +export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise { try { const { benutzer } = await rechtErforderlich("publicKeyAusrollen"); + const id = z.string().min(1).max(64); return { vorgangId: await trockenlaufStarten( - { schluesselId: z.string().min(1).max(64).parse(schluesselId) }, + "schluesselId" in auswahl + ? { schluesselId: id.parse(auswahl.schluesselId) } + : { hostId: id.parse(auswahl.hostId) }, akteurVon(benutzer), ), }; diff --git a/src/server/queries/ziele.ts b/src/server/queries/ziele.ts index a30b48b..06717b8 100644 --- a/src/server/queries/ziele.ts +++ b/src/server/queries/ziele.ts @@ -20,3 +20,36 @@ export async function zielAuswahl() { } export type ZielAuswahl = Awaited>; + +/** Was sshkv auf einem Host verwaltet (#016): Public Keys direkt und ueber Gruppen. */ +export async function verwendungenAufHost(hostId: string) { + const [oeffentlich, privat] = await Promise.all([ + prisma.publicVerwendung.findMany({ + where: { + zustand: { not: "ENTFERNT" }, + OR: [{ hostId }, { hostgruppe: { mitglieder: { some: { hostId } } } }], + }, + select: { + id: true, + zielbenutzer: true, + optionen: true, + zustand: true, + hostgruppe: { select: { name: true } }, + schluessel: { select: { id: true, name: true, typ: true, fingerprint: true } }, + }, + orderBy: [{ zielbenutzer: "asc" }, { angelegtAm: "asc" }], + }), + prisma.privateVerwendung.findMany({ + where: { hostId, zustand: { not: "ENTFERNT" } }, + select: { + id: true, + zielbenutzer: true, + pfad: true, + zustand: true, + schluessel: { select: { id: true, name: true, typ: true, fingerprint: true } }, + }, + orderBy: [{ zielbenutzer: "asc" }, { pfad: "asc" }], + }), + ]); + return { oeffentlich, privat }; +} diff --git a/src/server/services/soll.ts b/src/server/services/soll.ts index 7ca83ba..698f6fa 100644 --- a/src/server/services/soll.ts +++ b/src/server/services/soll.ts @@ -100,11 +100,20 @@ export type Ziel = { readonly hostId: string; readonly zielbenutzer: string }; export async function zieleVonVerwendungen(where: { schluesselId?: string; ids?: string[]; + hostId?: string; }): Promise { const verwendungen = await prisma.publicVerwendung.findMany({ where: { ...(where.schluesselId ? { schluesselId: where.schluesselId } : {}), ...(where.ids ? { id: { in: where.ids } } : {}), + ...(where.hostId + ? { + OR: [ + { hostId: where.hostId }, + { hostgruppe: { mitglieder: { some: { hostId: where.hostId } } } }, + ], + } + : {}), zustand: { not: "ENTFERNT" }, }, select: { @@ -117,9 +126,11 @@ export async function zieleVonVerwendungen(where: { }); const ziele = new Map(); for (const verwendung of verwendungen) { - const hostIds = verwendung.hostId + const alleHostIds = verwendung.hostId ? [verwendung.hostId] : (verwendung.hostgruppe?.mitglieder.filter((m) => !m.host.archiviertAm).map((m) => m.hostId) ?? []); + // Bei der Auswahl nach Host nur diesen Host, auch wenn die Verwendung an einer Gruppe haengt. + const hostIds = where.hostId ? alleHostIds.filter((id) => id === where.hostId) : alleHostIds; for (const hostId of hostIds) { ziele.set(`${hostId}\u0000${verwendung.zielbenutzer}`, { hostId, diff --git a/src/server/services/verwendungen.ts b/src/server/services/verwendungen.ts index 6e5e466..f375a93 100644 --- a/src/server/services/verwendungen.ts +++ b/src/server/services/verwendungen.ts @@ -117,13 +117,14 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro /** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */ export async function trockenlaufStarten( - auswahl: { schluesselId?: string; verwendungIds?: string[]; ziele?: Ziel[] }, + auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] }, akteur: Akteur, ): Promise { const ziele = auswahl.ziele ?? (await zieleVonVerwendungen({ ...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}), + ...(auswahl.hostId ? { hostId: auswahl.hostId } : {}), ...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}), })); if (ziele.length === 0) throw new VerwendungsFehler("Es gibt nichts auszurollen."); @@ -131,6 +132,7 @@ export async function trockenlaufStarten( art: "TROCKENLAUF", angefordertVonId: akteur.id, schluesselId: auswahl.schluesselId ?? null, + hostId: auswahl.hostId ?? null, eingabe: { ziele }, }); } @@ -144,6 +146,7 @@ export async function ausrollenStarten(trockenlaufId: string, akteur: Akteur): P art: "AUSROLLEN", angefordertVonId: akteur.id, schluesselId: trockenlauf.schluesselId, + hostId: trockenlauf.hostId, eingabe: { trockenlaufId }, }); }