feat(verzeichnis): Entra ID als Quelle ueber Microsoft Graph (#48)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Ein Haus ohne lokales Active Directory hat kein LDAP, auf das sich
zeigen liesse. Eine Verzeichnisquelle hat jetzt eine **Art** — LDAP oder
GRAPH —, und beide laufen durch **denselben** Abgleich: anlegen,
aktualisieren, deaktivieren, nie loeschen. Nur das Holen der Eintraege
unterscheidet sich; zwei Abgleiche nebeneinander wuerden
auseinanderdriften, und die Zusage „es wird nichts geloescht“ gaelte
dann fuer eine Art und fuer die andere nicht mehr ganz.

Gelesen wird mit dem Client-Credentials-Flow, seitenweise ueber
@odata.nextLink, der Vorgesetzte ueber $expand=manager($select=id). Das
Client-Secret wird verschluesselt gespeichert wie das Bind-Passwort und
erreicht keine Antwort und keinen Protokolleintrag.

Die Art ist **unveraenderlich**, und das steht nicht im Issue: an ihr
haengt, was `externeId` bedeutet. Im AD 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, mit allen Laeufen und
Geraeten daran.

Der teuerste Fund beim Bauen: „in Entra ID deaktiviert“ als
*uebersprungen* zu melden haette die Leute **aktiv** gelassen.
Uebersprungen heisst im Abgleich „nicht zuordenbar“, und solche
Eintraege werden seit #016 absichtlich nicht abgeschaltet. Gefiltert
wird deshalb serverseitig mit `$filter=accountEnabled eq true`, als
Gegenstueck zum LDAP-Standardfilter. Gefunden hat es der Test — aber nur,
weil der Doppelgaenger den Filter wirklich auswertet statt ihn bloss zu
empfangen.

{{ou}} bleibt bei einer Graph-Quelle leer: Entra ID hat keine OU, und
etwas hineinzudeuten waere ein Wert, den kein PowerShell-Befehl brauchen
kann — er saehe nur richtig aus. Steht an der Abteilung eine Ziel-OU
gepflegt, greift die.

Gegen einen echten Entra-Mandanten ist das **nicht** gelaufen. Geprueft
ist es gegen einen HTTP-Doppelgaenger mit aufgezeichneten Antworten; die
Pruefliste fuer den ersten echten Lauf steht am Ende von
docs/betrieb/entra-id.md.

Closes #48

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-06 23:36:36 +02:00
co-authored by Claude Opus 5
parent 0158d0292f
commit 76a61ec771
25 changed files with 2446 additions and 382 deletions
+209 -51
View File
@@ -1,17 +1,27 @@
import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { QuellenArt, Betriebsmodus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
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";
import { INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, intervallMeldung } from "@/lib/verzeichnis";
import { eintraegeLesen } from "@/server/ldap/leser";
import { type LdapZugang, urlPruefen } from "@/server/ldap/verbindung";
// 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,
} 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,
@@ -48,16 +58,24 @@ export { AD_ZUORDNUNG, STANDARD_FILTER };
* 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;
readonly url: string;
readonly bindDn: string;
readonly basisDn: string;
readonly filter: string;
/** 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;
readonly attributZuordnung: AttributZuordnung;
readonly caZertifikatVorhanden: boolean;
readonly abgleichIntervallMinuten: number;
@@ -68,11 +86,18 @@ export type QuellenSicht = {
export type NeueQuelle = {
name: string;
url: string;
bindDn: string;
bindPasswort: string;
basisDn: 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;
@@ -150,11 +175,19 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
id: zeile.id,
name: zeile.name,
aktiv: zeile.aktiv,
art: zeile.art,
url: zeile.url,
bindDn: zeile.bindDn,
basisDn: zeile.basisDn,
filter: zeile.filter,
attributZuordnung: zuordnungLesen(zeile.attributZuordnung),
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()),
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()),
@@ -168,6 +201,64 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
/** 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),
};
}
throw new VerzeichnisFehler(
`Quellen der Art ${art} lassen sich noch nicht anlegen. Der SCIM-Endpunkt kommt mit #029.`,
);
}
export async function quelleAnlegen(
eingabe: NeueQuelle,
benutzer: HandelnderBenutzer,
@@ -177,31 +268,28 @@ export async function quelleAnlegen(
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 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);
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 }),
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 }),
@@ -215,8 +303,20 @@ export async function quelleAnlegen(
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 },
/*
* 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;
@@ -236,6 +336,35 @@ export async function quelleBearbeiten(
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) {
@@ -243,7 +372,40 @@ export async function quelleBearbeiten(
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) {
@@ -252,6 +414,7 @@ export async function quelleBearbeiten(
}
}
if (aenderung.bindDn !== undefined) {
nurBei("LDAP", "Bind-DN");
const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN");
if (bindDn !== vorher.bindDn) {
daten.bindDn = bindDn;
@@ -259,6 +422,7 @@ export async function quelleBearbeiten(
}
}
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.
@@ -269,6 +433,7 @@ export async function quelleBearbeiten(
}
}
if (aenderung.basisDn !== undefined) {
nurBei("LDAP", "Basis-DN");
const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN");
if (basisDn !== vorher.basisDn) {
daten.basisDn = basisDn;
@@ -276,6 +441,7 @@ export async function quelleBearbeiten(
}
}
if (aenderung.filter !== undefined) {
nurBei("LDAP", "Filter");
const filter = aenderung.filter.trim() || STANDARD_FILTER;
if (filter !== vorher.filter) {
daten.filter = filter;
@@ -400,13 +566,15 @@ export async function verbindungTesten(
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),
},
/*
* **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 };
@@ -433,13 +601,3 @@ export async function quelleZeileHolen(id: string): Promise<Verzeichnisquelle> {
}
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,
};
}