feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #26.
This commit is contained in:
2026-09-25 13:30:31 +00:00
parent f85bc87324
commit eec5de404a
21 changed files with 4069 additions and 166 deletions
+367
View File
@@ -0,0 +1,367 @@
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import {
namenEindeutigBilden,
NamensregelnSchema,
type Namensregeln,
type Namenswerte,
type Zielformat,
} from "@/lib/namensregeln";
import { PASSWORT_HINWEIS } from "@/lib/passwort";
import { platzhalterErsetzen, type ErsetzungsErgebnis } from "@/lib/platzhalter-werte";
import {
platzhalterAusserhalbAnfuehrungszeichen,
ShellFehler,
skriptBauen,
wertMaskieren,
type Shell,
type SkriptSchritt,
} from "@/lib/shell";
import { STANDARD_SHELL } from "@/lib/editor-aufbau";
import { betriebsmodus, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
import { platzhalterWerteDesLaufs, type LaufKontext, type PlatzhalterWerte } from "./platzhalter";
/**
* Fertige Befehle und Namensvorschlaege (#012).
*
* Hier laufen die drei Teile zusammen: die Werte eines Laufs
* (`services/platzhalter.ts`), das Ersetzen (`lib/platzhalter-werte.ts`) und die
* Maskierung je Shell (`lib/shell.ts`). Das Ergebnis ist der Text, den ein
* Techniker kopiert — und alles, was ihn verfaelscht, wird hier verfaelscht.
*
* Die Reihenfolge ist nicht beliebig: **erst ersetzen, dann maskieren** waere
* falsch. Maskiert wird der **eingesetzte Wert**, nicht der fertige Befehl —
* sonst maskierte man die Anfuehrungszeichen des Befehls gleich mit.
* `platzhalterErsetzen` bekommt die Maskierung deshalb als Funktion und wendet
* sie auf jeden Wert einzeln an.
*/
export type BefehlsSchritt = {
readonly titel: string;
readonly shell: Shell;
readonly befehl: string;
};
/** Ein Schritt mit eingesetzten Werten, bereit zum Kopieren. */
export type FertigerBefehl = {
readonly titel: string;
readonly shell: Shell;
readonly befehl: string;
/** Platzhalter, die niemand kennt – sie stehen im Befehl noch als `{{name}}`. */
readonly unbekannt: readonly string[];
/** Kam `{{generiertes_passwort}}` vor? Dann gehoert der Hinweis daneben. */
readonly mitPasswort: boolean;
/**
* Der Satz, der neben einem Passwort stehen muss. Steht hier und nicht in der
* Oberflaeche, damit jede Anzeige denselben bekommt — ein Passwort ohne diesen
* Hinweis ist ein verlorenes Passwort.
*/
readonly hinweis?: string;
/**
* Gesetzt, wenn sich ein Wert fuer diese Shell **nicht sicher** maskieren
* laesst. Dann steht in `befehl` der Befehl mit stehen gebliebenen
* Platzhaltern, und die Oberflaeche bietet kein Kopieren an.
*/
readonly fehler?: string;
};
/**
* Setzt die Werte in einen einzelnen Befehl ein.
*
* Der Titel wird **ohne** Shell-Maskierung ersetzt: er wird gelesen, nicht
* ausgefuehrt. Ein maskierter Titel stuende mit Backticks auf dem Bildschirm.
*/
export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte): FertigerBefehl {
const titel = platzhalterErsetzen(schritt.titel, werte).text;
/*
* Erst die Annahme pruefen, auf der die ganze Maskierung ruht: steht der
* Platzhalter nicht in Anfuehrungszeichen, kann keine Maskierung daraus noch
* **ein** Argument machen. Ein Leerzeichen im Wert zerlegt ihn, ein `;`
* beendet den Befehl.
*/
const nackt = platzhalterAusserhalbAnfuehrungszeichen(schritt.befehl, schritt.shell);
if (nackt.length > 0) {
const zeichen = schritt.shell === "bash" ? "einfache" : "doppelte";
return {
titel,
shell: schritt.shell,
befehl: schritt.befehl,
unbekannt: [],
mitPasswort: false,
fehler:
`${nackt.map((name) => `„{{${name}}}“`).join(", ")} steht nicht in Anführungszeichen. ` +
`Ein eingesetzter Wert lässt sich dann nicht sicher maskieren — bitte ${zeichen} ` +
"Anführungszeichen um den Platzhalter setzen.",
};
}
try {
const befehl = platzhalterErsetzen(schritt.befehl, werte, {
art: "befehl",
maskieren: (wert) => wertMaskieren(wert, schritt.shell),
});
return {
titel,
shell: schritt.shell,
befehl: befehl.text,
unbekannt: befehl.unbekannt,
mitPasswort: befehl.mitPasswort,
...(befehl.mitPasswort ? { hinweis: PASSWORT_HINWEIS } : {}),
};
} catch (fehler) {
/*
* `wertMaskieren` **wirft** fuer `cmd`, wenn ein Wert ein
* Anfuehrungszeichen oder einen Umbruch enthaelt: dort laesst sich das nicht
* sicher maskieren, und ein `"` beendet das Argument — alles danach liest
* cmd.exe als Befehl.
*
* Ein ungefangener Wurf risse hier die ganze Abarbeitungsansicht ab. Statt
* dessen bleibt der Befehl mit seinen Platzhaltern stehen und traegt die
* Begruendung: der Techniker sieht, was gemeint war, und dass er es von Hand
* einsetzen muss. Einen halb maskierten Befehl zum Kopieren anzubieten waere
* die gefaehrlichste aller Antworten.
*/
if (fehler instanceof ShellFehler) {
return {
titel,
shell: schritt.shell,
befehl: schritt.befehl,
unbekannt: [],
mitPasswort: false,
fehler: fehler.message,
};
}
throw fehler;
}
}
/**
* Alle Befehle eines Abschnitts als ein Skript zum Kopieren.
*
* **Die Passwoerter darin entstehen genau jetzt.** Wer das Skript zweimal
* kopiert, bekommt zweimal verschiedene — das ist die ehrliche Folge der Regel
* „bei jeder Anzeige neu“ und der Grund, warum der Hinweis daneben steht.
*/
/**
* Ein Skript aus **bereits gefuellten** Schritten.
*
* Entscheidend, sobald ein `{{generiertes_passwort}}` im Spiel ist: es entsteht
* bei jedem Fuellen neu. Wer dieselben Schritte zweimal fuellt — einmal fuer die
* Anzeige, einmal fuer das Skript —, zeigt dem Techniker Passwort X und gibt ihm
* Passwort Y zum Kopieren. Er legt das Konto mit Y an und nennt dem Benutzer X,
* und weil nichts gespeichert wird, ist Y danach unwiederbringlich.
*/
export function skriptAusFertigen(fertige: readonly FertigerBefehl[]): {
readonly skript: string;
readonly mitPasswort: boolean;
readonly unbekannt: readonly string[];
readonly nichtMaskierbar: readonly string[];
} {
/*
* Ein Schritt, der sich nicht sicher maskieren laesst, faellt aus dem Skript
* heraus — mit seinen Platzhaltern drin waere es ein Skript, das etwas anderes
* tut, als es liest. Gemeldet wird er trotzdem, sonst fehlte er stillschweigend.
*/
const alsSkript: SkriptSchritt[] = fertige
.filter((schritt) => schritt.fehler === undefined)
.map((schritt) => ({ titel: schritt.titel, shell: schritt.shell, befehl: schritt.befehl }));
const unbekannt = new Set<string>();
for (const schritt of fertige) for (const name of schritt.unbekannt) unbekannt.add(name);
return {
skript: skriptBauen(alsSkript),
mitPasswort: fertige.some((schritt) => schritt.mitPasswort),
unbekannt: [...unbekannt],
nichtMaskierbar: fertige
.filter((schritt) => schritt.fehler !== undefined)
.map((schritt) => schritt.titel),
};
}
/** Fuellt die Schritte und baut daraus das Skript – in **einem** Durchgang. */
export function skriptFuerAbschnitt(
schritte: readonly BefehlsSchritt[],
werte: PlatzhalterWerte,
): {
readonly skript: string;
readonly mitPasswort: boolean;
readonly unbekannt: readonly string[];
/** Schritte, deren Werte sich nicht sicher maskieren liessen. */
readonly nichtMaskierbar: readonly string[];
} {
return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte)));
}
/**
* Die Befehle eines Laufs, abschnittsweise.
*
* Liest die Version, an der der Lauf haengt — nicht die neueste. Ein Lauf zeigt
* fuer immer die Schritte, mit denen er gestartet wurde (#009).
*/
export async function befehleDesLaufs(kontext: LaufKontext): Promise<
readonly {
readonly abschnitt: string;
readonly schritte: readonly FertigerBefehl[];
readonly skript: string;
/** Platzhalter, die niemand kennt – sie stehen noch als `{{name}}` im Befehl. */
readonly unbekannt: readonly string[];
/** Schritte, die nicht ins Skript konnten, weil ihre Werte nicht maskierbar sind. */
readonly nichtMaskierbar: readonly string[];
}[]
> {
const lauf = await prisma.lauf.findUnique({
where: { id: kontext.laufId },
select: {
vorlagenVersion: {
select: {
abschnitte: {
orderBy: { position: "asc" },
select: {
titel: true,
schritte: {
where: { typ: "BEFEHL" },
orderBy: { position: "asc" },
select: { titel: true, konfig: true },
},
},
},
},
},
},
});
if (!lauf) return [];
const werte = await platzhalterWerteDesLaufs(kontext);
return lauf.vorlagenVersion.abschnitte
.map((abschnitt) => {
const schritte: BefehlsSchritt[] = abschnitt.schritte.map((schritt) => ({
titel: schritt.titel,
shell: shellAus(schritt.konfig),
befehl: befehlAus(schritt.konfig),
}));
// **Einmal** fuellen und daraus beides bauen: ein zweiter Durchgang
// erzeugte ein zweites Passwort (siehe skriptAusFertigen).
const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte));
const skript = skriptAusFertigen(gefuellt);
return {
abschnitt: abschnitt.titel,
schritte: gefuellt,
skript: skript.skript,
unbekannt: skript.unbekannt,
nichtMaskierbar: skript.nichtMaskierbar,
};
})
.filter((abschnitt) => abschnitt.schritte.length > 0);
}
/**
* Shell und Befehl aus dem `konfig`-Json.
*
* Das Feld ist ein Json und kann alles enthalten — eine aeltere Fassung, ein
* Import, ein Eingriff per psql. Was nicht passt, wird zum Standard bzw. leer;
* ein leerer Befehl faellt aus dem Skript heraus, statt es zu sprengen.
*/
function shellAus(konfig: unknown): Shell {
const wert = lesen(konfig, "shell");
return wert === "powershell" || wert === "bash" || wert === "cmd" ? wert : STANDARD_SHELL;
}
function befehlAus(konfig: unknown): string {
return lesen(konfig, "befehl") ?? "";
}
function lesen(konfig: unknown, schluessel: string): string | undefined {
if (typeof konfig !== "object" || konfig === null || Array.isArray(konfig)) return undefined;
const wert = (konfig as Record<string, unknown>)[schluessel];
return typeof wert === "string" ? wert : undefined;
}
/**
* Die geltenden Namensregeln.
*
* Global aus `Einstellung`, im Modus DIENSTLEISTUNG je Kunde ueberlagert: eine
* gesetzte Kundenregel gewinnt, eine fehlende faellt auf die globale zurueck.
* **Feld fuer Feld**, nicht als Ganzes — ein Kunde, der nur eine eigene
* Mail-Domain hat, soll nicht auch seine Hostname-Regel neu schreiben muessen.
*/
export async function namensregelnFuer(kundeId: string | null): Promise<Namensregeln> {
const [einstellung, kunde] = await Promise.all([
prisma.einstellung.findUnique({ where: { id: "global" }, select: { namensregeln: true } }),
kundeId
? prisma.kunde.findUnique({ where: { id: kundeId }, select: { namensregeln: true } })
: Promise.resolve(null),
]);
const global = regelnLesen(einstellung?.namensregeln);
if ((await betriebsmodus()) !== Betriebsmodus.DIENSTLEISTUNG) return global;
const eigene = regelnLesen(kunde?.namensregeln);
return {
...global,
...Object.fromEntries(Object.entries(eigene).filter(([, wert]) => (wert ?? "").trim() !== "")),
};
}
/** Ein unbrauchbarer Json-Inhalt darf keinen Namensvorschlag sprengen. */
function regelnLesen(roh: unknown): Namensregeln {
const gelesen = NamensregelnSchema.safeParse(roh);
return gelesen.success ? gelesen.data : {};
}
export type NamensVorschlaege = Partial<Record<Zielformat, string>>;
/**
* Vorschlaege fuer `upn`, `sam`, `mail` und `hostname`.
*
* Eindeutigkeit wird gegen die Datenbank geprueft: `sam` und `upn` gegen die
* Mitarbeiter desselben Kunden, `hostname` gegen dessen Geraete. Verbindlich ist
* sie trotzdem erst beim Schreiben — zwischen Frage und Anlegen kann ein
* zweiter Lauf denselben Namen belegen. Ein Vorschlag ist ein Vorschlag.
*/
export async function namenVorschlagen(
kundeIdAusEingabe: string | null,
werte: Namenswerte,
benutzer: HandelnderBenutzer,
): Promise<NamensVorschlaege> {
/*
* Ueber `kundeErmitteln`, nicht ueber die mitgeschickte Kennung: im Modus
* INTERN wird sie verworfen, im Modus DIENSTLEISTUNG gegen die sichtbaren
* Kunden geprueft (CLAUDE.md, Regel „Mandant“). Ohne das waere `istVergeben`
* ein Orakel ueber Kundengrenzen hinweg — „gibt es bei euch einen mhuber?“.
*/
const kundeId = await kundeErmitteln(kundeIdAusEingabe, benutzer);
const regeln = await namensregelnFuer(kundeId);
const vorschlaege: NamensVorschlaege = {};
for (const zielformat of ["upn", "sam", "mail", "hostname"] as const) {
const regel = regeln[zielformat];
if (!regel) continue;
const name = await namenEindeutigBilden(zielformat, regel, werte, (kandidat) =>
istVergeben(zielformat, kandidat, kundeId),
);
if (name) vorschlaege[zielformat] = name;
}
return vorschlaege;
}
async function istVergeben(zielformat: Zielformat, kandidat: string, kundeId: string): Promise<boolean> {
if (zielformat === "hostname") {
const geraet = await prisma.geraet.findFirst({
where: { kundeId, hostname: { equals: kandidat, mode: "insensitive" } },
select: { id: true },
});
return geraet !== null;
}
const feld = zielformat === "mail" ? "mail" : zielformat;
const person = await prisma.mitarbeiter.findFirst({
where: { kundeId, [feld]: { equals: kandidat, mode: "insensitive" } },
select: { id: true },
});
return person !== null;
}
export type { ErsetzungsErgebnis };