import { randomBytes, timingSafeEqual } from "node:crypto"; import { hash, verify } from "@node-rs/argon2"; import type { Mitarbeiter, Prisma, Verzeichnisquelle } from "@/generated/prisma/client"; import { Herkunft } from "@/generated/prisma/enums"; import { prisma } from "@/lib/db"; import type { ScimEingabe, ScimPerson } from "@/lib/scim"; import { darf } from "@/server/auth/rollen"; import { eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant"; import { VerzeichnisFehler } from "@/server/ldap/fehler"; /** * SCIM: Token und Schreiboperationen (#029). * * Der Abgleich holt, SCIM bekommt — aber **danach gelten dieselben Regeln**: * anlegen, übernehmen, aktualisieren, deaktivieren, **nie löschen**. Ein * `DELETE` vom Anbieter setzt `aktiv = false`; die Zeile bleibt, weil Läufe und * Geräte an ihr hängen. * * **Das Token ist der einzige Mandantenbeweis, den ein Push mitbringt.** Es * identifiziert die Quelle, und die Quelle den Kunden. Deshalb steht es als * argon2-Hash in der Datenbank und wird genau einmal angezeigt. */ export class ScimFehler extends Error { readonly status: number; readonly scimType: string | undefined; constructor(status: number, meldung: string, scimType?: string) { super(meldung); this.name = "ScimFehler"; this.status = status; this.scimType = scimType; } } /* ------------------------------------------------------------------ */ /* Token */ /* ------------------------------------------------------------------ */ /** * Wie lang ein Token ist. * * 32 Byte aus `randomBytes`, base64url — 43 Zeichen. Das ist weit jenseits * dessen, was sich raten lässt, und kurz genug, dass es in ein Formularfeld * eines Anbieters passt, ohne umzubrechen. */ const TOKEN_BYTES = 32; /** Das Präfix macht ein gefundenes Token erkennbar — in einem Log, in einer Zwischenablage. */ export const TOKEN_PRAEFIX = "lzscim_"; export function tokenErzeugen(): string { return `${TOKEN_PRAEFIX}${randomBytes(TOKEN_BYTES).toString("base64url")}`; } function rechtPruefen(benutzer: HandelnderBenutzer): void { if (!darf(benutzer.rolle, "verwaltung")) { throw new VerzeichnisFehler( `Ein SCIM-Token zu verwalten verlangt das Recht "verwaltung"; die Rolle ${benutzer.rolle} hat es nicht.`, ); } } /** * Erzeugt ein neues Token für eine SCIM-Quelle und gibt es **einmal** zurück. * * Ein vorhandenes wird dabei ersetzt — es gibt genau eines je Quelle. Das ist * eine Entscheidung gegen Bequemlichkeit: zwei gültige Token wären zwei Dinge, * die man widerrufen muss, und eines davon vergisst man. */ export async function scimTokenErzeugen( quelleId: string, benutzer: HandelnderBenutzer, ): Promise<{ readonly token: string }> { rechtPruefen(benutzer); const quelle = await prisma.verzeichnisquelle.findUnique({ where: { id: quelleId } }); if (!quelle) throw new VerzeichnisFehler("Diese Verzeichnisquelle gibt es nicht (mehr)."); const eigene = await eigeneOrganisation(); if (quelle.kundeId !== eigene.id) { throw new VerzeichnisFehler( `„${quelle.name}“ gehört nicht zur eigenen Organisation. Der Verzeichnisabgleich gilt nur für sie.`, ); } if (quelle.art !== "SCIM") { throw new VerzeichnisFehler( `„${quelle.name}“ ist eine Quelle der Art ${quelle.art}. Ein SCIM-Token gilt nur für die Art SCIM.`, ); } const token = tokenErzeugen(); await prisma.verzeichnisquelle.update({ where: { id: quelleId }, data: { scimTokenHash: await hash(token), scimTokenGesetztAm: new Date() }, }); await prisma.protokoll.create({ data: { kundeId: quelle.kundeId, entitaet: "Verzeichnisquelle", entitaetId: quelle.id, aktion: quelle.scimTokenHash ? "SCIM-Token ersetzt" : "SCIM-Token erzeugt", benutzerId: benutzer.id, // Kein Token, auch nicht gekürzt: das Protokoll wird gelesen (#023). nachher: { name: quelle.name }, }, }); return { token }; } /** Widerruft das Token. Danach nimmt die Quelle nichts mehr an. */ export async function scimTokenWiderrufen(quelleId: string, benutzer: HandelnderBenutzer): Promise { rechtPruefen(benutzer); const quelle = await prisma.verzeichnisquelle.findUnique({ where: { id: quelleId } }); if (!quelle) throw new VerzeichnisFehler("Diese Verzeichnisquelle gibt es nicht (mehr)."); const eigene = await eigeneOrganisation(); if (quelle.kundeId !== eigene.id) { throw new VerzeichnisFehler(`„${quelle.name}“ gehört nicht zur eigenen Organisation.`); } if (!quelle.scimTokenHash) return; await prisma.verzeichnisquelle.update({ where: { id: quelleId }, data: { scimTokenHash: null, scimTokenGesetztAm: null }, }); await prisma.protokoll.create({ data: { kundeId: quelle.kundeId, entitaet: "Verzeichnisquelle", entitaetId: quelle.id, aktion: "SCIM-Token widerrufen", benutzerId: benutzer.id, nachher: { name: quelle.name }, }, }); } /** * Die Quelle zu einem Token – oder `undefined`. * * **Durchsucht wird, nicht nachgeschlagen.** Ein argon2-Hash ist je Aufruf * anders, es gibt also keinen Index, über den sich ein Token finden ließe. Bei * einer Handvoll SCIM-Quellen ist das gleichgültig; bei hunderten wäre es ein * Grund, zusätzlich eine Kennung im Token zu führen. * * **Ohne Vorab-Prüfung auf das Präfix wäre das ein Zeitorakel**: ein Token mit * falschem Präfix würde gegen jeden Hash geprüft, eines mit richtigem nur bis * zum Treffer. Deshalb erst die Form, dann der Hash — und das Präfix ist kein * Geheimnis. */ export async function quelleZuToken(token: string): Promise { const wert = token.trim(); if (!wert.startsWith(TOKEN_PRAEFIX)) return undefined; const quellen = await prisma.verzeichnisquelle.findMany({ where: { art: "SCIM", aktiv: true, scimTokenHash: { not: null } }, }); for (const quelle of quellen) { if (!quelle.scimTokenHash) continue; try { if (await verify(quelle.scimTokenHash, wert)) return quelle; } catch { /* * Ein unlesbarer Hash ist ein Datenfehler, nicht ein falsches Token. Die * uebrigen Quellen werden weiter geprueft — eine kaputte Zeile darf nicht * die Zuweisung aller anderen anhalten. */ console.error(`SCIM-Token-Hash der Quelle ${quelle.id} ist unlesbar.`); } } return undefined; } /** * Zeitkonstanter Textvergleich – für Stellen, an denen kein Hash im Spiel ist. * * Steht hier, weil der Handler das `Bearer`-Schema prüft und ein `===` darauf * zwar harmlos wäre, aber das Muster vorgibt. Ein Vergleich, der früh abbricht, * gehört in dieser Datei nirgends hin. */ export function textGleich(a: string, b: string): boolean { const links = Buffer.from(a, "utf8"); const rechts = Buffer.from(b, "utf8"); if (links.length !== rechts.length) return false; return timingSafeEqual(links, rechts); } /* ------------------------------------------------------------------ */ /* Personen */ /* ------------------------------------------------------------------ */ function alsPerson(zeile: Mitarbeiter): ScimPerson { return { id: zeile.id, externeId: zeile.externeId, upn: zeile.upn, vorname: zeile.vorname, nachname: zeile.nachname, anzeigename: zeile.anzeigename, mail: zeile.mail, abteilung: zeile.abteilung, titel: zeile.titel, aktiv: zeile.aktiv, vorgesetzterId: zeile.vorgesetzterId, erstelltAm: zeile.erstelltAm, geaendertAm: zeile.geaendertAm, }; } /** Der Anzeigename, wenn der Anbieter keinen schickt. Wie beim Abgleich. */ function anzeigenameBauen(eingabe: ScimEingabe): string { if (eingabe.anzeigename) return eingabe.anzeigename; const gebaut = [eingabe.nachname, eingabe.vorname].filter((teil) => teil).join(", "); return gebaut || eingabe.upn || "Ohne Namen"; } /** * Den Vorgesetzten auflösen: aus der Kennung des Anbieters eine Zeile. * * **Drei Gründe, warum das `null` ergeben darf, und keiner davon ist ein * Fehler:** der Vorgesetzte wurde Laufzettel noch nicht zugewiesen (der * Anbieter schickt die Leute in beliebiger Reihenfolge), er gehört zu einer * anderen Quelle, oder jemand ist sein eigener Vorgesetzter. Beim nächsten * `PATCH` auf diese Person wird es nachgezogen — der Anbieter schickt die * Zuordnung erneut, sobald sie sich ändert. * * Eine fremde Quelle wird ausgeschlossen, weil `manager` sonst ein Weg über die * Mandantengrenze wäre: eine Kennung aus einer anderen Quelle zeigt auf jemanden, * den diese hier nicht sehen darf. */ async function vorgesetzterAufloesen( quelle: Verzeichnisquelle, eigeneId: string | null, scimId: string | null, ): Promise<{ readonly vorgesetzterId: string | null; readonly vorgesetzterExterneId: string | null }> { if (!scimId || scimId === eigeneId) return { vorgesetzterId: null, vorgesetzterExterneId: null }; const zeile = await prisma.mitarbeiter.findFirst({ where: { id: scimId, quelleId: quelle.id }, select: { id: true, externeId: true }, }); return zeile ? { vorgesetzterId: zeile.id, vorgesetzterExterneId: zeile.externeId } : { vorgesetzterId: null, vorgesetzterExterneId: null }; } async function zustellungVermerken(quelleId: string): Promise { await prisma.verzeichnisquelle.update({ where: { id: quelleId }, data: { letzteScimZustellungAm: new Date() }, }); } /** * Protokolleintrag mit der **Quelle** als Urheber. * * `benutzerId` bleibt leer — das ist ein Systemlauf, wie beim Zeitplan des * Abgleichs. Ein erfundener Administrator wäre eine Lüge im Protokoll, und das * Protokoll ist ansehbar (#023). */ async function vermerken( quelle: Verzeichnisquelle, mitarbeiterId: string, aktion: string, nachher: Prisma.InputJsonObject, ): Promise { await prisma.protokoll.create({ data: { kundeId: quelle.kundeId, entitaet: "Mitarbeiter", entitaetId: mitarbeiterId, aktion: `${aktion} (SCIM: ${quelle.name})`, nachher, }, }); } /** Eine Person dieser Quelle, über die Laufzettel-Kennung. */ export async function personHolen(quelle: Verzeichnisquelle, id: string): Promise { /* * **Immer mit `quelleId`.** Die Kennung allein wäre genug, um die Zeile zu * finden — und genau das ist der Fehler, der einem fremden Token Zugriff auf * die Personen einer anderen Quelle gäbe. */ const zeile = await prisma.mitarbeiter.findFirst({ where: { id, quelleId: quelle.id } }); return zeile ? alsPerson(zeile) : undefined; } /** Personen dieser Quelle, gefiltert nach `userName` oder alle. */ export async function personenSuchen( quelle: Verzeichnisquelle, upn?: string, ): Promise { const zeilen = await prisma.mitarbeiter.findMany({ where: { quelleId: quelle.id, ...(upn ? { upn: { equals: upn, mode: "insensitive" } } : {}), }, orderBy: { anzeigename: "asc" }, // Eine Obergrenze, damit ein `GET /Users` ohne Filter nicht das ganze // Verzeichnis in eine Antwort schreibt. Die Anbieter fragen gefiltert. take: 200, }); return zeilen.map(alsPerson); } /** * Legt eine Person an – oder weist ab, wenn es sie schon gibt. * * **Zweimal dasselbe `POST` ergibt `409`**, nicht eine zweite Person. Das ist * kein Komfort, sondern der Normalfall: ein Anbieter, der eine Zuweisung * wiederholt (nach einem Fehler, nach einem Neustart), schickt dasselbe `POST` * erneut. * * Erkannt wird eine Dublette an `externalId` **oder** am `userName` — der * Anbieter schickt nicht immer beides, und beides identifiziert dieselbe Person. */ export async function personAnlegen(quelle: Verzeichnisquelle, eingabe: ScimEingabe): Promise { if (!eingabe.upn && !eingabe.externeId) { throw new ScimFehler( 400, "Ohne userName und ohne externalId lässt sich niemand anlegen.", "invalidValue", ); } const vorhanden = await prisma.mitarbeiter.findFirst({ where: { quelleId: quelle.id, OR: [ ...(eingabe.externeId ? [{ externeId: eingabe.externeId }] : []), ...(eingabe.upn ? [{ upn: { equals: eingabe.upn, mode: "insensitive" as const } }] : []), ], }, }); if (vorhanden) { throw new ScimFehler(409, `„${vorhanden.anzeigename}“ gibt es in dieser Quelle schon.`, "uniqueness"); } /* * **Übernahme statt Dublette**, wie beim Abgleich (#016): beim Onboarding legt * jemand die Person von Hand an, das Konto beim Anbieter entsteht danach. * Gesucht wird nur unter Zeilen **ohne** Quelle — eine Person, die eine andere * Quelle führt, gehört der. */ const vonHand = eingabe.upn ? await prisma.mitarbeiter.findFirst({ where: { kundeId: quelle.kundeId, quelleId: null, upn: { equals: eingabe.upn, mode: "insensitive" }, }, }) : null; // Beim Anlegen gibt es noch keine eigene Kennung, auf die `manager` zeigen // koennte – deshalb `null` als eigene. const vorgesetzter = await vorgesetzterAufloesen(quelle, null, eingabe.vorgesetzterScimId); const daten = { externeId: eingabe.externeId, upn: eingabe.upn, ...vorgesetzter, vorname: eingabe.vorname ?? "", nachname: eingabe.nachname ?? "", anzeigename: anzeigenameBauen(eingabe), mail: eingabe.mail, abteilung: eingabe.abteilung, titel: eingabe.titel, // Ohne `active` wird aktiv angelegt: ein Anbieter weist niemandem ein // deaktiviertes Konto zu. aktiv: eingabe.aktiv ?? true, herkunft: Herkunft.ABGLEICH, zuletztGesehenAm: new Date(), }; const zeile = vonHand ? await prisma.mitarbeiter.update({ where: { id: vonHand.id }, data: { ...daten, quelleId: quelle.id }, }) : await prisma.mitarbeiter.create({ data: { ...daten, kundeId: quelle.kundeId, quelleId: quelle.id }, }); await vermerken(quelle, zeile.id, vonHand ? "Person übernommen" : "Person angelegt", { upn: zeile.upn, anzeigename: zeile.anzeigename, }); await zustellungVermerken(quelle.id); return alsPerson(zeile); } /** * Ändert eine Person. `eingabe` ist der **vollständige Zielzustand**. * * Was hier `null` ist, wird geleert — auch bei `PATCH`. Das ist der * entscheidende Punkt, und er hat einen Anlauf gekostet (#029): diese Funktion * hatte einen Schalter `nurGesetzte`, der bei `PATCH` jedes `null` als „nicht * mitgeschickt“ las und den alten Wert stehen ließ. Damit wirkte ein * `remove` **gar nicht** — jemand nimmt beim Anbieter die Abteilung aus dem * Profil, und in Laufzettel stand sie weiter. * * Nötig war der Schalter nie: `patchAnwenden` setzt auf dem vorhandenen Stand * auf und liefert deshalb immer einen vollständigen Zustand, in dem „nicht * mitgeschickt“ schon als alter Wert steht. Der Unterschied zwischen `PUT` und * `PATCH` liegt also **vor** dieser Funktion und nicht in ihr. */ export async function personAendern( quelle: Verzeichnisquelle, id: string, eingabe: ScimEingabe, ): Promise { const vorhanden = await prisma.mitarbeiter.findFirst({ where: { id, quelleId: quelle.id } }); if (!vorhanden) throw new ScimFehler(404, "Diese Person gibt es in dieser Quelle nicht."); const vorgesetzter = await vorgesetzterAufloesen(quelle, id, eingabe.vorgesetzterScimId); const zeile = await prisma.mitarbeiter.update({ where: { id }, data: { externeId: eingabe.externeId, upn: eingabe.upn, ...vorgesetzter, vorname: eingabe.vorname ?? "", nachname: eingabe.nachname ?? "", anzeigename: eingabe.anzeigename ?? anzeigenameBauen(eingabe), mail: eingabe.mail, abteilung: eingabe.abteilung, titel: eingabe.titel, // Ohne `active` im Rumpf bleibt es aktiv: ein `PUT` ohne das Feld ist // keine Abschaltung, und bei `PATCH` steht hier ohnehin der alte Wert. aktiv: eingabe.aktiv ?? true, zuletztGesehenAm: new Date(), }, }); const wurdeDeaktiviert = vorhanden.aktiv && !zeile.aktiv; const wurdeAktiviert = !vorhanden.aktiv && zeile.aktiv; await vermerken( quelle, zeile.id, wurdeDeaktiviert ? "Person deaktiviert" : wurdeAktiviert ? "Person wieder aktiv" : "Person aktualisiert", { upn: zeile.upn, anzeigename: zeile.anzeigename, aktiv: zeile.aktiv }, ); await zustellungVermerken(quelle.id); return alsPerson(zeile); } /** * `DELETE` vom Anbieter: **deaktiviert, löscht nicht.** * * Die Regel des ganzen Projekts. An einer Person hängen Läufe und Geräte; ein * archivierter Lauf muss auch in zehn Jahren noch sagen können, für wen er lief. * Der Anbieter bekommt `204` und hält die Person damit für gelöscht — das ist * aus seiner Sicht richtig, denn sie wird ihm nicht mehr zugewiesen. * * **Das muss in der Anleitung stehen**, sonst wirkt es wie ein Fehler: jemand * entfernt eine Person beim Anbieter, und sie steht in Laufzettel weiter in der * Liste — inaktiv, aber sichtbar. */ export async function personDeaktivieren(quelle: Verzeichnisquelle, id: string): Promise { const vorhanden = await prisma.mitarbeiter.findFirst({ where: { id, quelleId: quelle.id } }); if (!vorhanden) throw new ScimFehler(404, "Diese Person gibt es in dieser Quelle nicht."); if (vorhanden.aktiv) { await prisma.mitarbeiter.update({ where: { id }, data: { aktiv: false, zuletztGesehenAm: new Date() }, }); await vermerken(quelle, id, "Person deaktiviert (DELETE)", { upn: vorhanden.upn, anzeigename: vorhanden.anzeigename, hinweis: "Die Zeile bleibt: Läufe und Geräte hängen daran.", }); } await zustellungVermerken(quelle.id); }