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;
|
||
}
|