Merge branch 'phase/023-024' (Rotation, Widerruf)
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon } from "@/server/actions/formular";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { FachFehler } from "@/server/services/fachfehler";
|
||||
import { rotationAltWiderrufen, rotationStarten, rotationUebernehmen } from "@/server/services/rotation";
|
||||
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||
|
||||
/**
|
||||
* Gefuehrte Rotation (#023): je Schritt eine duenne Aktion, die Oberflaeche verkettet sie.
|
||||
* Rotieren heisst einen Schluessel erzeugen und ausrollen: `schluesselVerwalten` und
|
||||
* `publicKeyAusrollen`; fuer Private-Key-Zuweisungen zusaetzlich `privateKeyAusrollen` (Service).
|
||||
*/
|
||||
export type RotationsAntwort = { readonly vorgangId?: string; readonly fehler?: string };
|
||||
|
||||
function alsAntwort(fehler: unknown): RotationsAntwort {
|
||||
if (
|
||||
fehler instanceof RechteFehler ||
|
||||
fehler instanceof BestaetigungFehler ||
|
||||
fehler instanceof VerwendungsFehler ||
|
||||
fehler instanceof FachFehler
|
||||
) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
|
||||
async function berechtigt() {
|
||||
await rechtErforderlich("schluesselVerwalten");
|
||||
return rechtErforderlich("publicKeyAusrollen");
|
||||
}
|
||||
|
||||
export async function rotationStartenAktion(schluesselId: string): Promise<RotationsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await berechtigt();
|
||||
return { vorgangId: await rotationStarten(id.parse(schluesselId), akteurVon(benutzer)) };
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotationUebernehmenAktion(schluesselId: string): Promise<RotationsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await berechtigt();
|
||||
await rotationUebernehmen(id.parse(schluesselId), akteurVon(benutzer), {
|
||||
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
});
|
||||
revalidatePath(`/schluessel/${schluesselId}`);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rotationAltWiderrufenAktion(schluesselId: string): Promise<RotationsAntwort> {
|
||||
try {
|
||||
const { benutzer } = await berechtigt();
|
||||
await rotationAltWiderrufen(id.parse(schluesselId), akteurVon(benutzer), {
|
||||
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
});
|
||||
revalidatePath(`/schluessel/${schluesselId}`);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,11 @@ export async function publicEntfernenAktion(
|
||||
}
|
||||
}
|
||||
|
||||
export type AusrollAuswahl = { readonly schluesselId: string } | { readonly hostId: string };
|
||||
export type AusrollAuswahl =
|
||||
| { readonly schluesselId: string }
|
||||
| { readonly hostId: string }
|
||||
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
|
||||
| { readonly schluesselIds: readonly string[] };
|
||||
|
||||
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
|
||||
try {
|
||||
@@ -84,7 +88,9 @@ export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<Vorgan
|
||||
vorgangId: await trockenlaufStarten(
|
||||
"schluesselId" in auswahl
|
||||
? { schluesselId: id.parse(auswahl.schluesselId) }
|
||||
: { hostId: id.parse(auswahl.hostId) },
|
||||
: "schluesselIds" in auswahl
|
||||
? { schluesselIds: z.array(id).min(1).max(200).parse(auswahl.schluesselIds) }
|
||||
: { hostId: id.parse(auswahl.hostId) },
|
||||
akteurVon(benutzer),
|
||||
// Private-Key-Ziele nur fuer Admins – ein Operator rollt so nie einen Private Key aus.
|
||||
darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon } from "@/server/actions/formular";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||
import {
|
||||
widerrufAbschliessen,
|
||||
widerrufEinleiten,
|
||||
widerrufZielkontenEntfernen,
|
||||
type AbschlussErgebnis,
|
||||
} from "@/server/services/widerruf";
|
||||
|
||||
/**
|
||||
* Widerruf und Massenwiderruf (#024). Duenn: Recht, Eingabe, Service.
|
||||
*
|
||||
* Rechte: einen Public Key entfernen darf, wer ausrollen darf; sobald Private Keys betroffen
|
||||
* sind, nur ein Admin (prueft der Service mit `privatErlaubt`). Mehrere Schluessel auf einmal
|
||||
* (Massenwiderruf) und das Entfernen von Zielkonten verlangen Admin mit frischer Bestaetigung.
|
||||
*/
|
||||
export type WiderrufAntwort = {
|
||||
readonly fehler?: string;
|
||||
/** Frische Bestaetigung noetig: die Oberflaeche verlinkt /bestaetigen. */
|
||||
readonly bestaetigen?: boolean;
|
||||
};
|
||||
|
||||
function alsAntwort(fehler: unknown): WiderrufAntwort {
|
||||
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
|
||||
if (fehler instanceof RechteFehler || fehler instanceof VerwendungsFehler)
|
||||
return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const idListe = z.array(z.string().min(1).max(64)).min(1).max(200);
|
||||
|
||||
function pfadeNeuLaden(ids: readonly string[]) {
|
||||
revalidatePath("/schluessel");
|
||||
for (const id of ids) revalidatePath(`/schluessel/${id}`);
|
||||
}
|
||||
|
||||
export async function widerrufEinleitenAktion(schluesselIds: string[]): Promise<WiderrufAntwort> {
|
||||
try {
|
||||
const ids = idListe.parse(schluesselIds);
|
||||
const { benutzer } = await rechtErforderlich(ids.length > 1 ? "massenwiderruf" : "publicKeyAusrollen");
|
||||
await widerrufEinleiten(ids, akteurVon(benutzer), {
|
||||
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
});
|
||||
pfadeNeuLaden(ids);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function widerrufZielkontenAktion(
|
||||
schluesselIds: string[],
|
||||
zielkontoIds: string[],
|
||||
): Promise<WiderrufAntwort & { vorgaenge?: { id: string; beschriftung: string }[] }> {
|
||||
try {
|
||||
const ids = idListe.parse(schluesselIds);
|
||||
const konten = idListe.parse(zielkontoIds);
|
||||
// Ein Benutzer samt Home verschwindet: Admin mit frischer Bestaetigung (#018).
|
||||
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
|
||||
return { vorgaenge: await widerrufZielkontenEntfernen(ids, konten, akteurVon(benutzer)) };
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function widerrufAbschliessenAktion(
|
||||
schluesselIds: string[],
|
||||
): Promise<WiderrufAntwort & { ergebnisse?: AbschlussErgebnis[] }> {
|
||||
try {
|
||||
const ids = idListe.parse(schluesselIds);
|
||||
// Abschliessen setzt nur den Status nach dem, was die Datenbank belegt – kein Host-Zugriff.
|
||||
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||
const ergebnisse = await widerrufAbschliessen(ids, akteurVon(benutzer));
|
||||
pfadeNeuLaden(ids);
|
||||
return { ergebnisse };
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const SCHLUESSEL_LISTE_SELECT = {
|
||||
ablaufAm: true,
|
||||
masterKeyVersion: true,
|
||||
passphraseGeschuetzt: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
person: { select: { id: true, name: true } },
|
||||
_count: {
|
||||
select: {
|
||||
@@ -50,12 +51,15 @@ export const SCHLUESSEL_DETAIL_SELECT = {
|
||||
tags: true,
|
||||
ablaufAm: true,
|
||||
zuletztVerwendetAm: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
angelegtAm: true,
|
||||
personId: true,
|
||||
vorlageId: true,
|
||||
person: { select: { id: true, name: true } },
|
||||
vorlage: { select: { id: true, name: true } },
|
||||
erstelltVon: { select: { name: true } },
|
||||
vorgaenger: { select: { id: true, name: true, status: true } },
|
||||
nachfolger: { select: { id: true, name: true, status: true } },
|
||||
publicVerwendungen: {
|
||||
where: { zustand: { not: "ENTFERNT" } },
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
@@ -101,13 +105,34 @@ function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
|
||||
return STATI.find((s) => s === wert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heute 00:00 UTC nach dem Kalender in Berlin – Ablaufdaten liegen als UTC-Mitternacht ihres
|
||||
* Tages in der Datenbank, und `ablaufZustand()` rechnet ebenfalls mit dem Berliner Datum.
|
||||
*/
|
||||
function heute(jetzt = new Date()): Date {
|
||||
const tag = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" }).format(jetzt);
|
||||
return new Date(`${tag}T00:00:00Z`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status als Filter (#024): ein aktiver Schluessel mit Ablaufdatum in der Vergangenheit gilt als
|
||||
* abgelaufen, auch wenn niemand den Status von Hand gesetzt hat. Automatisch entfernt wird nichts.
|
||||
*/
|
||||
function statusFilter(status: SchluesselStatus | undefined): Prisma.SchluesselWhereInput {
|
||||
if (status === "AKTIV") return { status, OR: [{ ablaufAm: null }, { ablaufAm: { gte: heute() } }] };
|
||||
if (status === "ABGELAUFEN") {
|
||||
return { OR: [{ status }, { status: "AKTIV", ablaufAm: { lt: heute() } }] };
|
||||
}
|
||||
return status ? { status } : {};
|
||||
}
|
||||
|
||||
export async function schluesselListe(filter: SchluesselFilter = {}) {
|
||||
const q = filter.q?.trim().slice(0, 100);
|
||||
const kategorie = alsKategorie(filter.kategorie);
|
||||
const status = alsStatus(filter.status);
|
||||
const where: Prisma.SchluesselWhereInput = {
|
||||
...(kategorie ? { kategorie } : {}),
|
||||
...(status ? { status } : {}),
|
||||
AND: [statusFilter(status)],
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
@@ -166,3 +191,44 @@ export async function vorlagenAuswahl() {
|
||||
select: { id: true, name: true, standardLaufzeitTage: true },
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Rotationskette (#023)
|
||||
|
||||
export type KettenGlied = { readonly id: string; readonly name: string; readonly status: SchluesselStatus };
|
||||
|
||||
/**
|
||||
* Die ganze Rotationskette eines Schluessels, aeltester zuerst. Begrenzt, damit eine (durch
|
||||
* Fehler entstandene) Schleife die Seite nicht aufhaengt.
|
||||
*/
|
||||
export async function rotationsKette(id: string, maximal = 25): Promise<KettenGlied[]> {
|
||||
const glied = (schluesselId: string) =>
|
||||
prisma.schluessel.findUnique({
|
||||
where: { id: schluesselId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
vorgaengerId: true,
|
||||
nachfolger: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
const start = await glied(id);
|
||||
if (!start) return [];
|
||||
const kette: KettenGlied[] = [{ id: start.id, name: start.name, status: start.status }];
|
||||
const gesehen = new Set([start.id]);
|
||||
for (let davor = start.vorgaengerId; davor && kette.length < maximal && !gesehen.has(davor);) {
|
||||
const g = await glied(davor);
|
||||
if (!g) break;
|
||||
gesehen.add(g.id);
|
||||
kette.unshift({ id: g.id, name: g.name, status: g.status });
|
||||
davor = g.vorgaengerId;
|
||||
}
|
||||
for (let danach = start.nachfolger?.id; danach && kette.length < maximal && !gesehen.has(danach);) {
|
||||
const g = await glied(danach);
|
||||
if (!g) break;
|
||||
gesehen.add(g.id);
|
||||
kette.push({ id: g.id, name: g.name, status: g.status });
|
||||
danach = g.nachfolger?.id;
|
||||
}
|
||||
return kette;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
offeneEntfernungen,
|
||||
widerrufGesperrt,
|
||||
zielkontoKandidaten,
|
||||
type OffeneEntfernung,
|
||||
type ZielkontoKandidat,
|
||||
} from "@/server/services/widerruf";
|
||||
|
||||
/**
|
||||
* Was ein Widerruf bewirkt (#024) – fuer den Bestaetigungsdialog, die Massenwiderruf-Seite und
|
||||
* die Fortsetzen-Ansicht. Nur Namen, Ziele und Pfade; nie Schluesselinhalte.
|
||||
*/
|
||||
export type WiderrufSchluessel = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly kategorie: "PERSON" | "DIENST" | "DEPLOYMENT";
|
||||
readonly status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
|
||||
readonly eingeleitet: boolean;
|
||||
readonly person: string | null;
|
||||
readonly gesperrt: string | null;
|
||||
};
|
||||
|
||||
export type WiderrufZiel = {
|
||||
readonly id: string;
|
||||
readonly schluesselId: string;
|
||||
readonly ziel: string;
|
||||
readonly archiviert: boolean;
|
||||
};
|
||||
|
||||
export type WiderrufPrivatZiel = WiderrufZiel & {
|
||||
readonly pfad: string;
|
||||
readonly alias: string | null;
|
||||
readonly beimWiderruf: "LOESCHEN" | "UMBENENNEN";
|
||||
};
|
||||
|
||||
export type WiderrufUebersicht = {
|
||||
readonly schluessel: WiderrufSchluessel[];
|
||||
readonly public: WiderrufZiel[];
|
||||
readonly privat: WiderrufPrivatZiel[];
|
||||
readonly zielkonten: ZielkontoKandidat[];
|
||||
readonly offen: OffeneEntfernung[];
|
||||
};
|
||||
|
||||
export async function widerrufUebersicht(schluesselIds: readonly string[]): Promise<WiderrufUebersicht> {
|
||||
const ids = [...new Set(schluesselIds)];
|
||||
const schluessel = await prisma.schluessel.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
kategorie: true,
|
||||
status: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
person: { select: { name: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
const [oeffentlich, privat, zielkonten, offen] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
schluesselId: true,
|
||||
zielbenutzer: true,
|
||||
host: { select: { name: true, archiviertAm: true } },
|
||||
hostgruppe: { select: { name: true } },
|
||||
},
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
schluesselId: true,
|
||||
zielbenutzer: true,
|
||||
pfad: true,
|
||||
sshConfig: true,
|
||||
beimWiderruf: true,
|
||||
host: { select: { name: true, archiviertAm: true } },
|
||||
},
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
zielkontoKandidaten(ids),
|
||||
offeneEntfernungen(ids),
|
||||
]);
|
||||
return {
|
||||
schluessel: schluessel.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
kategorie: s.kategorie,
|
||||
status: s.status,
|
||||
eingeleitet: Boolean(s.widerrufEingeleitetAm),
|
||||
person: s.person?.name ?? null,
|
||||
gesperrt: widerrufGesperrt(s),
|
||||
})),
|
||||
public: oeffentlich.map((v) => ({
|
||||
id: v.id,
|
||||
schluesselId: v.schluesselId,
|
||||
ziel: v.host
|
||||
? `${v.zielbenutzer}@${v.host.name}`
|
||||
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
|
||||
archiviert: Boolean(v.host?.archiviertAm),
|
||||
})),
|
||||
privat: privat.map((v) => {
|
||||
const config =
|
||||
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
||||
return {
|
||||
id: v.id,
|
||||
schluesselId: v.schluesselId,
|
||||
ziel: `${v.zielbenutzer}@${v.host.name}`,
|
||||
pfad: v.pfad,
|
||||
alias: typeof config?.alias === "string" ? config.alias : null,
|
||||
beimWiderruf: v.beimWiderruf,
|
||||
archiviert: Boolean(v.host.archiviertAm),
|
||||
};
|
||||
}),
|
||||
zielkonten,
|
||||
offen,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rotationsDatum, rotationsName, rotationsPfad } from "@/lib/rotation";
|
||||
import { NAME_MAX, type SchluesselVorgangEingabe } from "@/lib/schluessel";
|
||||
import { eindeutigerSlug } from "@/server/services/schluessel";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import {
|
||||
VerwendungsFehler,
|
||||
privateVerwendungAnlegen,
|
||||
publicVerwendungAnlegen,
|
||||
} from "@/server/services/verwendungen";
|
||||
import { widerrufEinleiten } from "@/server/services/widerruf";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Gefuehrte Rotation (#023): neuer Schluessel gleichen Typs, alle aktiven Zuweisungen
|
||||
* uebernehmen, ausrollen, Verbindungen testen – und erst dann den alten Schluessel ueber den
|
||||
* Widerruf (#024) entfernen. Der alte endet als ROTIERT, der neue zeigt per `vorgaengerId` auf ihn.
|
||||
*
|
||||
* Jeder Schritt ist idempotent und liest seinen Stand aus der Datenbank: die Oberflaeche kann
|
||||
* jederzeit neu laden und an derselben Stelle weitermachen.
|
||||
*/
|
||||
export class RotationsFehler extends VerwendungsFehler {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "RotationsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
/** Zustaende, in denen eine Verwendung mitgenommen wird. */
|
||||
const AKTIVE = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
|
||||
|
||||
const ALT_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
typ: true,
|
||||
kategorie: true,
|
||||
status: true,
|
||||
fingerprint: true,
|
||||
tags: true,
|
||||
ablaufAm: true,
|
||||
personId: true,
|
||||
vorlageId: true,
|
||||
masterKeyVersion: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
nachfolger: { select: { id: true, name: true, status: true } },
|
||||
_count: { select: { privateVerwendungen: { where: { zustand: { in: [...AKTIVE] } } } } },
|
||||
} satisfies Prisma.SchluesselSelect;
|
||||
|
||||
type Alt = Prisma.SchluesselGetPayload<{ select: typeof ALT_SELECT }>;
|
||||
|
||||
/**
|
||||
* Warum sshkv diesen Schluessel nicht selbst rotieren kann – oder null. Ein Personen-Key (oder
|
||||
* ein Dienst-Key, dessen Private Key woanders liegt) braucht einen neuen Public Key von aussen.
|
||||
*/
|
||||
export function rotationGesperrt(
|
||||
s: Pick<Alt, "kategorie" | "status" | "masterKeyVersion" | "widerrufEingeleitetAm"> & {
|
||||
readonly privatZuweisungen: number;
|
||||
},
|
||||
): { grund: string; neuerPublicKeyNoetig?: boolean } | null {
|
||||
if (s.kategorie === "DEPLOYMENT")
|
||||
return { grund: "Der Deployment-Key wird über eine eigene Rotation ersetzt." };
|
||||
if (s.status !== "AKTIV" && s.status !== "ABGELAUFEN")
|
||||
return { grund: "Nur aktive oder abgelaufene Schlüssel lassen sich rotieren." };
|
||||
if (s.kategorie === "PERSON" || (s.masterKeyVersion === null && s.privatZuweisungen === 0)) {
|
||||
return {
|
||||
grund:
|
||||
"Den Private Key hat sshkv nicht – rotieren heißt hier: Die Person erzeugt ein neues Schlüsselpaar und liefert den neuen Public Key. Diesen importieren, genauso zuweisen und danach diesen Schlüssel widerrufen.",
|
||||
neuerPublicKeyNoetig: true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function altLaden(id: string): Promise<Alt> {
|
||||
const alt = await prisma.schluessel.findUnique({ where: { id }, select: ALT_SELECT });
|
||||
if (!alt) throw new RotationsFehler("Diesen Schlüssel gibt es nicht.");
|
||||
return alt;
|
||||
}
|
||||
|
||||
function gesperrtPruefen(alt: Alt) {
|
||||
// Ein laufender Widerruf gehoert zur Rotation, wenn es schon einen Nachfolger gibt.
|
||||
if (alt.widerrufEingeleitetAm && !alt.nachfolger)
|
||||
throw new RotationsFehler("Dieser Schlüssel wird gerade widerrufen.");
|
||||
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
|
||||
if (gesperrt) throw new RotationsFehler(gesperrt.grund);
|
||||
}
|
||||
|
||||
/** Schritt 1: Nachfolger im Worker erzeugen lassen. Liefert die Vorgangs-ID. */
|
||||
export async function rotationStarten(altId: string, akteur: Akteur): Promise<string> {
|
||||
const alt = await altLaden(altId);
|
||||
gesperrtPruefen(alt);
|
||||
if (alt.nachfolger) throw new RotationsFehler("Für diesen Schlüssel gibt es schon einen Nachfolger.");
|
||||
const name = rotationsName(alt.name, rotationsDatum().anzeige, NAME_MAX);
|
||||
const slug = await eindeutigerSlug(null, name);
|
||||
// Ein vergangenes Ablaufdatum wuerde der Nachfolger sonst erben – er waere sofort abgelaufen.
|
||||
const ablaufAm = alt.ablaufAm && alt.ablaufAm > new Date() ? alt.ablaufAm.toISOString() : null;
|
||||
const eingabe: SchluesselVorgangEingabe = {
|
||||
name,
|
||||
slug,
|
||||
tags: alt.tags,
|
||||
ablaufAm,
|
||||
personId: alt.personId,
|
||||
vorlageId: alt.vorlageId,
|
||||
kommentar: null,
|
||||
typ: alt.typ === "RSA4096" ? "RSA4096" : "ED25519",
|
||||
vorgaengerId: alt.id,
|
||||
};
|
||||
return vorgangEinreihen({ art: "SCHLUESSEL_ERZEUGEN", angefordertVonId: akteur.id, eingabe });
|
||||
}
|
||||
|
||||
async function nachfolgerVon(alt: Alt) {
|
||||
if (!alt.nachfolger) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
||||
if (alt.nachfolger.status !== "AKTIV") throw new RotationsFehler("Der neue Schlüssel ist nicht aktiv.");
|
||||
return alt.nachfolger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schritt 2: aktive Zuweisungen fuer den Nachfolger uebernehmen. Public: gleiche Ziele und
|
||||
* Optionen. Private: gleicher Host/Benutzer und Alias, neuer Pfad – der config-Block zeigt nach
|
||||
* dem Ausrollen auf die neue Datei (juengste Zuweisung gewinnt je Alias, src/worker/ablaeufe/privat.ts).
|
||||
*/
|
||||
export async function rotationUebernehmen(
|
||||
altId: string,
|
||||
akteur: Akteur,
|
||||
optionen: { readonly privatErlaubt: boolean },
|
||||
): Promise<{ public: number; privat: number }> {
|
||||
const alt = await altLaden(altId);
|
||||
gesperrtPruefen(alt);
|
||||
const neu = await nachfolgerVon(alt);
|
||||
const [altPublic, altPrivat, neuPublic, neuPrivat] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } },
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } },
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
prisma.publicVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }),
|
||||
prisma.privateVerwendung.findMany({ where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } } }),
|
||||
]);
|
||||
if (altPrivat.length > 0 && !optionen.privatErlaubt)
|
||||
throw new RotationsFehler("Private Keys rollt nur ein Admin aus.");
|
||||
|
||||
let anzahlPublic = 0;
|
||||
for (const v of altPublic) {
|
||||
const schon = neuPublic.some(
|
||||
(n) => n.hostId === v.hostId && n.hostgruppeId === v.hostgruppeId && n.zielbenutzer === v.zielbenutzer,
|
||||
);
|
||||
if (schon) continue;
|
||||
await publicVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: neu.id,
|
||||
hostId: v.hostId,
|
||||
hostgruppeId: v.hostgruppeId,
|
||||
zielbenutzer: v.zielbenutzer,
|
||||
optionen: v.optionen,
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
anzahlPublic += 1;
|
||||
}
|
||||
|
||||
const datum = rotationsDatum().kompakt;
|
||||
let anzahlPrivat = 0;
|
||||
for (const v of altPrivat) {
|
||||
const config = v.sshConfig as { alias: string; hostName: string; benutzer: string; port: number } | null;
|
||||
const schon = neuPrivat.some(
|
||||
(n) =>
|
||||
n.hostId === v.hostId &&
|
||||
n.zielbenutzer === v.zielbenutzer &&
|
||||
((n.sshConfig as { alias?: string } | null)?.alias ?? null) === (config?.alias ?? null),
|
||||
);
|
||||
if (schon) continue;
|
||||
// Auch entfernte Zuweisungen belegen einen Pfad (eindeutiger Index ueber alle Zustaende).
|
||||
const belegt = new Set(
|
||||
(
|
||||
await prisma.privateVerwendung.findMany({
|
||||
where: { hostId: v.hostId, zielbenutzer: v.zielbenutzer },
|
||||
select: { pfad: true },
|
||||
})
|
||||
).map((p) => p.pfad),
|
||||
);
|
||||
await privateVerwendungAnlegen(
|
||||
{
|
||||
schluesselId: neu.id,
|
||||
hostId: v.hostId,
|
||||
zielbenutzer: v.zielbenutzer,
|
||||
pfad: rotationsPfad(v.pfad, datum, belegt),
|
||||
sshConfig: config,
|
||||
knownHostsFuer: v.knownHostsFuer,
|
||||
beimWiderruf: v.beimWiderruf,
|
||||
},
|
||||
akteur,
|
||||
);
|
||||
anzahlPrivat += 1;
|
||||
}
|
||||
if (anzahlPublic + anzahlPrivat > 0) {
|
||||
await protokollieren({
|
||||
akteur,
|
||||
aktion: "schluessel.rotation.uebernommen",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: alt.fingerprint,
|
||||
details: { nachfolgerId: neu.id, public: anzahlPublic, privat: anzahlPrivat },
|
||||
});
|
||||
}
|
||||
return { public: anzahlPublic, privat: anzahlPrivat };
|
||||
}
|
||||
|
||||
/** Verbindungstests, die der Nachfolger bestehen muss: jede Private-Zuweisung mit Alias. */
|
||||
async function testZiele(neuId: string) {
|
||||
const verwendungen = await prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: neuId, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
zielbenutzer: true,
|
||||
sshConfig: true,
|
||||
zustand: true,
|
||||
zuletztAngewendetAm: true,
|
||||
host: { select: { name: true } },
|
||||
},
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
});
|
||||
const mitAlias = verwendungen.filter((v) => (v.sshConfig as { alias?: string } | null)?.alias);
|
||||
return Promise.all(
|
||||
mitAlias.map(async (v) => {
|
||||
// Gruen ist nur ein Test nach dem letzten Ausrollen – ein aelterer bewiese nichts.
|
||||
const test = await prisma.vorgang.findFirst({
|
||||
where: {
|
||||
art: "VERBINDUNGSTEST",
|
||||
eingabe: { equals: { privatVerwendungId: v.id } },
|
||||
...(v.zuletztAngewendetAm ? { angelegtAm: { gte: v.zuletztAngewendetAm } } : {}),
|
||||
},
|
||||
orderBy: { angelegtAm: "desc" },
|
||||
select: { status: true, fehler: true },
|
||||
});
|
||||
return {
|
||||
id: v.id,
|
||||
ziel: `${v.zielbenutzer}@${v.host.name} → ${(v.sshConfig as { alias: string }).alias}`,
|
||||
ausgerollt: v.zustand === "AUSGEROLLT",
|
||||
status: test?.status ?? null,
|
||||
fehler: test?.fehler ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export type RotationsStand = Awaited<ReturnType<typeof rotationsStand>>;
|
||||
|
||||
/** Wo steht die Rotation? Fuer die Schrittanzeige – alles aus der Datenbank. */
|
||||
export async function rotationsStand(altId: string) {
|
||||
const alt = await altLaden(altId);
|
||||
const gesperrt = rotationGesperrt({ ...alt, privatZuweisungen: alt._count.privateVerwendungen });
|
||||
const neu = alt.nachfolger;
|
||||
if (!neu) return { alt, gesperrt, neu: null, schritt: 0 as const };
|
||||
|
||||
const [altPublic, altPrivat, neuPublic, neuPrivat, tests] = await Promise.all([
|
||||
prisma.publicVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
|
||||
prisma.privateVerwendung.count({ where: { schluesselId: alt.id, zustand: { in: [...AKTIVE] } } }),
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } },
|
||||
select: { zustand: true },
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: neu.id, zustand: { not: "ENTFERNT" } },
|
||||
select: { zustand: true },
|
||||
}),
|
||||
testZiele(neu.id),
|
||||
]);
|
||||
const neuOffen = [...neuPublic, ...neuPrivat].filter((v) => v.zustand !== "AUSGEROLLT").length;
|
||||
// Uebernommen, sobald der Neue mindestens so viele Zuweisungen hat wie der Alte (noch) aktive.
|
||||
const uebernommen =
|
||||
alt.widerrufEingeleitetAm !== null || (neuPublic.length >= altPublic && neuPrivat.length >= altPrivat);
|
||||
const testsGruen = tests.every((t) => t.ausgerollt && t.status === "ERLEDIGT");
|
||||
const schritt =
|
||||
alt.status === "ROTIERT"
|
||||
? (5 as const)
|
||||
: !uebernommen
|
||||
? (1 as const)
|
||||
: alt.widerrufEingeleitetAm
|
||||
? (4 as const)
|
||||
: neuOffen > 0
|
||||
? (2 as const)
|
||||
: !testsGruen
|
||||
? (3 as const)
|
||||
: (4 as const);
|
||||
return { alt, gesperrt, neu, schritt, neuOffen, tests, testsGruen };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schritt 5: den alten Schluessel widerrufen – nur wenn der Nachfolger ueberall ausgerollt ist
|
||||
* und jeder Verbindungstest seit dem letzten Ausrollen gruen war.
|
||||
*/
|
||||
export async function rotationAltWiderrufen(
|
||||
altId: string,
|
||||
akteur: Akteur,
|
||||
optionen: { readonly privatErlaubt: boolean },
|
||||
): Promise<void> {
|
||||
const stand = await rotationsStand(altId);
|
||||
if (!stand.neu) throw new RotationsFehler("Erst den neuen Schlüssel erzeugen.");
|
||||
if (stand.schritt === 5) return;
|
||||
if (stand.schritt < 4) {
|
||||
throw new RotationsFehler(
|
||||
stand.schritt === 3
|
||||
? "Erst wenn alle Verbindungstests mit dem neuen Schlüssel grün sind, wird der alte entfernt."
|
||||
: "Der neue Schlüssel ist noch nicht überall ausgerollt.",
|
||||
);
|
||||
}
|
||||
await widerrufEinleiten([altId], akteur, optionen);
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export class VerwendungsFehler extends Error {
|
||||
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
/** Ein Schluessel im Widerruf (#024) bekommt keine neuen Ziele – sonst wuerde er nie fertig. */
|
||||
const WIDERRUF_LAEUFT = "Dieser Schlüssel wird gerade widerrufen und lässt sich nicht mehr zuweisen.";
|
||||
|
||||
export type PublicZuweisung = {
|
||||
readonly schluesselId: string;
|
||||
readonly hostId?: string | null;
|
||||
@@ -46,11 +49,12 @@ export async function publicVerwendungAnlegen(eingabe: PublicZuweisung, akteur:
|
||||
}
|
||||
const schluessel = await prisma.schluessel.findUnique({
|
||||
where: { id: eingabe.schluesselId },
|
||||
select: { status: true, fingerprint: true, kategorie: true },
|
||||
select: { status: true, fingerprint: true, kategorie: true, widerrufEingeleitetAm: 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 zuweisen.");
|
||||
if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
|
||||
if (schluessel.kategorie === "DEPLOYMENT") {
|
||||
throw new VerwendungsFehler("Der Deployment-Key wird von sshkv selbst verwaltet.");
|
||||
}
|
||||
@@ -116,13 +120,47 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro
|
||||
});
|
||||
}
|
||||
|
||||
/** Ziele mehrerer Schluessel ohne Doppelte – ein gemeinsamer Dry-Run beim Massenwiderruf (#024). */
|
||||
async function zieleMehrererSchluessel(
|
||||
schluesselIds: readonly string[],
|
||||
mitPrivat: boolean,
|
||||
): Promise<Ziel[]> {
|
||||
const ziele = new Map<string, Ziel>();
|
||||
for (const schluesselId of schluesselIds) {
|
||||
const liste = [
|
||||
...(await zieleVonVerwendungen({ schluesselId })),
|
||||
...(mitPrivat ? await zieleVonPrivatVerwendungen({ schluesselId }) : []),
|
||||
];
|
||||
for (const ziel of liste)
|
||||
ziele.set(`${ziel.hostId}\u0000${ziel.zielbenutzer}\u0000${ziel.art ?? "public"}`, ziel);
|
||||
}
|
||||
return [...ziele.values()];
|
||||
}
|
||||
|
||||
/** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */
|
||||
export async function trockenlaufStarten(
|
||||
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
||||
auswahl: {
|
||||
schluesselId?: string;
|
||||
/** Mehrere Schluessel in einem Dry-Run (Massenwiderruf, #024). */
|
||||
schluesselIds?: readonly string[];
|
||||
hostId?: string;
|
||||
verwendungIds?: string[];
|
||||
ziele?: Ziel[];
|
||||
},
|
||||
akteur: Akteur,
|
||||
/** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */
|
||||
mitPrivat = false,
|
||||
): Promise<string> {
|
||||
if (auswahl.schluesselIds && !auswahl.ziele) {
|
||||
return trockenlaufStarten(
|
||||
{
|
||||
ziele: await zieleMehrererSchluessel(auswahl.schluesselIds, mitPrivat),
|
||||
...(auswahl.schluesselIds.length === 1 ? { schluesselId: auswahl.schluesselIds[0] } : {}),
|
||||
},
|
||||
akteur,
|
||||
mitPrivat,
|
||||
);
|
||||
}
|
||||
const ziele = auswahl.ziele ?? [
|
||||
...(await zieleVonVerwendungen({
|
||||
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||
@@ -197,11 +235,13 @@ export async function privateVerwendungAnlegen(eingabe: PrivateZuweisung, akteur
|
||||
kategorie: true,
|
||||
privatGeloeschtAm: true,
|
||||
masterKeyVersion: true,
|
||||
widerrufEingeleitetAm: 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.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
|
||||
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).
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Widerruf und Massenwiderruf (#024).
|
||||
*
|
||||
* Ablauf in drei Schritten, damit der Status nie mehr verspricht, als auf den Hosts passiert ist:
|
||||
*
|
||||
* 1. `widerrufEinleiten`: alle Verwendungen auf ENTFERNEN, `widerrufEingeleitetAm` setzen.
|
||||
* Der Schluessel bleibt AKTIV, laesst sich aber nicht mehr zuweisen.
|
||||
* 2. Dry-Run und Ausrollen wie sonst auch (#016/#017). Was gelingt, wird ENTFERNT; was auf einem
|
||||
* Host scheitert (nicht erreichbar, Datei geaendert), bleibt ENTFERNEN.
|
||||
* 3. `widerrufAbschliessen`: erst wenn keine Verwendung mehr offen ist, wird der Status
|
||||
* WIDERRUFEN (bzw. ROTIERT, wenn ein Nachfolger aus einer Rotation existiert, #023).
|
||||
*
|
||||
* Optional dazwischen: Zielkonten entfernen, die fuer diese Schluessel angelegt wurden.
|
||||
*/
|
||||
export class WiderrufFehler extends VerwendungsFehler {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "WiderrufFehler";
|
||||
}
|
||||
}
|
||||
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
/** Verwendungen in diesen Zustaenden zaehlen als „noch in Gebrauch“ (fuer Zielkonten). */
|
||||
const IN_GEBRAUCH = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
|
||||
|
||||
const SCHLUESSEL_KOPF = {
|
||||
id: true,
|
||||
name: true,
|
||||
kategorie: true,
|
||||
status: true,
|
||||
fingerprint: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
nachfolger: { select: { id: true, status: true } },
|
||||
} satisfies Prisma.SchluesselSelect;
|
||||
|
||||
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
|
||||
|
||||
/** Warum ein Schluessel nicht widerrufen werden kann – oder null. */
|
||||
export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | null {
|
||||
// Der Deployment-Key ist sshkvs eigener Zugang; ohne ihn ist kein Host mehr erreichbar.
|
||||
if (s.kategorie === "DEPLOYMENT")
|
||||
return "Der Deployment-Key lässt sich nicht widerrufen – er wird über eine eigene Rotation ersetzt.";
|
||||
if (s.status === "WIDERRUFEN") return `„${s.name}“ ist bereits widerrufen.`;
|
||||
if (s.status === "ROTIERT") return `„${s.name}“ ist bereits rotiert und von allen Hosts entfernt.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function schluesselLaden(ids: readonly string[]): Promise<SchluesselKopf[]> {
|
||||
const eindeutig = [...new Set(ids)];
|
||||
if (eindeutig.length === 0) throw new WiderrufFehler("Bitte mindestens einen Schlüssel wählen.");
|
||||
const liste = await prisma.schluessel.findMany({
|
||||
where: { id: { in: eindeutig } },
|
||||
select: SCHLUESSEL_KOPF,
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht.");
|
||||
return liste;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zielkonten
|
||||
|
||||
export type ZielkontoKandidat = {
|
||||
readonly id: string;
|
||||
readonly hostId: string;
|
||||
readonly hostName: string;
|
||||
readonly benutzername: string;
|
||||
readonly sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||
/** Namen anderer Schluessel, die das Konto noch nutzen – dann bleibt es. */
|
||||
readonly nochGenutztVon: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Zielkonten, die sshkv fuer diese Schluessel angelegt hat: ein von sshkv angelegtes, noch
|
||||
* vorhandenes Konto auf einem Host, auf dem einer der Schluessel (in irgendeinem Zustand) fuer
|
||||
* genau diesen Benutzer zugewiesen war. Ein Zielkonto kennt seinen Schluessel nicht; die
|
||||
* Zuweisung ist die einzige belastbare Verbindung.
|
||||
*/
|
||||
export async function zielkontoKandidaten(schluesselIds: readonly string[]): Promise<ZielkontoKandidat[]> {
|
||||
const ids = [...new Set(schluesselIds)];
|
||||
const [oeffentlich, privat] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids } },
|
||||
select: {
|
||||
zielbenutzer: true,
|
||||
hostId: true,
|
||||
hostgruppe: { select: { mitglieder: { select: { hostId: true } } } },
|
||||
},
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids } },
|
||||
select: { zielbenutzer: true, hostId: true },
|
||||
}),
|
||||
]);
|
||||
const paare = new Set<string>();
|
||||
for (const v of oeffentlich) {
|
||||
const hosts = v.hostId ? [v.hostId] : (v.hostgruppe?.mitglieder.map((m) => m.hostId) ?? []);
|
||||
for (const hostId of hosts) paare.add(`${hostId}\u0000${v.zielbenutzer}`);
|
||||
}
|
||||
for (const v of privat) paare.add(`${v.hostId}\u0000${v.zielbenutzer}`);
|
||||
if (paare.size === 0) return [];
|
||||
|
||||
const konten = await prisma.zielkonto.findMany({
|
||||
where: {
|
||||
entferntAm: null,
|
||||
vonSshkvAngelegt: true,
|
||||
host: { archiviertAm: null },
|
||||
OR: [...paare].map((p) => {
|
||||
const [hostId, benutzername] = p.split("\u0000") as [string, string];
|
||||
return { hostId, benutzername };
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hostId: true,
|
||||
benutzername: true,
|
||||
sudoModus: true,
|
||||
host: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ host: { name: "asc" } }, { benutzername: "asc" }],
|
||||
});
|
||||
|
||||
const ergebnis: ZielkontoKandidat[] = [];
|
||||
for (const konto of konten) {
|
||||
const [andere, anderePrivat] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: {
|
||||
schluesselId: { notIn: ids },
|
||||
zielbenutzer: konto.benutzername,
|
||||
zustand: { in: [...IN_GEBRAUCH] },
|
||||
schluessel: { widerrufEingeleitetAm: null },
|
||||
OR: [{ hostId: konto.hostId }, { hostgruppe: { mitglieder: { some: { hostId: konto.hostId } } } }],
|
||||
},
|
||||
select: { schluessel: { select: { name: true } } },
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: {
|
||||
schluesselId: { notIn: ids },
|
||||
hostId: konto.hostId,
|
||||
zielbenutzer: konto.benutzername,
|
||||
zustand: { in: [...IN_GEBRAUCH] },
|
||||
schluessel: { widerrufEingeleitetAm: null },
|
||||
},
|
||||
select: { schluessel: { select: { name: true } } },
|
||||
}),
|
||||
]);
|
||||
ergebnis.push({
|
||||
id: konto.id,
|
||||
hostId: konto.hostId,
|
||||
hostName: konto.host.name,
|
||||
benutzername: konto.benutzername,
|
||||
sudoModus: konto.sudoModus,
|
||||
nochGenutztVon: [...new Set([...andere, ...anderePrivat].map((v) => v.schluessel.name))].sort(),
|
||||
});
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Einleiten
|
||||
|
||||
export type EinleitenOptionen = {
|
||||
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
|
||||
readonly privatErlaubt: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Schritt 1: Verwendungen zum Entfernen vormerken. Idempotent – ein zweiter Aufruf (etwa beim
|
||||
* Fortsetzen) aendert an bereits vorgemerkten Verwendungen nichts.
|
||||
*/
|
||||
export async function widerrufEinleiten(
|
||||
schluesselIds: readonly string[],
|
||||
akteur: Akteur,
|
||||
optionen: EinleitenOptionen,
|
||||
): Promise<void> {
|
||||
const liste = await schluesselLaden(schluesselIds);
|
||||
for (const s of liste) {
|
||||
const grund = widerrufGesperrt(s);
|
||||
if (grund) throw new WiderrufFehler(grund);
|
||||
}
|
||||
const ids = liste.map((s) => s.id);
|
||||
const privatOffen = await prisma.privateVerwendung.count({
|
||||
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||
});
|
||||
if (privatOffen > 0 && !optionen.privatErlaubt) {
|
||||
throw new WiderrufFehler(
|
||||
"Private Keys auf Hosts entfernt nur ein Admin. Bitte einen Admin widerrufen lassen.",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const jetzt = new Date();
|
||||
for (const s of liste) {
|
||||
const [oeffentlich, privat] = await Promise.all([
|
||||
tx.publicVerwendung.findMany({
|
||||
where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
zustand: true,
|
||||
zuletztAngewendetAm: true,
|
||||
host: { select: { name: true, archiviertAm: true } },
|
||||
},
|
||||
}),
|
||||
tx.privateVerwendung.findMany({
|
||||
where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
zustand: true,
|
||||
zuletztAngewendetAm: true,
|
||||
host: { select: { name: true, archiviertAm: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
// Nie ausgerollt: auf dem Host steht nichts. Archivierter Host: sshkv verbindet sich dort
|
||||
// nicht mehr – das Protokoll haelt fest, dass dort nichts entfernt wurde.
|
||||
const sofort = (v: (typeof oeffentlich)[number]) =>
|
||||
(v.zustand === "GEPLANT" && !v.zuletztAngewendetAm) || Boolean(v.host?.archiviertAm);
|
||||
const archiviert = [...oeffentlich, ...privat]
|
||||
.filter((v) => v.host?.archiviertAm)
|
||||
.map((v) => v.host!.name);
|
||||
await tx.publicVerwendung.updateMany({
|
||||
where: { id: { in: oeffentlich.filter(sofort).map((v) => v.id) } },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
await tx.publicVerwendung.updateMany({
|
||||
where: { id: { in: oeffentlich.filter((v) => !sofort(v)).map((v) => v.id) } },
|
||||
data: { zustand: "ENTFERNEN" },
|
||||
});
|
||||
await tx.privateVerwendung.updateMany({
|
||||
where: { id: { in: privat.filter(sofort).map((v) => v.id) } },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
await tx.privateVerwendung.updateMany({
|
||||
where: { id: { in: privat.filter((v) => !sofort(v)).map((v) => v.id) } },
|
||||
data: { zustand: "ENTFERNEN" },
|
||||
});
|
||||
if (!s.widerrufEingeleitetAm) {
|
||||
await tx.schluessel.update({
|
||||
where: { id: s.id },
|
||||
data: { widerrufEingeleitetAm: jetzt },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
await protokollieren(
|
||||
{
|
||||
akteur,
|
||||
aktion: "schluessel.widerruf.eingeleitet",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: s.fingerprint,
|
||||
details: {
|
||||
name: s.name,
|
||||
public: oeffentlich.length,
|
||||
privat: privat.length,
|
||||
ohneHostZugriff: oeffentlich.filter(sofort).length + privat.filter(sofort).length,
|
||||
archivierteHosts: [...new Set(archiviert)].sort(),
|
||||
massenwiderruf: liste.length > 1,
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Offene Entfernungen
|
||||
|
||||
export type OffeneEntfernung = {
|
||||
readonly id: string;
|
||||
readonly schluesselId: string;
|
||||
readonly art: "public" | "privat";
|
||||
readonly ziel: string;
|
||||
readonly zustand: string;
|
||||
};
|
||||
|
||||
/** Was fuer diese Schluessel noch nicht entfernt ist – fuer Seite und Abschluss. */
|
||||
export async function offeneEntfernungen(schluesselIds: readonly string[]): Promise<OffeneEntfernung[]> {
|
||||
const ids = [...new Set(schluesselIds)];
|
||||
const [oeffentlich, privat] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
schluesselId: true,
|
||||
zielbenutzer: true,
|
||||
zustand: true,
|
||||
host: { select: { name: true } },
|
||||
hostgruppe: { select: { name: true } },
|
||||
},
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
schluesselId: true,
|
||||
zielbenutzer: true,
|
||||
pfad: true,
|
||||
zustand: true,
|
||||
host: { select: { name: true } },
|
||||
},
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
]);
|
||||
return [
|
||||
...oeffentlich.map((v) => ({
|
||||
id: v.id,
|
||||
schluesselId: v.schluesselId,
|
||||
art: "public" as const,
|
||||
ziel: v.host
|
||||
? `${v.zielbenutzer}@${v.host.name}:~/.ssh/authorized_keys`
|
||||
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe):~/.ssh/authorized_keys`,
|
||||
zustand: v.zustand,
|
||||
})),
|
||||
...privat.map((v) => ({
|
||||
id: v.id,
|
||||
schluesselId: v.schluesselId,
|
||||
art: "privat" as const,
|
||||
ziel: `${v.zielbenutzer}@${v.host.name}:${v.pfad}`,
|
||||
zustand: v.zustand,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zielkonten entfernen
|
||||
|
||||
/**
|
||||
* Optionaler Schritt nach dem Ausrollen: ausgewaehlte Zielkonten entfernen. Erst wenn die
|
||||
* Schluessel von den Hosts verschwunden sind – sonst haette ein Fehlschlag beim Ausrollen einen
|
||||
* halb geloeschten Benutzer mit noch gueltigem Schluessel zur Folge.
|
||||
*/
|
||||
export async function widerrufZielkontenEntfernen(
|
||||
schluesselIds: readonly string[],
|
||||
zielkontoIds: readonly string[],
|
||||
akteur: Akteur,
|
||||
): Promise<{ id: string; beschriftung: string }[]> {
|
||||
const liste = await schluesselLaden(schluesselIds);
|
||||
if (liste.some((s) => !s.widerrufEingeleitetAm))
|
||||
throw new WiderrufFehler("Bitte zuerst den Widerruf einleiten.");
|
||||
const offen = await offeneEntfernungen(liste.map((s) => s.id));
|
||||
if (offen.length > 0) {
|
||||
throw new WiderrufFehler(
|
||||
"Zielkonten werden erst entfernt, wenn die Schlüssel von allen Hosts entfernt sind.",
|
||||
);
|
||||
}
|
||||
const kandidaten = new Map((await zielkontoKandidaten(liste.map((s) => s.id))).map((k) => [k.id, k]));
|
||||
const vorgaenge: { id: string; beschriftung: string }[] = [];
|
||||
const gewaehlt = [...new Set(zielkontoIds)];
|
||||
for (const id of gewaehlt) {
|
||||
const konto = kandidaten.get(id);
|
||||
if (!konto) throw new WiderrufFehler("Dieses Zielkonto gehört nicht zu den gewählten Schlüsseln.");
|
||||
if (konto.nochGenutztVon.length > 0) {
|
||||
throw new WiderrufFehler(
|
||||
`${konto.benutzername}@${konto.hostName} wird noch von „${konto.nochGenutztVon.join("“, „")}“ genutzt und bleibt.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const id of gewaehlt) {
|
||||
const konto = kandidaten.get(id)!;
|
||||
vorgaenge.push({
|
||||
id: await vorgangEinreihen({
|
||||
art: "ZIELKONTO_ENTFERNEN",
|
||||
angefordertVonId: akteur.id,
|
||||
hostId: konto.hostId,
|
||||
eingabe: { benutzer: konto.benutzername },
|
||||
}),
|
||||
beschriftung: `${konto.benutzername}@${konto.hostName}`,
|
||||
});
|
||||
}
|
||||
return vorgaenge;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Abschliessen
|
||||
|
||||
export type AbschlussErgebnis = {
|
||||
readonly schluesselId: string;
|
||||
readonly name: string;
|
||||
readonly status: "WIDERRUFEN" | "ROTIERT" | "OFFEN";
|
||||
readonly offen: OffeneEntfernung[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Schritt 3: Status setzen – aber nur fuer Schluessel ohne offene Entfernung. Die anderen
|
||||
* bleiben im Zwischenzustand; die Oberflaeche zeigt, was fehlt.
|
||||
*/
|
||||
export async function widerrufAbschliessen(
|
||||
schluesselIds: readonly string[],
|
||||
akteur: Akteur,
|
||||
): Promise<AbschlussErgebnis[]> {
|
||||
const liste = await schluesselLaden(schluesselIds);
|
||||
const offen = await offeneEntfernungen(liste.map((s) => s.id));
|
||||
const ergebnisse: AbschlussErgebnis[] = [];
|
||||
for (const s of liste) {
|
||||
if (s.status === "WIDERRUFEN" || s.status === "ROTIERT") {
|
||||
ergebnisse.push({ schluesselId: s.id, name: s.name, status: s.status, offen: [] });
|
||||
continue;
|
||||
}
|
||||
if (!s.widerrufEingeleitetAm) throw new WiderrufFehler(`Für „${s.name}“ ist kein Widerruf eingeleitet.`);
|
||||
const eigene = offen.filter((o) => o.schluesselId === s.id);
|
||||
if (eigene.length > 0) {
|
||||
ergebnisse.push({ schluesselId: s.id, name: s.name, status: "OFFEN", offen: eigene });
|
||||
continue;
|
||||
}
|
||||
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
|
||||
const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN";
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const geaendert = await tx.schluessel.updateMany({
|
||||
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
|
||||
data: { status },
|
||||
});
|
||||
if (geaendert.count === 0) return;
|
||||
await protokollieren(
|
||||
{
|
||||
akteur,
|
||||
aktion: status === "ROTIERT" ? "schluessel.rotiert" : "schluessel.widerrufen",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: s.fingerprint,
|
||||
details: {
|
||||
name: s.name,
|
||||
...(s.nachfolger && status === "ROTIERT" ? { nachfolgerId: s.nachfolger.id } : {}),
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
ergebnisse.push({ schluesselId: s.id, name: s.name, status, offen: [] });
|
||||
}
|
||||
return ergebnisse;
|
||||
}
|
||||
Reference in New Issue
Block a user