feat(verzeichnis): Entra ID als Quelle ueber Microsoft Graph (#48)
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:
co-authored by
Claude Opus 5
parent
0158d0292f
commit
76a61ec771
@@ -85,8 +85,30 @@ function zuordnungAusFormular(formular: FormData) {
|
||||
);
|
||||
}
|
||||
|
||||
const QuelleSchema = z.object({
|
||||
/**
|
||||
* Was fuer **jede** Art gilt.
|
||||
*
|
||||
* Seit #028 haengen die uebrigen Felder an der Art (`QuelleSchema` je Art
|
||||
* darunter). Zwei Schemas und nicht eines mit lauter `optional`: ein Schema, das
|
||||
* alles zulaesst, pruefte nichts — und die Meldung „Bitte die Adresse angeben“
|
||||
* fuer eine Graph-Quelle waere schlicht falsch.
|
||||
*/
|
||||
const GemeinsamSchema = z.object({
|
||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||
art: z.enum(["LDAP", "GRAPH"], {
|
||||
message: "Bitte die Art der Quelle wählen: LDAP oder Microsoft Graph.",
|
||||
}),
|
||||
caZertifikatPem: z.string().trim().optional(),
|
||||
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
|
||||
abgleichIntervallMinuten: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(INTERVALL_MINDESTENS, intervallMeldung())
|
||||
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
|
||||
aktiv: z.boolean(),
|
||||
});
|
||||
|
||||
const LdapSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -99,16 +121,39 @@ const QuelleSchema = z.object({
|
||||
bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."),
|
||||
basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."),
|
||||
filter: z.string().trim().optional(),
|
||||
caZertifikatPem: z.string().trim().optional(),
|
||||
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
|
||||
abgleichIntervallMinuten: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(INTERVALL_MINDESTENS, intervallMeldung())
|
||||
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
|
||||
aktiv: z.boolean(),
|
||||
});
|
||||
|
||||
const GraphSchema = z.object({
|
||||
tenantId: z.string().trim().min(1, "Bitte die Mandanten-ID oder den Domänennamen angeben."),
|
||||
clientId: z.string().trim().min(1, "Bitte die Anwendungs-ID der App-Registrierung angeben."),
|
||||
});
|
||||
|
||||
/** Die artabhaengigen Felder aus dem Formular, geprueft. */
|
||||
function artFelder(
|
||||
art: "LDAP" | "GRAPH",
|
||||
formular: FormData,
|
||||
): { readonly werte: Record<string, unknown> } | { readonly fehler: string } {
|
||||
if (art === "LDAP") {
|
||||
const gelesen = LdapSchema.safeParse({
|
||||
url: text(formular, "url"),
|
||||
bindDn: text(formular, "bindDn"),
|
||||
basisDn: text(formular, "basisDn"),
|
||||
filter: text(formular, "filter") || undefined,
|
||||
});
|
||||
return gelesen.success
|
||||
? { werte: gelesen.data }
|
||||
: { fehler: gelesen.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
const gelesen = GraphSchema.safeParse({
|
||||
tenantId: text(formular, "tenantId"),
|
||||
clientId: text(formular, "clientId"),
|
||||
});
|
||||
return gelesen.success
|
||||
? { werte: gelesen.data }
|
||||
: { fehler: gelesen.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
export async function quelleAnlegenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
@@ -116,12 +161,9 @@ export async function quelleAnlegenAktion(
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
|
||||
const eingabe = QuelleSchema.safeParse({
|
||||
const eingabe = GemeinsamSchema.safeParse({
|
||||
name: text(formular, "name"),
|
||||
url: text(formular, "url"),
|
||||
bindDn: text(formular, "bindDn"),
|
||||
basisDn: text(formular, "basisDn"),
|
||||
filter: text(formular, "filter") || undefined,
|
||||
art: text(formular, "art") || "LDAP",
|
||||
caZertifikatPem: text(formular, "caZertifikatPem") || undefined,
|
||||
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
|
||||
aktiv: formular.get("aktiv") === "1",
|
||||
@@ -130,15 +172,34 @@ export async function quelleAnlegenAktion(
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
const bindPasswort = text(formular, "bindPasswort");
|
||||
if (!bindPasswort) return { fehler: "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande." };
|
||||
const felder = artFelder(eingabe.data.art, formular);
|
||||
if ("fehler" in felder) return felder;
|
||||
|
||||
/*
|
||||
* Das Geheimnis heisst je Art anders, und die Meldung soll das auch sagen.
|
||||
* „Ohne Passwort kommt keine Anmeldung zustande“ ist fuer eine
|
||||
* App-Registrierung die falsche Auskunft.
|
||||
*/
|
||||
const geheimnis =
|
||||
eingabe.data.art === "LDAP"
|
||||
? { bindPasswort: text(formular, "bindPasswort") }
|
||||
: { clientSecret: text(formular, "clientSecret") };
|
||||
const geheimnisWert = Object.values(geheimnis)[0];
|
||||
if (!geheimnisWert) {
|
||||
return {
|
||||
fehler:
|
||||
eingabe.data.art === "LDAP"
|
||||
? "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande."
|
||||
: "Ohne Client-Secret kommt keine Anmeldung am Mandanten zustande.",
|
||||
};
|
||||
}
|
||||
|
||||
const zuordnung = zuordnungAusFormular(formular);
|
||||
if ("fehler" in zuordnung) return zuordnung;
|
||||
|
||||
try {
|
||||
const quelle = await quelleAnlegen(
|
||||
{ ...eingabe.data, bindPasswort, attributZuordnung: zuordnung.zuordnung },
|
||||
{ ...eingabe.data, ...felder.werte, ...geheimnis, attributZuordnung: zuordnung.zuordnung },
|
||||
zugang.benutzer,
|
||||
);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
@@ -158,12 +219,9 @@ export async function quelleBearbeitenAktion(
|
||||
const id = text(formular, "id");
|
||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||
|
||||
const eingabe = QuelleSchema.safeParse({
|
||||
const eingabe = GemeinsamSchema.safeParse({
|
||||
name: text(formular, "name"),
|
||||
url: text(formular, "url"),
|
||||
bindDn: text(formular, "bindDn"),
|
||||
basisDn: text(formular, "basisDn"),
|
||||
filter: text(formular, "filter") || undefined,
|
||||
art: text(formular, "art") || "LDAP",
|
||||
// Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das
|
||||
// Schema nur zwei.
|
||||
caZertifikatPem: undefined,
|
||||
@@ -174,6 +232,9 @@ export async function quelleBearbeitenAktion(
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
const felder = artFelder(eingabe.data.art, formular);
|
||||
if ("fehler" in felder) return felder;
|
||||
|
||||
/*
|
||||
* Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues
|
||||
* Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte
|
||||
@@ -200,11 +261,15 @@ export async function quelleBearbeitenAktion(
|
||||
if ("fehler" in zuordnung) return zuordnung;
|
||||
|
||||
try {
|
||||
const clientSecret = text(formular, "clientSecret");
|
||||
const quelle = await quelleBearbeiten(
|
||||
id,
|
||||
{
|
||||
...eingabe.data,
|
||||
...felder.werte,
|
||||
// Leer heisst unveraendert — bei beiden Geheimnissen.
|
||||
...(bindPasswort ? { bindPasswort } : {}),
|
||||
...(clientSecret ? { clientSecret } : {}),
|
||||
...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}),
|
||||
attributZuordnung: zuordnung.zuordnung,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user