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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:19:27 +02:00
co-authored by Claude Opus 5.5
parent f66d87ad11
commit 30f271b23a
7 changed files with 151 additions and 13 deletions
+7 -2
View File
@@ -70,12 +70,17 @@ export async function publicEntfernenAktion(
}
}
export async function trockenlaufAktion(schluesselId: string): Promise<VorgangsAntwort> {
export type AusrollAuswahl = { readonly schluesselId: string } | { readonly hostId: string };
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
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),
),
};
+33
View File
@@ -20,3 +20,36 @@ export async function zielAuswahl() {
}
export type ZielAuswahl = Awaited<ReturnType<typeof zielAuswahl>>;
/** 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 };
}
+12 -1
View File
@@ -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<Ziel[]> {
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<string, Ziel>();
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,
+4 -1
View File
@@ -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<string> {
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 },
});
}