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
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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).",
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user