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
+14 -1
View File
@@ -164,8 +164,21 @@ export default async function VerzeichnisSeite() {
)}
</div>
{/*
* Die Art steht **an jeder Quelle** (#028). Ohne sie waere bei
* zwei Zeilen nicht zu sehen, welche aus dem AD liest und welche
* aus Entra ID — und die Felder darunter heissen je Art anders.
*/}
<p className="mono mt-2 text-xs break-all text-nebentext">
{quelle.url} · {quelle.basisDn}
{quelle.art === "GRAPH" ? (
<>
Microsoft Graph · {quelle.tenantId} · App {quelle.clientId}
</>
) : (
<>
LDAP · {quelle.url} · {quelle.basisDn}
</>
)}
</p>
<Status quelle={quelle} />
+256 -139
View File
@@ -5,6 +5,7 @@ import { KeyRound, Save, Server, ShieldAlert, ShieldCheck } from "lucide-react";
import { FELD, KAESTCHEN } from "@/lib/formular";
import {
AD_ZUORDNUNG,
GRAPH_ZUORDNUNG,
INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS,
ZUORDNUNGS_BESCHRIFTUNG,
@@ -36,6 +37,14 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
const [zuordnungOffen, setzeZuordnungOffen] = useState(false);
const [adresse, setzeAdresse] = useState(quelle?.url ?? "");
const klartext = adresse.trim().startsWith("ldap://");
/*
* Die Art ist beim Anlegen waehlbar und beim Bearbeiten **fest** (#028): an
* ihr haengt, was die dauerhafte Kennung bedeutet, und nach einem Wechsel
* wuerde kein Mitarbeiter wiedererkannt. Der Dienst weist einen Wechsel
* ohnehin ab; hier gibt es ihn gar nicht erst zur Auswahl.
*/
const [art, setzeArt] = useState<"LDAP" | "GRAPH">(quelle?.art === "GRAPH" ? "GRAPH" : "LDAP");
const istGraph = art === "GRAPH";
return (
<form action={aktion} className="flex flex-col gap-4">
@@ -52,108 +61,213 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
/>
</label>
{/*
* Die Erklärung steht **außerhalb** des `label` und hängt über
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
* ein Screenreader läse „Adresse Drei Formen: ldaps:// …“ als Bezeichnung
* des Eingabefelds, und kein Werkzeug fände das Feld noch über seinen
* Namen.
*/}
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-url" className="text-nebentext">
Adresse
</label>
<input
id="quelle-url"
name="url"
value={adresse}
onChange={(ereignis) => setzeAdresse(ereignis.target.value)}
required
aria-describedby="quelle-url-hinweis"
placeholder="ldaps://dc01.firma.local:636"
className={`${FELD} mono`}
/>
<span id="quelle-url-hinweis" className="text-xs text-nebentext">
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die richtige
Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und dann
hochgestuft) und <span className="mono">ldap://</span>.
</span>
{klartext ? (
/*
* Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer
* dasteht, liest nach dem dritten Mal niemand mehr.
*/
<span role="status" className="flex items-start gap-2 text-xs text-faellig">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span>
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das Netz.
Für ein Dienstkonto im Active Directory ist das keine gute Idee.
{bearbeiten ? (
<>
<input type="hidden" name="art" value={art} />
<p className="text-sm text-nebentext">
Art: <strong className="text-text">{istGraph ? "Microsoft Graph" : "LDAP"}</strong> — sie lässt
sich nicht ändern. Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein
Mitarbeiter wiedererkannt.
</p>
</>
) : (
<fieldset className="flex flex-col gap-2 text-sm">
<legend className="mb-1 text-nebentext">Art</legend>
{(
[
{
wert: "LDAP" as const,
titel: "LDAP",
text: "Ein lokales Active Directory oder ein anderes LDAP-Verzeichnis.",
},
{
wert: "GRAPH" as const,
titel: "Microsoft Graph",
text: "Entra ID (Microsoft 365). Für Häuser ohne lokales AD.",
},
] satisfies readonly { wert: "LDAP" | "GRAPH"; titel: string; text: string }[]
).map((wahl) => (
<label
key={wahl.wert}
className="flex min-h-11 cursor-pointer items-start gap-3 rounded-karte border border-linie p-3 md:min-h-10"
>
<input
type="radio"
name="art"
value={wahl.wert}
checked={art === wahl.wert}
onChange={() => setzeArt(wahl.wert)}
className="mt-0.5 size-4 accent-akzent"
/>
<span>
<span className="font-medium">{wahl.titel}</span>
<span className="block text-xs text-nebentext">{wahl.text}</span>
</span>
</label>
))}
</fieldset>
)}
{istGraph ? (
<>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Mandanten-ID</span>
<input
name="tenantId"
defaultValue={quelle?.tenantId ?? ""}
required
placeholder="contoso.onmicrosoft.com oder die GUID"
className={`${FELD} mono`}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anwendungs-ID</span>
<input
name="clientId"
defaultValue={quelle?.clientId ?? ""}
required
placeholder="GUID der App-Registrierung"
className={`${FELD} mono`}
/>
</label>
</div>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-secret" className="flex items-center gap-2 text-nebentext">
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
Client-Secret
</label>
<input
id="quelle-secret"
name="clientSecret"
type="password"
autoComplete="new-password"
aria-describedby="quelle-secret-hinweis"
placeholder={quelle?.clientSecretVorhanden ? "unverändert lassen" : ""}
required={!bearbeiten}
className={FELD}
/>
<span id="quelle-secret-hinweis" className="text-xs text-nebentext">
{quelle?.clientSecretVorhanden
? "Leer lassen behält das gespeicherte Geheimnis."
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
</span>
</span>
) : null}
</div>
</div>
<p className="max-w-prose text-xs text-nebentext">
Die App-Registrierung braucht die <strong>Anwendungsberechtigung</strong>{" "}
<span className="mono">User.Read.All</span> mit Administratoreinwilligung. Wie das eingerichtet
wird, steht in <span className="mono">docs/betrieb/entra-id.md</span>. Entra ID kennt keine OU,
deshalb bleibt <span className="mono">{"{{ou}}"}</span> bei dieser Art leer.
</p>
</>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anmelde-DN</span>
<input
name="bindDn"
defaultValue={quelle?.bindDn ?? ""}
required
placeholder="CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local"
className={`${FELD} mono`}
/>
</label>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-passwort" className="flex items-center gap-2 text-nebentext">
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
Passwort
{!istGraph ? (
<>
{/*
* Die Erklärung steht **außerhalb** des `label` und hängt über
* `aria-describedby` am Feld. Im `label` wäre sie Teil des Feldnamens:
* ein Screenreader läse „Adresse Drei Formen: ldaps:// …“ als Bezeichnung
* des Eingabefelds, und kein Werkzeug fände das Feld noch über seinen
* Namen.
*/}
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-url" className="text-nebentext">
Adresse
</label>
<input
id="quelle-url"
name="url"
value={adresse}
onChange={(ereignis) => setzeAdresse(ereignis.target.value)}
required
aria-describedby="quelle-url-hinweis"
placeholder="ldaps://dc01.firma.local:636"
className={`${FELD} mono`}
/>
<span id="quelle-url-hinweis" className="text-xs text-nebentext">
Drei Formen: <span className="mono">ldaps://</span> (verschlüsselt ab dem ersten Byte, die
richtige Wahl), <span className="mono">ldap+starttls://</span> (unverschlüsselt verbunden und
dann hochgestuft) und <span className="mono">ldap://</span>.
</span>
{klartext ? (
/*
* Die Warnung erscheint erst, wenn sie zutrifft — eine, die immer
* dasteht, liest nach dem dritten Mal niemand mehr.
*/
<span role="status" className="flex items-start gap-2 text-xs text-faellig">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span>
Mit <span className="mono">ldap://</span> geht auch das Bind-Passwort im Klartext über das
Netz. Für ein Dienstkonto im Active Directory ist das keine gute Idee.
</span>
</span>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anmelde-DN</span>
<input
name="bindDn"
defaultValue={quelle?.bindDn ?? ""}
required
placeholder="CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local"
className={`${FELD} mono`}
/>
</label>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-passwort" className="flex items-center gap-2 text-nebentext">
<KeyRound className="size-4" strokeWidth={1.75} aria-hidden="true" />
Passwort
</label>
<input
id="quelle-passwort"
name="bindPasswort"
type="password"
autoComplete="new-password"
aria-describedby="quelle-passwort-hinweis"
placeholder={bearbeiten ? "unverändert lassen" : ""}
required={!bearbeiten}
className={FELD}
/>
<span id="quelle-passwort-hinweis" className="text-xs text-nebentext">
{bearbeiten
? "Leer lassen behält das gespeicherte Passwort."
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
</span>
</div>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Basis-DN</span>
<input
name="basisDn"
defaultValue={quelle?.basisDn ?? ""}
required
placeholder="OU=Benutzer,DC=firma,DC=local"
className={`${FELD} mono`}
/>
</label>
<input
id="quelle-passwort"
name="bindPasswort"
type="password"
autoComplete="new-password"
aria-describedby="quelle-passwort-hinweis"
placeholder={bearbeiten ? "unverändert lassen" : ""}
required={!bearbeiten}
className={FELD}
/>
<span id="quelle-passwort-hinweis" className="text-xs text-nebentext">
{bearbeiten
? "Leer lassen behält das gespeicherte Passwort."
: "Wird verschlüsselt gespeichert und nie wieder angezeigt."}
</span>
</div>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Basis-DN</span>
<input
name="basisDn"
defaultValue={quelle?.basisDn ?? ""}
required
placeholder="OU=Benutzer,DC=firma,DC=local"
className={`${FELD} mono`}
/>
</label>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-filter" className="text-nebentext">
Filter (optional)
</label>
<input
id="quelle-filter"
name="filter"
defaultValue={quelle?.filter ?? ""}
aria-describedby="quelle-filter-hinweis"
className={`${FELD} mono`}
/>
<span id="quelle-filter-hinweis" className="text-xs text-nebentext">
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer aus
dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
</span>
</div>
<div className="flex flex-col gap-1.5 text-sm">
<label htmlFor="quelle-filter" className="text-nebentext">
Filter (optional)
</label>
<input
id="quelle-filter"
name="filter"
defaultValue={quelle?.filter ?? ""}
aria-describedby="quelle-filter-hinweis"
className={`${FELD} mono`}
/>
<span id="quelle-filter-hinweis" className="text-xs text-nebentext">
Leer lassen nimmt den Standard: aktive Personenkonten. Er schließt deaktivierte Konten aus — wer
aus dem Ergebnis verschwindet, wird hier deaktiviert und nie gelöscht.
</span>
</div>
</>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
@@ -200,7 +314,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
<input
name={`attr.${feld}`}
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
placeholder={AD_ZUORDNUNG[feld]}
placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]}
className={`${FELD} mono`}
/>
</label>
@@ -212,48 +326,51 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
</p>
</details>
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Eigenes CA-Zertifikat hinterlegen
</summary>
{/*
* Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur
* die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das
* leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis
* darunter behauptete es auch (Reviewbefund zu #016).
*/}
{quelle?.caZertifikatVorhanden ? (
<p className="mt-2 flex items-start gap-2 text-xs text-erledigt">
<ShieldCheck className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span>
Ein CA-Zertifikat ist hinterlegt. Das Feld unten bleibt leer — Text eintragen ersetzt es, leer
lassen behält es.
{/* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber oeffentliches TLS. */}
{!istGraph ? (
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Eigenes CA-Zertifikat hinterlegen
</summary>
{/*
* Der Zertifikatstext selbst kommt nicht zurück in die Oberfläche — nur
* die Auskunft, dass eines hinterlegt ist. Ohne diese Zeile sah das
* leere Feld beim Bearbeiten aus wie „kein Zertifikat“, und der Hinweis
* darunter behauptete es auch (Reviewbefund zu #016).
*/}
{quelle?.caZertifikatVorhanden ? (
<p className="mt-2 flex items-start gap-2 text-xs text-erledigt">
<ShieldCheck className="mt-0.5 size-3.5 shrink-0" strokeWidth={2} aria-hidden="true" />
<span>
Ein CA-Zertifikat ist hinterlegt. Das Feld unten bleibt leer — Text eintragen ersetzt es, leer
lassen behält es.
</span>
</p>
) : null}
<label className="mt-2 flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">PEM</span>
<textarea
name="caZertifikatPem"
rows={4}
defaultValue=""
placeholder="-----BEGIN CERTIFICATE-----"
className={`${FELD} mono py-2`}
/>
<span className="text-xs text-nebentext">
Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt.
{quelle?.caZertifikatVorhanden
? " Ohne hinterlegtes Zertifikat gelten die Wurzelzertifikate des Systems."
: " Ohne Eintrag gelten die Wurzelzertifikate des Systems."}
</span>
</p>
) : null}
<label className="mt-2 flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">PEM</span>
<textarea
name="caZertifikatPem"
rows={4}
defaultValue=""
placeholder="-----BEGIN CERTIFICATE-----"
className={`${FELD} mono py-2`}
/>
<span className="text-xs text-nebentext">
Nur nötig, wenn der Domänencontroller ein Zertifikat aus einer eigenen CA trägt.
{quelle?.caZertifikatVorhanden
? " Ohne hinterlegtes Zertifikat gelten die Wurzelzertifikate des Systems."
: " Ohne Eintrag gelten die Wurzelzertifikate des Systems."}
</span>
</label>
{quelle?.caZertifikatVorhanden ? (
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
<input type="checkbox" name="caZertifikatEntfernen" value="1" className={KAESTCHEN} />
Hinterlegtes Zertifikat entfernen
</label>
) : null}
</details>
{quelle?.caZertifikatVorhanden ? (
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
<input type="checkbox" name="caZertifikatEntfernen" value="1" className={KAESTCHEN} />
Hinterlegtes Zertifikat entfernen
</label>
) : null}
</details>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
+42 -2
View File
@@ -60,6 +60,40 @@ export const AD_ZUORDNUNG: AttributZuordnung = {
externeId: "objectGUID",
};
/**
* Dieselben Felder mit den Namen, die **Microsoft Graph** benutzt (#028).
*
* Vier Unterschiede zum AD, und jeder hat einen Grund:
*
* - `surname` statt `sn` — Graph schreibt die Namen aus.
* - `jobTitle` statt `title` — `title` gibt es bei Graph auch, es ist aber die
* Anrede („Dr.“), nicht die Funktion.
* - `onPremisesSamAccountName` statt `sAMAccountName` — der Wert kommt aus dem
* lokalen AD und ist **leer**, wenn der Mandant kein AD synchronisiert. Eine
* reine Cloud-Umgebung hat keinen Anmeldenamen in diesem Sinn; `{{sam}}`
* bleibt dort leer, statt dass etwas hineingedeutet wird.
* - `id` statt `objectGUID` — dieselbe GUID, nur schon als Text. Das Umsortieren
* der Bytes (`src/lib/ldap-guid.ts`) braucht es hier nicht.
*/
export const GRAPH_ZUORDNUNG: AttributZuordnung = {
vorname: "givenName",
nachname: "surname",
anzeigename: "displayName",
mail: "mail",
upn: "userPrincipalName",
sam: "onPremisesSamAccountName",
abteilung: "department",
titel: "jobTitle",
vorgesetzter: "manager",
externeId: "id",
};
/** Die Vorgaben je Quellenart. `SCIM` hat noch keine (#029). */
export const ZUORDNUNGS_VORGABE: Record<"LDAP" | "GRAPH", AttributZuordnung> = {
LDAP: AD_ZUORDNUNG,
GRAPH: GRAPH_ZUORDNUNG,
};
/** Die Felder in der Reihenfolge, in der das Formular sie zeigt. */
export const ZUORDNUNGS_FELDER = Object.keys(AD_ZUORDNUNG) as (keyof AttributZuordnung)[];
@@ -92,9 +126,15 @@ export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
* mitschreiben muessen, und eine von Hand geleerte Zeile darf kein Attribut mit
* leerem Namen anfragen.
*/
export function zuordnungLesen(roh: unknown): AttributZuordnung {
export function zuordnungLesen(roh: unknown, art: "LDAP" | "GRAPH" = "LDAP"): AttributZuordnung {
const felder = (typeof roh === "object" && roh !== null ? roh : {}) as Record<string, unknown>;
const gelesen = { ...AD_ZUORDNUNG };
/*
* Die Vorgabe haengt an der **Art** (#028). Ohne diesen Parameter bekaeme eine
* Graph-Quelle mit leerer Zuordnung die AD-Namen — und fragte dann bei Graph
* nach `sn` und `objectGUID`, die es dort nicht gibt. Der Standardwert `LDAP`
* haelt jeden Aufrufer von vor #028 bei seinem bisherigen Verhalten.
*/
const gelesen = { ...ZUORDNUNGS_VORGABE[art] };
for (const schluessel of ZUORDNUNGS_FELDER) {
const wert = felder[schluessel];
if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim();
+87 -22
View File
@@ -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,
},
+279
View File
@@ -0,0 +1,279 @@
import type { AttributZuordnung } from "@/lib/verzeichnis";
import { ersatzName, type RoherEintrag, type Uebersprungen } from "@/server/ldap/zuordnung";
import { GraphFehler, graphBasis, tokenHolen, type GraphZugang } from "@/server/graph/zugang";
/**
* Benutzer aus Microsoft Graph lesen (#028).
*
* Derselbe Vertrag wie `src/server/ldap/leser.ts`: Seite für Seite, jeder Eintrag
* einzeln weitergegeben, und der Abgleich schreibt mit. Bei einem Mandanten mit
* einigen Tausend Konten ist das der Unterschied zwischen einem Abgleich und
* einem Speicherproblem.
*
* **Was Graph anders macht als LDAP, und was daraus folgt:**
*
* - Es gibt **keinen DN und keine OU**. `dn` bleibt `null`, und `{{ou}}` damit
* leer — statt dass etwas hineingedeutet wird, das kein Befehl brauchen kann.
* - Der Vorgesetzte kommt über `$expand` **direkt als Kennung** mit. Die
* DN-Auflösung des LDAP-Wegs entfällt; `vorgesetzterDirekt` sagt das dem
* Abgleich.
* - **`$filter=accountEnabled eq true` ersetzt den LDAP-Standardfilter.** Ein
* deaktiviertes Konto wird gar nicht geliefert, und was nicht geliefert wird,
* setzt der Abgleich auf `aktiv = false` — dieselbe Mechanik, nur mit anderer
* Abfrage. Serverseitig gefiltert und nicht hier beim Zuordnen: „übersprungen“
* heisst im Abgleich „nicht zuordenbar“, und solche Eintraege werden
* absichtlich **nicht** abgeschaltet.
*/
/**
* Wie viele Konten je Seite.
*
* 999 und nicht 1000: Graph lässt bei `/users` höchstens 999 zu und antwortet
* auf 1000 mit einem Fehler. Eine Zahl, die man einmal nachliest und dann
* aufschreibt.
*/
export const SEITENGROESSE = 999;
/** Wie viele Seiten höchstens, als Reißleine gegen eine Schleife. */
const SEITEN_HOECHSTENS = 1000;
export type GraphLeseBilanz = {
readonly gesamtGelesen: number;
readonly uebersprungen: readonly Uebersprungen[];
/** Wie viele Seiten geholt wurden — der Seitenabruf ist sonst unsichtbar. */
readonly seiten: number;
};
type GraphBenutzer = Record<string, unknown> & {
manager?: { id?: unknown } | null;
};
type GraphSeite = {
value?: unknown;
"@odata.nextLink"?: unknown;
error?: { message?: unknown; code?: unknown };
};
/** Ein einzelner Wert als Text, oder `null`. */
function textWert(nutzer: GraphBenutzer, feld: string): string | null {
const wert = nutzer[feld];
if (typeof wert === "string") return wert.trim() || null;
if (typeof wert === "number" || typeof wert === "boolean") return String(wert);
return null;
}
/**
* Welche Felder angefragt werden – genau die zugeordneten, nichts sonst.
*
* Dieselbe Begründung wie bei LDAP: ohne `$select` liefert Graph ein breites
* Standardprofil. Gespeichert wird davon nichts, aber gelesen eben doch — es
* stünde im Speicher, in einer Fehlerausgabe, in einem Netzwerkmitschnitt.
*
* `manager` fliegt dabei heraus: der ist keine Eigenschaft, sondern eine
* Beziehung und kommt über `$expand`. Ihn in `$select` zu schreiben beantwortet
* Graph mit einem Fehler, und der ist schwer zu lesen.
*/
export function angefragteFelder(zuordnung: AttributZuordnung): string[] {
const felder = new Set<string>();
for (const [name, wert] of Object.entries(zuordnung)) {
if (name === "vorgesetzter") continue;
if (wert) felder.add(wert);
}
// Immer dabei: ohne `id` gibt es keine Wiedererkennung, ohne `accountEnabled`
// keine Unterscheidung zwischen gesperrt und gelöscht.
felder.add("id");
felder.add("accountEnabled");
return [...felder];
}
/**
* Der Filter, der deaktivierte Konten gar nicht erst liefert.
*
* **Das Gegenstück zum LDAP-Standardfilter** (`!(userAccountControl…=2)`), und
* aus demselben Grund: was nicht im Ergebnis steht, setzt der Abgleich auf
* `aktiv = false`. Ein gesperrtes Konto in Entra ID soll genauso behandelt
* werden wie ein gesperrtes im AD.
*
* **Warum serverseitig und nicht hier beim Zuordnen:** „übersprungen“ bedeutet
* im Abgleich „konnte nicht zugeordnet werden“, und solche Einträge werden
* absichtlich **nicht** deaktiviert — ein Konto ohne GUID soll nicht die Person
* abschalten. Ein deaktiviertes Konto als übersprungen zu melden hätte es
* deshalb aktiv gelassen. Gefunden vom Test „setzt aktiv = false“.
*/
export const GRAPH_FILTER = "accountEnabled eq true";
/**
* Die erste Adresse der Abfrage. Danach folgt der Abruf `@odata.nextLink`.
*
* Die Parameter werden **von Hand** zusammengesetzt und nicht mit
* `URLSearchParams`: das kodiert `$` zu `%24` und die Klammern in
* `manager($select=id)` zu `%28`/`%29`. Graph nimmt das teils an und antwortet
* teils mit „Invalid expand clause“ — und eine Adresse, die in einem Mitschnitt
* anders aussieht als in der Dokumentation, kostet bei der Fehlersuche eine
* Stunde. Die Werte selbst sind feste Feldnamen aus `ATTRIBUT_MUSTER`
* (Buchstaben, Ziffern, Bindestrich), also ist hier nichts zu maskieren.
*/
export function ersteAdresse(zuordnung: AttributZuordnung, seitengroesse = SEITENGROESSE): string {
const teile = [
`$select=${angefragteFelder(zuordnung).join(",")}`,
`$top=${seitengroesse}`,
`$filter=${encodeURIComponent(GRAPH_FILTER)}`,
"$expand=manager($select=id)",
];
return `${graphBasis()}/v1.0/users?${teile.join("&")}`;
}
/**
* Ein Graph-Benutzer wird ein `RoherEintrag`, oder er wird übersprungen.
*
* Übersprungen wird genau ein Fall, wie bei LDAP: ohne stabile Kennung gibt es
* nichts, woran ein zweiter Abgleich den Eintrag wiedererkennen könnte.
*
* Deaktivierte Konten kommen hier **nicht** an — dafür sorgt `GRAPH_FILTER` in
* der Abfrage. Kommt doch eines, ist das ein Fehler in der Abfrage, und die
* Funktion wirft; die Begründung steht unten an der Stelle.
*/
export function benutzerZuordnen(
nutzer: GraphBenutzer,
zuordnung: AttributZuordnung,
): { readonly eintrag: RoherEintrag } | { readonly uebersprungen: Uebersprungen } {
const externeId = textWert(nutzer, zuordnung.externeId);
/*
* Als Kennzeichen in der Übersprungen-Liste dient der UPN und nicht ein DN —
* den gibt es hier nicht. Die Liste soll sagen, **wer** übersprungen wurde;
* dafür ist der Anmeldename die brauchbarste Angabe.
*/
const kennzeichen = textWert(nutzer, zuordnung.upn) ?? externeId ?? "(ohne Kennung)";
if (!externeId) {
return {
uebersprungen: {
dn: kennzeichen,
grund: `Ohne ${zuordnung.externeId} gibt es keine dauerhafte Kennung.`,
},
};
}
/*
* **Kein Fall für die Übersprungen-Liste.** Deaktivierte Konten filtert die
* Abfrage serverseitig heraus (`GRAPH_FILTER`); kommt hier trotzdem eines an,
* ist der Filter nicht angekommen. Dann ist das ein Fehler in der Abfrage und
* keine Eigenschaft des Kontos — und ein Eintrag in der Übersprungen-Liste
* hätte zur Folge, dass die Person **aktiv** bleibt, weil der Abgleich
* übersprungene Einträge absichtlich nicht abschaltet.
*/
if (nutzer["accountEnabled"] === false) {
throw new GraphFehler(
`Graph hat ein deaktiviertes Konto geliefert (${kennzeichen}), obwohl der Filter ` +
`„${GRAPH_FILTER}“ gesetzt war. Das ist ein Fehler in der Abfrage, nicht am Konto.`,
);
}
const vorname = textWert(nutzer, zuordnung.vorname);
const nachname = textWert(nutzer, zuordnung.nachname);
const vorgesetzterId = typeof nutzer.manager?.id === "string" ? nutzer.manager.id.trim() : "";
return {
eintrag: {
externeId,
vorname: vorname ?? "",
nachname: nachname ?? "",
anzeigename: textWert(nutzer, zuordnung.anzeigename) ?? ersatzName(vorname, nachname, kennzeichen),
mail: textWert(nutzer, zuordnung.mail),
upn: textWert(nutzer, zuordnung.upn),
sam: textWert(nutzer, zuordnung.sam),
// Kein DN in Entra ID – siehe Kopfkommentar.
dn: null,
abteilung: textWert(nutzer, zuordnung.abteilung),
titel: textWert(nutzer, zuordnung.titel),
vorgesetzterDn: null,
vorgesetzterDirekt: vorgesetzterId || null,
},
};
}
/**
* Läuft über alle Seiten und gibt jeden Eintrag einzeln weiter.
*
* `grenze` zählt die **zugeordneten** Einträge, wie bei LDAP: „die ersten zehn
* Treffer zeigen“ meint zehn Zeilen in der Tabelle. Ist die Grenze erreicht,
* wird keine weitere Seite geholt.
*/
export async function graphEintraegeStroemen(
zugang: GraphZugang,
zuordnung: AttributZuordnung,
verarbeiten: (eintrag: RoherEintrag) => Promise<void> | void,
grenze?: number,
seitengroesse = SEITENGROESSE,
): Promise<GraphLeseBilanz> {
const token = await tokenHolen(zugang);
let adresse: string | undefined = ersteAdresse(zuordnung, seitengroesse);
let gesamtGelesen = 0;
let zugeordnet = 0;
let seiten = 0;
const uebersprungen: Uebersprungen[] = [];
while (adresse) {
seiten += 1;
if (seiten > SEITEN_HOECHSTENS) {
/*
* Reißleine. Ein `@odata.nextLink`, der auf sich selbst zeigt, wäre sonst
* eine Schleife, die bis zum Speicherende läuft — und zwar still, weil
* jeder einzelne Abruf gelingt.
*/
throw new GraphFehler(
`Der Seitenabruf hat ${SEITEN_HOECHSTENS} Seiten überschritten. Das ist kein Verzeichnis ` +
"dieser Größe, sondern ein Fehler bei der Weiterleitung.",
);
}
let antwort: Response;
try {
antwort = await fetch(adresse, {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
signal: AbortSignal.timeout(60_000),
});
} catch (fehler) {
throw new GraphFehler(
`Graph war nicht erreichbar: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
);
}
const seite = (await antwort.json().catch(() => ({}))) as GraphSeite;
if (!antwort.ok) {
const meldung = typeof seite.error?.message === "string" ? seite.error.message : antwort.statusText;
const code = typeof seite.error?.code === "string" ? ` (${seite.error.code})` : "";
/*
* 403 ist hier der häufigste Fehler und hat genau eine Ursache: die
* Administratoreinwilligung für `User.Read.All` fehlt. Das steht in der
* Meldung, weil es sonst niemand errät.
*/
const hinweis =
antwort.status === 403
? " Fehlt die Administratoreinwilligung für User.Read.All in der App-Registrierung?"
: "";
throw new GraphFehler(`Graph antwortete mit ${antwort.status}${code}: ${meldung}.${hinweis}`);
}
const nutzer = Array.isArray(seite.value) ? (seite.value as GraphBenutzer[]) : [];
for (const einer of nutzer) {
gesamtGelesen += 1;
const ergebnis = benutzerZuordnen(einer, zuordnung);
if ("uebersprungen" in ergebnis) {
uebersprungen.push(ergebnis.uebersprungen);
continue;
}
await verarbeiten(ergebnis.eintrag);
zugeordnet += 1;
if (grenze !== undefined && zugeordnet >= grenze) {
return { gesamtGelesen, uebersprungen, seiten };
}
}
const weiter = seite["@odata.nextLink"];
adresse = typeof weiter === "string" && weiter ? weiter : undefined;
}
return { gesamtGelesen, uebersprungen, seiten };
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Zugang zu Microsoft Graph (#028).
*
* Gelesen wird mit dem **Client-Credentials-Flow**: die Anwendung meldet sich als
* sie selbst an, nicht im Namen eines Menschen. Das ist für einen Abgleich der
* richtige Weg — er läuft nachts, wenn niemand angemeldet ist.
*
* **Die Endpunkte sind einstellbar**, aus zwei Gründen. Erstens gibt es Graph
* nicht nur unter `graph.microsoft.com`: eine Behörde liegt in einer nationalen
* Cloud mit eigener Adresse. Zweitens prüft `tests/graph.test.ts` den ganzen
* Weg gegen einen HTTP-Doppelgänger — gegen einen echten Mandanten ist dieser
* Code **nicht** gelaufen, und das steht so auch im Issue.
*/
import { entschluesseln } from "@/lib/geheimnisse";
export class GraphFehler extends Error {
constructor(meldung: string) {
super(meldung);
this.name = "GraphFehler";
}
}
export type GraphZugang = {
readonly tenantId: string;
readonly clientId: string;
readonly clientSecret: string;
};
/** `https://login.microsoftonline.com` – umstellbar für nationale Clouds und Tests. */
export function loginBasis(): string {
return (process.env["GRAPH_LOGIN_URL"]?.trim() || "https://login.microsoftonline.com").replace(/\/+$/, "");
}
/** `https://graph.microsoft.com` – dito. */
export function graphBasis(): string {
return (process.env["GRAPH_API_URL"]?.trim() || "https://graph.microsoft.com").replace(/\/+$/, "");
}
/**
* Der Zugang aus einer Quellenzeile.
*
* Wirft, wenn Felder fehlen: bei `art = GRAPH` hält die Datenbank sie fest
* (CHECK `Verzeichnisquelle_felder_je_art`), aber ein Aufruf mit einer
* LDAP-Zeile wäre ein Programmierfehler, und der soll nicht als
* `undefined`-Zugang weiterlaufen.
*/
export function graphZugangAus(quelle: {
readonly art: string;
readonly tenantId: string | null;
readonly clientId: string | null;
readonly clientSecretVerschluesselt: string | null;
}): GraphZugang {
if (!quelle.tenantId || !quelle.clientId || !quelle.clientSecretVerschluesselt) {
throw new GraphFehler(
"Dieser Quelle fehlen die Graph-Zugangsdaten (Mandant, Anwendungs-ID oder Geheimnis).",
);
}
return {
tenantId: quelle.tenantId,
clientId: quelle.clientId,
clientSecret: entschluesseln(quelle.clientSecretVerschluesselt),
};
}
type Zwischenspeicher = { readonly token: string; readonly gueltigBis: number };
/*
* Ein Token je Mandant und Anwendung, an `globalThis` wie der Prisma-Client.
*
* **Warum überhaupt zwischenspeichern:** ein Token gilt eine Stunde, und ein
* Abgleich über 5000 Konten holt rund fünf Seiten — ohne Zwischenspeicher wäre
* das fünfmal dieselbe Anmeldung. Microsoft begrenzt die Zahl der
* Token-Anfragen, und ein Abgleich, der dagegen läuft, scheitert mitten im
* Schreiben.
*
* **Warum an `globalThis`:** Hot Reload lädt das Modul neu, und ein Token je
* Speichervorgang wäre dasselbe Problem im Kleinen.
*/
const global = globalThis as typeof globalThis & {
laufzettelGraphToken?: Map<string, Zwischenspeicher>;
};
function speicher(): Map<string, Zwischenspeicher> {
global.laufzettelGraphToken ??= new Map();
return global.laufzettelGraphToken;
}
/**
* Sicherheitsabstand zum Ablauf.
*
* Ein Token, das in zehn Sekunden abläuft, ist für eine Seite, die zwei Sekunden
* braucht, formal noch gültig — und in der Mitte der nächsten nicht mehr.
* Sechzig Sekunden Abstand kosten nichts und ersparen den Fehler, der am
* schwersten zu lesen ist: ein Abgleich, der nach der dritten Seite mit 401
* abbricht.
*/
const ABSTAND_MS = 60_000;
function schluessel(zugang: GraphZugang): string {
return `${zugang.tenantId}/${zugang.clientId}`;
}
/** Nur für Tests: den Zwischenspeicher leeren. */
export function tokenSpeicherLeeren(): void {
speicher().clear();
}
type TokenAntwort = { access_token?: unknown; expires_in?: unknown; error_description?: unknown };
/**
* Ein Zugriffstoken holen – aus dem Zwischenspeicher, sonst neu.
*
* Der Fehlerfall ist hier wichtiger als der Normalfall: Entra ID antwortet bei
* einem falschen Geheimnis mit einer **sehr** ausführlichen Meldung, die den
* Mandanten, die Anwendungs-ID und eine Ablaufverfolgungskennung enthält. Die
* gehört ins Protokoll des Prozesses, nicht in eine Antwort und nicht an die
* Quelle — `letzterAbgleichFehler` ist in der Oberfläche sichtbar.
*/
export async function tokenHolen(zugang: GraphZugang, jetzt = Date.now()): Promise<string> {
const vorhanden = speicher().get(schluessel(zugang));
if (vorhanden && vorhanden.gueltigBis - ABSTAND_MS > jetzt) return vorhanden.token;
const adresse = `${loginBasis()}/${encodeURIComponent(zugang.tenantId)}/oauth2/v2.0/token`;
const rumpf = new URLSearchParams({
grant_type: "client_credentials",
client_id: zugang.clientId,
client_secret: zugang.clientSecret,
scope: `${graphBasis()}/.default`,
});
let antwort: Response;
try {
antwort = await fetch(adresse, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: rumpf,
signal: AbortSignal.timeout(20_000),
});
} catch (fehler) {
throw new GraphFehler(
`Der Anmeldedienst war nicht erreichbar: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
);
}
const daten = (await antwort.json().catch(() => ({}))) as TokenAntwort;
if (!antwort.ok) {
/*
* Die ausführliche Meldung ins Protokoll des Prozesses, eine kurze nach
* draußen. Dieselbe Trennung wie beim LDAP-Bind-Fehler (#016) und beim
* Healthcheck (#039): eine Meldung, die Mandant und Anwendungs-ID nennt,
* gehört nicht in eine Oberfläche.
*/
console.error(
"Graph-Anmeldung fehlgeschlagen:",
typeof daten.error_description === "string" ? daten.error_description : antwort.statusText,
);
throw new GraphFehler(
`Die Anmeldung am Mandanten ist fehlgeschlagen (${antwort.status}). Prüfe Mandant, ` +
"Anwendungs-ID und Geheimnis; Einzelheiten stehen im Protokoll des Dienstes.",
);
}
const token = typeof daten.access_token === "string" ? daten.access_token : undefined;
if (!token) {
throw new GraphFehler("Der Anmeldedienst hat kein Zugriffstoken geliefert.");
}
const sekunden = typeof daten.expires_in === "number" ? daten.expires_in : 3600;
speicher().set(schluessel(zugang), { token, gueltigBis: jetzt + sekunden * 1000 });
return token;
}
+32 -7
View File
@@ -45,7 +45,15 @@ export type GemappterEintrag = {
mail: string | null;
upn: string | null;
sam: string | null;
dn: string;
/**
* Der DN im Verzeichnis — **`null` bei einer Graph-Quelle** (#028).
*
* Entra ID hat keinen DN und keine OU. Ihn dort zu erfinden hiesse, `{{ou}}`
* mit etwas zu fuellen, das kein PowerShell-Befehl brauchen kann. `null` ist
* die ehrliche Antwort: `{{ou}}` bleibt dann leer und faellt auf die Ziel-OU
* der Abteilung zurueck, wenn eine gepflegt ist.
*/
dn: string | null;
abteilung: string | null;
titel: string | null;
vorgesetzterExterneId: string | null;
@@ -62,6 +70,14 @@ export type GemappterEintrag = {
*/
export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & {
vorgesetzterDn: string | null;
/**
* Die Kennung des Vorgesetzten, falls sie schon beim Lesen feststeht (#028).
*
* Graph liefert sie ueber `$expand=manager($select=id)` direkt mit — dort gibt
* es keinen DN, der erst aufgeloest werden muesste. Ist dieses Feld gesetzt,
* uebergeht `vorgesetzteAufloesen` die DN-Tabelle.
*/
vorgesetzterDirekt?: string | null;
};
/**
@@ -149,6 +165,9 @@ export function eintragZuordnen(
zuordnung: AttributZuordnung,
): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } {
const dn = eintrag.dn.trim();
// Bei LDAP steht immer ein DN; die Nullability von `dn` kommt von der
// Graph-Quelle (#028) und betrifft diese Funktion nicht.
let externeId: string | undefined;
try {
externeId = externeIdLesen(eintrag, zuordnung.externeId);
@@ -190,11 +209,11 @@ export function eintragZuordnen(
* wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste
* Teil des DN ein.
*/
function ersatzName(vorname: string | null, nachname: string | null, dn: string): string {
export function ersatzName(vorname: string | null, nachname: string | null, kennzeichen: string): string {
const gebaut = [nachname, vorname].filter((teil) => teil).join(", ");
if (gebaut) return gebaut;
const ersterTeil = dn.split(",")[0]?.split("=")[1]?.trim();
return ersterTeil ?? dn;
const ersterTeil = kennzeichen.split(",")[0]?.split("=")[1]?.trim();
return ersterTeil ?? kennzeichen;
}
/**
@@ -225,10 +244,16 @@ export function vorgesetzteAufloesen(
): GemappterEintrag[] {
const nachDn = new Map(zusaetzlicheKennungen ?? []);
for (const eintrag of eintraege) {
nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
if (eintrag.dn) nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
}
return eintraege.map(({ vorgesetzterDn, ...rest }) => ({
return eintraege.map(({ vorgesetzterDn, vorgesetzterDirekt, ...rest }) => ({
...rest,
vorgesetzterExterneId: vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null,
/*
* **Die direkte Kennung gewinnt** (#028). Eine Graph-Quelle kennt den
* Vorgesetzten schon beim Lesen; eine Aufloesung ueber DNs waere dort nicht
* nur unnoetig, sondern unmoeglich — es gibt keine.
*/
vorgesetzterExterneId:
vorgesetzterDirekt ?? (vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null),
}));
}
+144 -125
View File
@@ -4,9 +4,9 @@ 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 { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
import { fehlerMeldung } from "@/server/ldap/verbindung";
import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung";
import { dnSchluessel, type RoherEintrag } from "@/server/ldap/zuordnung";
import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
import { mitSperre } from "@/server/services/sperre";
import { abgleichMeldungEinreihen } from "@/server/services/benachrichtigungen";
@@ -147,7 +147,16 @@ async function vonHandAngelegteFinden(kundeId: string, eintrag: RoherEintrag): P
/** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */
type OffenerVorgesetzter = {
readonly externeId: string;
readonly vorgesetzterDn: string;
/** Bei LDAP: der DN, der im zweiten Durchgang aufgeloest wird. */
readonly vorgesetzterDn: string | null;
/**
* Bei Graph: die Kennung steht schon fest (#028).
*
* Beides als zwei Felder und nicht als eines mit zwei Bedeutungen: ein DN und
* eine GUID sehen verschieden aus, aber ein Feld, das mal das eine und mal
* das andere traegt, verwechselt irgendwann jemand.
*/
readonly vorgesetzterDirekt: string | null;
readonly vorherigeKennung: string | null;
readonly neuAngelegt: boolean;
};
@@ -207,144 +216,147 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
let bindPasswort: string | undefined;
try {
const zugang = zugangAus(quelle);
bindPasswort = zugang.bindPasswort;
/*
* Das Bind-Passwort nur bei einer LDAP-Quelle — es dient unten dem
* Filtern der Fehlermeldung. Eine Graph-Quelle hat keines; ihr Geheimnis
* erscheint nirgends in einer Meldung, weil `tokenHolen` die ausfuehrliche
* Antwort von Entra ID selbst ins Prozessprotokoll schreibt (#028).
*/
bindPasswort = quelle.art === "LDAP" ? zugangAus(quelle).bindPasswort : undefined;
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 bilanz = await eintraegeDerQuelleStroemen(quelle, async (eintrag) => {
verarbeitet += 1;
// Nur LDAP liefert einen DN; bei Graph bleibt die Tabelle leer, und der
// Vorgesetzte kommt ueber `vorgesetzterDirekt`.
if (eintrag.dn) 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,
};
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,
vorgesetzterId: 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 },
if (!vorhanden) {
await prisma.mitarbeiter.create({
data: {
kundeId: quelle.kundeId,
quelleId,
externeId: eintrag.externeId,
...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 } : {}),
/*
* Steht im Verzeichnis kein Vorgesetzter, wird der **rohe** Wert
* geleert. Die gepflegte Zuordnung nur dann, wenn sie vom
* Verzeichnis kam — erkennbar daran, dass dort eine Kennung stand.
* Eine von Hand gesetzte Zuordnung bleibt stehen: das Verzeichnis
* fuehrt sie nicht, also hat es sie auch nicht zu loeschen (#020).
*/
...(vorgesetztenLeeren
? {
vorgesetzterExterneId: null,
...(vorhanden.vorgesetzterExterneId !== null ? { vorgesetzterId: null } : {}),
}
: {}),
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine
// wiedereingestellte Person behaelt so ihre Laeufe.
// Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
// erst aufloesen, wenn alle Kennungen bekannt sind.
vorgesetzterExterneId: null,
vorgesetzterId: null,
aktiv: true,
herkunft: Herkunft.ABGLEICH,
zuletztGesehenAm: beginn,
},
});
if (!vorgesetztenLeeren) {
angelegt += 1;
if (eintrag.vorgesetzterDn || eintrag.vorgesetzterDirekt) {
offene.push({
externeId: eintrag.externeId,
vorgesetzterDn: eintrag.vorgesetzterDn as string,
vorherigeKennung: vorhanden.vorgesetzterExterneId,
neuAngelegt: false,
vorgesetzterDn: eintrag.vorgesetzterDn,
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
vorherigeKennung: null,
neuAngelegt: true,
});
}
},
);
return;
}
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
// geleert. Steht einer, entscheidet der zweite Durchgang.
// Kein Vorgesetzter heisst: keiner auf **beiden** Wegen.
const vorgesetztenLeeren =
eintrag.vorgesetzterDn === null && (eintrag.vorgesetzterDirekt ?? null) === 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 } : {}),
/*
* Steht im Verzeichnis kein Vorgesetzter, wird der **rohe** Wert
* geleert. Die gepflegte Zuordnung nur dann, wenn sie vom
* Verzeichnis kam — erkennbar daran, dass dort eine Kennung stand.
* Eine von Hand gesetzte Zuordnung bleibt stehen: das Verzeichnis
* fuehrt sie nicht, also hat es sie auch nicht zu loeschen (#020).
*/
...(vorgesetztenLeeren
? {
vorgesetzterExterneId: null,
...(vorhanden.vorgesetzterExterneId !== null ? { vorgesetzterId: 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,
vorgesetzterDirekt: eintrag.vorgesetzterDirekt ?? null,
vorherigeKennung: vorhanden.vorgesetzterExterneId,
neuAngelegt: false,
});
}
});
gelesen = bilanz.gesamtGelesen;
// --- Zweiter Durchgang: Vorgesetzte --------------------------------------
@@ -354,7 +366,14 @@ async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Pr
// jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von
// Hand angelegte Person konnte keinen Vorgesetzten haben.
for (const offen of offene) {
const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
/*
* **Die direkte Kennung gewinnt** (#028). Bei Graph steht sie schon fest;
* die DN-Tabelle ist dort leer, und ein Nachschlagen darin ergaebe
* `null` — also keinen Vorgesetzten, obwohl einer bekannt ist.
*/
const kennung =
offen.vorgesetzterDirekt ??
(offen.vorgesetzterDn ? (kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null) : null);
if (kennung === offen.vorherigeKennung) continue;
const vorgesetzter =
kennung === null
+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,
};
}
+65
View File
@@ -0,0 +1,65 @@
import type { Verzeichnisquelle } from "@/generated/prisma/client";
import { zuordnungLesen } from "@/lib/verzeichnis";
import { graphEintraegeStroemen } from "@/server/graph/leser";
import { graphZugangAus } from "@/server/graph/zugang";
import { eintraegeStroemen } from "@/server/ldap/leser";
import type { RoherEintrag, Uebersprungen } from "@/server/ldap/zuordnung";
import { VerzeichnisFehler } from "@/server/ldap/fehler";
import { ldapVorgabeAus, zugangAus } from "@/server/verzeichnis/zugang";
/**
* Einträge lesen, egal woher (#028).
*
* **Das ist die ganze Weiche.** Der Abgleich in `services/abgleich.ts` ruft nur
* noch diese Funktion; was er danach tut — anlegen, übernehmen, aktualisieren,
* deaktivieren, **nie löschen** — ist für LDAP und Graph dasselbe.
*
* Genau so steht es im Issue, und es ist die wichtigste Entscheidung dieses
* Meilensteins: zwei Abgleiche nebeneinander würden auseinanderdriften. Der eine
* bekäme einen Reviewbefund nachgetragen, der andere nicht — und die Zusage
* „es wird nichts gelöscht“ gälte dann für eine Art und für die andere nicht
* mehr ganz.
*/
export type QuellenBilanz = {
readonly gesamtGelesen: number;
readonly uebersprungen: readonly Uebersprungen[];
};
/**
* Läuft über die Einträge einer Quelle und gibt jeden einzeln weiter.
*
* `grenze` zählt die zugeordneten Einträge — „die ersten zehn Treffer zeigen“.
*/
export async function eintraegeDerQuelleStroemen(
quelle: Verzeichnisquelle,
verarbeiten: (eintrag: RoherEintrag) => Promise<void> | void,
grenze?: number,
): Promise<QuellenBilanz> {
if (quelle.art === "LDAP") {
return eintraegeStroemen(zugangAus(quelle), ldapVorgabeAus(quelle), verarbeiten, grenze);
}
if (quelle.art === "GRAPH") {
const bilanz = await graphEintraegeStroemen(
graphZugangAus(quelle),
zuordnungLesen(quelle.attributZuordnung, "GRAPH"),
verarbeiten,
grenze,
);
// `seiten` interessiert nur den Graph-Test; der Abgleich rechnet mit zwei Zahlen.
return { gesamtGelesen: bilanz.gesamtGelesen, uebersprungen: bilanz.uebersprungen };
}
/*
* `SCIM` steht im Enum, weil #029 dieselbe Weiche braucht — behandelt wird es
* dort. **Bis dahin eine Meldung und kein stilles Nichts:** ein Abgleich, der
* null Einträge liest und `ERFOLG` meldet, würde über die Abschaltregel in
* `abgleichAusfuehren` die ganze Belegschaft deaktivieren. Genau dieser Fall
* hat in #016 einen Reviewbefund gekostet.
*/
throw new VerzeichnisFehler(
`Quellen der Art ${quelle.art} können noch nicht abgeglichen werden. ` +
"Der SCIM-Endpunkt kommt mit einem eigenen Meilenstein (#029).",
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { Verzeichnisquelle } from "@/generated/prisma/client";
import type { QuellenArt } from "@/generated/prisma/enums";
import { entschluesseln } from "@/lib/geheimnisse";
import { type AttributZuordnung, zuordnungLesen } from "@/lib/verzeichnis";
import { VerzeichnisFehler } from "@/server/ldap/fehler";
import type { LdapZugang } from "@/server/ldap/verbindung";
/**
* Aus einer Quellenzeile wird ein Zugang (#016, je Art seit #028).
*
* **Warum ein eigenes Modul und nicht weiter in `services/verzeichnis.ts`:** die
* Weiche in `quellenleser.ts` braucht diese Funktionen, und
* `services/verzeichnis.ts` braucht die Weiche fuer „Verbindung testen“. Beides
* in einer Datei waere ein Import-Zyklus — der laeuft in ESM oft, aber nicht
* zuverlaessig, und ein Modul, das beim Laden halb leer ist, ergibt Fehler, die
* niemand liest.
*/
/**
* Entschluesselt das Bind-Passwort. Das Ergebnis bleibt in der Aufrufkette.
*
* Wirft, wenn die LDAP-Felder fehlen. Seit #028 sind sie nur bei `art = LDAP`
* belegt — die Datenbank haelt das fest (CHECK
* `Verzeichnisquelle_felder_je_art`), aber ein Aufruf mit einer Graph-Zeile waere
* ein Programmierfehler, und der soll nicht als Verbindung zu `null` weiterlaufen.
*/
export function zugangAus(quelle: Verzeichnisquelle): LdapZugang {
if (!quelle.url || !quelle.bindDn || !quelle.bindPasswortVerschluesselt) {
throw new VerzeichnisFehler(
`„${quelle.name}“ ist keine LDAP-Quelle oder hat keine vollständigen Zugangsdaten.`,
);
}
return {
url: quelle.url,
bindDn: quelle.bindDn,
bindPasswort: entschluesseln(quelle.bindPasswortVerschluesselt),
caZertifikatPem: quelle.caZertifikatPem,
};
}
/**
* Die LDAP-Lesevorgabe einer Quelle.
*
* Dieselbe Pruefung und derselbe Grund wie bei `zugangAus`: `basisDn` und
* `filter` sind seit #028 nullable.
*/
export function ldapVorgabeAus(quelle: Verzeichnisquelle): {
readonly basisDn: string;
readonly filter: string;
readonly zuordnung: AttributZuordnung;
} {
if (!quelle.basisDn || !quelle.filter) {
throw new VerzeichnisFehler(
`„${quelle.name}“ fehlt der Basis-DN oder der Filter — beides braucht eine LDAP-Quelle.`,
);
}
return {
basisDn: quelle.basisDn,
filter: quelle.filter,
zuordnung: zuordnungLesen(quelle.attributZuordnung, "LDAP"),
};
}
/**
* Welche Zuordnungs-Vorgaben fuer diese Art gelten.
*
* `SCIM` hat noch keine (#029) und faellt auf die LDAP-Namen zurueck — die Art
* wird vom Dienst ohnehin abgewiesen, und ein Wurf an dieser Stelle machte jede
* Liste unbenutzbar, in der eine SCIM-Zeile steht.
*/
export function zuordnungsArt(art: QuellenArt): "LDAP" | "GRAPH" {
return art === "GRAPH" ? "GRAPH" : "LDAP";
}