feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #30.
This commit is contained in:
2026-09-26 00:21:56 +00:00
parent ab5777a57e
commit ae290345b7
57 changed files with 6834 additions and 61 deletions
+574
View File
@@ -0,0 +1,574 @@
import { hostname } from "node:os";
import { randomUUID } from "node:crypto";
import type { Mitarbeiter, Verzeichnisquelle } from "@/generated/prisma/client";
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { satzEnde } from "@/lib/satzzeichen";
import { darf } from "@/server/auth/rollen";
import { betriebsmodus, eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant";
import { eintraegeStroemen } from "@/server/ldap/leser";
import { fehlerMeldung } from "@/server/ldap/verbindung";
import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung";
import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
/**
* Der Abgleich (#016).
*
* Die eine Regel, die alles andere traegt: **es wird nichts geloescht.** Wer aus
* dem Suchergebnis verschwindet — gekuendigt, deaktiviert, in eine andere OU
* gezogen —, wird `aktiv = false` und bleibt stehen. Ein archivierter Lauf muss
* auch in zehn Jahren noch sagen koennen, fuer wen er lief; ein Loeschen waere
* gegen den Zweck des Archivs, und der Fremdschluessel steht ohnehin auf
* `Restrict`.
*
* Der Abgleich laeuft **nicht** in einer Transaktion. Das ist eine Entscheidung
* mit Folgen, und sie steht hier, damit niemand etwas anderes annimmt: bricht der
* Lauf in der Mitte ab, bleiben die bis dahin geschriebenen Personen so stehen,
* wie sie geschrieben wurden. Deaktiviert wird dagegen **nur nach einem
* vollstaendigen Durchlauf** — sonst haette ein Netzwerkfehler nach der halben
* Liste die andere Haelfte der Firma abgeschaltet. Eine Transaktion ueber das
* gesamte Verzeichnis haette die Tabelle solange gesperrt, wie das Lesen dauert.
*/
export type AbgleichErgebnis = {
readonly angelegt: number;
/**
* Personen, die es schon von Hand gab und die jetzt das Verzeichnis fuehrt.
*
* Eigene Spalte und nicht in `angelegt` oder `aktualisiert` mitgezaehlt: es ist
* die einzige Zahl, die etwas ueber die Datenqualitaet sagt. Steht sie bei jedem
* Lauf hoch, legt jemand Personen an, die das Verzeichnis kurz danach liefert.
*/
readonly uebernommen: number;
readonly aktualisiert: number;
readonly deaktiviert: number;
readonly gelesen: number;
readonly status: "ERFOLG" | "FEHLER";
readonly fehler: string | null;
/**
* Etwas, das der Administrator wissen sollte, ohne dass der Lauf gescheitert
* waere — etwa uebersprungene Eintraege. Steht **nicht** in
* `letzterAbgleichFehler`: das Feld heisst, dass etwas schiefging.
*/
readonly hinweis: string | null;
};
/**
* Der zweite gleichzeitige Abgleich derselben Quelle wird **abgewiesen**, nicht
* eingereiht: eine Warteschlange waere ein zweiter Lauf auf denselben Daten,
* Minuten spaeter, ohne neuen Erkenntniswert. Eine eigene Klasse, damit die
* Oberflaeche „laeuft schon“ von „ist schiefgegangen“ unterscheiden kann.
*/
export class AbgleichLaeuftBereitsFehler extends VerzeichnisFehler {
constructor(nachricht: string) {
super(nachricht);
this.name = "AbgleichLaeuftBereitsFehler";
}
}
/**
* Wie lange die Sperre hoechstens gilt.
*
* Sie muss ablaufen: stuerzt der Prozess mitten im Abgleich ab, bleibt die Zeile
* stehen, und ohne Ablauf waere die Quelle fuer immer blockiert — ein
* Datenbankeintrag, den niemand mehr findet und den ein Neustart nicht raeumt.
* Zwei Stunden sind reichlich fuer ein grosses Verzeichnis und kurz genug, dass
* ein Absturz am Morgen den Abgleich am Mittag nicht verhindert.
*/
const SPERRE_MINUTEN = 120;
function sperrSchluessel(quelleId: string): string {
return `abgleich:${quelleId}`;
}
/**
* Wer die Sperre haelt: Rechner, Prozess und ein Zufallsanteil.
*
* Der Zufallsanteil ist nicht Zierde. Ohne ihn koennten zwei Abgleiche im
* **selben** Prozess (ein Klick und der Zeitplan im selben Moment) einander die
* Sperre wegnehmen und der zweite die des ersten beim Aufraeumen loeschen.
*/
function haltername(): string {
return `${hostname()}/${process.pid}/${randomUUID().slice(0, 8)}`;
}
/**
* Fuehrt `was` unter der Sperre aus; `undefined`, wenn sie belegt ist.
*
* Das Belegen ist **eine** Anweisung, und das ist der Punkt: `SELECT` und danach
* `INSERT` waeren zwei, und zwischen ihnen passt genau der zweite Abgleich, den
* die Sperre verhindern soll. Das `WHERE` am `ON CONFLICT` uebernimmt eine
* abgelaufene Sperre und laesst eine lebende in Ruhe, sodass die Zeile nie
* dauerhaft blockiert.
*/
export async function mitAbgleichSperre<T>(
schluessel: string,
dauerMinuten: number,
was: () => Promise<T>,
): Promise<T | undefined> {
const halter = haltername();
const bekommen = await prisma.$executeRaw`
INSERT INTO "AbgleichSperre" ("schluessel", "gehaltenVon", "gehaltenSeit", "laeuftAbAm")
VALUES (${schluessel}, ${halter}, now(), now() + make_interval(mins => ${dauerMinuten}))
ON CONFLICT ("schluessel") DO UPDATE
SET "gehaltenVon" = EXCLUDED."gehaltenVon",
"gehaltenSeit" = EXCLUDED."gehaltenSeit",
"laeuftAbAm" = EXCLUDED."laeuftAbAm"
WHERE "AbgleichSperre"."laeuftAbAm" <= now()
`;
if (bekommen === 0) return undefined;
try {
return await was();
} finally {
// Nur die eigene Sperre freigeben. Ist sie zwischenzeitlich abgelaufen und
// von einem anderen Lauf uebernommen worden, gehoert die Zeile diesem — sie
// hier zu loeschen hiesse, ihm die Sperre unter den Fuessen wegzuziehen.
await prisma.$executeRaw`
DELETE FROM "AbgleichSperre" WHERE "schluessel" = ${schluessel} AND "gehaltenVon" = ${halter}
`;
}
}
/**
* Quellen, deren Intervall abgelaufen ist – fuer den Zeitplan.
*
* Gerechnet wird in der Datenbank, weil das Intervall je Quelle in der Zeile
* steht: `letzterAbgleichAm + Intervall <= jetzt`. Eine Quelle, die noch nie
* gelaufen ist, ist sofort faellig. Sortiert wird nach dem aeltesten Abgleich,
* damit bei vielen faelligen Quellen keine dauerhaft hinten steht.
*
* Im Modus DIENSTLEISTUNG ist die Liste leer: dort gibt es kein Verzeichnis.
*/
export async function faelligeQuellen(
jetzt: Date = new Date(),
): Promise<readonly { id: string; name: string }[]> {
if ((await betriebsmodus()) !== Betriebsmodus.INTERN) return [];
/*
* Nur die eigene Organisation. Eine Quelle, die an einem anderen Kunden
* haengt, weist `quelleZeileHolen` beim Abgleich ohnehin ab — aber erst
* **nachdem** der Zeitplan sie geholt hat. Ohne diese Bedingung stuende sie
* jede Minute neu in der Liste, schriebe jede Minute „unerwartet gescheitert“
* ins Protokoll des Prozesses und bliebe fuer immer faellig (Reviewbefund zu
* #016). Anlegen laesst sich so eine Quelle ueber die Anwendung nicht; ueber
* `psql` und eine Migration schon.
*/
const eigene = await eigeneOrganisation();
return prisma.$queryRaw<{ id: string; name: string }[]>`
SELECT "id", "name"
FROM "Verzeichnisquelle"
WHERE "aktiv" = true
AND "kundeId" = ${eigene.id}
AND ("letzterAbgleichAm" IS NULL
OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt})
ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC
`;
}
/**
* Die von Hand angelegte Person zu einem Verzeichniseintrag – oder `null`.
*
* Gesucht wird nur im eigenen Kunden und nur unter Zeilen **ohne** Quelle: eine
* Person, die schon eine andere Quelle fuehrt, gehoert der und wird nicht
* uebernommen. Verglichen wird ohne Rucksicht auf Gross- und Kleinschreibung,
* weil `MHuber` und `mhuber` derselbe Anmeldename sind.
*
* `upn` zaehlt vor `sam`: der UPN traegt die Domaene und ist damit die genauere
* Aussage. Ein blosser Namensvergleich waere hier falsch — zwei Menschen koennen
* „Maria Huber“ heissen, aber nicht denselben Anmeldenamen haben.
*/
async function vonHandAngelegteFinden(kundeId: string, eintrag: RoherEintrag): Promise<Mitarbeiter | null> {
const wege = [
...(eintrag.upn ? [{ upn: { equals: eintrag.upn, mode: "insensitive" as const } }] : []),
...(eintrag.sam ? [{ sam: { equals: eintrag.sam, mode: "insensitive" as const } }] : []),
];
for (const weg of wege) {
const treffer = await prisma.mitarbeiter.findFirst({
where: { kundeId, quelleId: null, externeId: null, ...weg },
// Die aelteste zuerst: sie hat die Laeufe und Geraete an sich.
orderBy: { erstelltAm: "asc" },
});
if (treffer) return treffer;
}
return null;
}
/** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */
type OffenerVorgesetzter = {
readonly externeId: string;
readonly vorgesetzterDn: string;
readonly vorherigeKennung: string | null;
readonly neuAngelegt: boolean;
};
export async function abgleichAusfuehren(
quelleId: string,
benutzer: HandelnderBenutzer,
): Promise<AbgleichErgebnis> {
if ((await betriebsmodus()) !== Betriebsmodus.INTERN) {
throw new VerzeichnisFehler(
"Der Verzeichnisabgleich gilt nur für die eigene Organisation und ist im Modus " +
"Dienstleistung abgeschaltet.",
);
}
if (!darf(benutzer.rolle, "verwaltung")) {
throw new VerzeichnisFehler(
`Einen Abgleich zu starten verlangt das Recht "verwaltung"; die Rolle ${benutzer.rolle} hat es nicht.`,
);
}
const quelle = await quelleZeileHolen(quelleId);
const ergebnis = await mitAbgleichSperre(sperrSchluessel(quelleId), SPERRE_MINUTEN, () =>
lauf(quelle, benutzer),
);
if (ergebnis === undefined) {
throw new AbgleichLaeuftBereitsFehler(
`Für „${quelle.name}“ läuft gerade schon ein Abgleich. Bitte das Ergebnis abwarten.`,
);
}
return ergebnis;
}
/** Der eigentliche Lauf, immer unter der Sperre. */
async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Promise<AbgleichErgebnis> {
const quelleId = quelle.id;
// Ein Zeitpunkt fuer den ganzen Lauf. Daran haengt, wer als "gesehen" gilt;
// mit einem Zeitstempel je Person waere die Grenze unscharf.
const beginn = new Date();
let angelegt = 0;
let uebernommen = 0;
let gelesen = 0;
/**
* Wie viele Eintraege bis hierher wirklich durch die Hand gegangen sind.
*
* Bricht das Lesen mittendrin ab, gibt es keine Bilanz — und `gelesen` bliebe
* null, waehrend `angelegt` schon bei 1500 stuende. Eine Zahl, die nicht
* aufgeht, ist schlimmer als ein sichtbarer Fehler (Reviewbefund zu #016).
*/
let verarbeitet = 0;
/**
* Wer sich wirklich geaendert hat — als Menge und nicht als Zaehler.
*
* Eine Person, an der sich Nachname **und** Vorgesetzter geaendert haben, ist
* eine aktualisierte Person und nicht zwei. Der zweite Durchgang traegt in
* dieselbe Menge ein.
*/
const geaendert = new Set<string>();
let bindPasswort: string | undefined;
try {
const zugang = zugangAus(quelle);
bindPasswort = zugang.bindPasswort;
const kennungNachDn = new Map<string, string>();
const offene: OffenerVorgesetzter[] = [];
const bilanz = await eintraegeStroemen(
zugang,
{
basisDn: quelle.basisDn,
filter: quelle.filter,
zuordnung: zuordnungLesen(quelle.attributZuordnung),
},
async (eintrag) => {
verarbeitet += 1;
kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
let vorhanden = await prisma.mitarbeiter.findFirst({
where: { quelleId, externeId: eintrag.externeId },
});
/*
* Kennt die Quelle die Person noch nicht, kann sie trotzdem schon in der
* Datenbank stehen: beim Onboarding legt jemand sie von Hand an, das
* AD-Konto entsteht danach, und der naechste Abgleich findet sie.
*
* Ohne diese Uebernahme entstuenden zwei Maria Huber — eine mit Laeufen
* und Geraeten, eine mit der Kennung. Die von Hand angelegte waere die mit
* den Daten und die falsche in jeder Auswahlliste (Reviewbefund zu #016).
*/
let uebernommenJetzt = false;
if (!vorhanden) {
const vonHand = await vonHandAngelegteFinden(quelle.kundeId, eintrag);
if (vonHand) {
vorhanden = vonHand;
uebernommenJetzt = true;
uebernommen += 1;
}
}
const felder = {
vorname: eintrag.vorname,
nachname: eintrag.nachname,
anzeigename: eintrag.anzeigename,
mail: eintrag.mail,
upn: eintrag.upn,
sam: eintrag.sam,
dn: eintrag.dn,
abteilung: eintrag.abteilung,
titel: eintrag.titel,
};
if (!vorhanden) {
await prisma.mitarbeiter.create({
data: {
kundeId: quelle.kundeId,
quelleId,
externeId: eintrag.externeId,
...felder,
// Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
// erst aufloesen, wenn alle Kennungen bekannt sind.
vorgesetzterExterneId: null,
aktiv: true,
herkunft: Herkunft.ABGLEICH,
zuletztGesehenAm: beginn,
},
});
angelegt += 1;
if (eintrag.vorgesetzterDn) {
offene.push({
externeId: eintrag.externeId,
vorgesetzterDn: eintrag.vorgesetzterDn,
vorherigeKennung: null,
neuAngelegt: true,
});
}
return;
}
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
// geleert. Steht einer, entscheidet der zweite Durchgang.
const vorgesetztenLeeren = eintrag.vorgesetzterDn === null;
if (
// Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
// als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
!uebernommenJetzt &&
(vorhanden.vorname !== felder.vorname ||
vorhanden.nachname !== felder.nachname ||
vorhanden.anzeigename !== felder.anzeigename ||
vorhanden.mail !== felder.mail ||
vorhanden.upn !== felder.upn ||
vorhanden.sam !== felder.sam ||
vorhanden.dn !== felder.dn ||
vorhanden.abteilung !== felder.abteilung ||
vorhanden.titel !== felder.titel ||
!vorhanden.aktiv ||
vorhanden.herkunft !== Herkunft.ABGLEICH ||
(vorgesetztenLeeren && vorhanden.vorgesetzterExterneId !== null))
) {
geaendert.add(eintrag.externeId);
}
await prisma.mitarbeiter.update({
where: { id: vorhanden.id },
data: {
...felder,
// Bei einer Uebernahme kommt die Herkunft dazu: ab jetzt fuehrt das
// Verzeichnis diese Person, und der naechste Lauf findet sie ueber die
// Kennung.
...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}),
...(vorgesetztenLeeren ? { vorgesetzterExterneId: null } : {}),
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine
// wiedereingestellte Person behaelt so ihre Laeufe.
aktiv: true,
herkunft: Herkunft.ABGLEICH,
zuletztGesehenAm: beginn,
},
});
if (!vorgesetztenLeeren) {
offene.push({
externeId: eintrag.externeId,
vorgesetzterDn: eintrag.vorgesetzterDn as string,
vorherigeKennung: vorhanden.vorgesetzterExterneId,
neuAngelegt: false,
});
}
},
);
gelesen = bilanz.gesamtGelesen;
// --- Zweiter Durchgang: Vorgesetzte --------------------------------------
for (const offen of offene) {
const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
if (kennung === offen.vorherigeKennung) continue;
await prisma.mitarbeiter.updateMany({
where: { quelleId, externeId: offen.externeId },
data: { vorgesetzterExterneId: kennung },
});
// Eine gerade angelegte Person ist angelegt, nicht zusaetzlich
// aktualisiert.
if (!offen.neuAngelegt) geaendert.add(offen.externeId);
}
/*
* --- Konnte ueberhaupt etwas zugeordnet werden? -------------------------
*
* Der Fall, den der Review zu #016 gefunden hat und der die ganze Firma
* kostet: steht in der Attributzuordnung ein Kennungsattribut, das es am
* Server nicht gibt — ein Tippfehler, oder ein OpenLDAP ohne `objectGUID` —,
* dann wird **jeder** Eintrag uebersprungen. Der Durchlauf ist vollstaendig,
* das Lesen hat geklappt, niemand wurde wiedergesehen: die Abschaltung
* unten deaktivierte die gesamte Belegschaft und meldete `ERFOLG`. Danach
* sucht niemand mehr nach dem Grund, weil alles gruen aussieht.
*
* Deshalb: nichts gelesen, was sich zuordnen liess, obwohl es Eintraege gab
* **und** wir bisher aktive Personen aus dieser Quelle kennen ⇒ kein
* Abschalten, sondern ein Fehler mit Namen.
*/
const zugeordnet = gelesen - bilanz.uebersprungen.length;
if (zugeordnet === 0 && gelesen > 0) {
const bisherAktiv = await prisma.mitarbeiter.count({
where: { quelleId, herkunft: Herkunft.ABGLEICH, aktiv: true },
});
if (bisherAktiv > 0) {
const ergebnis: AbgleichErgebnis = {
angelegt: 0,
uebernommen: 0,
aktualisiert: 0,
deaktiviert: 0,
gelesen,
status: "FEHLER",
fehler:
`Kein einziger der ${gelesen} gelesenen Einträge ließ sich zuordnen — ` +
`Grund des ersten: ${satzEnde(bilanz.uebersprungen[0]?.grund)} ` +
"Niemand wurde deaktiviert. Prüf die Attributzuordnung, besonders das Kennungsattribut.",
hinweis: null,
};
await abschlussSchreiben(quelleId, benutzer, ergebnis, bilanz.uebersprungen.length);
return ergebnis;
}
}
/*
* --- Verschwundene abschalten -------------------------------------------
*
* Nur, was aus dem Abgleich stammt: eine von Hand angelegte Person steht
* nicht im Verzeichnis und darf davon nicht abgeschaltet werden.
*
* **Uebersprungene Eintraege sind nicht verschwunden.** Sie standen im
* Ergebnis, nur ohne brauchbare Kennung — wer sie mit abschaltete, naehme
* einem anwesenden Menschen seinen Zugang. Erkannt werden sie am `dn`, den
* der Leser mitliefert.
*/
const uebersprungeneDns = bilanz.uebersprungen
.map((eintrag) => eintrag.dn)
.filter((dn): dn is string => Boolean(dn));
const { count: deaktiviert } = await prisma.mitarbeiter.updateMany({
where: {
quelleId,
herkunft: Herkunft.ABGLEICH,
aktiv: true,
OR: [{ zuletztGesehenAm: null }, { zuletztGesehenAm: { lt: beginn } }],
...(uebersprungeneDns.length > 0 ? { NOT: { dn: { in: uebersprungeneDns } } } : {}),
},
data: { aktiv: false },
});
const ergebnis: AbgleichErgebnis = {
angelegt,
uebernommen,
aktualisiert: geaendert.size,
deaktiviert,
gelesen,
status: "ERFOLG",
fehler: null,
/*
* Kein Fehlschlag, aber auch keine Nebensache: uebersprungene Eintraege
* stehen im Verzeichnis und fehlen hier. Sie kommen deshalb als **Hinweis**
* zurueck und nicht als Fehler — `letzterAbgleichFehler` soll heissen, dass
* etwas schiefging, und nicht „ein Konto war krumm“.
*/
hinweis:
bilanz.uebersprungen.length === 0
? null
: `${bilanz.uebersprungen.length} von ${gelesen} Einträgen übersprungen — ` +
`Grund des ersten: ${satzEnde(bilanz.uebersprungen[0]?.grund)}`,
};
await abschlussSchreiben(quelleId, benutzer, ergebnis, bilanz.uebersprungen.length);
return ergebnis;
} catch (fehler) {
// Gesaeubert, weil die Meldung gespeichert und angezeigt wird. `mitVerbindung`
// tut das fuer LDAP-Fehler schon; hier faengt es alles andere ab, damit kein
// Weg daran vorbeifuehrt.
const ergebnis: AbgleichErgebnis = {
angelegt,
uebernommen,
aktualisiert: geaendert.size,
// Bei einem Fehler wird niemand abgeschaltet. Siehe oben: eine halbe Liste
// saehe aus wie eine halbe Firma, die gegangen ist.
deaktiviert: 0,
/*
* `gelesen` kommt aus dem Zaehler, der **waehrend** des Stroemens
* mitlaeuft, nicht aus der Bilanz am Ende: bricht das Lesen nach der
* Haelfte ab, gibt es keine Bilanz, und „angelegt: 1500, gelesen: 0“
* waere eine Zahl, die nicht aufgeht (Reviewbefund zu #016). Was der
* Server insgesamt gehabt haette, weiss hier niemand — gezaehlt wird, was
* wirklich durch die Hand ging.
*/
gelesen: gelesen > 0 ? gelesen : verarbeitet,
status: "FEHLER",
fehler: fehlerMeldung(fehler, bindPasswort === undefined ? [] : [bindPasswort]),
hinweis: null,
};
await abschlussSchreiben(quelleId, benutzer, ergebnis, 0);
return ergebnis;
}
}
/**
* Die Kennung fuer das Protokoll – oder `null` fuer „das System selbst“.
*
* Der Zeitplan hat keinen Benutzer und schickt eine leere Kennung.
* `Protokoll.benutzerId` ist genau dafuer optional (CLAUDE.md,
* „Erweiterbarkeit“: ein Systemlauf ist darstellbar). Eine leere Zeichenkette
* durchzureichen waere kein Systemlauf, sondern ein Verstoss gegen den
* Fremdschluessel — der Abgleich wuerde erst beim Schreiben des Protokolls
* abbrechen, also nachdem er die Mitarbeiter schon geaendert hat.
*/
function urheber(benutzer: HandelnderBenutzer): string | null {
const id = benutzer.id.trim();
return id ? id : null;
}
/**
* Ergebnis an die Quelle und ins Protokoll.
*
* `letzterAbgleichAm` wird auch bei einem Fehler gesetzt: es beantwortet „wann
* wurde es zuletzt versucht“, und daran haengt der Zeitplan. Stuende dort nur der
* letzte Erfolg, liefe eine kaputte Quelle im Minutentakt gegen die Wand.
*/
async function abschlussSchreiben(
quelleId: string,
benutzer: HandelnderBenutzer,
ergebnis: AbgleichErgebnis,
uebersprungen: number,
): Promise<void> {
await prisma.$transaction(async (tx) => {
await tx.verzeichnisquelle.update({
where: { id: quelleId },
data: {
letzterAbgleichAm: new Date(),
letzterAbgleichStatus: ergebnis.status,
letzterAbgleichFehler: ergebnis.fehler,
},
});
await tx.protokoll.create({
data: {
entitaet: "Verzeichnisquelle",
entitaetId: quelleId,
aktion:
ergebnis.status === "ERFOLG" ? "Verzeichnis abgeglichen" : "Verzeichnisabgleich fehlgeschlagen",
benutzerId: urheber(benutzer),
nachher: {
angelegt: ergebnis.angelegt,
uebernommen: ergebnis.uebernommen,
aktualisiert: ergebnis.aktualisiert,
deaktiviert: ergebnis.deaktiviert,
gelesen: ergebnis.gelesen,
uebersprungen,
status: ergebnis.status,
...(ergebnis.fehler === null ? {} : { fehler: ergebnis.fehler }),
},
},
});
});
}
+353 -2
View File
@@ -1,5 +1,6 @@
import type { Mitarbeiter, Prisma } from "@/generated/prisma/client";
import { Herkunft } from "@/generated/prisma/enums";
import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv";
import { prisma } from "@/lib/db";
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
@@ -91,10 +92,23 @@ function anzeigenameBilden(eingabe: MitarbeiterEingabe, vorname: string, nachnam
return gewuenscht ?? `${vorname} ${nachname}`.trim();
}
/**
* Woher diese Zeile kam — nur fuers Protokoll (#016).
*
* Der CSV-Import legt ueber **denselben** Weg an wie das Formular; unterscheiden
* soll sich nur der Eintrag im Protokoll. Ohne die Zeilennummer laesst sich ein
* halb misslungener Import spaeter nicht mehr auf die Datei zurueckfuehren.
*/
export type ProtokollVermerk = {
readonly aktion: string;
readonly zeile?: number;
};
/** Legt einen Mitarbeiter von Hand an — `herkunft = MANUELL`. */
export async function mitarbeiterAnlegen(
eingabe: MitarbeiterEingabe,
benutzer: HandelnderBenutzer,
vermerk?: ProtokollVermerk,
): Promise<Mitarbeiter> {
const vorname = pflicht(eingabe.vorname, "Der Vorname");
const nachname = pflicht(eingabe.nachname, "Der Nachname");
@@ -119,9 +133,13 @@ export async function mitarbeiterAnlegen(
data: {
entitaet: "Mitarbeiter",
entitaetId: mitarbeiter.id,
aktion: "Mitarbeiter angelegt",
aktion: vermerk?.aktion ?? "Mitarbeiter angelegt",
benutzerId: benutzer.id,
nachher: { anzeigename: mitarbeiter.anzeigename, herkunft: mitarbeiter.herkunft },
nachher: {
anzeigename: mitarbeiter.anzeigename,
herkunft: mitarbeiter.herkunft,
...(vermerk?.zeile === undefined ? {} : { zeile: vermerk.zeile }),
},
},
});
return mitarbeiter;
@@ -250,3 +268,336 @@ function auszug(zeile: Mitarbeiter, felder: readonly string[]): Prisma.InputJson
}
return ausschnitt;
}
// ---- CSV-Import (#016) -----------------------------------------------------
/**
* Obergrenze je Datei.
*
* Dieselbe Zahl und derselbe Grund wie beim Geraeteimport: ohne Grenze laeuft
* eine versehentlich exportierte Riesentabelle minutenlang durch, und die
* Oberflaeche nennt die Zahl, **bevor** jemand dagegen laeuft.
*/
export const MITARBEITER_IMPORT_HOECHSTZEILEN = 500;
/**
* Die Spalten aus dem Auftrag – englisch, wie das Verzeichnis sie nennt – und
* jeweils die deutsche Entsprechung.
*
* Gross- und Kleinschreibung, Leerraum, Unterstriche und Bindestriche sind
* unerheblich, dafuer sorgt `spaltenName` in src/lib/csv.ts. Die Reihenfolge der
* Schluessel ist die Reihenfolge, in der die Oberflaeche die Spalten nennt.
*/
export const MITARBEITER_IMPORT_SPALTEN = {
vorname: ["givenName", "vorname"],
nachname: ["sn", "surname", "nachname"],
anzeigename: ["displayName", "anzeigename"],
mail: ["mail", "email", "mailadresse"],
upn: ["upn", "userPrincipalName"],
sam: ["sam", "sAMAccountName"],
abteilung: ["department", "abteilung"],
titel: ["title", "titel"],
} as const;
type ImportFeld = keyof typeof MITARBEITER_IMPORT_SPALTEN;
const IMPORT_FELDER = Object.keys(MITARBEITER_IMPORT_SPALTEN) as readonly ImportFeld[];
/**
* Die Werte einer Zeile – und zwar **nur** die, die in der Datei gefuellt sind.
*
* Eine leere Zelle ist keine Loeschung: in einer Onboarding-Liste steht die
* Abteilung, aber selten der Titel, und ein Import, der den vorhandenen Titel
* dabei leert, richtet mehr Schaden an als er Nutzen bringt. Deshalb fehlt der
* Schluessel hier ganz, statt `null` zu tragen.
*/
type ImportFelder = {
readonly vorname: string;
readonly nachname: string;
readonly anzeigename?: string;
readonly mail?: string;
readonly upn?: string;
readonly sam?: string;
readonly abteilung?: string;
readonly titel?: string;
};
type Aenderung = { -readonly [K in keyof ImportFelder]?: string };
export type MitarbeiterImportAbweisung = {
/** Zeilennummer in der Datei, wie sie ein Editor anzeigt. */
readonly zeile: number;
readonly grund: string;
};
/**
* Je Zeile genau ein Fach.
*
* `angelegt + aktualisiert + unveraendert + abgewiesen === gesamt` gilt immer,
* und darauf prueft ein Test. Im Review von #011 hatte der Geraeteimport „3 von 3
* angelegt“ gemeldet, obwohl Zeilen verschluckt worden waren — eine Zahl, die
* nicht aufgeht, ist schlimmer als ein sichtbarer Fehler.
*/
export type MitarbeiterImportErgebnis = {
/** Datenzeilen in der Datei, ohne Kopfzeile und ohne Leerzeilen. */
readonly gesamt: number;
readonly angelegt: number;
readonly aktualisiert: number;
/** Erkannt, aber es stand nichts Neues drin. */
readonly unveraendert: number;
readonly abgewiesen: readonly MitarbeiterImportAbweisung[];
};
/**
* Eine Zeile zu Feldern. Wirft bei einem Zeilenfehler.
*
* Fehlen `givenName` und `sn`, wird `displayName` am **letzten** Leerzeichen
* geteilt. Das ist eine Annahme und keine Wahrheit — bei „Anna van der Berg“
* landet „van der“ beim Vornamen. Deshalb bleibt `displayName` als Anzeigename
* genau so stehen, wie er in der Datei steht: was ein Mensch zu lesen bekommt,
* ist dann auf jeden Fall richtig, und die Teilung betrifft nur die beiden Felder,
* die ein Techniker beim Nachpflegen ohnehin sieht.
*/
function zeileLesen(zeile: CsvZeile): { readonly felder: ImportFelder; readonly anzeigename: string } {
const spalten = MITARBEITER_IMPORT_SPALTEN;
const angezeigt = wertAus(zeile, ...spalten.anzeigename);
let vorname = wertAus(zeile, ...spalten.vorname);
let nachname = wertAus(zeile, ...spalten.nachname);
if (!vorname && !nachname) {
const trennstelle = angezeigt.lastIndexOf(" ");
if (trennstelle > 0) {
vorname = angezeigt.slice(0, trennstelle).trim();
nachname = angezeigt.slice(trennstelle + 1).trim();
}
}
if (!vorname || !nachname) {
throw new MitarbeiterFehler(
"In dieser Zeile fehlt ein Name. Gebraucht werden „givenName“ und „sn“ – oder ein " +
"„displayName“ aus mindestens zwei Teilen, der dann am letzten Leerzeichen geteilt wird.",
);
}
const weitere: Aenderung = {};
for (const feld of IMPORT_FELDER) {
if (feld === "vorname" || feld === "nachname") continue;
const wert = wertAus(zeile, ...spalten[feld]);
if (wert) weitere[feld] = wert;
}
return {
felder: { vorname, nachname, ...weitere },
anzeigename: angezeigt || `${vorname} ${nachname}`,
};
}
/**
* Die Kennungen, unter denen diese Zeile wiedererkannt wird.
*
* `sam` und `upn` sind die Felder, die das Verzeichnis eindeutig haelt und die der
* Techniker vor sich hat; `mail` steht bewusst **nicht** dabei — eine
* Sammeladresse trifft mehrere Personen, und eine Adresse wechselt bei einer
* Heirat, ohne dass es eine andere Person wird.
*
* Steht keine der beiden in der Zeile, bleibt der Anzeigename. Das ist der
* schwaechere Schluessel, aber der einzige, der bleibt — und ohne ihn legte
* dieselbe Onboarding-Liste beim zweiten Einlesen jede Person ein zweites Mal an.
* Genau diese Sorte stiller Verdopplung war der Blocker in #011.
*/
function kennungen(felder: ImportFelder, anzeigename: string): readonly string[] {
const liste: string[] = [];
if (felder.sam) liste.push(`sam:${felder.sam.toLowerCase()}`);
if (felder.upn) liste.push(`upn:${felder.upn.toLowerCase()}`);
if (liste.length === 0) liste.push(`name:${anzeigename.toLowerCase()}`);
return liste;
}
/** Die Felder, die der Import vergleicht – mehr braucht der Abgleich mit der Zeile nicht. */
const VORHANDEN_AUSWAHL = {
id: true,
anzeigename: true,
herkunft: true,
vorname: true,
nachname: true,
mail: true,
upn: true,
sam: true,
abteilung: true,
titel: true,
} satisfies Prisma.MitarbeiterSelect;
type VorhandenePerson = {
readonly id: string;
readonly herkunft: Herkunft;
} & { readonly [K in keyof ImportFelder]-?: string | null } & { readonly anzeigename: string };
/**
* Sucht die Person, die zu dieser Zeile gehoert – innerhalb **desselben** Kunden.
*
* Passen zwei Personen, wird die Zeile abgewiesen statt eine davon zu nehmen: hier
* zu raten heisst, die Stammdaten der falschen Person zu ueberschreiben.
*/
async function vorhandenePersonSuchen(
kundeId: string,
felder: ImportFelder,
anzeigename: string,
): Promise<VorhandenePerson | null> {
const bedingungen: Prisma.MitarbeiterWhereInput[] = [];
if (felder.sam) bedingungen.push({ sam: { equals: felder.sam, mode: "insensitive" } });
if (felder.upn) bedingungen.push({ upn: { equals: felder.upn, mode: "insensitive" } });
if (bedingungen.length === 0) {
bedingungen.push({ anzeigename: { equals: anzeigename, mode: "insensitive" } });
}
const treffer = await prisma.mitarbeiter.findMany({
where: { kundeId, OR: bedingungen },
select: VORHANDEN_AUSWAHL,
orderBy: { erstelltAm: "asc" },
take: 2,
});
if (treffer.length > 1) {
throw new MitarbeiterFehler(
`Diese Zeile passt auf „${treffer[0]?.anzeigename}“ und „${treffer[1]?.anzeigename}“. ` +
"Welche der beiden gemeint ist, steht nicht in der Datei — bitte sAMAccountName oder UPN eintragen.",
);
}
return treffer[0] ?? null;
}
/**
* Spielt eine CSV-Datei mit Personen ein.
*
* **Je Zeile ein Ergebnis.** Eine fehlerhafte Zeile bricht den Import nicht ab;
* sie wird mit Zeilennummer und Grund gemeldet, die uebrigen werden verarbeitet.
* Deshalb schreibt jede Zeile in ihrer eigenen Transaktion — eine gemeinsame
* haette am Ende alles zurueckgerollt, was vor dem Fehler gelungen war.
*
* Geschrieben wird ueber `mitarbeiterAnlegen` und `mitarbeiterBearbeiten`, nicht
* an ihnen vorbei: die Namensregeln, die 20-Zeichen-Grenze des `sam` und die
* Sperre der abgeglichenen Felder gelten damit hier genauso wie im Formular.
*
* Eine Person mit `herkunft = ABGLEICH` wird **nicht** angetastet und die Zeile
* mit Begruendung gemeldet — der naechste Abgleich drehte die Aenderung ohnehin
* zurueck, und stillschweigend zu ueberspringen hiesse, einen Import zu melden,
* der nicht stattgefunden hat.
*/
export async function mitarbeiterImportieren(
csvText: string,
benutzer: HandelnderBenutzer,
kundeIdAusEingabe?: string | null,
): Promise<MitarbeiterImportErgebnis> {
const kundeId = await kundeErmitteln(kundeIdAusEingabe, benutzer);
let tabelle;
try {
tabelle = csvZerlegen(csvText, { hoechstZeilen: MITARBEITER_IMPORT_HOECHSTZEILEN });
} catch (fehler) {
if (fehler instanceof CsvFehler) throw new MitarbeiterFehler(fehler.message);
throw fehler;
}
const spalten = MITARBEITER_IMPORT_SPALTEN;
// Ohne eine Namensspalte koennte keine einzige Zeile gelingen. Das als 500
// gleichlautende Zeilenfehler zu melden waere richtig, aber nutzlos.
if (!hatSpalte(tabelle, ...spalten.vorname, ...spalten.nachname, ...spalten.anzeigename)) {
throw new MitarbeiterFehler(
"In der Kopfzeile fehlt der Name — „givenName“ und „sn“ oder wenigstens „displayName“. " +
`Gelesen werden diese Spalten: ${Object.values(spalten)
.map((namen) => namen[0])
.join(", ")}.`,
);
}
const abgewiesen: MitarbeiterImportAbweisung[] = [];
let angelegt = 0;
let aktualisiert = 0;
let unveraendert = 0;
/** Kennung zu Zeilennummer – damit die Meldung sagen kann, *wo* es schon stand. */
const gesehen = new Map<string, number>();
for (const zeile of tabelle.zeilen) {
if (zeile.fehler !== undefined) {
abgewiesen.push({ zeile: zeile.nummer, grund: zeile.fehler });
continue;
}
try {
const { felder, anzeigename } = zeileLesen(zeile);
const schluessel = kennungen(felder, anzeigename);
const doppelt = schluessel.find((eintrag) => gesehen.has(eintrag));
if (doppelt !== undefined) {
abgewiesen.push({
zeile: zeile.nummer,
grund: `„${anzeigename}“ steht in dieser Datei schon in Zeile ${gesehen.get(doppelt)}.`,
});
continue;
}
const vorhanden = await vorhandenePersonSuchen(kundeId, felder, anzeigename);
if (vorhanden === null) {
await mitarbeiterAnlegen({ ...felder, kundeId }, benutzer, {
aktion: "Mitarbeiter aus CSV angelegt",
zeile: zeile.nummer,
});
angelegt += 1;
for (const eintrag of schluessel) gesehen.set(eintrag, zeile.nummer);
continue;
}
if (vorhanden.herkunft === Herkunft.ABGLEICH) {
abgewiesen.push({
zeile: zeile.nummer,
grund:
`„${vorhanden.anzeigename}“ kommt aus dem Verzeichnis. Von Hand eingelesene Werte würde ` +
"der nächste Abgleich zurücksetzen — geändert wird diese Person im Verzeichnis.",
});
continue;
}
const aenderung: Aenderung = {};
for (const feld of IMPORT_FELDER) {
const wert = felder[feld];
if (wert === undefined) continue;
const alt = vorhanden[feld];
/*
* `sam` und `upn` sind die Kennungen, ueber die diese Zeile gefunden
* wurde — und gefunden wird ohne Ruecksicht auf Gross- und
* Kleinschreibung. Dann darf ein „AMUSTER“ in der Datei den vorhandenen
* Wert auch nicht umschreiben: es ist dieselbe Kennung, und sonst
* schriebe jeder Import aus einem Werkzeug, das anders schreibt, einen
* Protokolleintrag ueber einen Unterschied, der keiner ist. Bei den
* uebrigen Feldern zaehlt die Schreibweise: „vertrieb“ und „Vertrieb“
* sind zwei Abteilungsnamen, und nur einer davon trifft eine `Abteilung`.
*/
const gleich =
feld === "sam" || feld === "upn"
? alt !== null && alt.toLowerCase() === wert.toLowerCase()
: alt === wert;
if (!gleich) aenderung[feld] = wert;
}
if (Object.keys(aenderung).length === 0) {
unveraendert += 1;
} else {
// Der Protokolleintrag entsteht in `mitarbeiterBearbeiten` und nennt die
// geaenderten Felder. Die Zeilennummer steht dort nicht — sie ist beim
// Anlegen die einzige Spur zur Datei, beim Aendern sagt das Protokoll
// ohnehin, was vorher und was nachher drin stand.
await mitarbeiterBearbeiten(vorhanden.id, aenderung, benutzer);
aktualisiert += 1;
}
for (const eintrag of schluessel) gesehen.set(eintrag, zeile.nummer);
} catch (fehler) {
if (fehler instanceof MitarbeiterFehler) {
abgewiesen.push({ zeile: zeile.nummer, grund: fehler.message });
continue;
}
// Alles Uebrige – ein Verbindungsabbruch etwa – ist kein Zeilenproblem und
// wuerde in den folgenden Zeilen genauso auftreten.
throw fehler;
}
}
return { gesamt: tabelle.zeilen.length, angelegt, aktualisiert, unveraendert, abgewiesen };
}
+442
View File
@@ -0,0 +1,442 @@
import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { entschluesseln, 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";
import { INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, intervallMeldung } from "@/lib/verzeichnis";
import { eintraegeLesen } from "@/server/ldap/leser";
import { type LdapZugang, urlPruefen } from "@/server/ldap/verbindung";
import {
AD_ZUORDNUNG,
type AttributZuordnung,
type GemappterEintrag,
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 };
/** Ohne Passwort – diese Form geht an die Oberflaeche. */
export type QuellenSicht = {
readonly id: string;
readonly name: string;
readonly aktiv: boolean;
readonly url: string;
readonly bindDn: string;
readonly basisDn: string;
readonly filter: string;
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;
url: string;
bindDn: string;
bindPasswort: string;
basisDn: string;
filter?: 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,
url: zeile.url,
bindDn: zeile.bindDn,
basisDn: zeile.basisDn,
filter: zeile.filter,
attributZuordnung: zuordnungLesen(zeile.attributZuordnung),
// 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 };
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 url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses");
urlPruefen(url);
const bindDn = pflichtText(eingabe.bindDn, "Der Bind-DN");
const bindPasswort = eingabe.bindPasswort?.trim();
if (!bindPasswort) {
throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt.");
}
const basisDn = pflichtText(eingabe.basisDn, "Der Basis-DN");
const intervall = intervallPruefen(eingabe.abgleichIntervallMinuten);
const zeile = await prisma.$transaction(async (tx) => {
const angelegt = await tx.verzeichnisquelle.create({
data: {
kundeId,
name,
// 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,
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
basisDn,
filter: eingabe.filter?.trim() || STANDARD_FILTER,
attributZuordnung: zuordnungLesen({ ...AD_ZUORDNUNG, ...eingabe.attributZuordnung }),
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: {
entitaet: "Verzeichnisquelle",
entitaetId: angelegt.id,
aktion: "Verzeichnisquelle angelegt",
benutzerId: benutzer.id,
// Kein Passwort, auch nicht verschluesselt: das Protokoll wird gelesen.
nachher: { name: angelegt.name, url: angelegt.url, basisDn: angelegt.basisDn },
},
});
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[] = [];
if (aenderung.name !== undefined) {
const name = pflichtText(aenderung.name, "Der Name der Quelle");
if (name !== vorher.name) {
daten.name = name;
geaendert.push("Name");
}
}
if (aenderung.url !== undefined) {
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) {
const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN");
if (bindDn !== vorher.bindDn) {
daten.bindDn = bindDn;
geaendert.push("Bind-DN");
}
}
if (aenderung.bindPasswort !== undefined) {
// 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) {
const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN");
if (basisDn !== vorher.basisDn) {
daten.basisDn = basisDn;
geaendert.push("Basis-DN");
}
}
if (aenderung.filter !== undefined) {
const filter = aenderung.filter.trim() || STANDARD_FILTER;
if (filter !== vorher.filter) {
daten.filter = filter;
geaendert.push("Filter");
}
}
if (aenderung.attributZuordnung !== undefined) {
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: {
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: {
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);
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
const { eintraege, gesamtGelesen, uebersprungen } = await eintraegeLesen(
zugangAus(quelle),
{
basisDn: quelle.basisDn,
filter: quelle.filter,
zuordnung: zuordnungLesen(quelle.attributZuordnung),
},
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;
}
/** Entschluesselt das Bind-Passwort. Das Ergebnis bleibt in der Aufrufkette. */
export function zugangAus(quelle: Verzeichnisquelle): LdapZugang {
return {
url: quelle.url,
bindDn: quelle.bindDn,
bindPasswort: entschluesseln(quelle.bindPasswortVerschluesselt),
caZertifikatPem: quelle.caZertifikatPem,
};
}