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; }; /** * 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; **gespeichert wird * die Zuordnung nicht**: `vorgesetzterExterneId` zeigt auf die `externeId` * derselben Verzeichnisquelle, und eine von Hand angelegte Person hat beides * nicht. Im Onboarding-Lauf wird der Vorgesetzte ohnehin beim Start gewaehlt. */ export const PERSONEN: readonly PersonenVorgabe[] = [ { vorname: "Maria", nachname: "Huber", sam: "mhuber", upn: "m.huber@firma.example", abteilung: "Buchhaltung", titel: "Sachbearbeiterin Rechnungsprüfung", }, { 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 { 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 { 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(); 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); personen += 1; } // 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 { 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; }