feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
This commit was merged in pull request #30.
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user