import type { Host } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import type { SollEintrag } from "@/lib/ssh/authorized-keys"; /** * Der Soll-Zustand einer authorized_keys (#016): welche Public Keys gehoeren in den * verwalteten Block eines Benutzers auf einem Host – direkt zugewiesen oder ueber eine * Hostgruppe. Laeuft im Worker und im Web (Vorschau), deshalb hier und nicht in src/worker. */ /** Dienstbenutzer auf den Hosts (Freigabe Frage 1); gleiche Regel wie im Worker. */ export function anmeldebenutzerVon(host: Pick): string { return host.anmeldeart === "ROOT" ? "root" : "sshkv"; } /** Zustaende, in denen eine Verwendung zum Soll gehoert. */ const IM_SOLL = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const; export type Soll = { readonly eintraege: SollEintrag[]; /** Fingerprints, die nie aus dem Block fallen duerfen (Deployment-Key). */ readonly pflicht: string[]; /** Uebernommene Keys (#022), deren Zeile ausserhalb des Blocks verschwinden soll. */ readonly ausserhalbEntfernen: string[]; /** Verwendungen, die in diesem Soll stecken oder entfernt werden – fuer den Zustand danach. */ readonly verwendungIds: { readonly drin: string[]; readonly raus: string[] }; }; async function deploymentKeyFuer(host: Pick) { if (host.deploymentSchluesselId) { return prisma.schluessel.findUnique({ where: { id: host.deploymentSchluesselId }, select: { publicKey: true, fingerprint: true }, }); } const gruppe = await prisma.hostgruppeMitglied.findFirst({ where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } }, select: { hostgruppe: { select: { deploymentSchluessel: { select: { publicKey: true, fingerprint: true } } } }, }, }); if (gruppe?.hostgruppe.deploymentSchluessel) return gruppe.hostgruppe.deploymentSchluessel; return prisma.schluessel.findFirst({ where: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" }, select: { publicKey: true, fingerprint: true }, }); } /** * Der Nachfolger des Instanz-Deployment-Keys waehrend einer Rotation (#031): neu erzeugt, * aber noch nicht Standard. Er gehoert schon in jeden Block – neben den alten –, damit kein * Host je ohne funktionierenden Deployment-Key dasteht. */ export async function deploymentNachfolger() { return prisma.schluessel.findFirst({ where: { kategorie: "DEPLOYMENT", instanzStandard: false, status: "AKTIV", vorgaenger: { kategorie: "DEPLOYMENT", instanzStandard: true, status: "AKTIV" }, }, select: { id: true, publicKey: true, fingerprint: true, vorgaengerId: true, angelegtAm: true }, }); } export async function sollFuer(hostId: string, zielbenutzer: string): Promise { const host = await prisma.host.findUniqueOrThrow({ where: { id: hostId }, select: { id: true, anmeldeart: true, deploymentSchluesselId: true }, }); const verwendungen = await prisma.publicVerwendung.findMany({ where: { zielbenutzer, OR: [{ hostId }, { hostgruppe: { mitglieder: { some: { hostId } } } }], zustand: { not: "ENTFERNT" }, }, select: { id: true, optionen: true, ausserhalbEntfernen: true, zustand: true, angelegtAm: true, schluessel: { select: { publicKey: true, fingerprint: true, status: true } }, }, // Deterministische Reihenfolge im Block: aelteste Zuweisung zuerst. orderBy: [{ angelegtAm: "asc" }, { id: "asc" }], }); const eintraege: SollEintrag[] = []; const pflicht: string[] = []; if (zielbenutzer === anmeldebenutzerVon(host)) { const deployment = await deploymentKeyFuer(host); if (deployment) { eintraege.push({ publicKey: deployment.publicKey, fingerprint: deployment.fingerprint }); pflicht.push(deployment.fingerprint); } // Nur Hosts mit dem Instanz-Key nehmen an dessen Rotation teil (#031). const eigener = host.deploymentSchluesselId !== null; const nachfolger = eigener ? null : await deploymentNachfolger(); if (nachfolger && (await deploymentKeyFuer(host))?.fingerprint !== nachfolger.fingerprint) { const gruppe = await prisma.hostgruppeMitglied.count({ where: { hostId: host.id, hostgruppe: { deploymentSchluesselId: { not: null } } }, }); if (gruppe === 0) { eintraege.push({ publicKey: nachfolger.publicKey, fingerprint: nachfolger.fingerprint }); pflicht.push(nachfolger.fingerprint); } } } const drin: string[] = []; const raus: string[] = []; const ausserhalbEntfernen: string[] = []; for (const verwendung of verwendungen) { const aktiv = verwendung.schluessel.status === "AKTIV" || verwendung.schluessel.status === "ROTIERT"; if ((IM_SOLL as readonly string[]).includes(verwendung.zustand) && aktiv) { eintraege.push({ publicKey: verwendung.schluessel.publicKey, fingerprint: verwendung.schluessel.fingerprint, optionen: verwendung.optionen, }); drin.push(verwendung.id); if (verwendung.ausserhalbEntfernen) ausserhalbEntfernen.push(verwendung.schluessel.fingerprint); } else { raus.push(verwendung.id); } } return { eintraege, pflicht, ausserhalbEntfernen, verwendungIds: { drin, raus } }; } /** 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: { 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: { zielbenutzer: true, hostId: true, hostgruppe: { select: { mitglieder: { select: { hostId: true, host: { select: { archiviertAm: true } } } } }, }, }, }); const ziele = new Map(); for (const verwendung of verwendungen) { 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, zielbenutzer: verwendung.zielbenutzer, }); } } 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 { 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(); for (const v of verwendungen) { ziele.set(`${v.hostId}\u0000${v.zielbenutzer}`, { hostId: v.hostId, zielbenutzer: v.zielbenutzer, art: "privat", }); } return [...ziele.values()]; }