Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich zeigen liesse. Eine Verzeichnisquelle hat jetzt eine **Art** — LDAP oder GRAPH —, und beide laufen durch **denselben** Abgleich: anlegen, aktualisieren, deaktivieren, nie loeschen. Nur das Holen der Eintraege unterscheidet sich; zwei Abgleiche nebeneinander wuerden auseinanderdriften, und die Zusage „es wird nichts geloescht“ gaelte dann fuer eine Art und fuer die andere nicht mehr ganz. Gelesen wird mit dem Client-Credentials-Flow, seitenweise ueber @odata.nextLink, der Vorgesetzte ueber $expand=manager($select=id). Das Client-Secret wird verschluesselt gespeichert wie das Bind-Passwort und erreicht keine Antwort und keinen Protokolleintrag. Die Art ist **unveraenderlich**, und das steht nicht im Issue: an ihr haengt, was `externeId` bedeutet. Im AD eine objectGUID, in Entra ID eine andere GUID — nach einem Wechsel wuerde kein einziger Mitarbeiter wiedererkannt, der naechste Abgleich legte die ganze Belegschaft neu an und setzte die alten Zeilen auf aktiv = false, mit allen Laeufen und Geraeten daran. Der teuerste Fund beim Bauen: „in Entra ID deaktiviert“ als *uebersprungen* zu melden haette die Leute **aktiv** gelassen. Uebersprungen heisst im Abgleich „nicht zuordenbar“, und solche Eintraege werden seit #016 absichtlich nicht abgeschaltet. Gefiltert wird deshalb serverseitig mit `$filter=accountEnabled eq true`, als Gegenstueck zum LDAP-Standardfilter. Gefunden hat es der Test — aber nur, weil der Doppelgaenger den Filter wirklich auswertet statt ihn bloss zu empfangen. {{ou}} bleibt bei einer Graph-Quelle leer: Entra ID hat keine OU, und etwas hineinzudeuten waere ein Wert, den kein PowerShell-Befehl brauchen kann — er saehe nur richtig aus. Steht an der Abteilung eine Ziel-OU gepflegt, greift die. Gegen einen echten Entra-Mandanten ist das **nicht** gelaufen. Geprueft ist es gegen einen HTTP-Doppelgaenger mit aufgezeichneten Antworten; die Pruefliste fuer den ersten echten Lauf steht am Ende von docs/betrieb/entra-id.md. Closes #48 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
195 lines
7.2 KiB
TypeScript
195 lines
7.2 KiB
TypeScript
/**
|
|
* Was ueber eine Verzeichnisquelle auf beiden Seiten gilt (#016).
|
|
*
|
|
* Hier stehen die Attributzuordnung und die Grenzen des Abgleich-Intervalls: Zahlen
|
|
* und Namen, die das Formular genauso braucht wie der Dienst. Sie ein zweites Mal
|
|
* im Formular zu schreiben ging eine Weile gut und lief dann auseinander — die
|
|
* Oberflaeche liess hoechstens 1440 Minuten zu, der Dienst 10080, und wer das
|
|
* Gegenteil glaubte, stand vor einer Grenze, die nirgends stand (Reviewbefund zu
|
|
* #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.
|
|
*
|
|
* Diese Datei liegt in `src/lib` und nicht bei den uebrigen LDAP-Modulen, weil das
|
|
* Formular dieselben Feldnamen und dieselben Vorgaben braucht — und eine
|
|
* Client-Komponente nichts aus `src/server/*` importieren darf. Hier steht
|
|
* deshalb nur, was ohne Netz und ohne Datenbank gilt.
|
|
*/
|
|
|
|
/**
|
|
* Die Grenzen des Abgleich-Intervalls, in Minuten.
|
|
*
|
|
* Unten fuenf Minuten: haeufiger ist kein Abgleich, sondern Last auf dem
|
|
* Domaenencontroller. Oben eine Woche: seltener gelesen heisst, dass ein
|
|
* ausgetretener Mitarbeiter tagelang aktiv bleibt.
|
|
*/
|
|
export const INTERVALL_MINDESTENS = 5;
|
|
export const INTERVALL_HOECHSTENS = 7 * 24 * 60;
|
|
|
|
/** Dieselbe Regel als Satz, damit Dienst und Formular dasselbe sagen. */
|
|
export function intervallMeldung(): string {
|
|
return `Das Abgleich-Intervall muss zwischen ${INTERVALL_MINDESTENS} und ${INTERVALL_HOECHSTENS} Minuten liegen (fünf Minuten bis eine Woche).`;
|
|
}
|
|
|
|
export type AttributZuordnung = {
|
|
vorname: string;
|
|
nachname: string;
|
|
anzeigename: string;
|
|
mail: string;
|
|
upn: string;
|
|
sam: string;
|
|
abteilung: string;
|
|
titel: string;
|
|
vorgesetzter: string;
|
|
externeId: string;
|
|
};
|
|
|
|
/** Die Vorgaben aus dem Auftrag: die Attributnamen eines Active Directory. */
|
|
export const AD_ZUORDNUNG: AttributZuordnung = {
|
|
vorname: "givenName",
|
|
nachname: "sn",
|
|
anzeigename: "displayName",
|
|
mail: "mail",
|
|
upn: "userPrincipalName",
|
|
sam: "sAMAccountName",
|
|
abteilung: "department",
|
|
titel: "title",
|
|
vorgesetzter: "manager",
|
|
externeId: "objectGUID",
|
|
};
|
|
|
|
/**
|
|
* Dieselben Felder mit den Namen, die **Microsoft Graph** benutzt (#028).
|
|
*
|
|
* Vier Unterschiede zum AD, und jeder hat einen Grund:
|
|
*
|
|
* - `surname` statt `sn` — Graph schreibt die Namen aus.
|
|
* - `jobTitle` statt `title` — `title` gibt es bei Graph auch, es ist aber die
|
|
* Anrede („Dr.“), nicht die Funktion.
|
|
* - `onPremisesSamAccountName` statt `sAMAccountName` — der Wert kommt aus dem
|
|
* lokalen AD und ist **leer**, wenn der Mandant kein AD synchronisiert. Eine
|
|
* reine Cloud-Umgebung hat keinen Anmeldenamen in diesem Sinn; `{{sam}}`
|
|
* bleibt dort leer, statt dass etwas hineingedeutet wird.
|
|
* - `id` statt `objectGUID` — dieselbe GUID, nur schon als Text. Das Umsortieren
|
|
* der Bytes (`src/lib/ldap-guid.ts`) braucht es hier nicht.
|
|
*/
|
|
export const GRAPH_ZUORDNUNG: AttributZuordnung = {
|
|
vorname: "givenName",
|
|
nachname: "surname",
|
|
anzeigename: "displayName",
|
|
mail: "mail",
|
|
upn: "userPrincipalName",
|
|
sam: "onPremisesSamAccountName",
|
|
abteilung: "department",
|
|
titel: "jobTitle",
|
|
vorgesetzter: "manager",
|
|
externeId: "id",
|
|
};
|
|
|
|
/** Die Vorgaben je Quellenart. `SCIM` hat noch keine (#029). */
|
|
export const ZUORDNUNGS_VORGABE: Record<"LDAP" | "GRAPH", AttributZuordnung> = {
|
|
LDAP: AD_ZUORDNUNG,
|
|
GRAPH: GRAPH_ZUORDNUNG,
|
|
};
|
|
|
|
/** Die Felder in der Reihenfolge, in der das Formular sie zeigt. */
|
|
export const ZUORDNUNGS_FELDER = Object.keys(AD_ZUORDNUNG) as (keyof AttributZuordnung)[];
|
|
|
|
/** Wie das Feld in der Oberflaeche heisst. Menschenlesbar, also mit Umlauten. */
|
|
export const ZUORDNUNGS_BESCHRIFTUNG: Record<keyof AttributZuordnung, string> = {
|
|
vorname: "Vorname",
|
|
nachname: "Nachname",
|
|
anzeigename: "Anzeigename",
|
|
mail: "E-Mail",
|
|
upn: "UPN",
|
|
sam: "Anmeldename (sam)",
|
|
abteilung: "Abteilung",
|
|
titel: "Titel",
|
|
vorgesetzter: "Vorgesetzter",
|
|
externeId: "Kennung (dauerhaft)",
|
|
};
|
|
|
|
/**
|
|
* Ein Attributname, wie ein Verzeichnis ihn kennt: Buchstabe voran, dann
|
|
* Buchstaben, Ziffern und Bindestriche. Mehr braucht es nicht, und mehr
|
|
* zuzulassen hiesse, Klammern und Sternchen in eine LDAP-Anfrage zu lassen.
|
|
*/
|
|
export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
|
|
|
|
/**
|
|
* Liest eine gespeicherte Zuordnung aus dem Json-Feld.
|
|
*
|
|
* Fehlende oder leere Felder bekommen die AD-Vorgabe. Das ist Absicht: eine
|
|
* Quelle, die nur `anzeigename` abweichend belegt, soll nicht alle zehn Zeilen
|
|
* mitschreiben muessen, und eine von Hand geleerte Zeile darf kein Attribut mit
|
|
* leerem Namen anfragen.
|
|
*/
|
|
export function zuordnungLesen(roh: unknown, art: "LDAP" | "GRAPH" = "LDAP"): AttributZuordnung {
|
|
const felder = (typeof roh === "object" && roh !== null ? roh : {}) as Record<string, unknown>;
|
|
/*
|
|
* Die Vorgabe haengt an der **Art** (#028). Ohne diesen Parameter bekaeme eine
|
|
* Graph-Quelle mit leerer Zuordnung die AD-Namen — und fragte dann bei Graph
|
|
* nach `sn` und `objectGUID`, die es dort nicht gibt. Der Standardwert `LDAP`
|
|
* haelt jeden Aufrufer von vor #028 bei seinem bisherigen Verhalten.
|
|
*/
|
|
const gelesen = { ...ZUORDNUNGS_VORGABE[art] };
|
|
for (const schluessel of ZUORDNUNGS_FELDER) {
|
|
const wert = felder[schluessel];
|
|
if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim();
|
|
}
|
|
return gelesen;
|
|
}
|
|
|
|
/**
|
|
* Die Attributzuordnung aus den Formularfeldern, geprueft.
|
|
*
|
|
* Steht hier und nicht in der Server Action, weil es reine Textarbeit ist und
|
|
* jeden Sonderfall einzeln geprueft gehoert: ein leeres Feld faellt heraus (der
|
|
* Dienst legt dann die Vorgabe darueber), und ein Name, der kein Attributname
|
|
* ist, wird abgewiesen statt in eine LDAP-Anfrage geschrieben.
|
|
*/
|
|
export function zuordnungAusEingabe(
|
|
werte: Partial<Record<keyof AttributZuordnung, string>>,
|
|
): { readonly zuordnung: Partial<AttributZuordnung> } | { readonly fehler: string } {
|
|
const zuordnung: Partial<AttributZuordnung> = {};
|
|
for (const feld of ZUORDNUNGS_FELDER) {
|
|
const wert = werte[feld]?.trim();
|
|
if (!wert) continue;
|
|
if (!ATTRIBUT_MUSTER.test(wert)) {
|
|
return {
|
|
fehler:
|
|
`„${wert}“ ist kein Attributname (${ZUORDNUNGS_BESCHRIFTUNG[feld]}). ` +
|
|
"Erwartet werden Buchstaben, Ziffern und Bindestriche, beginnend mit einem Buchstaben.",
|
|
};
|
|
}
|
|
zuordnung[feld] = wert;
|
|
}
|
|
return { zuordnung };
|
|
}
|
|
|
|
/**
|
|
* Was mit dem CA-Zertifikat geschehen soll: drei Faelle aus zwei Feldern.
|
|
*
|
|
* `undefined` heisst **unveraendert** — das hinterlegte bleibt. `""` heisst
|
|
* entfernen. Alles andere ist der neue Text. Beides zugleich ist keine Eingabe,
|
|
* die sich ausfuehren liesse, sondern ein Missverstaendnis, und wird als solches
|
|
* gemeldet.
|
|
*/
|
|
export function zertifikatWahl(
|
|
text: string,
|
|
entfernen: boolean,
|
|
): { readonly wert: string | undefined } | { readonly fehler: string } {
|
|
const neu = text.trim();
|
|
if (neu && entfernen) {
|
|
return {
|
|
fehler:
|
|
"Entweder ein neues CA-Zertifikat eintragen oder das hinterlegte entfernen — beides zugleich " +
|
|
"ergibt keinen Sinn.",
|
|
};
|
|
}
|
|
if (entfernen) return { wert: "" };
|
|
return { wert: neu || undefined };
|
|
}
|