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
@@ -0,0 +1,97 @@
-- ---------------------------------------------------------------------------
-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die
-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes
-- (Archivsuche), die partiellen eindeutigen Indizes und alle Trigger: es kennt
-- sie nicht, weil sie sich im Schema nicht ausdruecken lassen. Diese
-- Loeschbefehle sind bewusst gestrichen.
--
-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind –
-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf.
-- ---------------------------------------------------------------------------
-- Quellenarten und Microsoft-Graph-Quelle (#028).
CREATE TYPE "QuellenArt" AS ENUM ('LDAP', 'GRAPH', 'SCIM');
/*
* **Die Reihenfolge der drei Schritte ist nicht beliebig.**
*
* Erst die Spalte mit Vorgabe `LDAP` anlegen, dann die bisherigen
* Pflichtfelder lockern, und erst danach den CHECK setzen. Umgekehrt waere der
* CHECK fuer die Dauer einer Anweisung gegen Zeilen gerichtet, die ihn noch
* nicht erfuellen koennen.
*
* Die Vorgabe `LDAP` ist zugleich die Nachfuehrung fuer alle bestehenden Zeilen:
* vor #028 war jede Quelle implizit LDAP, und `ADD COLUMN ... DEFAULT` schreibt
* den Wert in jede vorhandene Zeile. Ein eigenes `UPDATE` braucht es deshalb
* nicht — aber die Pruefung unten steht trotzdem da.
*/
ALTER TABLE "Verzeichnisquelle"
ADD COLUMN "art" "QuellenArt" NOT NULL DEFAULT 'LDAP';
-- Die LDAP-Felder gelten nur noch fuer LDAP-Quellen.
ALTER TABLE "Verzeichnisquelle"
ALTER COLUMN "url" DROP NOT NULL,
ALTER COLUMN "bindDn" DROP NOT NULL,
ALTER COLUMN "bindPasswortVerschluesselt" DROP NOT NULL,
ALTER COLUMN "basisDn" DROP NOT NULL,
ALTER COLUMN "filter" DROP NOT NULL;
-- Und die Felder einer Graph-Quelle.
ALTER TABLE "Verzeichnisquelle"
ADD COLUMN "tenantId" TEXT,
ADD COLUMN "clientId" TEXT,
ADD COLUMN "clientSecretVerschluesselt" TEXT;
/*
* **Welche Felder eine Art braucht, haelt die Datenbank fest.**
*
* Ohne das waere eine Graph-Quelle mit `basisDn` moeglich — eine Zeile, die zwei
* Dinge gleichzeitig behauptet, und bei der niemand mehr sagen kann, was gilt.
* Der Dienst prueft dasselbe noch einmal, nur mit besserer Meldung.
*
* `SCIM` fehlt hier mit Absicht: die Art steht im Enum, weil #029 dieselbe
* Weiche braucht, hat aber noch keine Felder. Eine Bedingung auf Felder, die es
* nicht gibt, waere eine Zeile, die beim naechsten Meilenstein umgeschrieben
* werden muss — und der Dienst weist `SCIM` bis dahin ohnehin ab.
*/
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_felder_je_art"
CHECK (
("art" = 'LDAP' AND "url" IS NOT NULL AND "bindDn" IS NOT NULL
AND "bindPasswortVerschluesselt" IS NOT NULL AND "basisDn" IS NOT NULL
AND "filter" IS NOT NULL
AND "tenantId" IS NULL AND "clientId" IS NULL AND "clientSecretVerschluesselt" IS NULL)
OR
("art" = 'GRAPH' AND "tenantId" IS NOT NULL AND "clientId" IS NOT NULL
AND "clientSecretVerschluesselt" IS NOT NULL
AND "url" IS NULL AND "bindDn" IS NULL AND "bindPasswortVerschluesselt" IS NULL
AND "basisDn" IS NULL AND "filter" IS NULL)
OR
("art" = 'SCIM')
);
-- Das Client-Secret verschluesselt, dieselbe Pruefung wie beim Bind-Passwort.
ALTER TABLE "Verzeichnisquelle"
ADD CONSTRAINT "Verzeichnisquelle_clientsecret_verschluesselt"
CHECK ("clientSecretVerschluesselt" ~ '^v[0-9]+\.');
-- Der Zeitplan fragt nach faelligen **aktiven** Quellen; die Art kommt jetzt in
-- dieselbe Abfrage, weil das Holen der Eintraege je Art ein anderes ist.
CREATE INDEX "Verzeichnisquelle_art_idx" ON "Verzeichnisquelle"("art");
/*
* Gegenprobe zur Nachfuehrung: nach dem `ADD COLUMN ... DEFAULT` darf keine
* Zeile ohne Art dastehen. `art` ist NOT NULL, das kann also nicht schiefgehen —
* aber eine Migration, die ihre eigene Nachfuehrung behauptet, ohne sie zu
* pruefen, ist genau die Art Zusage, die dieses Projekt teuer bezahlt hat.
*/
DO $$
DECLARE
ohne_art INTEGER;
BEGIN
SELECT count(*) INTO ohne_art FROM "Verzeichnisquelle" WHERE "art" IS NULL;
IF ohne_art > 0 THEN
RAISE EXCEPTION 'Nachfuehrung fehlgeschlagen: % Quellen ohne Art.', ohne_art;
END IF;
END $$;
+31 -7
View File
@@ -314,18 +314,42 @@ model BenutzerKunde {
/// genau eine (das Firmen-AD); mehrere sind erlaubt, etwa eine zweite Domaene.
/// Nur fuer die eigene Organisation – im Modus DIENSTLEISTUNG gibt es keinen
/// Abgleich zu Kunden-ADs.
/// Woher eine Verzeichnisquelle liest (#028).
///
/// Bis #028 war jede Quelle implizit LDAP. `SCIM` steht hier bereits, weil #029
/// dieselbe Weiche braucht — behandelt wird es noch nicht, und der Dienst weist
/// es mit einer Meldung ab, statt stillschweigend nichts zu tun.
enum QuellenArt {
LDAP
GRAPH
SCIM
}
model Verzeichnisquelle {
id String @id @default(cuid())
id String @id @default(cuid())
kundeId String
name String
aktiv Boolean @default(false)
url String
bindDn String
aktiv Boolean @default(false)
/// Bestehende Zeilen sind LDAP – das war vor #028 die einzige Art.
art QuellenArt @default(LDAP)
/// Ab hier: **nur bei `art = LDAP`** gesetzt. Welche Felder eine Art braucht,
/// haelt der CHECK `Verzeichnisquelle_felder_je_art` fest — eine Graph-Quelle
/// mit einem `basisDn` waere eine Zeile, die zwei Dinge gleichzeitig behauptet.
url String?
bindDn String?
/// AES-256-GCM aus APP_SECRET. Darf keine API-Antwort erreichen; dafuer sorgt
/// der Serializer in der Service-Schicht, abgedeckt durch einen eigenen Test.
bindPasswortVerschluesselt String
basisDn String
filter String
bindPasswortVerschluesselt String?
basisDn String?
filter String?
/// Ab hier: **nur bei `art = GRAPH`**. Der Mandant in Entra ID und die
/// App-Registrierung, mit der gelesen wird (Client-Credentials-Flow).
tenantId String?
clientId String?
/// Verschluesselt wie das Bind-Passwort und mit demselben Vorsatz: es darf
/// keine Antwort und keinen Protokolleintrag erreichen.
clientSecretVerschluesselt String?
/// Zuordnung der LDAP-Attribute auf unsere Felder, mit AD-Vorgaben.
attributZuordnung Json
/// Eigenes CA-Zertifikat, falls ldaps gegen eine interne CA laeuft.