Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.
Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.
Schwer:
- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
(/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.
Mittel:
- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
Graph-Leser sich im Kommentar darauf berief.
Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.
Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.
1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
672 lines
26 KiB
TypeScript
672 lines
26 KiB
TypeScript
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<AttributZuordnung>;
|
||
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<void> {
|
||
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<QuellenSicht> {
|
||
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<NeueQuelle>,
|
||
benutzer: HandelnderBenutzer,
|
||
): Promise<QuellenSicht> {
|
||
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<void> {
|
||
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<Verzeichnisquelle> {
|
||
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;
|
||
}
|