feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
This commit was merged in pull request #30.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user