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>
325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
/**
|
||
* SCIM 2.0: Formen und Zuordnung (#029).
|
||
*
|
||
* Framework-frei, weil hier zwei Dinge zusammenkommen, die beide einzeln
|
||
* prüfbar sein müssen: **was ein Anbieter schickt** und **was RFC 7644 als
|
||
* Antwort verlangt**. Beides ist reine Textarbeit; ein Test dafür braucht keinen
|
||
* Server und keine Datenbank.
|
||
*
|
||
* **Umgesetzt ist nicht der ganze Standard, sondern was die Anbieter schicken.**
|
||
* authentik, Entra ID und Okta benutzen einen kleinen, gut dokumentierten
|
||
* Ausschnitt. Der Rest — komplexe Filter, Sortierung, `bulk` — wäre Code ohne
|
||
* Aufrufer, und ungenutzter Code ist ungeprüfter Code.
|
||
*/
|
||
|
||
export const SCHEMA_USER = "urn:ietf:params:scim:schemas:core:2.0:User";
|
||
export const SCHEMA_ENTERPRISE = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
|
||
export const SCHEMA_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
||
export const SCHEMA_ERROR = "urn:ietf:params:scim:api:messages:2.0:Error";
|
||
export const SCHEMA_PATCH = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
|
||
|
||
/** Der Medientyp, den SCIM verlangt. Nicht `application/json`. */
|
||
export const SCIM_TYP = "application/scim+json";
|
||
|
||
/**
|
||
* Eine Person, wie Laufzettel sie nach außen zeigt.
|
||
*
|
||
* Bewusst **nicht** die ganze `Mitarbeiter`-Zeile: ein Identitätsanbieter hat
|
||
* die Daten selbst geschickt, er braucht sie nicht zurück, und was hier steht,
|
||
* steht auch in jedem Protokoll des Anbieters.
|
||
*/
|
||
export type ScimPerson = {
|
||
readonly id: string;
|
||
readonly externeId: string | null;
|
||
readonly upn: string | null;
|
||
readonly vorname: string;
|
||
readonly nachname: string;
|
||
readonly anzeigename: string;
|
||
readonly mail: string | null;
|
||
readonly abteilung: string | null;
|
||
readonly titel: string | null;
|
||
readonly aktiv: boolean;
|
||
/** Die Laufzettel-Kennung des Vorgesetzten, wenn er bekannt ist. */
|
||
readonly vorgesetzterId: string | null;
|
||
readonly erstelltAm: Date;
|
||
readonly geaendertAm: Date;
|
||
};
|
||
|
||
/** Was aus einer eingehenden Darstellung gelesen wird. Mehr nimmt Laufzettel nicht. */
|
||
export type ScimEingabe = {
|
||
readonly externeId: string | null;
|
||
readonly upn: string | null;
|
||
readonly vorname: string | null;
|
||
readonly nachname: string | null;
|
||
readonly anzeigename: string | null;
|
||
readonly mail: string | null;
|
||
readonly abteilung: string | null;
|
||
readonly titel: string | null;
|
||
readonly aktiv: boolean | null;
|
||
/**
|
||
* Der Vorgesetzte, wie der Anbieter ihn nennt: die `id`, die Laufzettel ihm
|
||
* beim Anlegen zurueckgegeben hat — **nicht** die Kennung im Verzeichnis des
|
||
* Anbieters. Sie aufzuloesen ist Sache des Dienstes, denn sie kann auf
|
||
* jemanden zeigen, der noch nicht zugewiesen wurde.
|
||
*/
|
||
readonly vorgesetzterScimId: string | null;
|
||
};
|
||
|
||
function text(wert: unknown): string | null {
|
||
return typeof wert === "string" && wert.trim() ? wert.trim() : null;
|
||
}
|
||
|
||
/**
|
||
* Die Kennung des Vorgesetzten aus `manager`.
|
||
*
|
||
* Drei Formen kommen vor, und alle drei stehen so in Mitschnitten der Anbieter:
|
||
* `{ value: "…" }` (RFC 7643), `{ $ref: "…/Users/…" }` und die nackte
|
||
* Zeichenkette. Die letzte ist nicht standardkonform, aber authentik schickt sie
|
||
* bei manchen Zuordnungen — und ein Vorgesetzter, der daran scheitert, fehlt
|
||
* stillschweigend.
|
||
*/
|
||
export function vorgesetzterAus(wert: unknown): string | null {
|
||
if (typeof wert === "string") return text(wert);
|
||
if (typeof wert !== "object" || wert === null) return null;
|
||
const eintrag = wert as Record<string, unknown>;
|
||
const direkt = text(eintrag["value"]);
|
||
if (direkt) return direkt;
|
||
// Aus `$ref` das letzte Pfadstueck: `https://…/Users/abc` → `abc`.
|
||
const verweis = text(eintrag["$ref"]);
|
||
return verweis ? (text(verweis.split("/").pop()) ?? null) : null;
|
||
}
|
||
|
||
/**
|
||
* Die primäre Mailadresse aus `emails`.
|
||
*
|
||
* `primary: true` gewinnt; ohne Kennzeichnung die erste. Das ist die Reihenfolge
|
||
* aus RFC 7643 — und die Anbieter halten sich unterschiedlich daran: authentik
|
||
* setzt `primary`, Entra ID schickt oft nur einen Eintrag ohne das Feld.
|
||
*/
|
||
export function mailAus(wert: unknown): string | null {
|
||
if (!Array.isArray(wert)) return null;
|
||
const eintraege = wert.filter((e): e is Record<string, unknown> => typeof e === "object" && e !== null);
|
||
const primaer = eintraege.find((e) => e["primary"] === true);
|
||
return text((primaer ?? eintraege[0])?.["value"]);
|
||
}
|
||
|
||
/**
|
||
* Liest eine eingehende Benutzer-Darstellung.
|
||
*
|
||
* Fehlende Felder werden `null` und **nicht** zu leeren Zeichenketten: der
|
||
* Unterschied entscheidet beim Aktualisieren darüber, ob ein Wert gelöscht oder
|
||
* nicht mitgeschickt wurde.
|
||
*/
|
||
export function benutzerLesen(rumpf: unknown): ScimEingabe {
|
||
const daten = (typeof rumpf === "object" && rumpf !== null ? rumpf : {}) as Record<string, unknown>;
|
||
const name = (typeof daten["name"] === "object" && daten["name"] !== null ? daten["name"] : {}) as Record<
|
||
string,
|
||
unknown
|
||
>;
|
||
const erweiterung = (
|
||
typeof daten[SCHEMA_ENTERPRISE] === "object" && daten[SCHEMA_ENTERPRISE] !== null
|
||
? daten[SCHEMA_ENTERPRISE]
|
||
: {}
|
||
) as Record<string, unknown>;
|
||
|
||
return {
|
||
externeId: text(daten["externalId"]),
|
||
upn: text(daten["userName"]),
|
||
vorname: text(name["givenName"]),
|
||
nachname: text(name["familyName"]),
|
||
anzeigename: text(daten["displayName"]),
|
||
mail: mailAus(daten["emails"]),
|
||
abteilung: text(erweiterung["department"]),
|
||
titel: text(daten["title"]) ?? text(erweiterung["jobTitle"]),
|
||
/*
|
||
* `active` fehlt bei `POST` manchmal. `null` heisst „nicht mitgeschickt“ —
|
||
* der Dienst legt dann aktiv an, aendert aber beim Aktualisieren nichts.
|
||
*/
|
||
aktiv: typeof daten["active"] === "boolean" ? daten["active"] : null,
|
||
vorgesetzterScimId: vorgesetzterAus(erweiterung["manager"]),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Eine Person als SCIM-Darstellung.
|
||
*
|
||
* `meta.location` ist Pflicht nach RFC 7644 und nicht Zierde: manche Anbieter
|
||
* folgen ihr, statt die Adresse selbst zu bauen.
|
||
*/
|
||
export function personDarstellen(person: ScimPerson, basis: string): Record<string, unknown> {
|
||
return {
|
||
schemas: [SCHEMA_USER, SCHEMA_ENTERPRISE],
|
||
id: person.id,
|
||
...(person.externeId ? { externalId: person.externeId } : {}),
|
||
userName: person.upn ?? person.id,
|
||
name: { givenName: person.vorname, familyName: person.nachname },
|
||
displayName: person.anzeigename,
|
||
...(person.mail ? { emails: [{ value: person.mail, primary: true, type: "work" }] } : {}),
|
||
...(person.titel ? { title: person.titel } : {}),
|
||
active: person.aktiv,
|
||
[SCHEMA_ENTERPRISE]: {
|
||
...(person.abteilung ? { department: person.abteilung } : {}),
|
||
...(person.vorgesetzterId
|
||
? { manager: { value: person.vorgesetzterId, $ref: `${basis}/Users/${person.vorgesetzterId}` } }
|
||
: {}),
|
||
},
|
||
meta: {
|
||
resourceType: "User",
|
||
created: person.erstelltAm.toISOString(),
|
||
lastModified: person.geaendertAm.toISOString(),
|
||
location: `${basis}/Users/${person.id}`,
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Eine Liste nach RFC 7644. `startIndex` ist **1**-basiert, nicht 0. */
|
||
export function listeDarstellen(
|
||
personen: readonly ScimPerson[],
|
||
basis: string,
|
||
gesamt = personen.length,
|
||
startIndex = 1,
|
||
): Record<string, unknown> {
|
||
return {
|
||
schemas: [SCHEMA_LIST],
|
||
totalResults: gesamt,
|
||
itemsPerPage: personen.length,
|
||
startIndex,
|
||
Resources: personen.map((person) => personDarstellen(person, basis)),
|
||
};
|
||
}
|
||
|
||
/** Ein Fehler nach RFC 7644. `status` ist dort eine **Zeichenkette**. */
|
||
export function fehlerDarstellen(status: number, detail: string, scimType?: string): Record<string, unknown> {
|
||
return {
|
||
schemas: [SCHEMA_ERROR],
|
||
status: String(status),
|
||
...(scimType ? { scimType } : {}),
|
||
detail,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Liest den Filter `userName eq "wert"`.
|
||
*
|
||
* **Nur dieser eine Filter**, und das ist Absicht: er ist der einzige, den die
|
||
* Anbieter benutzen — sie fragen damit vor jedem Anlegen, ob die Person schon da
|
||
* ist. Einen allgemeinen Filter-Parser zu bauen hieße, eine kleine Abfragesprache
|
||
* zu stützen, die niemand schickt.
|
||
*
|
||
* `undefined`, wenn kein Filter dasteht; `null`, wenn einer dasteht, den wir
|
||
* nicht verstehen — der Aufrufer antwortet dann mit `400` statt stillschweigend
|
||
* alles zu liefern. Alles zu liefern wäre die gefährlichere Antwort: der Anbieter
|
||
* schlösse daraus, die Person gäbe es schon.
|
||
*/
|
||
export function benutzernameAusFilter(filter: string | null): string | undefined | null {
|
||
if (!filter?.trim()) return undefined;
|
||
const treffer = /^\s*userName\s+eq\s+"([^"]*)"\s*$/iu.exec(filter);
|
||
return treffer?.[1] ?? null;
|
||
}
|
||
|
||
export type PatchSchritt = {
|
||
readonly op: "add" | "replace" | "remove";
|
||
readonly path: string | undefined;
|
||
readonly value: unknown;
|
||
};
|
||
|
||
/**
|
||
* Liest eine `PatchOp`-Anfrage.
|
||
*
|
||
* `op` ist nach RFC unabhängig von Groß- und Kleinschreibung, und die Anbieter
|
||
* nutzen das aus: authentik schickt `replace`, Entra ID `Replace`.
|
||
*/
|
||
export function patchLesen(rumpf: unknown): readonly PatchSchritt[] | null {
|
||
const daten = (typeof rumpf === "object" && rumpf !== null ? rumpf : {}) as Record<string, unknown>;
|
||
const rohe = daten["Operations"] ?? daten["operations"];
|
||
if (!Array.isArray(rohe)) return null;
|
||
|
||
const schritte: PatchSchritt[] = [];
|
||
for (const roh of rohe) {
|
||
if (typeof roh !== "object" || roh === null) return null;
|
||
const eintrag = roh as Record<string, unknown>;
|
||
const op = text(eintrag["op"])?.toLowerCase();
|
||
if (op !== "add" && op !== "replace" && op !== "remove") return null;
|
||
schritte.push({ op, path: text(eintrag["path"]) ?? undefined, value: eintrag["value"] });
|
||
}
|
||
return schritte;
|
||
}
|
||
|
||
/**
|
||
* Wendet die Patch-Schritte auf eine Eingabe an.
|
||
*
|
||
* **Die Pfade, die die Anbieter wirklich schicken** — nicht der ganze Standard.
|
||
* Der wichtigste ist `active`: so deaktiviert Entra ID ein Konto, und so
|
||
* aktiviert es es wieder.
|
||
*
|
||
* Ein Schritt ohne `path` trägt ein ganzes Objekt in `value` (so macht es
|
||
* authentik beim `replace`); dann wird es wie ein `PUT`-Rumpf gelesen.
|
||
*
|
||
* Ein unbekannter Pfad wird **übergangen** und nicht als Fehler gemeldet. Das
|
||
* ist die Stelle, an der Nachsicht richtig ist: Anbieter schicken Felder, die
|
||
* Laufzettel nicht führt (`preferredLanguage`, `addresses`), und ein `400`
|
||
* darauf brächte die ganze Zuweisung zum Stehen — wegen eines Feldes, das
|
||
* niemand braucht.
|
||
*/
|
||
export function patchAnwenden(
|
||
schritte: readonly PatchSchritt[],
|
||
vorher: ScimEingabe,
|
||
): { readonly eingabe: ScimEingabe; readonly uebergangen: readonly string[] } {
|
||
let eingabe = { ...vorher };
|
||
const uebergangen: string[] = [];
|
||
|
||
for (const schritt of schritte) {
|
||
if (!schritt.path) {
|
||
// Ganzes Objekt: wie ein PUT-Rumpf lesen, aber nur die gesetzten Felder.
|
||
const gelesen = benutzerLesen(schritt.value);
|
||
eingabe = {
|
||
externeId: gelesen.externeId ?? eingabe.externeId,
|
||
upn: gelesen.upn ?? eingabe.upn,
|
||
vorname: gelesen.vorname ?? eingabe.vorname,
|
||
nachname: gelesen.nachname ?? eingabe.nachname,
|
||
anzeigename: gelesen.anzeigename ?? eingabe.anzeigename,
|
||
mail: gelesen.mail ?? eingabe.mail,
|
||
abteilung: gelesen.abteilung ?? eingabe.abteilung,
|
||
titel: gelesen.titel ?? eingabe.titel,
|
||
aktiv: gelesen.aktiv ?? eingabe.aktiv,
|
||
vorgesetzterScimId: gelesen.vorgesetzterScimId ?? eingabe.vorgesetzterScimId,
|
||
};
|
||
continue;
|
||
}
|
||
|
||
const pfad = schritt.path.toLowerCase();
|
||
const entfernen = schritt.op === "remove";
|
||
const wert = entfernen ? null : schritt.value;
|
||
|
||
if (pfad === "active") {
|
||
eingabe = {
|
||
...eingabe,
|
||
aktiv: entfernen ? false : wert === true || wert === "True" || wert === "true",
|
||
};
|
||
} else if (pfad === "username") {
|
||
eingabe = { ...eingabe, upn: text(wert) };
|
||
} else if (pfad === "displayname") {
|
||
eingabe = { ...eingabe, anzeigename: text(wert) };
|
||
} else if (pfad === "name.givenname") {
|
||
eingabe = { ...eingabe, vorname: text(wert) };
|
||
} else if (pfad === "name.familyname") {
|
||
eingabe = { ...eingabe, nachname: text(wert) };
|
||
} else if (pfad === "title") {
|
||
eingabe = { ...eingabe, titel: text(wert) };
|
||
} else if (pfad === "externalid") {
|
||
eingabe = { ...eingabe, externeId: text(wert) };
|
||
} else if (pfad.startsWith("emails")) {
|
||
// `emails[type eq "work"].value` und `emails` – beide kommen vor.
|
||
eingabe = { ...eingabe, mail: text(wert) ?? mailAus(wert) };
|
||
} else if (pfad.endsWith("manager") || pfad.endsWith("manager.value")) {
|
||
eingabe = { ...eingabe, vorgesetzterScimId: entfernen ? null : vorgesetzterAus(wert) };
|
||
} else if (pfad.endsWith("department")) {
|
||
eingabe = { ...eingabe, abteilung: text(wert) };
|
||
} else {
|
||
uebergangen.push(schritt.path);
|
||
}
|
||
}
|
||
|
||
return { eingabe, uebergangen };
|
||
}
|