/** * 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; 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 => 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; 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; 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 { 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 { 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 { 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; 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; 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 }; }