Einen Schlüssel zu tauschen soll keinen Ausfall riskieren: Der Nachfolger übernimmt alle aktiven Zuweisungen, wird ausgerollt und per Verbindungstest geprüft – erst wenn alle Tests seit dem letzten Ausrollen grün sind, entfernt der Widerruf aus #024 den alten Schlüssel, der dann als ROTIERT endet. Private Keys bekommen einen neuen Pfad (-rJJJJMMTT) bei gleichem Alias. Damit ssh den neuen nimmt, schreibt privat.ts je Alias nur noch einen config-Eintrag; die jüngste Zuweisung gewinnt. Der Worker setzt vorgaengerId schon beim Erzeugen, so ist die Kette auch nach einem Abbruch vollständig und die Schrittfolge wiederaufnehmbar. Personen-Keys ohne Private Key in sshkv bekommen nur einen Hinweis: dafür braucht es einen neuen Public Key von der Person. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
7.7 KiB
TypeScript
235 lines
7.7 KiB
TypeScript
import type { Prisma } from "@/generated/prisma/client";
|
||
import type { SchluesselKategorie, SchluesselStatus } from "@/generated/prisma/enums";
|
||
import { prisma } from "@/lib/db";
|
||
|
||
/**
|
||
* Lesemodelle fuer Schluessel (#012). Alles hier darf jede angemeldete Rolle sehen.
|
||
*
|
||
* Feste `select`-Listen ohne Chiffrat-Felder (CLAUDE.md, „Feste Regeln“). Ob ein Private Key
|
||
* gespeichert ist, verraet `masterKeyVersion`: der DB-CHECK `schluessel_umschlag_vollstaendig`
|
||
* setzt sie genau dann, wenn auch ein Chiffrat da ist – so muss das Chiffrat nie gelesen
|
||
* werden, nicht einmal fuer ein „ist null?“.
|
||
*/
|
||
|
||
export const SCHLUESSEL_LISTE_SELECT = {
|
||
id: true,
|
||
name: true,
|
||
slug: true,
|
||
typ: true,
|
||
kategorie: true,
|
||
status: true,
|
||
publicKey: true,
|
||
fingerprint: true,
|
||
ablaufAm: true,
|
||
masterKeyVersion: true,
|
||
passphraseGeschuetzt: true,
|
||
widerrufEingeleitetAm: true,
|
||
person: { select: { id: true, name: true } },
|
||
_count: {
|
||
select: {
|
||
publicVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||
privateVerwendungen: { where: { zustand: { not: "ENTFERNT" } } },
|
||
},
|
||
},
|
||
} satisfies Prisma.SchluesselSelect;
|
||
|
||
export const SCHLUESSEL_DETAIL_SELECT = {
|
||
id: true,
|
||
name: true,
|
||
slug: true,
|
||
typ: true,
|
||
kategorie: true,
|
||
status: true,
|
||
publicKey: true,
|
||
fingerprint: true,
|
||
kommentar: true,
|
||
masterKeyVersion: true,
|
||
privatGeloeschtAm: true,
|
||
passphraseGeschuetzt: true,
|
||
instanzStandard: true,
|
||
selbstHinterlegt: true,
|
||
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" },
|
||
select: {
|
||
id: true,
|
||
zielbenutzer: true,
|
||
optionen: true,
|
||
zustand: true,
|
||
zuletztAngewendetAm: true,
|
||
host: { select: { id: true, name: true } },
|
||
hostgruppe: { select: { id: true, name: true } },
|
||
},
|
||
},
|
||
privateVerwendungen: {
|
||
where: { zustand: { not: "ENTFERNT" } },
|
||
orderBy: { angelegtAm: "asc" },
|
||
select: {
|
||
id: true,
|
||
zielbenutzer: true,
|
||
pfad: true,
|
||
sshConfig: true,
|
||
zustand: true,
|
||
zuletztAngewendetAm: true,
|
||
host: { select: { id: true, name: true } },
|
||
},
|
||
},
|
||
} satisfies Prisma.SchluesselSelect;
|
||
|
||
export type SchluesselFilter = {
|
||
readonly q?: string;
|
||
readonly kategorie?: string;
|
||
readonly status?: string;
|
||
};
|
||
|
||
const KATEGORIEN: readonly SchluesselKategorie[] = ["PERSON", "DIENST", "DEPLOYMENT"];
|
||
const STATI: readonly SchluesselStatus[] = ["AKTIV", "ROTIERT", "WIDERRUFEN", "ABGELAUFEN"];
|
||
|
||
function alsKategorie(wert: string | undefined): SchluesselKategorie | undefined {
|
||
return KATEGORIEN.find((k) => k === wert);
|
||
}
|
||
|
||
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 } : {}),
|
||
AND: [statusFilter(status)],
|
||
...(q
|
||
? {
|
||
OR: [
|
||
{ name: { contains: q, mode: "insensitive" } },
|
||
{ slug: { contains: q.toLowerCase() } },
|
||
{ fingerprint: { contains: q } },
|
||
{ kommentar: { contains: q, mode: "insensitive" } },
|
||
{ tags: { has: q.toLowerCase() } },
|
||
{ person: { name: { contains: q, mode: "insensitive" } } },
|
||
],
|
||
}
|
||
: {}),
|
||
};
|
||
const zeilen = await prisma.schluessel.findMany({
|
||
where,
|
||
orderBy: [{ name: "asc" }],
|
||
select: SCHLUESSEL_LISTE_SELECT,
|
||
});
|
||
return zeilen.map(({ _count, masterKeyVersion, ...rest }) => ({
|
||
...rest,
|
||
privatGespeichert: masterKeyVersion !== null,
|
||
verwendungen: _count.publicVerwendungen + _count.privateVerwendungen,
|
||
}));
|
||
}
|
||
|
||
export type SchluesselListenEintrag = Awaited<ReturnType<typeof schluesselListe>>[number];
|
||
|
||
export async function schluesselDetail(id: string) {
|
||
const schluessel = await prisma.schluessel.findUnique({ where: { id }, select: SCHLUESSEL_DETAIL_SELECT });
|
||
if (!schluessel) return null;
|
||
const { masterKeyVersion, ...rest } = schluessel;
|
||
return { ...rest, privatGespeichert: masterKeyVersion !== null };
|
||
}
|
||
|
||
export type SchluesselDetail = NonNullable<Awaited<ReturnType<typeof schluesselDetail>>>;
|
||
|
||
/** Nur Slug und Public Key – fuer den Download `<slug>.pub`. */
|
||
export async function publicKeyExport(id: string) {
|
||
return prisma.schluessel.findUnique({ where: { id }, select: { slug: true, publicKey: true } });
|
||
}
|
||
|
||
// ---------------------------------------------------------------- Auswahllisten
|
||
|
||
export async function personenAuswahl() {
|
||
return prisma.person.findMany({
|
||
where: { archiviertAm: null },
|
||
orderBy: { name: "asc" },
|
||
select: { id: true, name: true, organisation: true },
|
||
});
|
||
}
|
||
|
||
export async function vorlagenAuswahl() {
|
||
return prisma.vorlage.findMany({
|
||
where: { archiviertAm: null },
|
||
orderBy: { name: "asc" },
|
||
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;
|
||
}
|