feat(verzeichnis): SCIM-Endpunkt als dritte Quellenart (#029)
Bisher holt Laufzettel Personen aus dem Verzeichnis. Das setzt Lesezugriff und einen Netzweg dorthin voraus, und beides ist nicht ueberall gegeben. Mit der Art SCIM ruft stattdessen der Anbieter an — sofort, nicht beim naechsten Intervall. Umgesetzt ist, was authentik, Entra ID und Okta wirklich schicken: Users mit dem Filter userName eq, POST/GET/PUT/PATCH/DELETE, dazu ServiceProviderConfig, Schemas und ResourceTypes. Jeder Handler prueft sein Token selbst. Ein Token je Quelle, argon2-gehasht, einmal angezeigt — es ist der einzige Mandantenbeweis, den eine Zustellung mitbringt. DELETE deaktiviert und loescht nichts. Eine SCIM-Quelle wird nicht abgeglichen, an zwei Stellen gehalten: ein Abgleich, der dort nichts liest und ERFOLG meldet, haette ueber die Abschaltregel die ganze Belegschaft deaktiviert. Zwei Befunde kamen aus Gegenproben, nicht aus Tests: - PATCH remove wirkte gar nicht. Der Schalter nurGesetzte las bei PATCH jedes null als "nicht mitgeschickt"; eine beim Anbieter entfernte Abteilung stand hier weiter. Noetig war er nie — patchAnwenden liefert immer den vollstaendigen Zielzustand. - Entra ID schickt active als "True"/"False". Beim Deaktivieren kommt mit einem reinen value === true zufaellig das Richtige heraus, beim Aktivieren nicht: ein wieder freigeschaltetes Konto waere fuer immer inaktiv geblieben. Dazu ein Fehler aus #028: in verbindungTestenAktion stand zweimal dieselbe Bedingung, der zweite Zweig war seit dem ersten Tag unerreichbar. 1432 Tests (vorher 1373). Klickprobe am gebauten Stand mit einer echten Zustellung. Gegen echte Mandanten ist nichts gelaufen — die Anfrageformen stammen aus der Dokumentation, und das steht so in der Testdatei. Closes #49 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
76a61ec771
commit
fcd67018b8
@@ -14,6 +14,7 @@ import { z } from "@/lib/validierung";
|
||||
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich";
|
||||
import { scimTokenErzeugen, scimTokenWiderrufen } from "@/server/services/scim";
|
||||
import {
|
||||
VerzeichnisFehler,
|
||||
quelleAnlegen,
|
||||
@@ -95,8 +96,8 @@ function zuordnungAusFormular(formular: FormData) {
|
||||
*/
|
||||
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.",
|
||||
art: z.enum(["LDAP", "GRAPH", "SCIM"], {
|
||||
message: "Bitte die Art der Quelle wählen: LDAP, Microsoft Graph oder SCIM.",
|
||||
}),
|
||||
caZertifikatPem: z.string().trim().optional(),
|
||||
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
|
||||
@@ -130,9 +131,16 @@ const GraphSchema = z.object({
|
||||
|
||||
/** Die artabhaengigen Felder aus dem Formular, geprueft. */
|
||||
function artFelder(
|
||||
art: "LDAP" | "GRAPH",
|
||||
art: "LDAP" | "GRAPH" | "SCIM",
|
||||
formular: FormData,
|
||||
): { readonly werte: Record<string, unknown> } | { readonly fehler: string } {
|
||||
/*
|
||||
* **SCIM hat keine.** Eine SCIM-Quelle braucht einen Namen und sonst nichts:
|
||||
* Laufzettel verbindet sich nicht, es wird beliefert, und das Token entsteht
|
||||
* erst danach (#029).
|
||||
*/
|
||||
if (art === "SCIM") return { werte: {} };
|
||||
|
||||
if (art === "LDAP") {
|
||||
const gelesen = LdapSchema.safeParse({
|
||||
url: text(formular, "url"),
|
||||
@@ -183,9 +191,11 @@ export async function quelleAnlegenAktion(
|
||||
const geheimnis =
|
||||
eingabe.data.art === "LDAP"
|
||||
? { bindPasswort: text(formular, "bindPasswort") }
|
||||
: { clientSecret: text(formular, "clientSecret") };
|
||||
: eingabe.data.art === "GRAPH"
|
||||
? { clientSecret: text(formular, "clientSecret") }
|
||||
: {};
|
||||
const geheimnisWert = Object.values(geheimnis)[0];
|
||||
if (!geheimnisWert) {
|
||||
if (eingabe.data.art !== "SCIM" && !geheimnisWert) {
|
||||
return {
|
||||
fehler:
|
||||
eingabe.data.art === "LDAP"
|
||||
@@ -203,7 +213,12 @@ export async function quelleAnlegenAktion(
|
||||
zugang.benutzer,
|
||||
);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
return { erfolg: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.` };
|
||||
return {
|
||||
erfolg:
|
||||
eingabe.data.art === "SCIM"
|
||||
? `„${quelle.name}“ ist angelegt. Erzeug als Nächstes das Token und trag es beim Anbieter ein.`
|
||||
: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.`,
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden.");
|
||||
}
|
||||
@@ -300,6 +315,58 @@ export async function quelleLoeschenAktion(
|
||||
}
|
||||
}
|
||||
|
||||
export type TokenErgebnis = AktionsErgebnis & {
|
||||
/**
|
||||
* Das Token – **genau dieses eine Mal**.
|
||||
*
|
||||
* Es steht in der Datenbank nur als argon2-Hash, es gibt also keinen zweiten
|
||||
* Weg, es anzusehen. Wer es verliert, erzeugt ein neues; das alte gilt dann
|
||||
* nicht mehr.
|
||||
*/
|
||||
readonly token?: string;
|
||||
};
|
||||
|
||||
export async function scimTokenErzeugenAktion(
|
||||
_vorher: TokenErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<TokenErgebnis> {
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
const id = text(formular, "id");
|
||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||
|
||||
try {
|
||||
const { token } = await scimTokenErzeugen(id, zugang.benutzer);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
return {
|
||||
token,
|
||||
erfolg:
|
||||
"Das Token gilt ab jetzt. Kopier es in die Einstellungen des Anbieters — danach ist es " +
|
||||
"nicht mehr ansehbar.",
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Das Token konnte nicht erzeugt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function scimTokenWiderrufenAktion(
|
||||
_vorher: TokenErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<TokenErgebnis> {
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
const id = text(formular, "id");
|
||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||
|
||||
try {
|
||||
await scimTokenWiderrufen(id, zugang.benutzer);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
return { erfolg: "Das Token ist widerrufen. Die Quelle nimmt ab jetzt nichts mehr an." };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Das Token konnte nicht widerrufen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export type TestErgebnis = AktionsErgebnis & {
|
||||
readonly treffer?: readonly GemappterEintrag[];
|
||||
readonly gesamtGelesen?: number;
|
||||
@@ -326,7 +393,7 @@ export async function verbindungTestenAktion(
|
||||
const ergebnis = await verbindungTesten(id, zugang.benutzer);
|
||||
return {
|
||||
erfolg:
|
||||
ergebnis.treffer.length === 0
|
||||
ergebnis.treffer.length === 0 && ergebnis.gesamtGelesen === 0
|
||||
? "Die Verbindung steht, aber der Filter findet niemanden."
|
||||
: ergebnis.treffer.length === 0
|
||||
? /*
|
||||
|
||||
Reference in New Issue
Block a user