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>
479 lines
18 KiB
TypeScript
479 lines
18 KiB
TypeScript
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<void> {
|
||
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<Verzeichnisquelle | undefined> {
|
||
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<void> {
|
||
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<void> {
|
||
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<ScimPerson | undefined> {
|
||
/*
|
||
* **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<readonly ScimPerson[]> {
|
||
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<ScimPerson> {
|
||
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<ScimPerson> {
|
||
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<void> {
|
||
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);
|
||
}
|