feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #30.
This commit is contained in:
2026-09-26 00:21:56 +00:00
parent ab5777a57e
commit ae290345b7
57 changed files with 6834 additions and 61 deletions
+234
View File
@@ -0,0 +1,234 @@
import type { Entry } from "ldapts";
import { GuidFehler, guidAusBytes } from "@/lib/ldap-guid";
import { AD_ZUORDNUNG, type AttributZuordnung, ZUORDNUNGS_FELDER, zuordnungLesen } from "@/lib/verzeichnis";
/*
* Die Liste der Felder liegt in `src/lib/verzeichnis.ts`, weil das
* Formular sie auch braucht. Sie wird hier weiter mitexportiert, damit die
* LDAP-Module eine Anlaufstelle behalten und ein Aufrufer nicht wissen muss,
* welcher Teil der Zuordnung framework-frei ist.
*/
export { AD_ZUORDNUNG, ZUORDNUNGS_FELDER, zuordnungLesen };
export type { AttributZuordnung };
/**
* Welches LDAP-Attribut wird welches Feld (#016).
*
* Die Zuordnung steht je Quelle in der Datenbank (`Verzeichnisquelle.attributZuordnung`,
* ein Json-Feld), weil nicht jedes Verzeichnis ein Active Directory ist. Die
* Vorgaben sind die AD-Namen; wer ein OpenLDAP anbindet, aendert einzelne Zeilen.
*
* Hier stehen deshalb nur reine Funktionen: aus einem `Entry` der Bibliothek
* wird ein Datensatz in unseren Feldern. Kein Netz, keine Datenbank — dadurch
* laesst sich die Zuordnung mit erfundenen Eintraegen pruefen, und der
* Integrationstest muss nicht jeden Sonderfall des Mappings nachbauen.
*/
/**
* Der Standardfilter: Personenkonten, die **nicht** deaktiviert sind.
*
* `userAccountControl:1.2.840.113556.1.4.803:=2` ist die Bit-UND-Regel des AD;
* Bit 2 (`ACCOUNTDISABLE`) gesetzt heisst deaktiviert. Das Ausschliessen hier ist
* kein Komfort, sondern die halbe Aufgabe: ein deaktiviertes Konto verschwindet
* damit aus dem Ergebnis, und was aus dem Ergebnis verschwindet, setzt der
* Abgleich auf `aktiv = false`.
*/
export const STANDARD_FILTER =
"(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))";
/** Ein Eintrag in unseren Feldern, so wie ihn „Verbindung testen“ zeigt. */
export type GemappterEintrag = {
externeId: string;
vorname: string;
nachname: string;
anzeigename: string;
mail: string | null;
upn: string | null;
sam: string | null;
dn: string;
abteilung: string | null;
titel: string | null;
vorgesetzterExterneId: string | null;
};
/**
* Derselbe Eintrag, bevor der Vorgesetzte aufgeloest ist.
*
* `manager` ist im Verzeichnis ein **DN**, gespeichert wird aber die
* `externeId` des Vorgesetzten — sonst zeigt `{{vorgesetzter_sam}}` ins Leere,
* sobald jemand umgezogen ist und sein DN sich geaendert hat. Die Aufloesung
* braucht die anderen Eintraege und kann deshalb nicht beim Lesen eines
* einzelnen Eintrags geschehen.
*/
export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & {
vorgesetzterDn: string | null;
};
/**
* Die Attribute, die angefragt werden — genau die zugeordneten, nichts sonst.
*
* Ohne diese Liste liefert der Server alle Attribute, also auch
* Gruppenmitgliedschaften und was sonst am Konto haengt. Gespeichert wird davon
* nichts, aber gelesen eben doch: es stuende im Speicher, in einer
* Fehlerausgabe, in einem Netzwerkmitschnitt. Wir fragen deshalb nur, was wir
* brauchen.
*/
export function angeforderteAttribute(zuordnung: AttributZuordnung): string[] {
// Set, weil zwei Felder dasselbe Attribut benutzen duerfen (etwa upn = mail).
return [...new Set(Object.values(zuordnung))];
}
/** Ein einzelner Attributwert als Text, oder `null`. */
function textWert(eintrag: Entry, attribut: string): string | null {
const wert = eintrag[attribut];
if (wert === undefined) return null;
// Mehrwertige Attribute kommen als Array. Der erste Wert gewinnt; ein Konto mit
// zwei Vornamen ist ein Datenfehler im Verzeichnis, kein Fall fuer Rateraten.
const erster = Array.isArray(wert) ? wert[0] : wert;
if (erster === undefined) return null;
const text = (typeof erster === "string" ? erster : erster.toString("utf8")).trim();
return text ? text : null;
}
/**
* Ist das lesbarer Text – oder sind es rohe Bytes, die nur so aussehen?
*
* Steuerzeichen sind das Unterscheidungsmerkmal. Ein verstuemmelter GUID-Puffer
* enthaelt praktisch immer welche; eine Kennung wie `entryUUID` nie.
*/
function istLesbar(text: string): boolean {
return text.length > 0 && !/[\u0000-\u001f\u007f]/u.test(text);
}
/**
* Die stabile Kennung als Text.
*
* `objectGUID` kommt dank `explicitBufferAttributes` als Puffer. Sind es genau 16
* Bytes, ist es eine GUID und wird gemischt-endian umsortiert. Ist es etwas
* anderes — wer `externeId` auf `entryUUID` eines OpenLDAP legt, bekommt 36
* Textbytes —, wird der Puffer als UTF-8 gelesen, **aber nur wenn dabei lesbarer
* Text herauskommt**.
*
* Diese Einschraenkung ist der Punkt: ein Puffer mit 7 Bytes ist eine
* verstuemmelte GUID und keine Kennung. Wuerde er als Text durchgehen, staende
* eine Zeichenfolge aus Steuerzeichen als `externeId` in der Datenbank — stabil
* genug, um kein Duplikat zu erzeugen, und falsch genug, dass sie zu keinem Konto
* im Verzeichnis passt. Lieber gar keine Kennung: dann wird der Eintrag
* uebersprungen und das faellt auf.
*/
export function externeIdLesen(eintrag: Entry, attribut: string): string | undefined {
const wert = eintrag[attribut];
const erster = Array.isArray(wert) ? wert[0] : wert;
if (erster === undefined) return undefined;
if (typeof erster !== "string") {
if (erster.length === 0) return undefined;
if (erster.length === 16) return guidAusBytes(erster);
const text = erster.toString("utf8").trim();
return istLesbar(text) ? text : undefined;
}
const text = erster.trim();
return istLesbar(text) ? text : undefined;
}
/**
* Warum ein Eintrag uebersprungen wurde. Nur `externeId` fehlt — alles andere
* darf fehlen.
*/
export type Uebersprungen = { dn: string; grund: string };
/**
* Ein `Entry` wird ein `RoherEintrag`, oder er wird uebersprungen.
*
* Uebersprungen wird genau ein Fall: ohne stabile Kennung gibt es nichts, woran
* ein zweiter Abgleich den Eintrag wiedererkennen koennte — er wuerde bei jedem
* Lauf erneut angelegt. Ein Abbruch waere die falsche Antwort: ein einzelnes
* krummes Konto darf nicht den Abgleich aller anderen verhindern.
*/
export function eintragZuordnen(
eintrag: Entry,
zuordnung: AttributZuordnung,
): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } {
const dn = eintrag.dn.trim();
let externeId: string | undefined;
try {
externeId = externeIdLesen(eintrag, zuordnung.externeId);
} catch (fehler) {
if (!(fehler instanceof GuidFehler)) throw fehler;
return { uebersprungen: { dn, grund: fehler.message } };
}
if (!externeId) {
return {
uebersprungen: {
dn,
grund: `Ohne ${zuordnung.externeId} gibt es keine dauerhafte Kennung.`,
},
};
}
const vorname = textWert(eintrag, zuordnung.vorname);
const nachname = textWert(eintrag, zuordnung.nachname);
return {
eintrag: {
externeId,
vorname: vorname ?? "",
nachname: nachname ?? "",
anzeigename: textWert(eintrag, zuordnung.anzeigename) ?? ersatzName(vorname, nachname, dn),
mail: textWert(eintrag, zuordnung.mail),
upn: textWert(eintrag, zuordnung.upn),
sam: textWert(eintrag, zuordnung.sam),
dn,
abteilung: textWert(eintrag, zuordnung.abteilung),
titel: textWert(eintrag, zuordnung.titel),
vorgesetzterDn: textWert(eintrag, zuordnung.vorgesetzter),
},
};
}
/**
* Ein Anzeigename muss stehen, das Feld ist nicht optional — und eine leere Zeile
* in einer Auswahlliste waere fuer den Techniker unbrauchbar. Gibt es keinen,
* wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste
* Teil des DN ein.
*/
function ersatzName(vorname: string | null, nachname: string | null, dn: string): string {
const gebaut = [nachname, vorname].filter((teil) => teil).join(", ");
if (gebaut) return gebaut;
const ersterTeil = dn.split(",")[0]?.split("=")[1]?.trim();
return ersterTeil ?? dn;
}
/**
* Vergleichbare Form eines DN.
*
* Verglichen wird als Text, klein geschrieben und ohne Leerzeichen um die
* Kommas. Das deckt, was ein AD ausgibt. Es ist **keine** Normalisierung nach
* RFC 4514: ein anders maskierter oder anders geschriebener DN bleibt
* unaufgeloest, und der Vorgesetzte fehlt dann — er wird nicht falsch geraten.
*/
export function dnSchluessel(dn: string): string {
return dn
.trim()
.toLowerCase()
.replace(/\s*,\s*/gu, ",");
}
/**
* Setzt `vorgesetzterExterneId` aus den DNs.
*
* Aufgeloest wird nur gegen die Kennungen, die dieser Lauf gesehen hat. Steht der
* Vorgesetzte ausserhalb des Suchbereichs oder ist er deaktiviert, bleibt das
* Feld leer — lieber kein Vorgesetzter als ein falscher.
*/
export function vorgesetzteAufloesen(
eintraege: readonly RoherEintrag[],
zusaetzlicheKennungen?: ReadonlyMap<string, string>,
): GemappterEintrag[] {
const nachDn = new Map(zusaetzlicheKennungen ?? []);
for (const eintrag of eintraege) {
nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
}
return eintraege.map(({ vorgesetzterDn, ...rest }) => ({
...rest,
vorgesetzterExterneId: vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null,
}));
}