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 & { 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, ): 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, })); }