feat(ausrollen): Public Keys mit Trockenlauf und Backup ausrollen (#016)

Zuweisungen von Public Keys an Hosts oder Hostgruppen, ein Trockenlauf
mit Diff je Ziel und das Ausrollen genau dieses Plans. Hat sich die
Datei oder das Soll seit dem Trockenlauf geaendert, bricht das Ziel
ab: es wird nie etwas anderes geschrieben, als jemand gesehen hat.

Auf dem Host geschieht alles als Zielbenutzer, mit Sicherung, Temp-
Datei, mv und 0700/0600; der Hash wird dort erneut geprueft. Der
Deployment-Key gehoert immer zum Soll des Anmeldebenutzers.

Integrationstests gegen echtes sshd, die Oberflaechenbausteine folgen
mit der Schluesselseite aus #012.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:17:35 +02:00
co-authored by Claude Opus 5.5
parent 1358055e40
commit f66d87ad11
13 changed files with 1339 additions and 3 deletions
+131
View File
@@ -0,0 +1,131 @@
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<Host, "anmeldeart">): 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[];
/** 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<Host, "id" | "deploymentSchluesselId">) {
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 },
});
}
export async function sollFuer(hostId: string, zielbenutzer: string): Promise<Soll> {
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,
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);
}
}
const drin: string[] = [];
const raus: 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);
} else {
raus.push(verwendung.id);
}
}
return { eintraege, pflicht, verwendungIds: { drin, raus } };
}
export type Ziel = { readonly hostId: string; readonly zielbenutzer: string };
/** Alle (Host, Benutzer)-Paare, die eine Public-Key-Verwendung betrifft – Gruppen aufgeloest. */
export async function zieleVonVerwendungen(where: {
schluesselId?: string;
ids?: string[];
}): Promise<Ziel[]> {
const verwendungen = await prisma.publicVerwendung.findMany({
where: {
...(where.schluesselId ? { schluesselId: where.schluesselId } : {}),
...(where.ids ? { id: { in: where.ids } } : {}),
zustand: { not: "ENTFERNT" },
},
select: {
zielbenutzer: true,
hostId: true,
hostgruppe: {
select: { mitglieder: { select: { hostId: true, host: { select: { archiviertAm: true } } } } },
},
},
});
const ziele = new Map<string, Ziel>();
for (const verwendung of verwendungen) {
const hostIds = verwendung.hostId
? [verwendung.hostId]
: (verwendung.hostgruppe?.mitglieder.filter((m) => !m.host.archiviertAm).map((m) => m.hostId) ?? []);
for (const hostId of hostIds) {
ziele.set(`${hostId}\u0000${verwendung.zielbenutzer}`, {
hostId,
zielbenutzer: verwendung.zielbenutzer,
});
}
}
return [...ziele.values()];
}