feat(verzeichnis): SCIM-Endpunkt als dritte Quellenart (#029)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

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:
Florian Wilde
2026-10-10 19:50:54 +02:00
co-authored by Claude Opus 5
parent 76a61ec771
commit fcd67018b8
32 changed files with 3672 additions and 141 deletions
+91 -18
View File
@@ -1,9 +1,13 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { AlertTriangle, CheckCircle2, Clock, Server } from "lucide-react";
import { AlertTriangle, CheckCircle2, Clock, Inbox, Server } from "lucide-react";
import { Betriebsmodus } from "@/generated/prisma/enums";
import { JetztAbgleichen } from "@/components/verzeichnis/jetzt-abgleichen";
import { QuelleFormular } from "@/components/verzeichnis/quelle-formular";
import { ScimToken } from "@/components/verzeichnis/scim-token";
import { VerbindungTesten } from "@/components/verzeichnis/verbindung-testen";
import { basisAdresse } from "@/lib/adressen";
import { QUELLEN_ART_TEXT } from "@/lib/verzeichnis";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { betriebsmodus } from "@/server/mandant";
import { abgleichLog, type AbgleichLogZeile, quellenListe } from "@/server/queries/verzeichnis";
@@ -30,6 +34,13 @@ const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
});
function Status({ quelle }: { quelle: QuellenSicht }) {
/*
* **Bei SCIM sagt „noch nie abgeglichen“ etwas Falsches** (#029): dort wird
* nie abgeglichen, und das ist kein Rückstand. Was an diese Stelle gehört,
* steht im Token-Abschnitt — „letzte Zustellung“.
*/
if (quelle.art === "SCIM") return null;
if (quelle.letzterAbgleichAm === null) {
return (
<p className="mt-2 text-sm text-nebentext">
@@ -123,11 +134,26 @@ export default async function VerzeichnisSeite() {
// daran, und ein hart gesetzter „ADMIN“ waere eine Hintertuer.
const handelnder = { id: benutzer.id, rolle: benutzer.rolle };
const quellen = await quellenListe(handelnder);
/*
* Die Adresse, die beim Anbieter eingetragen wird. Über `basisAdresse`, weil
* sie jemand **behält** — ein Unterpfad aus `APP_URL` darf dabei nicht
* herausfallen, sonst trägt der Administrator eine Adresse ein, die ins Leere
* zeigt (derselbe Grund wie bei den Etiketten, #014).
*/
const kopfzeilen = await headers();
const scimAdresse = new URL(
"api/scim/v2",
basisAdresse(`http://${kopfzeilen.get("host") ?? "127.0.0.1:3000"}`),
).toString();
// Je Quelle ein Log. Die Abfragen laufen parallel; es sind so viele wie Quellen,
// und das sind im Modus INTERN eine oder zwei.
const logs = new Map(
await Promise.all(
quellen.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
quellen
// SCIM-Quellen haben kein Abgleich-Log: sie werden nicht abgeglichen.
.filter((quelle) => quelle.art !== "SCIM")
.map(async (quelle) => [quelle.id, await abgleichLog(quelle.id, handelnder)] as const),
),
);
@@ -135,9 +161,10 @@ export default async function VerzeichnisSeite() {
<>
<h1 className="text-2xl font-bold">Verzeichnis</h1>
<p className="mt-2 max-w-prose text-nebentext">
Die Personen der eigenen Firma kommen aus dem Active Directory, damit beim Start eines Laufs nichts
abgetippt werden muss. Wer dort verschwindet oder deaktiviert wird, wird hier <strong>inaktiv</strong>{" "}
— gelöscht wird nie, sonst verlöre die Historie ihre Namen.
Die Personen der eigenen Firma kommen aus dem Verzeichnis, damit beim Start eines Laufs nichts
abgetippt werden muss. Laufzettel liest sie entweder selbst — aus einem Active Directory oder aus
Entra ID — oder lässt sich per SCIM beliefern. Wer dort verschwindet oder deaktiviert wird, wird hier{" "}
<strong>inaktiv</strong> — gelöscht wird nie, sonst verlöre die Historie ihre Namen.
</p>
{quellen.length === 0 ? (
@@ -152,7 +179,23 @@ export default async function VerzeichnisSeite() {
<div className="flex flex-wrap items-center gap-2">
<Server className="size-5 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<h2 className="text-lg font-semibold break-words">{quelle.name}</h2>
{quelle.aktiv ? (
{/*
* Bei SCIM gibt es keinen Zeitplan, und „kein Zeitplan“ wäre
* trotzdem die falsche Auskunft — es liest sich wie ein Mangel.
* Dort zählt, ob Zustellungen angenommen werden.
*/}
{quelle.art === "SCIM" ? (
<span
className={
quelle.aktiv
? "flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent"
: "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext"
}
>
<Inbox className="size-3" strokeWidth={2} aria-hidden="true" />
{quelle.aktiv ? "nimmt Zustellungen an" : "nimmt nichts an"}
</span>
) : quelle.aktiv ? (
<span className="flex items-center gap-1 rounded-pille bg-akzent-flaeche px-2.5 py-1 text-xs text-akzent">
<Clock className="size-3" strokeWidth={2} aria-hidden="true" />
alle {quelle.abgleichIntervallMinuten} min
@@ -172,11 +215,21 @@ export default async function VerzeichnisSeite() {
<p className="mono mt-2 text-xs break-all text-nebentext">
{quelle.art === "GRAPH" ? (
<>
Microsoft Graph · {quelle.tenantId} · App {quelle.clientId}
{QUELLEN_ART_TEXT.GRAPH} · {quelle.tenantId} · App {quelle.clientId}
</>
) : quelle.art === "SCIM" ? (
/*
* Bei SCIM steht hier die Adresse, die beim Anbieter
* eingetragen wird — die Angabe, die ein Administrator
* tatsächlich braucht, und die er sonst aus der Anleitung
* zusammensetzen müsste.
*/
<>
{QUELLEN_ART_TEXT.SCIM} · {scimAdresse}
</>
) : (
<>
LDAP · {quelle.url} · {quelle.basisDn}
{QUELLEN_ART_TEXT.LDAP} · {quelle.url} · {quelle.basisDn}
</>
)}
</p>
@@ -184,16 +237,35 @@ export default async function VerzeichnisSeite() {
<Status quelle={quelle} />
<div className="mt-4 flex flex-col gap-4">
<VerbindungTesten quelleId={quelle.id} />
<JetztAbgleichen quelleId={quelle.id} />
{/*
* **Zwei Arten, zwei Arbeitsweisen.** „Verbindung testen“ und
* „Jetzt abgleichen“ setzen voraus, dass Laufzettel holt; bei
* einem Push gibt es beides nicht, und ein Knopf, der nur eine
* Erklärung auswirft, ist kein Knopf.
*/}
{quelle.art === "SCIM" ? (
<ScimToken
quelleId={quelle.id}
vorhanden={quelle.scimTokenVorhanden}
gesetztAm={quelle.scimTokenGesetztAm}
letzteZustellungAm={quelle.letzteScimZustellungAm}
/>
) : (
<>
<VerbindungTesten quelleId={quelle.id} />
<JetztAbgleichen quelleId={quelle.id} />
</>
)}
</div>
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Abgleich-Log
</summary>
<Log zeilen={logs.get(quelle.id) ?? []} />
</details>
{quelle.art === "SCIM" ? null : (
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Abgleich-Log
</summary>
<Log zeilen={logs.get(quelle.id) ?? []} />
</details>
)}
<details className="mt-4 border-t border-linie pt-4">
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
@@ -211,8 +283,9 @@ export default async function VerzeichnisSeite() {
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Quelle anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Ein Dienstkonto mit Leserechten genügt. Laufzettel schreibt nie ins Verzeichnis und speichert nur
die zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
Zum Lesen genügt ein Dienstkonto mit Leserechten; bei SCIM braucht es gar keine Zugangsdaten, nur
ein Token für den Anbieter. Laufzettel schreibt nie ins Verzeichnis und speichert nur die
zugeordneten Felder — keine Passwörter, keine Gruppenmitgliedschaften.
</p>
<QuelleFormular />
</section>
@@ -0,0 +1,37 @@
import { SCHEMA_ENTERPRISE, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/ResourceTypes` (#029).
*
* Nur `User`. **Keine `Group`** — und das ist eine Entscheidung, nicht eine
* Luecke: Laufzettel fuehrt keine Gruppen. Die Abteilung kommt als Attribut der
* Person, und die Rollen kommen aus den OIDC-Gruppen beim Anmelden, nicht aus
* einer Zuweisung. Wuerde `Group` hier stehen, schickte ein Anbieter
* Gruppenzuweisungen, die niemand verarbeitet.
*/
export async function GET(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const typen = [
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ResourceType"],
id: "User",
name: "User",
endpoint: "/Users",
description: "Personen, die Laufzettel als Mitarbeiter fuehrt.",
schema: SCHEMA_USER,
schemaExtensions: [{ schema: SCHEMA_ENTERPRISE, required: false }],
meta: { resourceType: "ResourceType", location: `${zugang.basis}/ResourceTypes/User` },
},
];
return scimAntwort({
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: typen.length,
itemsPerPage: typen.length,
startIndex: 1,
Resources: typen,
});
}
+89
View File
@@ -0,0 +1,89 @@
import { SCHEMA_ENTERPRISE, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/Schemas` (#029).
*
* Gemeldet werden **genau die Attribute, die Laufzettel fuehrt** — nicht der
* ganze Standard. Ein Anbieter liest das und schickt danach nur noch, was hier
* steht; ein zu grosszuegiges Schema fuehrt zu Feldern, die stillschweigend
* verfallen.
*/
function attribut(
name: string,
typ: "string" | "boolean" | "complex" | "reference",
zusatz: Record<string, unknown> = {},
): Record<string, unknown> {
return {
name,
type: typ,
multiValued: false,
required: false,
caseExact: false,
mutability: "readWrite",
returned: "default",
uniqueness: "none",
...zusatz,
};
}
export async function GET(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const schemas = [
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
id: SCHEMA_USER,
name: "User",
description: "Eine Person, die Laufzettel als Mitarbeiter fuehrt.",
attributes: [
attribut("userName", "string", { required: true, uniqueness: "server" }),
attribut("externalId", "string"),
attribut("displayName", "string"),
attribut("title", "string"),
attribut("active", "boolean"),
attribut("name", "complex", {
subAttributes: [attribut("givenName", "string"), attribut("familyName", "string")],
}),
attribut("emails", "complex", {
multiValued: true,
subAttributes: [
attribut("value", "string"),
attribut("type", "string"),
attribut("primary", "boolean"),
],
}),
],
meta: { resourceType: "Schema", location: `${zugang.basis}/Schemas/${SCHEMA_USER}` },
},
{
schemas: ["urn:ietf:params:scim:schemas:core:2.0:Schema"],
id: SCHEMA_ENTERPRISE,
name: "EnterpriseUser",
description: "Die Erweiterung, aus der Laufzettel die Abteilung und den Vorgesetzten liest.",
attributes: [
attribut("department", "string"),
/*
* `manager` gehoert hierher, auch wenn Laufzettel daraus nur `value`
* braucht: ein Anbieter liest dieses Schema und schickt nur, was darin
* steht. Fehlte es, bliebe der Vorgesetzte dauerhaft leer — und zwar
* ohne Fehlermeldung auf beiden Seiten.
*/
attribut("manager", "complex", {
subAttributes: [attribut("value", "string"), attribut("$ref", "reference")],
}),
],
meta: { resourceType: "Schema", location: `${zugang.basis}/Schemas/${SCHEMA_ENTERPRISE}` },
},
];
return scimAntwort({
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
totalResults: schemas.length,
itemsPerPage: schemas.length,
startIndex: 1,
Resources: schemas,
});
}
@@ -0,0 +1,45 @@
import { SCHEMA_PATCH, SCHEMA_USER } from "@/lib/scim";
import { quelleAusAnfrage, scimAntwort } from "@/server/scim/handler";
/**
* SCIM: `/ServiceProviderConfig` (#029).
*
* **Ohne diesen Endpunkt faengt Entra ID nicht an.** Er sagt, was der Anbieter
* erwarten darf — und das Entscheidende daran sind die **Verneinungen**: `bulk`,
* `sort` und `filter` stehen auf `false`, weil Laufzettel sie nicht kann. Ein
* Anbieter, der sie fuer unterstuetzt haelt, schickt Anfragen, die niemand
* beantwortet.
*
* `filter.supported: false` sieht dabei strenger aus, als es ist: der eine
* Filter `userName eq "…"` wird beantwortet. Ihn hier als unterstuetzt zu
* melden, hiesse aber, die ganze Filtersprache zu versprechen — und ein
* Anbieter, der das glaubt, schickt `emails[type eq "work"].value co "@"`.
*/
export async function GET(anfrage: Request): Promise<Response> {
// Auch dieser Endpunkt verlangt ein Token: er verraet, dass hier ein
// SCIM-Dienst laeuft, und das muss nicht jeder wissen.
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
return scimAntwort({
schemas: ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
documentationUri: "https://git.wilde-server.de/florian.wilde/Laufzettel",
patch: { supported: true },
bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 },
filter: { supported: false, maxResults: 200 },
changePassword: { supported: false },
sort: { supported: false },
etag: { supported: false },
authenticationSchemes: [
{
type: "oauthbearertoken",
name: "OAuth Bearer Token",
description: "Ein Token je Verzeichnisquelle, in Laufzettel erzeugt und einmal angezeigt.",
primary: true,
},
],
meta: { resourceType: "ServiceProviderConfig", location: `${zugang.basis}/ServiceProviderConfig` },
// Nur zur Orientierung: welche Schemas dieser Dienst kennt.
...{ _schemas: [SCHEMA_USER, SCHEMA_PATCH] },
});
}
+130
View File
@@ -0,0 +1,130 @@
import { benutzerLesen, patchAnwenden, patchLesen, personDarstellen } from "@/lib/scim";
import { alsScimAntwort, quelleAusAnfrage, rumpfLesen, scimAntwort, scimFehler } from "@/server/scim/handler";
import { personAendern, personDeaktivieren, personHolen, type ScimFehler } from "@/server/services/scim";
/**
* SCIM: `/Users/{id}` (#029).
*
* Vier Verben, und das wichtigste ist `DELETE`: es **deaktiviert**. An einer
* Person haengen Laeufe und Geraete; geloescht wird in diesem Projekt nichts.
* Der Anbieter bekommt `204` und haelt sie damit fuer entfernt — aus seiner
* Sicht richtig, denn sie wird ihm nicht mehr zugewiesen.
*/
export async function GET(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
try {
const { id } = await params;
const person = await personHolen(zugang.quelle, id);
if (!person) return scimFehler(404, "Diese Person gibt es in dieser Quelle nicht.");
return scimAntwort(personDarstellen(person, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function PUT(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const gelesen = await rumpfLesen(anfrage);
if ("antwort" in gelesen) return gelesen.antwort;
try {
const { id } = await params;
/*
* `PUT` ersetzt die Darstellung vollstaendig: ein Feld, das nicht mitkommt,
* wird geleert. Genau das steht im gelesenen Rumpf, deshalb geht er
* unveraendert an den Dienst.
*/
const person = await personAendern(zugang.quelle, id, benutzerLesen(gelesen.rumpf));
return scimAntwort(personDarstellen(person, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function PATCH(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const gelesen = await rumpfLesen(anfrage);
if ("antwort" in gelesen) return gelesen.antwort;
try {
const { id } = await params;
const schritte = patchLesen(gelesen.rumpf);
if (!schritte) {
return scimFehler(
400,
"Der Rumpf ist keine PatchOp-Anfrage: erwartet wird „Operations“ mit op add, replace oder remove.",
"invalidSyntax",
);
}
const vorhanden = await personHolen(zugang.quelle, id);
if (!vorhanden) return scimFehler(404, "Diese Person gibt es in dieser Quelle nicht.");
/*
* Gepatcht wird auf dem **vorhandenen** Stand, nicht auf einer leeren
* Eingabe: ein `PATCH` auf `active` darf die uebrigen Felder nicht leeren.
* Was danach herauskommt, ist der vollstaendige Zielzustand — ein `null`
* darin ist eine Leerung und keine Luecke, und so liest der Dienst es auch.
*/
const { eingabe, uebergangen } = patchAnwenden(schritte, {
externeId: vorhanden.externeId,
upn: vorhanden.upn,
vorname: vorhanden.vorname,
nachname: vorhanden.nachname,
anzeigename: vorhanden.anzeigename,
mail: vorhanden.mail,
abteilung: vorhanden.abteilung,
titel: vorhanden.titel,
aktiv: vorhanden.aktiv,
vorgesetzterScimId: vorhanden.vorgesetzterId,
});
if (uebergangen.length > 0) {
/*
* Uebergangene Pfade sind kein Fehler — Anbieter schicken Felder, die
* Laufzettel nicht fuehrt. Ins Protokoll des Prozesses gehoeren sie
* trotzdem: sonst sucht beim naechsten „warum steht das nicht drin“
* jemand an der falschen Stelle.
*/
console.info(`SCIM-PATCH: übergangene Pfade: ${uebergangen.join(", ")}`);
}
const person = await personAendern(zugang.quelle, id, eingabe);
return scimAntwort(personDarstellen(person, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function DELETE(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
try {
const { id } = await params;
await personDeaktivieren(zugang.quelle, id);
// 204 ohne Rumpf – und die Zeile bleibt, siehe Kopfkommentar.
return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } });
} catch (fehler) {
return alsScimAntwort(fehler as ScimFehler);
}
}
+61
View File
@@ -0,0 +1,61 @@
import { benutzerLesen, benutzernameAusFilter, listeDarstellen, personDarstellen } from "@/lib/scim";
import { alsScimAntwort, quelleAusAnfrage, rumpfLesen, scimAntwort, scimFehler } from "@/server/scim/handler";
import { personAnlegen, personenSuchen } from "@/server/services/scim";
/**
* SCIM: `/Users` (#029).
*
* `GET` mit dem Filter `userName eq "…"` ist die Frage, die ein Anbieter **vor
* jedem Anlegen** stellt. `POST` legt an.
*
* Dieser Handler prueft sein Token selbst — die Huelle unter `(app)` liegt hier
* nicht darueber, und der Proxy laesst `/api/scim` ohne Cookie durch
* (CLAUDE.md).
*/
export async function GET(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
try {
const filter = new URL(anfrage.url).searchParams.get("filter");
const gesucht = benutzernameAusFilter(filter);
if (gesucht === null) {
/*
* Ein Filter, den wir nicht verstehen, wird **abgewiesen** und nicht
* ignoriert. Ihn zu ignorieren hiesse, alle Personen zu liefern — und der
* Anbieter schloesse daraus, die gesuchte sei darunter. Er legte sie dann
* nicht an.
*/
return scimFehler(
400,
`Von diesem Endpunkt wird nur der Filter userName eq "…" unterstützt, nicht „${filter}“.`,
"invalidFilter",
);
}
const personen = await personenSuchen(zugang.quelle, gesucht);
return scimAntwort(listeDarstellen(personen, zugang.basis));
} catch (fehler) {
return alsScimAntwort(fehler);
}
}
export async function POST(anfrage: Request): Promise<Response> {
const zugang = await quelleAusAnfrage(anfrage);
if ("antwort" in zugang) return zugang.antwort;
const gelesen = await rumpfLesen(anfrage);
if ("antwort" in gelesen) return gelesen.antwort;
try {
const person = await personAnlegen(zugang.quelle, benutzerLesen(gelesen.rumpf));
// 201 mit `Location`, wie RFC 7644 es verlangt.
return scimAntwort(personDarstellen(person, zugang.basis), 201, {
Location: `${zugang.basis}/Users/${person.id}`,
});
} catch (fehler) {
return alsScimAntwort(fehler);
}
}