Die Zuordnung lief allein ueber vorgesetzterExterneId, und die ist nur
innerhalb einer Verzeichnisquelle eindeutig: eine von Hand angelegte Person
konnte keinen Vorgesetzten haben, und {{vorgesetzter_sam}} blieb im
Onboarding-Befehl leer -- genau dort, wo er gebraucht wird.
Entschieden fuer eine eigene Selbstbeziehung vorgesetzterId. Die befuerchteten
"zwei Wege zur selben Auskunft" entstehen nicht, weil der Abgleich sie
mitschreibt: vorgesetzterExterneId ist jetzt wie abteilung nur der rohe Wert
des Verzeichnisses, gelesen wird allein vorgesetzterId. Die Alternative haette
manuellen Personen eine erfundene externeId geben muessen, und externeId heisst
"objectGUID aus dem Verzeichnis".
Liefert das Verzeichnis keinen manager, bleibt eine von Hand gesetzte Zuordnung
stehen -- geleert wird nur, was das Verzeichnis selbst gesetzt hatte.
Die Migration fuehrt bestehende Daten nach. Ohne das waere die Auskunft nach
dem Einspielen bei jeder Verzeichnis-Person still leer gewesen, bis der
naechste Abgleich laeuft. An einem nachgebauten Vor-Zustand gefahren.
Drei Grenzen, drei Stellen: der zusammengesetzte Fremdschluessel haelt die
Mandantengrenze, ein CHECK die kuerzeste Schleife, und der Dienst laeuft die
Kette hinauf fuer laengere.
Im gebauten Stand durchgeklickt: speichern, neu laden, Wert steht, entfernen,
390 px ohne horizontales Scrollen. Gegenprobe am Abgleich gefahren.
Closes #31
355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
||
import { verschluesseln } from "@/lib/geheimnisse";
|
||
import { istTagCode } from "@/lib/tagcode";
|
||
import { AD_ZUORDNUNG } from "@/lib/verzeichnis";
|
||
import { STANDARD_FILTER } from "@/server/ldap/zuordnung";
|
||
import { GeraeteTyp, Herkunft } from "../../src/generated/prisma/enums.js";
|
||
import type { PrismaClient } from "../../src/generated/prisma/client.js";
|
||
|
||
/**
|
||
* Beispielbestand: Abteilungen, Personen, Geraete, Tags (#017).
|
||
*
|
||
* Damit ist die Anwendung nach dem Seed nicht nur bedienbar, sondern vorfuehrbar:
|
||
* ein Lauf laesst sich starten, ein Etikett drucken, ein QR-Code scannen. Alles
|
||
* gehoert der eigenen Organisation; im Modus DIENSTLEISTUNG kommen die
|
||
* Demo-Kunden dazu (siehe `dienstleistung.ts`).
|
||
*
|
||
* **Idempotent ueber sprechende Schluessel**, nicht ueber Kennungen: Abteilungen
|
||
* ueber `(kundeId, name)`, Personen ueber den UPN, Geraete ueber den `tagCode`.
|
||
* Gibt es die Zeile schon, bleibt sie, wie sie ist — wer im Betrieb etwas
|
||
* geaendert hat, bekommt es nicht beim naechsten Seed-Lauf zurueckgesetzt.
|
||
*/
|
||
|
||
export type AbteilungsVorgabe = {
|
||
readonly name: string;
|
||
readonly zielOu: string;
|
||
readonly standardGruppen: readonly string[];
|
||
};
|
||
|
||
/**
|
||
* Nur die Buchhaltung ist im Auftrag ausbuchstabiert (OU und Gruppen); Vertrieb
|
||
* und IT bekommen dieselbe Form. Eine Abteilung ohne Ziel-OU waere im
|
||
* Onboarding-Lauf ein leeres `-Path` im `New-ADUser`-Befehl — genau die Stelle,
|
||
* an der man den Wert nicht selbst zusammenraten will.
|
||
*/
|
||
export const ABTEILUNGEN: readonly AbteilungsVorgabe[] = [
|
||
{
|
||
name: "Buchhaltung",
|
||
zielOu: "OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local",
|
||
standardGruppen: ["G-Buchhaltung", "G-Alle"],
|
||
},
|
||
{
|
||
name: "Vertrieb",
|
||
zielOu: "OU=Vertrieb,OU=Benutzer,DC=firma,DC=local",
|
||
standardGruppen: ["G-Vertrieb", "G-Alle"],
|
||
},
|
||
{
|
||
name: "IT",
|
||
zielOu: "OU=IT,OU=Benutzer,DC=firma,DC=local",
|
||
standardGruppen: ["G-IT", "G-Alle"],
|
||
},
|
||
];
|
||
|
||
export type PersonenVorgabe = {
|
||
readonly vorname: string;
|
||
readonly nachname: string;
|
||
readonly sam: string;
|
||
readonly upn: string;
|
||
readonly abteilung: string;
|
||
readonly titel: string;
|
||
/** UPN des Vorgesetzten – aufgeloest, nachdem alle Personen stehen. */
|
||
readonly vorgesetzterUpn?: string;
|
||
};
|
||
|
||
/**
|
||
* Fuenf Personen, von Hand angelegt (`Herkunft.MANUELL`) — so, wie sie in einem
|
||
* Haus ohne Verzeichnis entstehen.
|
||
*
|
||
* Maria Huber steht so im Auftrag und taucht in den Abnahmekriterien wieder auf.
|
||
* Ihre Vorgesetzte Petra Klein ist deshalb ebenfalls dabei — **und die Zuordnung
|
||
* wird gespeichert**, seit `vorgesetzterId` es erlaubt (#020). Vorher ging das
|
||
* nicht: die Auskunft lief allein ueber `vorgesetzterExterneId`, und die zeigt
|
||
* auf die `externeId` derselben Verzeichnisquelle, die eine von Hand angelegte
|
||
* Person nicht hat.
|
||
*/
|
||
export const PERSONEN: readonly PersonenVorgabe[] = [
|
||
{
|
||
vorname: "Maria",
|
||
nachname: "Huber",
|
||
sam: "mhuber",
|
||
upn: "m.huber@firma.example",
|
||
abteilung: "Buchhaltung",
|
||
titel: "Sachbearbeiterin Rechnungsprüfung",
|
||
vorgesetzterUpn: "p.klein@firma.example",
|
||
},
|
||
{
|
||
vorname: "Petra",
|
||
nachname: "Klein",
|
||
sam: "pklein",
|
||
upn: "p.klein@firma.example",
|
||
abteilung: "Buchhaltung",
|
||
titel: "Leiterin Buchhaltung",
|
||
},
|
||
{
|
||
vorname: "Thomas",
|
||
nachname: "Berger",
|
||
sam: "tberger",
|
||
upn: "t.berger@firma.example",
|
||
abteilung: "Vertrieb",
|
||
titel: "Vertriebsleiter",
|
||
},
|
||
{
|
||
vorname: "Sabine",
|
||
nachname: "Fischer",
|
||
sam: "sfischer",
|
||
upn: "s.fischer@firma.example",
|
||
abteilung: "Vertrieb",
|
||
titel: "Vertriebsinnendienst",
|
||
},
|
||
{
|
||
vorname: "Lukas",
|
||
nachname: "Mayr",
|
||
sam: "lmayr",
|
||
upn: "l.mayr@firma.example",
|
||
abteilung: "IT",
|
||
titel: "Systemadministrator",
|
||
},
|
||
];
|
||
|
||
/** Die beiden Geraete aus dem Auftrag, samt der Tag-Codes von den Aufklebern. */
|
||
export const PC_TAG = "LZ-7K3F9Q";
|
||
export const NB_TAG = "LZ-M2XH4T";
|
||
|
||
/**
|
||
* Fuenf vorgedruckte, noch nicht zugeordnete Tags.
|
||
*
|
||
* Feste Codes und keine gezogenen: ein Seed, der jedes Mal fuenf neue Codes
|
||
* zieht, ist nicht idempotent — beim dritten Lauf laegen fuenfzehn Aufkleber im
|
||
* Stapel. Die Codes stehen im Alphabet ohne I, O, 0 und 1, damit sie sich von
|
||
* einem Etikett ablesen lassen.
|
||
*/
|
||
export const OFFENE_TAGS: readonly string[] = [
|
||
"LZ-4B7NCD",
|
||
"LZ-9TQK2M",
|
||
"LZ-R5HXJW",
|
||
"LZ-P3VZ8L",
|
||
"LZ-K6DYFN",
|
||
];
|
||
|
||
export type StammdatenErgebnis = {
|
||
readonly abteilungen: number;
|
||
readonly personen: number;
|
||
readonly geraete: number;
|
||
readonly offeneTags: number;
|
||
};
|
||
|
||
/**
|
||
* Legt einen Tag an, wenn es ihn noch nicht gibt.
|
||
*
|
||
* `TagCodeVergabe` und `Geraet` werden gemeinsam geschrieben: ein Geraet mit
|
||
* einem Code, der nicht im Vergabebuch steht, waere nach dem Loeschen wieder
|
||
* frei — und ein alter Aufkleber im Regal zeigte auf ein fremdes Objekt.
|
||
*
|
||
* Gefragt werden deshalb **beide Buecher**, so wie es `freienTagCodeZiehen` im
|
||
* Geraetedienst tut: ein Code, der nur in einem von beiden steht, ist trotzdem
|
||
* vergeben. Vorher fragte der Seed nur nach dem Geraet — wer den Beispiel-PC
|
||
* endgueltig geloescht hatte (die eine erlaubte Ausnahme, siehe CLAUDE.md), bekam
|
||
* beim naechsten Seed-Lauf denselben Code auf einem neuen Geraet, und der alte
|
||
* Aufkleber im Regal zeigte auf ein fremdes Objekt (Reviewbefund zu #017).
|
||
*/
|
||
export async function geraetSaeen(
|
||
db: PrismaClient,
|
||
tagCode: string,
|
||
daten: {
|
||
readonly kundeId: string;
|
||
readonly typ: GeraeteTyp;
|
||
readonly hostname?: string;
|
||
readonly mitarbeiterId?: string | null;
|
||
readonly modell?: string;
|
||
readonly seriennummer?: string;
|
||
readonly registriert: boolean;
|
||
},
|
||
): Promise<boolean> {
|
||
if (!istTagCode(tagCode)) {
|
||
throw new Error(`„${tagCode}“ hat nicht die Form eines Tag-Codes.`);
|
||
}
|
||
const [vorhanden, vergeben] = await Promise.all([
|
||
db.geraet.findUnique({ where: { tagCode } }),
|
||
db.tagCodeVergabe.findUnique({ where: { tagCode } }),
|
||
]);
|
||
if (vorhanden) return false;
|
||
if (vergeben) {
|
||
// Verbrannt: der Code war schon einmal vergeben, das Geraet dazu gibt es
|
||
// nicht mehr. Er kommt nicht zurueck – auch nicht als Beispieldatensatz.
|
||
console.log(` Tag ${tagCode}: verbrannt, wird nicht neu vergeben`);
|
||
return false;
|
||
}
|
||
|
||
await db.$transaction(async (tx) => {
|
||
await tx.tagCodeVergabe.create({ data: { tagCode } });
|
||
await tx.geraet.create({
|
||
data: {
|
||
kundeId: daten.kundeId,
|
||
tagCode,
|
||
typ: daten.typ,
|
||
hostname: daten.hostname ?? null,
|
||
mitarbeiterId: daten.mitarbeiterId ?? null,
|
||
modell: daten.modell ?? null,
|
||
seriennummer: daten.seriennummer ?? null,
|
||
// Ein Tag ohne `registriertAm` ist ein vorgedruckter Aufkleber, der auf
|
||
// sein Geraet wartet; die Datenbank laesst ihn deshalb ohne Namen zu.
|
||
registriertAm: daten.registriert ? new Date() : null,
|
||
},
|
||
});
|
||
});
|
||
return true;
|
||
}
|
||
|
||
/** Abteilungen, Personen, Geraete und Tags fuer einen Kunden. */
|
||
export async function stammdatenSaeen(
|
||
db: PrismaClient,
|
||
kundeId: string,
|
||
optionen: { readonly hostnamePraefix?: string; readonly mitOffenenTags?: boolean } = {},
|
||
): Promise<StammdatenErgebnis> {
|
||
let abteilungen = 0;
|
||
for (const vorgabe of ABTEILUNGEN) {
|
||
// Erst nachsehen, dann anlegen: ein `upsert` mit leerem `update` waere
|
||
// kuerzer, aber der Rueckgabewert sagt nicht, ob etwas entstanden ist — und
|
||
// der Seed soll am Ende ehrlich zaehlen, was er getan hat.
|
||
const vorhanden = await db.abteilung.findUnique({
|
||
where: { kundeId_name: { kundeId, name: vorgabe.name } },
|
||
});
|
||
if (vorhanden) continue;
|
||
await db.abteilung.create({
|
||
data: {
|
||
kundeId,
|
||
name: vorgabe.name,
|
||
zielOu: vorgabe.zielOu,
|
||
standardGruppen: [...vorgabe.standardGruppen],
|
||
mailDomain: "firma.example",
|
||
},
|
||
});
|
||
abteilungen += 1;
|
||
}
|
||
|
||
let personen = 0;
|
||
const nachUpn = new Map<string, string>();
|
||
/** Nur diese bekommen eine Vorgesetzten-Zuordnung – siehe unten. */
|
||
const neuAngelegt = new Set<string>();
|
||
for (const vorgabe of PERSONEN) {
|
||
const vorhanden = await db.mitarbeiter.findFirst({ where: { kundeId, upn: vorgabe.upn } });
|
||
if (vorhanden) {
|
||
nachUpn.set(vorgabe.upn, vorhanden.id);
|
||
continue;
|
||
}
|
||
const angelegt = await db.mitarbeiter.create({
|
||
data: {
|
||
kundeId,
|
||
vorname: vorgabe.vorname,
|
||
nachname: vorgabe.nachname,
|
||
anzeigename: `${vorgabe.nachname}, ${vorgabe.vorname}`,
|
||
mail: vorgabe.upn,
|
||
upn: vorgabe.upn,
|
||
sam: vorgabe.sam,
|
||
abteilung: vorgabe.abteilung,
|
||
titel: vorgabe.titel,
|
||
herkunft: Herkunft.MANUELL,
|
||
aktiv: true,
|
||
},
|
||
});
|
||
nachUpn.set(vorgabe.upn, angelegt.id);
|
||
neuAngelegt.add(vorgabe.upn);
|
||
personen += 1;
|
||
}
|
||
|
||
/*
|
||
* Die Vorgesetzten erst jetzt, wenn alle Personen stehen: eine Zuordnung
|
||
* zeigt auf eine Zeile, die beim Anlegen der ersten noch nicht da war.
|
||
*
|
||
* Nur fuer Personen, die **dieser Lauf** angelegt hat. Eine leere Zuordnung
|
||
* sagt nicht, ob sie noch nie gesetzt oder im Betrieb entfernt wurde — und ein
|
||
* zweiter Seed-Lauf darf nichts zurueckdrehen. Dieselbe Regel wie fuer alles
|
||
* andere hier.
|
||
*/
|
||
for (const vorgabe of PERSONEN) {
|
||
if (!vorgabe.vorgesetzterUpn || !neuAngelegt.has(vorgabe.upn)) continue;
|
||
const personId = nachUpn.get(vorgabe.upn);
|
||
const vorgesetzterId = nachUpn.get(vorgabe.vorgesetzterUpn);
|
||
if (!personId || !vorgesetzterId) continue;
|
||
await db.mitarbeiter.update({ where: { id: personId }, data: { vorgesetzterId } });
|
||
}
|
||
|
||
// Im Modus DIENSTLEISTUNG traegt der Hostname das Kundenkuerzel – dieselbe
|
||
// Regel, die auch die Namensvorschlaege benutzen (`{kundenkuerzel}-PC-{nnn}`).
|
||
const praefix = optionen.hostnamePraefix ? `${optionen.hostnamePraefix}-` : "";
|
||
let geraete = 0;
|
||
if (
|
||
await geraetSaeen(db, PC_TAG, {
|
||
kundeId,
|
||
typ: GeraeteTyp.PC,
|
||
hostname: `${praefix}PC-001`,
|
||
// Ueber die Konstante und nicht ueber die Zeichenkette: wer den UPN in
|
||
// PERSONEN aendert, verlöre sonst still die Besitzerin von PC-001.
|
||
mitarbeiterId: nachUpn.get(PERSONEN[0]?.upn ?? "") ?? null,
|
||
modell: "Dell OptiPlex 7010",
|
||
registriert: true,
|
||
})
|
||
) {
|
||
geraete += 1;
|
||
}
|
||
if (
|
||
await geraetSaeen(db, NB_TAG, {
|
||
kundeId,
|
||
typ: GeraeteTyp.NOTEBOOK,
|
||
hostname: `${praefix}NB-001`,
|
||
modell: "Lenovo ThinkPad T14",
|
||
registriert: true,
|
||
})
|
||
) {
|
||
geraete += 1;
|
||
}
|
||
|
||
let offeneTags = 0;
|
||
if (optionen.mitOffenenTags !== false) {
|
||
for (const tagCode of OFFENE_TAGS) {
|
||
if (await geraetSaeen(db, tagCode, { kundeId, typ: GeraeteTyp.SONSTIGES, registriert: false })) {
|
||
offeneTags += 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
return { abteilungen, personen, geraete, offeneTags };
|
||
}
|
||
|
||
/**
|
||
* Die Beispiel-Verzeichnisquelle — **deaktiviert**.
|
||
*
|
||
* Sie ist eine ausgefuellte Vorlage zum Abschauen und kein Zugang: die Adresse
|
||
* zeigt auf einen Rechner, den es hier nicht gibt, und `aktiv = false` haelt den
|
||
* Zeitplan davon ab, sie anzufassen. Das Bind-Passwort ist ein bei jedem Seed neu
|
||
* gewuerfelter Zufallswert, der nirgends ausgegeben wird: ein fest verdrahtetes
|
||
* Beispielpasswort im Quelltext waere eines, das am Ende in einer echten
|
||
* Installation steht.
|
||
*/
|
||
export async function beispielQuelleSaeen(db: PrismaClient, kundeId: string): Promise<boolean> {
|
||
const name = "Firmen-AD (Beispiel)";
|
||
const vorhanden = await db.verzeichnisquelle.findFirst({ where: { kundeId, name } });
|
||
if (vorhanden) return false;
|
||
|
||
await db.verzeichnisquelle.create({
|
||
data: {
|
||
kundeId,
|
||
name,
|
||
url: "ldaps://dc01.firma.local:636",
|
||
bindDn: "CN=laufzettel,OU=Dienstkonten,DC=firma,DC=local",
|
||
bindPasswortVerschluesselt: verschluesseln(randomBytes(24).toString("base64url")),
|
||
basisDn: "OU=Benutzer,DC=firma,DC=local",
|
||
filter: STANDARD_FILTER,
|
||
attributZuordnung: { ...AD_ZUORDNUNG },
|
||
abgleichIntervallMinuten: 60,
|
||
aktiv: false,
|
||
},
|
||
});
|
||
return true;
|
||
}
|