Merge branch 'phase/017-private-key-ausrollen'

# Conflicts:
#	src/worker/handler.ts
This commit is contained in:
2026-10-12 10:54:28 +02:00
13 changed files with 1098 additions and 54 deletions
+31 -1
View File
@@ -94,7 +94,12 @@ export async function sollFuer(hostId: string, zielbenutzer: string): Promise<So
return { eintraege, pflicht, verwendungIds: { drin, raus } };
}
export type Ziel = { readonly hostId: string; readonly zielbenutzer: string };
/** Ein Ziel des Ausrollens: authorized_keys (public) oder ~/.ssh-Dateien eines Quell-Hosts (privat). */
export type Ziel = {
readonly hostId: string;
readonly zielbenutzer: string;
readonly art?: "public" | "privat";
};
/** Alle (Host, Benutzer)-Paare, die eine Public-Key-Verwendung betrifft – Gruppen aufgeloest. */
export async function zieleVonVerwendungen(where: {
@@ -140,3 +145,28 @@ export async function zieleVonVerwendungen(where: {
}
return [...ziele.values()];
}
/** Ziele der Private-Key-Verwendungen (#017): je Quell-Host und Benutzer ein Ziel. */
export async function zieleVonPrivatVerwendungen(where: {
schluesselId?: string;
hostId?: string;
}): Promise<Ziel[]> {
const verwendungen = await prisma.privateVerwendung.findMany({
where: {
...(where.schluesselId ? { schluesselId: where.schluesselId } : {}),
...(where.hostId ? { hostId: where.hostId } : {}),
zustand: { not: "ENTFERNT" },
host: { archiviertAm: null },
},
select: { hostId: true, zielbenutzer: true },
});
const ziele = new Map<string, Ziel>();
for (const v of verwendungen) {
ziele.set(`${v.hostId}\u0000${v.zielbenutzer}`, {
hostId: v.hostId,
zielbenutzer: v.zielbenutzer,
art: "privat",
});
}
return [...ziele.values()];
}
+125 -5
View File
@@ -3,7 +3,8 @@ import { prisma } from "@/lib/db";
import { BENUTZERNAME_MUSTER } from "@/lib/shell";
import { OptionenFehler, optionenPruefen } from "@/lib/ssh/optionen";
import { protokollieren } from "@/server/services/protokoll";
import { zieleVonVerwendungen, type Ziel } from "@/server/services/soll";
import { zieleVonPrivatVerwendungen, zieleVonVerwendungen, type Ziel } from "@/server/services/soll";
import { SshConfigFehler, privatPfadPruefen, sshConfigPruefen } from "@/lib/ssh/ssh-config";
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
/**
@@ -120,13 +121,20 @@ export async function trockenlaufStarten(
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
akteur: Akteur,
): Promise<string> {
const ziele =
auswahl.ziele ??
(await zieleVonVerwendungen({
const ziele = auswahl.ziele ?? [
...(await zieleVonVerwendungen({
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
...(auswahl.hostId ? { hostId: auswahl.hostId } : {}),
...(auswahl.verwendungIds ? { ids: auswahl.verwendungIds } : {}),
}));
})),
// Private Keys nur, wenn nicht gezielt einzelne Public-Verwendungen gemeint sind.
...(auswahl.verwendungIds
? []
: await zieleVonPrivatVerwendungen({
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
...(auswahl.hostId ? { hostId: auswahl.hostId } : {}),
})),
];
if (ziele.length === 0) throw new VerwendungsFehler("Es gibt nichts auszurollen.");
return vorgangEinreihen({
art: "TROCKENLAUF",
@@ -150,3 +158,115 @@ export async function ausrollenStarten(trockenlaufId: string, akteur: Akteur): P
eingabe: { trockenlaufId },
});
}
export type PrivateZuweisung = {
readonly schluesselId: string;
readonly hostId: string;
readonly zielbenutzer: string;
readonly pfad: string;
/** Optionaler Block in ~/.ssh/config des Zielbenutzers. */
readonly sshConfig?: { alias: string; hostName: string; benutzer: string; port: number } | null;
/** Hosts, deren Host-Key in known_hosts gepflegt wird. */
readonly knownHostsFuer?: string[];
readonly beimWiderruf?: "LOESCHEN" | "UMBENENNEN";
};
/**
* Private Key einem Quell-Host zuweisen (#017). Nur Admins (Recht privateKeyAusrollen, prueft
* die Aktion). Ein passphrasengeschuetzter Key ist fuer automatische Anmeldungen ungeeignet –
* die Oberflaeche warnt, verboten ist er nicht (Freigabe Frage 6).
*/
export async function privateVerwendungAnlegen(eingabe: PrivateZuweisung, akteur: Akteur): Promise<string> {
if (!BENUTZERNAME_MUSTER.test(eingabe.zielbenutzer))
throw new VerwendungsFehler("Ungültiger Zielbenutzer.");
let pfad: string;
try {
pfad = privatPfadPruefen(eingabe.pfad);
if (eingabe.sshConfig) sshConfigPruefen({ ...eingabe.sshConfig, identityFile: pfad });
} catch (fehler) {
if (fehler instanceof SshConfigFehler) throw new VerwendungsFehler(fehler.message);
throw fehler;
}
const schluessel = await prisma.schluessel.findUnique({
where: { id: eingabe.schluesselId },
select: {
status: true,
fingerprint: true,
kategorie: true,
privatGeloeschtAm: true,
masterKeyVersion: true,
},
});
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
if (schluessel.status !== "AKTIV")
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich ausrollen.");
if (schluessel.kategorie !== "DIENST")
throw new VerwendungsFehler("Private Keys werden nur für Service-Schlüssel ausgerollt.");
// masterKeyVersion ist genau dann gesetzt, wenn ein Private Key gespeichert ist (DB-CHECK).
if (schluessel.masterKeyVersion === null) {
throw new VerwendungsFehler(
schluessel.privatGeloeschtAm
? "Der Private Key wurde aus SSHKeyVault gelöscht."
: "Zu diesem Schlüssel ist kein Private Key gespeichert.",
);
}
const host = await prisma.host.findUnique({
where: { id: eingabe.hostId },
select: { name: true, archiviertAm: true },
});
if (!host || host.archiviertAm) throw new VerwendungsFehler("Den Host gibt es nicht (mehr).");
try {
const verwendung = await prisma.privateVerwendung.create({
data: {
schluesselId: eingabe.schluesselId,
hostId: eingabe.hostId,
zielbenutzer: eingabe.zielbenutzer,
pfad,
sshConfig: eingabe.sshConfig ?? undefined,
knownHostsFuer: eingabe.knownHostsFuer ?? [],
beimWiderruf: eingabe.beimWiderruf ?? "LOESCHEN",
angelegtVonId: akteur.id,
},
});
await protokollieren({
akteur,
aktion: "verwendung.private.angelegt",
ergebnis: "ERFOLG",
hostName: host.name,
zielbenutzer: eingabe.zielbenutzer,
fingerprint: schluessel.fingerprint,
details: { pfad, sshConfig: eingabe.sshConfig ?? null },
});
return verwendung.id;
} catch (fehler) {
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
throw new VerwendungsFehler(
"An diesem Pfad liegt für diesen Benutzer bereits ein verwalteter Schlüssel.",
);
}
throw fehler;
}
}
export async function privateVerwendungEntfernen(id: string, akteur: Akteur): Promise<void> {
const verwendung = await prisma.privateVerwendung.findUnique({
where: { id },
include: { schluessel: { select: { fingerprint: true } }, host: { select: { name: true } } },
});
if (!verwendung || verwendung.zustand === "ENTFERNT")
throw new VerwendungsFehler("Diese Zuweisung gibt es nicht.");
const nieAusgerollt = verwendung.zustand === "GEPLANT" && !verwendung.zuletztAngewendetAm;
await prisma.privateVerwendung.update({
where: { id },
data: { zustand: nieAusgerollt ? "ENTFERNT" : "ENTFERNEN" },
});
await protokollieren({
akteur,
aktion: "verwendung.private.entfernen",
ergebnis: "ERFOLG",
hostName: verwendung.host.name,
zielbenutzer: verwendung.zielbenutzer,
fingerprint: verwendung.schluessel.fingerprint,
});
}