Files
Laufzettel/src/lib/verzeichnis.ts
T
Florian WildeandClaude Opus 5 fcd67018b8
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(verzeichnis): SCIM-Endpunkt als dritte Quellenart (#029)
Bisher holt Laufzettel Personen aus dem Verzeichnis. Das setzt Lesezugriff und
einen Netzweg dorthin voraus, und beides ist nicht ueberall gegeben. Mit der Art
SCIM ruft stattdessen der Anbieter an — sofort, nicht beim naechsten Intervall.

Umgesetzt ist, was authentik, Entra ID und Okta wirklich schicken: Users mit dem
Filter userName eq, POST/GET/PUT/PATCH/DELETE, dazu ServiceProviderConfig,
Schemas und ResourceTypes. Jeder Handler prueft sein Token selbst.

Ein Token je Quelle, argon2-gehasht, einmal angezeigt — es ist der einzige
Mandantenbeweis, den eine Zustellung mitbringt. DELETE deaktiviert und loescht
nichts. Eine SCIM-Quelle wird nicht abgeglichen, an zwei Stellen gehalten: ein
Abgleich, der dort nichts liest und ERFOLG meldet, haette ueber die
Abschaltregel die ganze Belegschaft deaktiviert.

Zwei Befunde kamen aus Gegenproben, nicht aus Tests:

- PATCH remove wirkte gar nicht. Der Schalter nurGesetzte las bei PATCH jedes
  null als "nicht mitgeschickt"; eine beim Anbieter entfernte Abteilung stand
  hier weiter. Noetig war er nie — patchAnwenden liefert immer den
  vollstaendigen Zielzustand.
- Entra ID schickt active als "True"/"False". Beim Deaktivieren kommt mit einem
  reinen value === true zufaellig das Richtige heraus, beim Aktivieren nicht:
  ein wieder freigeschaltetes Konto waere fuer immer inaktiv geblieben.

Dazu ein Fehler aus #028: in verbindungTestenAktion stand zweimal dieselbe
Bedingung, der zweite Zweig war seit dem ersten Tag unerreichbar.

1432 Tests (vorher 1373). Klickprobe am gebauten Stand mit einer echten
Zustellung. Gegen echte Mandanten ist nichts gelaufen — die Anfrageformen
stammen aus der Dokumentation, und das steht so in der Testdatei.

Closes #49

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-10 19:50:54 +02:00

209 lines
7.7 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;
/**
* Wie eine Quellenart heisst, wo ein Mensch sie liest.
*
* Liegt in `src/lib`, weil beide Seiten sie brauchen: das Formular ist eine
* Client-Komponente, und die darf nichts aus `src/server/*` importieren
* (CLAUDE.md). „LDAP“ und „SCIM“ bleiben als Lehnwoerter stehen — so heissen sie
* im Haus und in den Einstellungen der Anbieter.
*/
export const QUELLEN_ART_TEXT: Record<"LDAP" | "GRAPH" | "SCIM", string> = {
LDAP: "LDAP",
GRAPH: "Microsoft Graph",
SCIM: "SCIM",
};
/**
* 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 };
}