Files
sshkeyvault/src/server/services/soll.ts
T
florian.wildeandClaude Opus 5.5 cab6f45510 feat(deploymentkey): geführte Rotation des Deployment-Keys (#031)
Bisher ließ sich der Instanz-Key nur von Hand auf jedem Host tauschen.
Die Rotation läuft jetzt in fünf Schritten: Nachfolger erzeugen, per
Dry-Run neben den alten verteilen, Anmeldung je Host prüfen, umstellen,
alten Key aus allen Blöcken entfernen und seinen Private Key verwerfen.

Der Nachfolger steht während der Rotation in Block und Pflichtliste,
damit kein Host ohne funktionierenden Key dasteht. Umgestellt wird nur
nach erfolgreicher Prüfung oder bewusstem Übergehen eines Hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:27:09 +02:00

207 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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[];
/** 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<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 },
});
}
/**
* 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<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,
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<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: {
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 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<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()];
}