Files
Laufzettel/src/server/ldap/zuordnung.ts
T
florian.wilde ae290345b7
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
2026-09-26 00:21:56 +00:00

235 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}));
}