import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client"; import { QuellenArt, Betriebsmodus } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db"; import { verschluesseln } from "@/lib/geheimnisse"; import { darf } from "@/server/auth/rollen"; import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant"; import { VerzeichnisFehler } from "@/server/ldap/fehler"; // Die Zugangs-Helfer liegen seit #028 in einem eigenen Modul – sonst waere die // Weiche in `quellenleser.ts` ein Import-Zyklus. Hier weitergereicht, damit // bestehende Aufrufer ihre Anlaufstelle behalten. import { ldapVorgabeAus, zugangAus, zuordnungsArt } from "@/server/verzeichnis/zugang"; import { ZUORDNUNGS_VORGABE, INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, intervallMeldung, zuordnungPruefen, } from "@/lib/verzeichnis"; import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser"; import { urlPruefen } from "@/server/ldap/verbindung"; import { AD_ZUORDNUNG, type AttributZuordnung, type GemappterEintrag, type RoherEintrag, STANDARD_FILTER, type Uebersprungen, vorgesetzteAufloesen, zuordnungLesen, } from "@/server/ldap/zuordnung"; /** * Verzeichnisquellen verwalten (#016). * * Drei Grenzen liegen hier, und alle drei sind absichtlich in der Service-Schicht * und nicht nur in der Oberflaeche: * * 1. **Nur im Modus INTERN.** Das Verzeichnis ist das der eigenen Firma. Im Modus * DIENSTLEISTUNG gibt es keinen Abgleich — dort stehen die Mitarbeiter der * Kunden, und die kommen nicht aus unserem AD. Das Ausblenden in der * Oberflaeche allein waere eine Zusage, die eine Server Action von Hand * umgehen koennte. * 2. **Nur `verwaltung`.** An einer Quelle haengen Zugangsdaten zum * Domaenencontroller. Die Aktion prueft das Recht ebenfalls; hier steht es * noch einmal, damit auch der Zeitplan und ein Skript daran vorbeikommen. * 3. **Das Bind-Passwort verlaesst die Schicht nicht.** Es wird mit * `src/lib/geheimnisse.ts` verschluesselt gespeichert, `QuellenSicht` hat kein * Feld dafuer, und jede Meldung der LDAP-Bibliothek geht durch * `fehlerMeldung`, bevor sie gespeichert wird. */ export type { AttributZuordnung, GemappterEintrag }; export { AD_ZUORDNUNG, STANDARD_FILTER }; /* * Die Klasse selbst liegt in `src/server/ldap/fehler.ts` — unterhalb dieser * Schicht, damit auch `server/ldap/verbindung.ts` von ihr erben kann, ohne die * Schichtung umzudrehen. Hier steht sie weiter zur Verfuegung, weil jeder * Aufrufer sie von hier kennt. */ export { VerzeichnisFehler }; export { ldapVorgabeAus, zugangAus, zuordnungsArt }; /** Ohne Passwort – diese Form geht an die Oberflaeche. */ export type QuellenSicht = { readonly id: string; readonly name: string; readonly aktiv: boolean; /** Woher gelesen wird (#028). Bis dahin war jede Quelle LDAP. */ readonly art: QuellenArt; /** Nur bei `art = LDAP` belegt. */ readonly url: string | null; readonly bindDn: string | null; readonly basisDn: string | null; readonly filter: string | null; /** Nur bei `art = GRAPH` belegt – das Geheimnis **nie**, nur ob eines da ist. */ readonly tenantId: string | null; readonly clientId: string | null; readonly clientSecretVorhanden: boolean; /** Nur bei `art = SCIM`. Das Token **nie**, nur ob eines gilt und seit wann. */ readonly scimTokenVorhanden: boolean; readonly scimTokenGesetztAm: Date | null; readonly letzteScimZustellungAm: Date | null; readonly attributZuordnung: AttributZuordnung; readonly caZertifikatVorhanden: boolean; readonly abgleichIntervallMinuten: number; readonly letzterAbgleichAm: Date | null; readonly letzterAbgleichStatus: string | null; readonly letzterAbgleichFehler: string | null; }; export type NeueQuelle = { name: string; /** Ohne Angabe `LDAP` – so verhalten sich Aufrufer von vor #028 unveraendert. */ art?: QuellenArt; /** Pflicht bei `art = LDAP`, bei Graph nicht erlaubt. */ url?: string; bindDn?: string; bindPasswort?: string; basisDn?: string; filter?: string; /** Pflicht bei `art = GRAPH`. */ tenantId?: string; clientId?: string; clientSecret?: string; attributZuordnung?: Partial; caZertifikatPem?: string | null; abgleichIntervallMinuten?: number; aktiv?: boolean; }; /** * Die Grenzen des Intervalls. * * Nach unten fuenf Minuten: ein Abgleich laeuft im selben Prozess wie die * Anwendung und liest das ganze Verzeichnis: alle dreissig Sekunden waere eine * Last, die niemand gewollt hat. Nach oben eine Woche, damit ein Tippfehler mit * einer Null zu viel nicht zu einer Quelle fuehrt, die nie wieder laeuft. */ /** „Verbindung testen“ zeigt die ersten zehn Treffer. Mehr braucht keine Probe. */ const PROBE_GRENZE = 10; const PROBE_GRENZE_HOECHSTENS = 50; /** * Das Modul ist im Modus DIENSTLEISTUNG nicht bloss unsichtbar, sondern * abgeschaltet. Gilt fuer jeden Schreibweg, auch fuer den Zeitplan. */ async function nurIntern(): Promise { if ((await betriebsmodus()) !== Betriebsmodus.INTERN) { throw new VerzeichnisFehler( "Der Verzeichnisabgleich gilt nur für die eigene Organisation und ist im Modus " + "Dienstleistung abgeschaltet.", ); } } function rechtPruefen(benutzer: HandelnderBenutzer): void { if (!darf(benutzer.rolle, "verwaltung")) { throw new VerzeichnisFehler( `Verzeichnisquellen zu pflegen verlangt das Recht "verwaltung"; die Rolle ${benutzer.rolle} hat es nicht.`, ); } } function pflichtText(wert: string | undefined, feld: string): string { const text = wert?.trim(); if (!text) throw new VerzeichnisFehler(`${feld} fehlt.`); return text; } function intervallPruefen(minuten: number | undefined): number | undefined { if (minuten === undefined) return undefined; if (!Number.isInteger(minuten) || minuten < INTERVALL_MINDESTENS || minuten > INTERVALL_HOECHSTENS) { throw new VerzeichnisFehler(intervallMeldung()); } return minuten; } /** * Ein Zertifikat, oder gar keines. * * Geprueft wird nur die Huelle — dass es ein PEM-Block ist. Ob das Zertifikat * passt, sagt erst der Verbindungstest, und das ist die richtige Stelle dafuer: * eine Pruefung hier koennte nur behaupten, was sie nicht weiss. */ function zertifikatPruefen(pem: string | null | undefined): string | null { const wert = pem?.trim(); if (!wert) return null; if (!wert.includes("-----BEGIN CERTIFICATE-----") || !wert.includes("-----END CERTIFICATE-----")) { throw new VerzeichnisFehler( "Das CA-Zertifikat muss im PEM-Format vorliegen, also mit „-----BEGIN CERTIFICATE-----“ beginnen.", ); } return wert; } function alsSicht(zeile: Verzeichnisquelle): QuellenSicht { return { id: zeile.id, name: zeile.name, aktiv: zeile.aktiv, art: zeile.art, url: zeile.url, bindDn: zeile.bindDn, basisDn: zeile.basisDn, filter: zeile.filter, tenantId: zeile.tenantId, clientId: zeile.clientId, /* * Wie beim Bind-Passwort: nur **ob** eines hinterlegt ist. Ein Feld, das es * nicht in eine Antwort schafft, kann auch nicht versehentlich hinausgehen. */ clientSecretVorhanden: Boolean(zeile.clientSecretVerschluesselt?.trim()), /* * Dasselbe fuer das SCIM-Token (#029), und hier waere es noch teurer: es ist * der einzige Mandantenbeweis, den ein Push mitbringt. Es steht als Hash in * der Datenbank und geht aus dem Dienst genau einmal heraus — in der * Antwort von `scimTokenErzeugen`, nie in einer Sicht. */ scimTokenVorhanden: Boolean(zeile.scimTokenHash?.trim()), scimTokenGesetztAm: zeile.scimTokenGesetztAm, letzteScimZustellungAm: zeile.letzteScimZustellungAm, attributZuordnung: zuordnungLesen(zeile.attributZuordnung, zuordnungsArt(zeile.art)), // Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines // hinterlegt ist, und braucht dafuer keinen Zertifikatstext. caZertifikatVorhanden: Boolean(zeile.caZertifikatPem?.trim()), abgleichIntervallMinuten: zeile.abgleichIntervallMinuten, letzterAbgleichAm: zeile.letzterAbgleichAm, letzterAbgleichStatus: zeile.letzterAbgleichStatus, letzterAbgleichFehler: zeile.letzterAbgleichFehler, }; } /** Auch fuer die Lesemodelle: die eine Stelle, die aus einer Zeile eine Sicht macht. */ export { alsSicht as quellenSichtBauen }; /** * Die artabhaengigen Felder einer neuen Quelle, geprueft. * * Eine eigene Funktion, weil beide Richtungen gelten muessen: was eine Art * **braucht**, und was sie **nicht haben darf**. Ohne den zweiten Teil liesse * sich eine Graph-Quelle mit `basisDn` anlegen — eine Zeile, die zwei Dinge * gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt. Die * Datenbank weist das ohnehin ab; hier entsteht die Meldung, die weiterhilft. */ function feldernJeArt(art: QuellenArt, eingabe: NeueQuelle) { if (art === "LDAP") { const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses"); urlPruefen(url); const bindPasswort = eingabe.bindPasswort?.trim(); if (!bindPasswort) { throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt."); } if (eingabe.tenantId?.trim() || eingabe.clientId?.trim() || eingabe.clientSecret?.trim()) { throw new VerzeichnisFehler( "Eine LDAP-Quelle hat keine Graph-Zugangsdaten. Für Entra ID gibt es die Art „Graph“.", ); } return { // Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://` // ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst // waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS // gewuenscht war. url, bindDn: pflichtText(eingabe.bindDn, "Der Bind-DN"), bindPasswortVerschluesselt: verschluesseln(bindPasswort), basisDn: pflichtText(eingabe.basisDn, "Der Basis-DN"), filter: eingabe.filter?.trim() || STANDARD_FILTER, }; } if (art === "GRAPH") { const clientSecret = eingabe.clientSecret?.trim(); if (!clientSecret) { throw new VerzeichnisFehler("Das Client-Secret der App-Registrierung fehlt."); } if (eingabe.url?.trim() || eingabe.bindDn?.trim() || eingabe.basisDn?.trim()) { throw new VerzeichnisFehler( "Eine Graph-Quelle hat keine Adresse, keinen Bind-DN und keinen Basis-DN — Entra ID kennt " + "beides nicht. Für ein lokales AD gibt es die Art „LDAP“.", ); } return { tenantId: pflichtText(eingabe.tenantId, "Die Mandanten-ID"), clientId: pflichtText(eingabe.clientId, "Die Anwendungs-ID"), clientSecretVerschluesselt: verschluesseln(clientSecret), }; } if (art === "SCIM") { /* * **Eine SCIM-Quelle hat keine Zugangsdaten — sie hat ein Token, und das * entsteht danach.** Erst steht die Quelle, dann erzeugt der Administrator * das Token und traegt es beim Anbieter ein. Eine Quelle ohne Token nimmt * nichts an und antwortet mit 401; das ist der richtige Anfangszustand. */ if ( eingabe.url?.trim() || eingabe.bindDn?.trim() || eingabe.basisDn?.trim() || eingabe.tenantId?.trim() || eingabe.clientId?.trim() ) { throw new VerzeichnisFehler( "Eine SCIM-Quelle hat weder LDAP- noch Graph-Zugangsdaten: Laufzettel verbindet sich nicht, " + "sondern wird beliefert. Sie braucht nur einen Namen — das Token kommt danach.", ); } return {}; } /* * Unerreichbar, solange `QuellenArt` drei Werte hat — und genau deshalb hier: * eine vierte Art faellt beim Uebersetzen auf und nicht im Betrieb. */ const unbekannt: never = art; throw new VerzeichnisFehler(`Quellen der Art ${String(unbekannt)} lassen sich nicht anlegen.`); } export async function quelleAnlegen( eingabe: NeueQuelle, benutzer: HandelnderBenutzer, ): Promise { await nurIntern(); rechtPruefen(benutzer); const kundeId = await kundeErmitteln(null, benutzer); const name = pflichtText(eingabe.name, "Der Name der Quelle"); const art = eingabe.art ?? "LDAP"; const intervall = intervallPruefen(eingabe.abgleichIntervallMinuten); /* * **Welche Felder gelten, haengt an der Art** (#028). Geprueft wird hier mit * einer brauchbaren Meldung; dieselbe Regel haelt die Datenbank fest (CHECK * `Verzeichnisquelle_felder_je_art`), damit sie auch gilt, wenn jemand an * dieser Schicht vorbei schreibt. */ const felder = feldernJeArt(art, eingabe); /* * **Die Zuordnung wird hier geprueft, nicht nur in der Server Action.** * Aus diesen Namen entsteht eine LDAP-Attributliste und ein `$select` in der * Graph-Adresse; `src/server/graph/leser.ts` berief sich im Kommentar darauf, * dass sie dem Muster entsprechen — waehrend die Pruefung eine Schicht * darueber in `zuordnungAusFormular` stand. Ein Name mit Komma und `$filter` * kam deshalb durch den Dienst und landete in der Abfrage (Reviewbefund zu * #029). Ein Skript, der Seed und ein Import gehen nicht durch das Formular. */ const zuordnungsMangel = zuordnungPruefen(eingabe.attributZuordnung ?? {}); if (zuordnungsMangel) throw new VerzeichnisFehler(zuordnungsMangel); const zeile = await prisma.$transaction(async (tx) => { const angelegt = await tx.verzeichnisquelle.create({ data: { kundeId, name, art, ...felder, attributZuordnung: zuordnungLesen( { ...ZUORDNUNGS_VORGABE[zuordnungsArt(art)], ...eingabe.attributZuordnung }, zuordnungsArt(art), ), caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem), // Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen. ...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }), aktiv: eingabe.aktiv ?? false, }, }); await tx.protokoll.create({ data: { kundeId: angelegt.kundeId, entitaet: "Verzeichnisquelle", entitaetId: angelegt.id, aktion: "Verzeichnisquelle angelegt", benutzerId: benutzer.id, /* * Kein Passwort und kein Client-Secret, auch nicht verschluesselt: das * Protokoll wird gelesen (#023). Der Mandant und die Anwendungs-ID sind * dagegen keine Geheimnisse — ohne sie liesse sich hinterher nicht * sagen, welche Registrierung gemeint war. */ nachher: { name: angelegt.name, art: angelegt.art, ...(angelegt.url ? { url: angelegt.url } : {}), ...(angelegt.basisDn ? { basisDn: angelegt.basisDn } : {}), ...(angelegt.tenantId ? { tenantId: angelegt.tenantId } : {}), ...(angelegt.clientId ? { clientId: angelegt.clientId } : {}), }, }, }); return angelegt; }); return alsSicht(zeile); } export async function quelleBearbeiten( id: string, aenderung: Partial, benutzer: HandelnderBenutzer, ): Promise { await nurIntern(); rechtPruefen(benutzer); const vorher = await quelleZeileHolen(id); const daten: Prisma.VerzeichnisquelleUpdateInput = {}; const geaendert: string[] = []; /* * **Die Art laesst sich nicht aendern** (#028). * * Nicht aus Bequemlichkeit: an der Art haengt, was `externeId` bedeutet. Im * AD ist es 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`: Laeufe und Geraete haengen dann an den inaktiven. * * Wer wechselt, legt eine neue Quelle an und laesst die alte stehen, bis die * Leute uebernommen sind — dafuer gibt es die Uebernahme von Hand angelegter * Personen aus #016. */ if (aenderung.art !== undefined && aenderung.art !== vorher.art) { throw new VerzeichnisFehler( `Die Art einer Quelle lässt sich nicht ändern (${vorher.art} → ${aenderung.art}). ` + "Daran hängt, was die dauerhafte Kennung bedeutet — nach einem Wechsel würde kein " + "Mitarbeiter wiedererkannt. Leg stattdessen eine neue Quelle an.", ); } const nurBei = (art: QuellenArt, feld: string): void => { if (vorher.art !== art) { throw new VerzeichnisFehler( `„${feld}“ gilt nur für eine Quelle der Art ${art}; „${vorher.name}“ ist ${vorher.art}.`, ); } }; if (aenderung.name !== undefined) { const name = pflichtText(aenderung.name, "Der Name der Quelle"); if (name !== vorher.name) { daten.name = name; geaendert.push("Name"); } } // --- Graph-Felder --------------------------------------------------------- if (aenderung.tenantId !== undefined) { nurBei("GRAPH", "Mandanten-ID"); const wert = pflichtText(aenderung.tenantId, "Die Mandanten-ID"); if (wert !== vorher.tenantId) { daten.tenantId = wert; geaendert.push("Mandant"); } } if (aenderung.clientId !== undefined) { nurBei("GRAPH", "Anwendungs-ID"); const wert = pflichtText(aenderung.clientId, "Die Anwendungs-ID"); if (wert !== vorher.clientId) { daten.clientId = wert; geaendert.push("Anwendung"); } } if (aenderung.clientSecret !== undefined) { nurBei("GRAPH", "Client-Secret"); /* * Leer heisst **unveraendert**, wie beim Bind-Passwort: das Formular zeigt * ein gesetztes Geheimnis nie an, also kommt es leer zurueck. Wuerde leer * als „loeschen“ gelesen, machte jedes Umbenennen die Quelle unbrauchbar. */ const wert = aenderung.clientSecret.trim(); if (wert) { daten.clientSecretVerschluesselt = verschluesseln(wert); geaendert.push("Client-Secret"); } } if (aenderung.url !== undefined) { nurBei("LDAP", "Adresse"); const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses"); urlPruefen(url); if (url !== vorher.url) { daten.url = url; geaendert.push("Adresse"); } } if (aenderung.bindDn !== undefined) { nurBei("LDAP", "Bind-DN"); const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN"); if (bindDn !== vorher.bindDn) { daten.bindDn = bindDn; geaendert.push("Bind-DN"); } } if (aenderung.bindPasswort !== undefined) { nurBei("LDAP", "Bind-Passwort"); // Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht // anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern // eine leere Zeichenkette und loeschte das Geheimnis. const neu = aenderung.bindPasswort.trim(); if (neu) { daten.bindPasswortVerschluesselt = verschluesseln(neu); geaendert.push("Bind-Passwort"); } } if (aenderung.basisDn !== undefined) { nurBei("LDAP", "Basis-DN"); const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN"); if (basisDn !== vorher.basisDn) { daten.basisDn = basisDn; geaendert.push("Basis-DN"); } } if (aenderung.filter !== undefined) { nurBei("LDAP", "Filter"); const filter = aenderung.filter.trim() || STANDARD_FILTER; if (filter !== vorher.filter) { daten.filter = filter; geaendert.push("Filter"); } } if (aenderung.attributZuordnung !== undefined) { const mangel = zuordnungPruefen(aenderung.attributZuordnung); if (mangel) throw new VerzeichnisFehler(mangel); const zuordnung = zuordnungLesen({ ...zuordnungLesen(vorher.attributZuordnung), ...aenderung.attributZuordnung, }); if (JSON.stringify(zuordnung) !== JSON.stringify(zuordnungLesen(vorher.attributZuordnung))) { daten.attributZuordnung = zuordnung; geaendert.push("Attributzuordnung"); } } if (aenderung.caZertifikatPem !== undefined) { const pem = zertifikatPruefen(aenderung.caZertifikatPem); if (pem !== vorher.caZertifikatPem) { daten.caZertifikatPem = pem; geaendert.push("CA-Zertifikat"); } } if (aenderung.abgleichIntervallMinuten !== undefined) { const minuten = intervallPruefen(aenderung.abgleichIntervallMinuten); if (minuten !== undefined && minuten !== vorher.abgleichIntervallMinuten) { daten.abgleichIntervallMinuten = minuten; geaendert.push("Intervall"); } } if (aenderung.aktiv !== undefined && aenderung.aktiv !== vorher.aktiv) { daten.aktiv = aenderung.aktiv; geaendert.push(aenderung.aktiv ? "aktiviert" : "abgeschaltet"); } if (geaendert.length === 0) return alsSicht(vorher); const zeile = await prisma.$transaction(async (tx) => { const nachher = await tx.verzeichnisquelle.update({ where: { id }, data: daten }); await tx.protokoll.create({ data: { kundeId: vorher.kundeId, entitaet: "Verzeichnisquelle", entitaetId: id, aktion: `${geaendert.join(", ")} geändert`, benutzerId: benutzer.id, vorher: { name: vorher.name, url: vorher.url, basisDn: vorher.basisDn, aktiv: vorher.aktiv }, nachher: { name: nachher.name, url: nachher.url, basisDn: nachher.basisDn, aktiv: nachher.aktiv }, }, }); return nachher; }); return alsSicht(zeile); } /** * Loeschen, aber nur solange nichts daran haengt. * * Mitarbeiter werden nie geloescht, und ein archivierter Lauf muss auch in zehn * Jahren noch sagen koennen, woher die Person kam. Der Fremdschluessel steht * deshalb auf `Restrict`; hier steht der Satz, den ein Mensch lesen kann, samt * dem Weg heraus: die Quelle abschalten statt sie zu loeschen. */ export async function quelleLoeschen(id: string, benutzer: HandelnderBenutzer): Promise { await nurIntern(); rechtPruefen(benutzer); const quelle = await quelleZeileHolen(id); const anzahl = await prisma.mitarbeiter.count({ where: { quelleId: id } }); if (anzahl > 0) { throw new VerzeichnisFehler( `An „${quelle.name}“ hängen ${anzahl} Mitarbeiter. Die Quelle lässt sich abschalten, ` + "aber nicht löschen – sonst verlieren die Personen ihre Herkunft.", ); } await prisma.$transaction(async (tx) => { await tx.verzeichnisquelle.delete({ where: { id } }); await tx.protokoll.create({ data: { kundeId: quelle.kundeId, entitaet: "Verzeichnisquelle", entitaetId: id, aktion: "Verzeichnisquelle gelöscht", benutzerId: benutzer.id, vorher: { name: quelle.name, url: quelle.url, basisDn: quelle.basisDn }, }, }); }); } /** * Liest hoechstens `grenze` Eintraege und **schreibt nichts**. * * Kein `Mitarbeiter`, kein `Protokoll`, kein `letzterAbgleich…` — das ist der * Zweck: man soll Zugangsdaten und Filter ausprobieren koennen, ohne damit * gleich Personen anzulegen. Die Rueckgabe zeigt die gemappten Felder, damit ein * falsch zugeordnetes Attribut sofort auffaellt. * * Der Vorgesetzte wird nur gegen die gelesenen Eintraege aufgeloest. In einer * Probe von zehn ist er meist nicht dabei und bleibt dann leer; das ist kein * Fehler der Zuordnung. */ export async function verbindungTesten( quelleId: string, benutzer: HandelnderBenutzer, grenze = PROBE_GRENZE, ): Promise<{ readonly treffer: readonly GemappterEintrag[]; readonly gesamtGelesen: number; /* * Die uebersprungenen Eintraege gehen mit nach draussen, weil „keine Treffer“ * zwei ganz verschiedene Ursachen hat: der Filter passt auf niemanden, oder er * passt und die Zuordnung scheitert an jedem Eintrag. Ohne diese Liste sagt der * Test beide Male denselben Satz — und ein falsch gesetztes Kennungsattribut * saehe aus wie ein zu enger Filter (Reviewbefund zu #016). */ readonly uebersprungen: readonly Uebersprungen[]; }> { await nurIntern(); rechtPruefen(benutzer); const quelle = await quelleZeileHolen(quelleId); /* * **Bei SCIM gibt es nichts zu testen, und das ist keine Lücke** (#029): * Laufzettel baut keine Verbindung auf, sondern nimmt eine an. Ob sie steht, * zeigt die erste Zustellung — deshalb steht in der Oberfläche „Letzte * Zustellung“ dort, wo bei LDAP der Verbindungstest sitzt. */ if (quelle.art === "SCIM") { throw new VerzeichnisFehler( `„${quelle.name}“ ist eine SCIM-Quelle: es gibt keine Verbindung, die sich von hier aus ` + "testen ließe. Trag das Token beim Anbieter ein und lass ihn eine Zuweisung schicken — " + "danach steht hier, wann die letzte angekommen ist.", ); } const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS); /* * **Dieselbe Weiche wie der Abgleich** (#028). Eine eigene Verzweigung hier * waere eine zweite Stelle, die sich an eine neue Quellenart erinnern muss — * und die vergisst man. */ const eintraege: RoherEintrag[] = []; const { gesamtGelesen, uebersprungen } = await eintraegeDerQuelleStroemen( quelle, (eintrag) => void eintraege.push(eintrag), wieViele, ); return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen }; } /** * Die Zeile samt verschluesseltem Passwort – nur fuer diese Schicht und den * Abgleich. Wer nach draussen antwortet, nimmt `alsSicht`. * * Geprueft wird hier auch die Zugehoerigkeit: die Quelle muss der eigenen * Organisation gehoeren. `quelleAnlegen` kann keine andere anlegen — es laeuft nur * im Modus INTERN —, aber Migrationen, `psql` und Skripte schreiben an dieser * Schicht vorbei. „Gilt nur fuer die eigene Organisation“ ist sonst eine Zusage, * die nur fuer den Weg durch das Formular gilt. */ export async function quelleZeileHolen(id: string): Promise { const zeile = await prisma.verzeichnisquelle.findUnique({ where: { id } }); if (!zeile) throw new VerzeichnisFehler("Diese Verzeichnisquelle gibt es nicht (mehr)."); const eigene = await eigeneOrganisation(); if (zeile.kundeId !== eigene.id) { throw new VerzeichnisFehler( `„${zeile.name}“ gehört nicht zur eigenen Organisation. Der Verzeichnisabgleich gilt nur für sie.`, ); } return zeile; }