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);
}
}
+118 -53
View File
@@ -8,6 +8,7 @@ import {
GRAPH_ZUORDNUNG,
INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS,
QUELLEN_ART_TEXT,
ZUORDNUNGS_BESCHRIFTUNG,
ZUORDNUNGS_FELDER,
} from "@/lib/verzeichnis";
@@ -43,8 +44,16 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
* wuerde kein Mitarbeiter wiedererkannt. Der Dienst weist einen Wechsel
* ohnehin ab; hier gibt es ihn gar nicht erst zur Auswahl.
*/
const [art, setzeArt] = useState<"LDAP" | "GRAPH">(quelle?.art === "GRAPH" ? "GRAPH" : "LDAP");
const [art, setzeArt] = useState<"LDAP" | "GRAPH" | "SCIM">(quelle?.art ?? "LDAP");
const istGraph = art === "GRAPH";
/*
* SCIM ist die Art ohne Zugangsdaten: Laufzettel verbindet sich nicht, es wird
* beliefert (#029). Deshalb faellt hier alles weg, was zum Holen gehoert —
* Adresse, Geheimnis, Filter, Zeitplan und die Attributzuordnung. Letztere
* bestimmt bei einem Push der Anbieter, sie steht in RFC 7643.
*/
const istScim = art === "SCIM";
const istLdap = art === "LDAP";
return (
<form action={aktion} className="flex flex-col gap-4">
@@ -65,9 +74,9 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
<>
<input type="hidden" name="art" value={art} />
<p className="text-sm text-nebentext">
Art: <strong className="text-text">{istGraph ? "Microsoft Graph" : "LDAP"}</strong> — sie lässt
sich nicht ändern. Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein
Mitarbeiter wiedererkannt.
Art: <strong className="text-text">{QUELLEN_ART_TEXT[art]}</strong> — sie lässt sich nicht ändern.
Daran hängt, was die dauerhafte Kennung bedeutet; nach einem Wechsel würde kein Mitarbeiter
wiedererkannt.
</p>
</>
) : (
@@ -85,7 +94,14 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
titel: "Microsoft Graph",
text: "Entra ID (Microsoft 365). Für Häuser ohne lokales AD.",
},
] satisfies readonly { wert: "LDAP" | "GRAPH"; titel: string; text: string }[]
{
wert: "SCIM" as const,
titel: "SCIM (wird geliefert)",
text:
"Der Anbieter schickt Änderungen, sobald sie dort entstehen — authentik, Entra ID, Okta. " +
"Laufzettel holt dabei nichts ab und braucht keine Zugangsdaten.",
},
] satisfies readonly { wert: "LDAP" | "GRAPH" | "SCIM"; titel: string; text: string }[]
).map((wahl) => (
<label
key={wahl.wert}
@@ -162,7 +178,7 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
</>
) : null}
{!istGraph ? (
{istLdap ? (
<>
{/*
* Die Erklärung steht **außerhalb** des `label` und hängt über
@@ -269,29 +285,59 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
</>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abgleich alle … Minuten</span>
{istScim ? (
<>
{/*
* Das Intervall gilt fuer SCIM nicht — es wird nichts geholt. Es geht
* trotzdem mit, weil die Spalte einen Wert braucht; sichtbar waere es
* eine Einstellung, die nichts tut.
*/}
<input
type="hidden"
name="abgleichIntervallMinuten"
type="number"
min={INTERVALL_MINDESTENS}
max={INTERVALL_HOECHSTENS}
defaultValue={quelle?.abgleichIntervallMinuten ?? 60}
className={FELD}
value={quelle?.abgleichIntervallMinuten ?? 60}
/>
</label>
<label className="flex min-h-11 items-center gap-2 self-end text-sm text-nebentext md:min-h-10">
<input
type="checkbox"
name="aktiv"
value="1"
defaultChecked={quelle?.aktiv ?? false}
className={KAESTCHEN}
/>
Nach Zeitplan abgleichen
</label>
</div>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext md:min-h-10">
<input
type="checkbox"
name="aktiv"
value="1"
defaultChecked={quelle?.aktiv ?? false}
className={KAESTCHEN}
/>
Zustellungen annehmen
</label>
<p className="max-w-prose text-xs text-nebentext">
Ohne Haken antwortet der Endpunkt mit <span className="mono">401</span>, auch mit gültigem Token —
so lässt sich eine Quelle stilllegen, ohne das Token beim Anbieter zu löschen. Das Token selbst
erzeugst du nach dem Speichern an der Quelle.
</p>
</>
) : (
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abgleich alle … Minuten</span>
<input
name="abgleichIntervallMinuten"
type="number"
min={INTERVALL_MINDESTENS}
max={INTERVALL_HOECHSTENS}
defaultValue={quelle?.abgleichIntervallMinuten ?? 60}
className={FELD}
/>
</label>
<label className="flex min-h-11 items-center gap-2 self-end text-sm text-nebentext md:min-h-10">
<input
type="checkbox"
name="aktiv"
value="1"
defaultChecked={quelle?.aktiv ?? false}
className={KAESTCHEN}
/>
Nach Zeitplan abgleichen
</label>
</div>
)}
{/*
* Die Attributzuordnung ist einklappbar, aber vollständig da. Vorher stand
@@ -299,35 +345,54 @@ export function QuelleFormular({ quelle }: { quelle?: QuellenSicht }) {
* OpenLDAP anbindet, ändert einzelne Zeilen“, und genau das ging über die
* Oberfläche nicht (Reviewbefund zu #016).
*/}
<details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Attributzuordnung anpassen
</summary>
<p className="mt-2 max-w-prose text-xs text-nebentext">
Die Vorgaben sind die Attributnamen eines Active Directory. Ein OpenLDAP heißt an einigen Stellen
anders — dort ist die dauerhafte Kennung etwa <span className="mono">entryUUID</span>.
{istScim ? (
<p className="max-w-prose text-xs text-nebentext">
Eine Attributzuordnung gibt es hier nicht: bei SCIM bestimmt der Anbieter die Feldnamen, und sie
stehen in RFC 7643 fest. Abteilung und Titel kommen aus der Erweiterung{" "}
<span className="mono">enterprise:2.0:User</span>; eine OU kennt SCIM nicht, deshalb bleibt{" "}
<span className="mono">{"{{ou}}"}</span> bei dieser Art leer.
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{ZUORDNUNGS_FELDER.map((feld) => (
<label key={feld} className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">{ZUORDNUNGS_BESCHRIFTUNG[feld]}</span>
<input
name={`attr.${feld}`}
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]}
className={`${FELD} mono`}
/>
</label>
))}
</div>
<p className="mt-2 max-w-prose text-xs text-nebentext">
Die Kennung ist das eine Feld, das später nicht mehr geändert werden sollte: an ihr erkennt der
Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
</p>
</details>
) : null}
{/* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber oeffentliches TLS. */}
{!istGraph ? (
{/*
* Bei SCIM gar nicht gerendert und nicht nur verborgen: ein verstecktes
* Feld wird mitgeschickt, und dann staende in der Datenbank eine
* Zuordnung, die nie etwas bedeutet.
*/}
{istScim ? null : (
<details open={zuordnungOffen} onToggle={(e) => setzeZuordnungOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Attributzuordnung anpassen
</summary>
<p className="mt-2 max-w-prose text-xs text-nebentext">
Die Vorgaben sind die Attributnamen eines Active Directory. Ein OpenLDAP heißt an einigen Stellen
anders — dort ist die dauerhafte Kennung etwa <span className="mono">entryUUID</span>.
</p>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{ZUORDNUNGS_FELDER.map((feld) => (
<label key={feld} className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">{ZUORDNUNGS_BESCHRIFTUNG[feld]}</span>
<input
name={`attr.${feld}`}
defaultValue={quelle?.attributZuordnung[feld] ?? AD_ZUORDNUNG[feld]}
placeholder={(istGraph ? GRAPH_ZUORDNUNG : AD_ZUORDNUNG)[feld]}
className={`${FELD} mono`}
/>
</label>
))}
</div>
<p className="mt-2 max-w-prose text-xs text-nebentext">
Die Kennung ist das eine Feld, das später nicht mehr geändert werden sollte: an ihr erkennt der
Abgleich eine Person wieder. Ein anderer Wert legt beim nächsten Lauf alle neu an.
</p>
</details>
)}
{/*
* Ein eigenes CA-Zertifikat gilt nur fuer LDAP – Graph laeuft ueber
* oeffentliches TLS, und bei SCIM baut Laufzettel keine Verbindung auf.
*/}
{istLdap ? (
<details open={zertifikatOffen} onToggle={(e) => setzeZertifikatOffen(e.currentTarget.open)}>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Eigenes CA-Zertifikat hinterlegen
+181
View File
@@ -0,0 +1,181 @@
"use client";
import { useActionState, useState } from "react";
import { Check, Copy, KeyRound, ShieldAlert, Trash2 } from "lucide-react";
import {
scimTokenErzeugenAktion,
scimTokenWiderrufenAktion,
type TokenErgebnis,
} from "@/server/actions/verzeichnis";
/**
* Das SCIM-Token einer Quelle: erzeugen, einmal zeigen, widerrufen (#029).
*
* **Einmal zeigen ist die ganze Schwierigkeit.** Das Token steht in der
* Datenbank nur als argon2-Hash, es gibt also keinen zweiten Weg, es anzusehen —
* und das ist Absicht, denn es ist der einzige Mandantenbeweis, den eine
* Zustellung mitbringt. Wer es verliert, erzeugt ein neues; das alte gilt dann
* nicht mehr.
*
* Deshalb steht es hier vollständig und auswählbar da und nicht nur hinter einem
* Kopier-Knopf: ohne Recht auf die Zwischenablage wäre es sonst unerreichbar,
* und eine zweite Anzeige gibt es nicht.
*/
export function ScimToken({
quelleId,
vorhanden,
gesetztAm,
letzteZustellungAm,
}: {
quelleId: string;
vorhanden: boolean;
gesetztAm: Date | null;
letzteZustellungAm: Date | null;
}) {
const [erzeugt, erzeugen, erzeugtLaeuft] = useActionState<TokenErgebnis, FormData>(
scimTokenErzeugenAktion,
{},
);
const [widerrufen, widerrufenAktion, widerrufenLaeuft] = useActionState<TokenErgebnis, FormData>(
scimTokenWiderrufenAktion,
{},
);
const [kopiert, setzeKopiert] = useState(false);
const zeitpunkt = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
async function kopieren(wert: string) {
try {
await navigator.clipboard.writeText(wert);
setzeKopiert(true);
window.setTimeout(() => setzeKopiert(false), 2000);
} catch {
setzeKopiert(false);
}
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1 text-sm">
<p className="flex flex-wrap items-center gap-2">
<KeyRound className="size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
{vorhanden ? (
<span className="text-nebentext">
Ein Token gilt{gesetztAm ? <> seit {zeitpunkt.format(gesetztAm)}</> : null}.
</span>
) : (
<span className="text-nebentext">
Kein Token. Ohne eines antwortet der Endpunkt auf jede Zustellung mit{" "}
<span className="mono">401</span>.
</span>
)}
</p>
{/*
* „Letzte Zustellung“ sitzt hier, wo bei LDAP „Verbindung testen“ steht —
* es ist die Auskunft, die an deren Stelle tritt. Bei einem Push gibt es
* nichts zu testen: ob die Einrichtung stimmt, zeigt erst die erste
* Zustellung.
*/}
<p className="text-nebentext">
{letzteZustellungAm ? (
<>Letzte Zustellung am {zeitpunkt.format(letzteZustellungAm)}.</>
) : (
<>
Noch keine Zustellung angekommen. Trag das Token beim Anbieter ein und weise dort eine Person zu
— danach steht hier der Zeitpunkt.
</>
)}
</p>
</div>
{/*
* Das Token steht nur in dieser einen Antwort. Ein Neuladen der Seite
* nimmt es mit, und es kommt nicht wieder — deshalb der Hinweis daneben
* und nicht darunter.
*/}
{erzeugt.token ? (
<div className="flex flex-col gap-2 rounded-karte border border-akzent bg-akzent-flaeche p-3">
<p className="flex items-start gap-2 text-sm text-text">
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-akzent" strokeWidth={2} aria-hidden="true" />
<span>
Kopier es jetzt. Es ist nur hier zu sehen — gespeichert wird es als Hash, und ein zweites
Ansehen gibt es nicht.
</span>
</p>
<code className="mono rounded-karte bg-grund p-3 text-xs break-all text-text select-all">
{erzeugt.token}
</code>
<button
type="button"
onClick={() => void kopieren(erzeugt.token ?? "")}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-4 text-sm text-text md:min-h-10"
>
{kopiert ? (
<Check className="size-[18px] text-erledigt" strokeWidth={2} aria-hidden="true" />
) : (
<Copy className="size-[18px]" strokeWidth={2} aria-hidden="true" />
)}
{kopiert ? "Kopiert" : "Token kopieren"}
</button>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<form action={erzeugen}>
<input type="hidden" name="id" value={quelleId} />
<button
type="submit"
disabled={erzeugtLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille bg-akzent px-5 text-sm font-semibold text-auf-akzent disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<KeyRound className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{erzeugtLaeuft ? "Wird erzeugt …" : vorhanden ? "Neues Token erzeugen" : "Token erzeugen"}
</button>
</form>
{vorhanden ? (
<form action={widerrufenAktion}>
<input type="hidden" name="id" value={quelleId} />
<button
type="submit"
disabled={widerrufenLaeuft}
className="flex min-h-11 w-fit items-center gap-2 rounded-pille border border-linie px-5 text-sm text-nebentext hover:text-text disabled:pointer-events-none disabled:opacity-40 md:min-h-10"
>
<Trash2 className="size-[18px]" strokeWidth={2} aria-hidden="true" />
{widerrufenLaeuft ? "Wird widerrufen …" : "Token widerrufen"}
</button>
</form>
) : null}
</div>
{vorhanden ? (
<p className="max-w-prose text-xs text-nebentext">
Ein neues Token ersetzt das bestehende: es gibt genau eines je Quelle. Bis es beim Anbieter
eingetragen ist, kommen dessen Zustellungen nicht an — und werden dort als Fehler vermerkt, nicht
verworfen.
</p>
) : null}
{[erzeugt, widerrufen].map((ergebnis, nummer) => (
<div key={nummer}>
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="max-w-prose text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
</div>
))}
</div>
);
}
+324
View File
@@ -0,0 +1,324 @@
/**
* 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 };
}
+14
View File
@@ -118,6 +118,20 @@ export const ZUORDNUNGS_BESCHRIFTUNG: Record<keyof AttributZuordnung, string> =
*/
export const ATTRIBUT_MUSTER = /^[A-Za-z][A-Za-z0-9-]*$/u;
/**
* Wie eine Quellenart heisst, wo ein Mensch sie liest.
*
* Liegt in `src/lib`, weil beide Seiten sie brauchen: das Formular ist eine
* Client-Komponente, und die darf nichts aus `src/server/*` importieren
* (CLAUDE.md). „LDAP“ und „SCIM“ bleiben als Lehnwoerter stehen — so heissen sie
* im Haus und in den Einstellungen der Anbieter.
*/
export const QUELLEN_ART_TEXT: Record<"LDAP" | "GRAPH" | "SCIM", string> = {
LDAP: "LDAP",
GRAPH: "Microsoft Graph",
SCIM: "SCIM",
};
/**
* Liest eine gespeicherte Zuordnung aus dem Json-Feld.
*
+8
View File
@@ -25,6 +25,14 @@ const OEFFENTLICH: readonly string[] = [
"/api/auth",
// Healthcheck fuer Docker und den Reverse Proxy.
"/api/gesundheit",
/*
* Der SCIM-Endpunkt (#029). Ein Identitaetsanbieter hat kein Sitzungscookie —
* er weist sich mit einem Bearer-Token aus, und **jeder** Handler darunter
* prueft es selbst, samt der Quelle und damit des Kunden. Ohne diesen Eintrag
* bekaeme der Anbieter 401 vom Proxy, bevor sein Token ueberhaupt gelesen
* wird — und die Meldung waere „nicht angemeldet“ statt „Token ungueltig“.
*/
"/api/scim",
"/manifest.webmanifest",
"/icon.svg",
];
+74 -7
View File
@@ -14,6 +14,7 @@ import { z } from "@/lib/validierung";
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich";
import { scimTokenErzeugen, scimTokenWiderrufen } from "@/server/services/scim";
import {
VerzeichnisFehler,
quelleAnlegen,
@@ -95,8 +96,8 @@ function zuordnungAusFormular(formular: FormData) {
*/
const GemeinsamSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
art: z.enum(["LDAP", "GRAPH"], {
message: "Bitte die Art der Quelle wählen: LDAP oder Microsoft Graph.",
art: z.enum(["LDAP", "GRAPH", "SCIM"], {
message: "Bitte die Art der Quelle wählen: LDAP, Microsoft Graph oder SCIM.",
}),
caZertifikatPem: z.string().trim().optional(),
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
@@ -130,9 +131,16 @@ const GraphSchema = z.object({
/** Die artabhaengigen Felder aus dem Formular, geprueft. */
function artFelder(
art: "LDAP" | "GRAPH",
art: "LDAP" | "GRAPH" | "SCIM",
formular: FormData,
): { readonly werte: Record<string, unknown> } | { readonly fehler: string } {
/*
* **SCIM hat keine.** Eine SCIM-Quelle braucht einen Namen und sonst nichts:
* Laufzettel verbindet sich nicht, es wird beliefert, und das Token entsteht
* erst danach (#029).
*/
if (art === "SCIM") return { werte: {} };
if (art === "LDAP") {
const gelesen = LdapSchema.safeParse({
url: text(formular, "url"),
@@ -183,9 +191,11 @@ export async function quelleAnlegenAktion(
const geheimnis =
eingabe.data.art === "LDAP"
? { bindPasswort: text(formular, "bindPasswort") }
: { clientSecret: text(formular, "clientSecret") };
: eingabe.data.art === "GRAPH"
? { clientSecret: text(formular, "clientSecret") }
: {};
const geheimnisWert = Object.values(geheimnis)[0];
if (!geheimnisWert) {
if (eingabe.data.art !== "SCIM" && !geheimnisWert) {
return {
fehler:
eingabe.data.art === "LDAP"
@@ -203,7 +213,12 @@ export async function quelleAnlegenAktion(
zugang.benutzer,
);
revalidatePath("/admin/verzeichnis");
return { erfolg: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.` };
return {
erfolg:
eingabe.data.art === "SCIM"
? `„${quelle.name}“ ist angelegt. Erzeug als Nächstes das Token und trag es beim Anbieter ein.`
: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.`,
};
} catch (fehler) {
return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden.");
}
@@ -300,6 +315,58 @@ export async function quelleLoeschenAktion(
}
}
export type TokenErgebnis = AktionsErgebnis & {
/**
* Das Token – **genau dieses eine Mal**.
*
* Es steht in der Datenbank nur als argon2-Hash, es gibt also keinen zweiten
* Weg, es anzusehen. Wer es verliert, erzeugt ein neues; das alte gilt dann
* nicht mehr.
*/
readonly token?: string;
};
export async function scimTokenErzeugenAktion(
_vorher: TokenErgebnis,
formular: FormData,
): Promise<TokenErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." };
try {
const { token } = await scimTokenErzeugen(id, zugang.benutzer);
revalidatePath("/admin/verzeichnis");
return {
token,
erfolg:
"Das Token gilt ab jetzt. Kopier es in die Einstellungen des Anbieters — danach ist es " +
"nicht mehr ansehbar.",
};
} catch (fehler) {
return alsErgebnis(fehler, "Das Token konnte nicht erzeugt werden.");
}
}
export async function scimTokenWiderrufenAktion(
_vorher: TokenErgebnis,
formular: FormData,
): Promise<TokenErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const id = text(formular, "id");
if (!id) return { fehler: "Es fehlt die Quelle." };
try {
await scimTokenWiderrufen(id, zugang.benutzer);
revalidatePath("/admin/verzeichnis");
return { erfolg: "Das Token ist widerrufen. Die Quelle nimmt ab jetzt nichts mehr an." };
} catch (fehler) {
return alsErgebnis(fehler, "Das Token konnte nicht widerrufen werden.");
}
}
export type TestErgebnis = AktionsErgebnis & {
readonly treffer?: readonly GemappterEintrag[];
readonly gesamtGelesen?: number;
@@ -326,7 +393,7 @@ export async function verbindungTestenAktion(
const ergebnis = await verbindungTesten(id, zugang.benutzer);
return {
erfolg:
ergebnis.treffer.length === 0
ergebnis.treffer.length === 0 && ergebnis.gesamtGelesen === 0
? "Die Verbindung steht, aber der Filter findet niemanden."
: ergebnis.treffer.length === 0
? /*
+115
View File
@@ -0,0 +1,115 @@
import type { Verzeichnisquelle } from "@/generated/prisma/client";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { SCIM_TYP, fehlerDarstellen } from "@/lib/scim";
import { ScimFehler, quelleZuToken } from "@/server/services/scim";
/**
* Was jeder SCIM-Handler gemeinsam braucht (#029).
*
* **Jeder Handler prüft selbst** — die Hülle unter `(app)` liegt hier nicht
* darüber, und der Proxy lässt `/api/scim` absichtlich ohne Cookie durch
* (CLAUDE.md). Diese Datei ist die eine Stelle, an der das Token zur Quelle
* wird; jeder Handler beginnt mit `quelleAusAnfrage`.
*/
/** Jede Antwort trägt `application/scim+json` — nicht `application/json`. */
export function scimAntwort(daten: unknown, status = 200, kopfzeilen: HeadersInit = {}): Response {
return new Response(JSON.stringify(daten), {
status,
headers: {
"Content-Type": SCIM_TYP,
// Nichts zwischenspeichern: eine Zuweisung ist kein Inhalt, der altert.
"Cache-Control": "no-store",
...kopfzeilen,
},
});
}
export function scimFehler(status: number, detail: string, scimType?: string): Response {
const kopfzeilen: HeadersInit =
status === 401
? /*
* `WWW-Authenticate` bei 401, weil RFC 7235 es verlangt und manche
* Anbieter daran erkennen, dass ein Token erwartet wird — ohne die
* Kopfzeile melden sie „unbekannter Fehler“ statt „Token fehlt“.
*/
{ "WWW-Authenticate": 'Bearer realm="Laufzettel SCIM"' }
: {};
return scimAntwort(fehlerDarstellen(status, detail, scimType), status, kopfzeilen);
}
/** Aus einem geworfenen Fehler eine SCIM-Antwort. */
export function alsScimAntwort(fehler: unknown): Response {
if (fehler instanceof ScimFehler) {
return scimFehler(fehler.status, fehler.message, fehler.scimType);
}
/*
* Alles andere ist ein Fehler bei uns, nicht beim Anbieter. Die Meldung geht
* ins Protokoll des Prozesses und **nicht** in die Antwort: eine
* Datenbankmeldung nennt Host, Port und Benutzernamen (#039).
*/
console.error(
"SCIM-Anfrage fehlgeschlagen:",
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : String(fehler),
);
return scimFehler(500, "Unerwarteter Fehler. Einzelheiten stehen im Protokoll des Dienstes.");
}
/**
* Das Bearer-Token aus der Anfrage.
*
* Verglichen wird das Schema ohne Rücksicht auf Groß- und Kleinschreibung: RFC
* 7235 schreibt das so vor, und die Anbieter schicken `Bearer` wie `bearer`.
*/
export function tokenAus(anfrage: Request): string | undefined {
const kopf = anfrage.headers.get("authorization")?.trim();
if (!kopf) return undefined;
const trenner = kopf.indexOf(" ");
if (trenner < 0) return undefined;
if (kopf.slice(0, trenner).toLowerCase() !== "bearer") return undefined;
return kopf.slice(trenner + 1).trim() || undefined;
}
export type ScimZugang =
{ readonly quelle: Verzeichnisquelle; readonly basis: string } | { readonly antwort: Response };
/**
* Die Quelle zu dieser Anfrage – oder eine fertige 401.
*
* **Dieselbe Antwort für „kein Token“ und „falsches Token“.** Die
* Unterscheidung wäre eine Auskunft darüber, ob ein geratenes Token die richtige
* Form hat.
*/
export async function quelleAusAnfrage(anfrage: Request): Promise<ScimZugang> {
const token = tokenAus(anfrage);
if (!token) {
return { antwort: scimFehler(401, "Dieser Endpunkt verlangt ein Bearer-Token.") };
}
const quelle = await quelleZuToken(token);
if (!quelle) {
return { antwort: scimFehler(401, "Das Token gilt nicht (mehr).") };
}
/*
* `meta.location` muss eine **absolute** Adresse sein. Gebaut wird sie über
* `oeffentlicheAdresse`, damit hinter einem Reverse Proxy die öffentliche
* Adresse herauskommt und nicht `127.0.0.1:3000` — ein Anbieter, der dieser
* Adresse folgt, landete sonst bei sich selbst.
*/
const basis = oeffentlicheAdresse("/api/scim/v2", new URL(anfrage.url).origin)
.toString()
.replace(/\/+$/, "");
return { quelle, basis };
}
/** Den Rumpf als JSON lesen – oder eine fertige 400. */
export async function rumpfLesen(
anfrage: Request,
): Promise<{ readonly rumpf: unknown } | { readonly antwort: Response }> {
try {
return { rumpf: await anfrage.json() };
} catch {
return { antwort: scimFehler(400, "Der Rumpf ist kein gültiges JSON.", "invalidSyntax") };
}
}
+8
View File
@@ -105,11 +105,19 @@ export async function faelligeQuellen(
* `psql` und eine Migration schon.
*/
const eigene = await eigeneOrganisation();
/*
* **Eine SCIM-Quelle wird nie abgeglichen** (#029). Bei ihr holt Laufzettel
* nicht, sie bekommt geschickt — und der Zeitplan haette jede Minute versucht
* zu lesen, waere an der Weiche gescheitert und haette „kann noch nicht
* abgeglichen werden“ an die Quelle geschrieben. Dauerhaft ein Fehler, der
* keiner ist, und `letzterAbgleichFehler` ist in der Oberflaeche sichtbar.
*/
return prisma.$queryRaw<{ id: string; name: string }[]>`
SELECT "id", "name"
FROM "Verzeichnisquelle"
WHERE "aktiv" = true
AND "kundeId" = ${eigene.id}
AND "art" <> 'SCIM'
AND ("letzterAbgleichAm" IS NULL
OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt})
ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC
+478
View File
@@ -0,0 +1,478 @@
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);
}
+56 -3
View File
@@ -76,6 +76,10 @@ export type QuellenSicht = {
readonly tenantId: string | null;
readonly clientId: string | null;
readonly clientSecretVorhanden: boolean;
/** Nur bei `art = SCIM`. Das Token **nie**, nur ob eines gilt und seit wann. */
readonly scimTokenVorhanden: boolean;
readonly scimTokenGesetztAm: Date | null;
readonly letzteScimZustellungAm: Date | null;
readonly attributZuordnung: AttributZuordnung;
readonly caZertifikatVorhanden: boolean;
readonly abgleichIntervallMinuten: number;
@@ -187,6 +191,15 @@ function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
* nicht in eine Antwort schafft, kann auch nicht versehentlich hinausgehen.
*/
clientSecretVorhanden: Boolean(zeile.clientSecretVerschluesselt?.trim()),
/*
* Dasselbe fuer das SCIM-Token (#029), und hier waere es noch teurer: es ist
* der einzige Mandantenbeweis, den ein Push mitbringt. Es steht als Hash in
* der Datenbank und geht aus dem Dienst genau einmal heraus — in der
* Antwort von `scimTokenErzeugen`, nie in einer Sicht.
*/
scimTokenVorhanden: Boolean(zeile.scimTokenHash?.trim()),
scimTokenGesetztAm: zeile.scimTokenGesetztAm,
letzteScimZustellungAm: zeile.letzteScimZustellungAm,
attributZuordnung: zuordnungLesen(zeile.attributZuordnung, zuordnungsArt(zeile.art)),
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
@@ -254,9 +267,34 @@ function feldernJeArt(art: QuellenArt, eingabe: NeueQuelle) {
};
}
throw new VerzeichnisFehler(
`Quellen der Art ${art} lassen sich noch nicht anlegen. Der SCIM-Endpunkt kommt mit #029.`,
);
if (art === "SCIM") {
/*
* **Eine SCIM-Quelle hat keine Zugangsdaten — sie hat ein Token, und das
* entsteht danach.** Erst steht die Quelle, dann erzeugt der Administrator
* das Token und traegt es beim Anbieter ein. Eine Quelle ohne Token nimmt
* nichts an und antwortet mit 401; das ist der richtige Anfangszustand.
*/
if (
eingabe.url?.trim() ||
eingabe.bindDn?.trim() ||
eingabe.basisDn?.trim() ||
eingabe.tenantId?.trim() ||
eingabe.clientId?.trim()
) {
throw new VerzeichnisFehler(
"Eine SCIM-Quelle hat weder LDAP- noch Graph-Zugangsdaten: Laufzettel verbindet sich nicht, " +
"sondern wird beliefert. Sie braucht nur einen Namen — das Token kommt danach.",
);
}
return {};
}
/*
* Unerreichbar, solange `QuellenArt` drei Werte hat — und genau deshalb hier:
* eine vierte Art faellt beim Uebersetzen auf und nicht im Betrieb.
*/
const unbekannt: never = art;
throw new VerzeichnisFehler(`Quellen der Art ${String(unbekannt)} lassen sich nicht anlegen.`);
}
export async function quelleAnlegen(
@@ -564,6 +602,21 @@ export async function verbindungTesten(
await nurIntern();
rechtPruefen(benutzer);
const quelle = await quelleZeileHolen(quelleId);
/*
* **Bei SCIM gibt es nichts zu testen, und das ist keine Lücke** (#029):
* Laufzettel baut keine Verbindung auf, sondern nimmt eine an. Ob sie steht,
* zeigt die erste Zustellung — deshalb steht in der Oberfläche „Letzte
* Zustellung“ dort, wo bei LDAP der Verbindungstest sitzt.
*/
if (quelle.art === "SCIM") {
throw new VerzeichnisFehler(
`„${quelle.name}“ ist eine SCIM-Quelle: es gibt keine Verbindung, die sich von hier aus ` +
"testen ließe. Trag das Token beim Anbieter ein und lass ihn eine Zuweisung schicken — " +
"danach steht hier, wann die letzte angekommen ist.",
);
}
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
/*
+11 -7
View File
@@ -52,14 +52,18 @@ export async function eintraegeDerQuelleStroemen(
}
/*
* `SCIM` steht im Enum, weil #029 dieselbe Weiche braucht — behandelt wird es
* dort. **Bis dahin eine Meldung und kein stilles Nichts:** ein Abgleich, der
* null Einträge liest und `ERFOLG` meldet, würde über die Abschaltregel in
* `abgleichAusfuehren` die ganze Belegschaft deaktivieren. Genau dieser Fall
* hat in #016 einen Reviewbefund gekostet.
* **Eine SCIM-Quelle wird nicht gelesen, sie liefert** (#029) — es gibt hier
* nichts abzuholen, und das bleibt so. Trotzdem eine Meldung und kein stilles
* Nichts: ein Abgleich, der null Einträge liest und `ERFOLG` meldet, würde
* über die Abschaltregel in `abgleichAusfuehren` die ganze Belegschaft
* deaktivieren. Genau dieser Fall hat in #016 einen Reviewbefund gekostet.
*
* Der Zeitplan kommt hier nicht an: `faelligeQuellen` nimmt SCIM-Quellen
* nicht auf. Diese Stelle fängt den Weg über „Jetzt abgleichen“.
*/
throw new VerzeichnisFehler(
`Quellen der Art ${quelle.art} können noch nicht abgeglichen werden. ` +
"Der SCIM-Endpunkt kommt mit einem eigenen Meilenstein (#029).",
`„${quelle.name}“ ist eine SCIM-Quelle: Laufzettel holt dort nichts ab, sondern wird ` +
"beliefert. Ein Abgleich gibt es für sie nicht — der Anbieter schickt Änderungen, sobald " +
"sie dort entstehen.",
);
}
+4 -3
View File
@@ -64,9 +64,10 @@ export function ldapVorgabeAus(quelle: Verzeichnisquelle): {
/**
* Welche Zuordnungs-Vorgaben fuer diese Art gelten.
*
* `SCIM` hat noch keine (#029) und faellt auf die LDAP-Namen zurueck — die Art
* wird vom Dienst ohnehin abgewiesen, und ein Wurf an dieser Stelle machte jede
* Liste unbenutzbar, in der eine SCIM-Zeile steht.
* `SCIM` hat keine und faellt auf die LDAP-Namen zurueck (#029): bei einem Push
* bestimmt der Anbieter die Feldnamen, sie stehen in RFC 7643 und sind nicht
* einstellbar. Ein Wurf an dieser Stelle machte jede Liste unbenutzbar, in der
* eine SCIM-Zeile steht.
*/
export function zuordnungsArt(art: QuellenArt): "LDAP" | "GRAPH" {
return art === "GRAPH" ? "GRAPH" : "LDAP";