feat(seed): sieben Vorlagen, Beispielbestand und Demo-Kunden (#33)
This commit was merged in pull request #33.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { geraetSaeen, stammdatenSaeen, type StammdatenErgebnis } from "./stammdaten.js";
|
||||
import { GeraeteTyp, Herkunft } from "../../src/generated/prisma/enums.js";
|
||||
import type { PrismaClient } from "../../src/generated/prisma/client.js";
|
||||
|
||||
/**
|
||||
* Die beiden Demo-Kunden fuer `SEED_MODE=DIENSTLEISTUNG` (#017).
|
||||
*
|
||||
* Im Modus DIENSTLEISTUNG betreut das Haus fremde Betriebe. Der Seed legt zwei
|
||||
* an, damit die Kundenwahl, das Kuerzel auf dem Etikett und die Hostname-Regel
|
||||
* `{kundenkuerzel}-PC-{nnn}` ueberhaupt etwas zu zeigen haben — bei einem Kunden
|
||||
* saehe jede Auswahlliste gleich aus, und ein Mandantenfehler fiele nicht auf.
|
||||
*
|
||||
* **„Stattdessen“, nicht „zusaetzlich“.** Der Beispielbestand haengt in diesem
|
||||
* Modus an der Musterfirma und nicht an der eigenen Organisation: der Auftrag
|
||||
* verlangt, dass `LZ-7K3F9Q` hier auf `MUS-PC-001` fuehrt. Lag derselbe
|
||||
* Aufkleber an der eigenen Organisation, liefe der Hauptablauf im Modus
|
||||
* DIENSTLEISTUNG gegen die falsche Zeile (Reviewbefund zu #017).
|
||||
*
|
||||
* **Kein Verzeichnis bei Kunden.** An fremde Active Directories bindet sich diese
|
||||
* Anwendung ausdruecklich nicht (#008); im Modus DIENSTLEISTUNG gibt es das
|
||||
* Verzeichnis-Modul ueberhaupt nicht.
|
||||
*/
|
||||
|
||||
export type DemoKunde = {
|
||||
readonly name: string;
|
||||
readonly kuerzel: string;
|
||||
/**
|
||||
* Der volle Beispielbestand (drei Abteilungen, fuenf Personen, die beiden
|
||||
* Beispielgeraete mit ihren Tag-Codes, fuenf vorgedruckte Tags) — oder der
|
||||
* kleine Satz aus einer Abteilung, einer Person und einem Geraet.
|
||||
*/
|
||||
readonly vollbestand: boolean;
|
||||
readonly abteilung?: string;
|
||||
readonly person?: {
|
||||
readonly vorname: string;
|
||||
readonly nachname: string;
|
||||
readonly sam: string;
|
||||
readonly upn: string;
|
||||
readonly titel: string;
|
||||
};
|
||||
/**
|
||||
* Eigener Tag-Code: ein `tagCode` ist global eindeutig und wird nie
|
||||
* wiederverwendet. Zwei Kunden koennen sich denselben Aufkleber also nicht
|
||||
* teilen, auch wenn bei beiden ein Geraet „PC-001“ steht.
|
||||
*/
|
||||
readonly pcTag?: string;
|
||||
};
|
||||
|
||||
export const DEMO_KUNDEN: readonly DemoKunde[] = [
|
||||
{ name: "Musterfirma GmbH", kuerzel: "MUS", vollbestand: true },
|
||||
{
|
||||
name: "Bäckerei Reiter",
|
||||
kuerzel: "REI",
|
||||
vollbestand: false,
|
||||
abteilung: "Verkauf",
|
||||
person: {
|
||||
vorname: "Josef",
|
||||
nachname: "Reiter",
|
||||
sam: "jreiter",
|
||||
upn: "j.reiter@baeckerei-reiter.example",
|
||||
titel: "Inhaber",
|
||||
},
|
||||
pcTag: "LZ-B2NJ5R",
|
||||
},
|
||||
];
|
||||
|
||||
export type DemoErgebnis = {
|
||||
readonly kunde: string;
|
||||
readonly kuerzel: string;
|
||||
readonly neu: boolean;
|
||||
readonly hostname: string;
|
||||
readonly bestand: StammdatenErgebnis | null;
|
||||
};
|
||||
|
||||
export async function demoKundenSaeen(db: PrismaClient): Promise<readonly DemoErgebnis[]> {
|
||||
const ergebnisse: DemoErgebnis[] = [];
|
||||
|
||||
for (const vorgabe of DEMO_KUNDEN) {
|
||||
/*
|
||||
* Gesucht wird ueber Kuerzel **und** Name, und nur unter fremden Kunden.
|
||||
* Vorher genuegte das Kuerzel: ein echter Kunde „Muster und Soehne KG“ mit
|
||||
* dem Kuerzel MUS bekam dann eine erfundene Person, eine erfundene Ziel-OU
|
||||
* und ein Geraet — und die Ausgabe sagte „vorhanden“, es fiel also niemandem
|
||||
* auf (Reviewbefund zu #017). Einen Kunden, den der Seed nicht angelegt hat,
|
||||
* fasst er nicht an.
|
||||
*/
|
||||
const vorhanden = await db.kunde.findFirst({
|
||||
where: { kuerzel: vorgabe.kuerzel, name: vorgabe.name, istEigeneOrganisation: false },
|
||||
});
|
||||
if (!vorhanden) {
|
||||
const fremder = await db.kunde.findFirst({ where: { kuerzel: vorgabe.kuerzel } });
|
||||
if (fremder) {
|
||||
console.log(
|
||||
` Kunde ${vorgabe.kuerzel}: „${fremder.name}“ trägt dieses Kürzel bereits — ` +
|
||||
"der Demo-Kunde wird nicht angelegt",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const kunde =
|
||||
vorhanden ??
|
||||
(await db.kunde.create({
|
||||
data: { name: vorgabe.name, kuerzel: vorgabe.kuerzel, istEigeneOrganisation: false },
|
||||
}));
|
||||
|
||||
if (vorgabe.vollbestand) {
|
||||
const bestand = await stammdatenSaeen(db, kunde.id, { hostnamePraefix: vorgabe.kuerzel });
|
||||
ergebnisse.push({
|
||||
kunde: kunde.name,
|
||||
kuerzel: vorgabe.kuerzel,
|
||||
neu: vorhanden === null,
|
||||
hostname: `${vorgabe.kuerzel}-PC-001`,
|
||||
bestand,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
ergebnisse.push({
|
||||
kunde: kunde.name,
|
||||
kuerzel: vorgabe.kuerzel,
|
||||
neu: vorhanden === null,
|
||||
hostname: `${vorgabe.kuerzel}-PC-001`,
|
||||
bestand: await kleinerBestand(db, kunde.id, vorgabe),
|
||||
});
|
||||
}
|
||||
|
||||
return ergebnisse;
|
||||
}
|
||||
|
||||
/** Eine Abteilung, eine Person, ein Geraet — genug, damit der Kunde sichtbar ist. */
|
||||
async function kleinerBestand(
|
||||
db: PrismaClient,
|
||||
kundeId: string,
|
||||
vorgabe: DemoKunde,
|
||||
): Promise<StammdatenErgebnis> {
|
||||
const { abteilung, person, pcTag, kuerzel } = vorgabe;
|
||||
if (!abteilung || !person || !pcTag) {
|
||||
throw new Error(`Dem Demo-Kunden ${kuerzel} fehlen Abteilung, Person oder Tag-Code.`);
|
||||
}
|
||||
|
||||
let abteilungen = 0;
|
||||
const vorhandeneAbteilung = await db.abteilung.findUnique({
|
||||
where: { kundeId_name: { kundeId, name: abteilung } },
|
||||
});
|
||||
if (!vorhandeneAbteilung) {
|
||||
await db.abteilung.create({
|
||||
data: {
|
||||
kundeId,
|
||||
name: abteilung,
|
||||
zielOu: `OU=${abteilung},DC=${kuerzel.toLowerCase()},DC=local`,
|
||||
standardGruppen: [`G-${abteilung}`],
|
||||
},
|
||||
});
|
||||
abteilungen = 1;
|
||||
}
|
||||
|
||||
let personen = 0;
|
||||
let zeile = await db.mitarbeiter.findFirst({ where: { kundeId, upn: person.upn } });
|
||||
if (!zeile) {
|
||||
zeile = await db.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId,
|
||||
vorname: person.vorname,
|
||||
nachname: person.nachname,
|
||||
anzeigename: `${person.nachname}, ${person.vorname}`,
|
||||
mail: person.upn,
|
||||
upn: person.upn,
|
||||
sam: person.sam,
|
||||
abteilung,
|
||||
titel: person.titel,
|
||||
herkunft: Herkunft.MANUELL,
|
||||
aktiv: true,
|
||||
},
|
||||
});
|
||||
personen = 1;
|
||||
}
|
||||
|
||||
const geraet = await geraetSaeen(db, pcTag, {
|
||||
kundeId,
|
||||
typ: GeraeteTyp.PC,
|
||||
hostname: `${kuerzel}-PC-001`,
|
||||
mitarbeiterId: zeile.id,
|
||||
modell: "HP ProDesk 400",
|
||||
registriert: true,
|
||||
});
|
||||
|
||||
return { abteilungen, personen, geraete: geraet ? 1 : 0, offeneTags: 0 };
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import { Betriebsmodus, Rolle } from "../../src/generated/prisma/enums.js";
|
||||
import type { PrismaClient } from "../../src/generated/prisma/client.js";
|
||||
|
||||
/**
|
||||
* Der Grundbestand, ohne den die Anwendung nicht bedienbar waere (#006, #017):
|
||||
* Betriebsmodus samt Namensregeln, die eigene Organisation und ein Administrator.
|
||||
*
|
||||
* Steht neben dem Seed-Skript und nicht darin, damit Tests diese Schritte einzeln
|
||||
* aufrufen koennen. `prisma/seed.ts` fuehrt beim Import den ganzen Seed aus — ein
|
||||
* Test, der es importierte, schriebe in die Datenbank aus `DATABASE_URL`.
|
||||
*
|
||||
* Die Funktionen nehmen den Client als Argument. Sie schreiben nur, was fehlt.
|
||||
*/
|
||||
|
||||
/** Akzeptiert den im Auftrag dokumentierten Wert SERVICE als Alias. */
|
||||
export function modusLesen(): Betriebsmodus {
|
||||
const roh = (process.env["SEED_MODE"] ?? "INTERN").trim().toUpperCase();
|
||||
if (roh === "DIENSTLEISTUNG" || roh === "SERVICE") return Betriebsmodus.DIENSTLEISTUNG;
|
||||
if (roh === "INTERN") return Betriebsmodus.INTERN;
|
||||
throw new Error(`SEED_MODE kennt nur INTERN und DIENSTLEISTUNG (Alias SERVICE), nicht "${roh}".`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Namensregeln, die zu den Beispieldaten passen.
|
||||
*
|
||||
* `{v}.{nachname}@firma.example` ergibt fuer Maria Huber genau
|
||||
* `m.huber@firma.example` und `mhuber` — dieselben Werte, die auch im
|
||||
* Beispielbestand stehen. Ohne diese Regeln antwortet „Namen aus den Regeln
|
||||
* vorschlagen“ im Onboarding mit „Für diesen Kunden sind keine Namensregeln
|
||||
* hinterlegt“, und das Abnahmekriterium „Vorname und Nachname eingeben → UPN,
|
||||
* sam und Mail werden vorgeschlagen“ liesse sich nach einem frischen Seed nicht
|
||||
* vorfuehren (Reviewbefund zu #017).
|
||||
*
|
||||
* Die Hostname-Regel haengt am Modus: im Modus DIENSTLEISTUNG traegt der Name
|
||||
* das Kundenkuerzel, wie der Auftrag es vorgibt (`{kundenkuerzel}-PC-{nnn}`).
|
||||
*/
|
||||
export function namensregelnFuerModus(modus: Betriebsmodus): Record<string, string> {
|
||||
return {
|
||||
upn: "{v}.{nachname}@firma.example",
|
||||
mail: "{v}.{nachname}@firma.example",
|
||||
sam: "{v}{nachname}",
|
||||
hostname: modus === Betriebsmodus.DIENSTLEISTUNG ? "{kundenkuerzel}-PC-{nnn}" : "PC-{nnn}",
|
||||
};
|
||||
}
|
||||
|
||||
/** Ziel-OU fuer deaktivierte Konten – Vorbelegung von `{{ou_gesperrt}}` im Offboarding. */
|
||||
export const GESPERRTE_OU = "OU=Gesperrt,OU=Benutzer,DC=firma,DC=local";
|
||||
|
||||
export async function einstellungAnlegen(db: PrismaClient, modus: Betriebsmodus): Promise<void> {
|
||||
// Nur anlegen, nicht ueberschreiben: ein spaeterer Seed-Lauf darf einen im
|
||||
// Betrieb umgeschalteten Modus nicht zuruecksetzen.
|
||||
const vorhanden = await db.einstellung.findUnique({ where: { id: "global" } });
|
||||
if (!vorhanden) {
|
||||
await db.einstellung.create({
|
||||
data: {
|
||||
id: "global",
|
||||
modus,
|
||||
namensregeln: namensregelnFuerModus(modus),
|
||||
gesperrteOu: GESPERRTE_OU,
|
||||
},
|
||||
});
|
||||
console.log(` Einstellung: angelegt, Modus ${modus}, Namensregeln und Sperr-OU gesetzt`);
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Zeile steht schon. Ergaenzt wird nur, was **leer** ist: eine gepflegte
|
||||
* Namensregel oder eine eigene Sperr-OU zurueckzusetzen waere eine Aenderung an
|
||||
* Betriebsdaten, und dafuer ist der Seed nicht da.
|
||||
*/
|
||||
const ohneRegeln =
|
||||
typeof vorhanden.namensregeln !== "object" ||
|
||||
vorhanden.namensregeln === null ||
|
||||
Object.keys(vorhanden.namensregeln as Record<string, unknown>).length === 0;
|
||||
const ohneSperrOu = (vorhanden.gesperrteOu ?? "").trim() === "";
|
||||
if (ohneRegeln || ohneSperrOu) {
|
||||
await db.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: {
|
||||
...(ohneRegeln ? { namensregeln: namensregelnFuerModus(vorhanden.modus) } : {}),
|
||||
...(ohneSperrOu ? { gesperrteOu: GESPERRTE_OU } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
const ergaenzt = [ohneRegeln ? "Namensregeln" : null, ohneSperrOu ? "Sperr-OU" : null].filter(
|
||||
(teil) => teil !== null,
|
||||
);
|
||||
console.log(
|
||||
` Einstellung: vorhanden, Modus ${vorhanden.modus} bleibt` +
|
||||
(ergaenzt.length > 0 ? ` (${ergaenzt.join(" und ")} ergänzt)` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
export async function eigeneOrganisationAnlegen(db: PrismaClient): Promise<string> {
|
||||
const name = process.env["SEED_ORG_NAME"]?.trim() || "Eigene Organisation";
|
||||
const kuerzel = (process.env["SEED_ORG_KUERZEL"]?.trim() || "EIGEN").toUpperCase();
|
||||
if (!/^[A-Z0-9]{2,6}$/.test(kuerzel)) {
|
||||
throw new Error(`SEED_ORG_KUERZEL muss 2 bis 6 Grossbuchstaben oder Ziffern sein, nicht "${kuerzel}".`);
|
||||
}
|
||||
|
||||
// Bewusst nicht aktualisieren: wer Name oder Kuerzel im Betrieb geaendert hat,
|
||||
// soll das nicht beim naechsten Seed-Lauf zurueckbekommen. Geaenderte Werte in
|
||||
// der Umgebung bleiben deshalb wirkungslos, sobald die Zeile existiert.
|
||||
const vorhanden = await db.kunde.findFirst({ where: { istEigeneOrganisation: true } });
|
||||
if (vorhanden) {
|
||||
console.log(` Eigene Organisation: vorhanden (${vorhanden.name})`);
|
||||
return vorhanden.id;
|
||||
}
|
||||
const kunde = await db.kunde.create({
|
||||
data: { name, kuerzel, istEigeneOrganisation: true },
|
||||
});
|
||||
console.log(` Eigene Organisation: angelegt (${kunde.name}, ${kunde.kuerzel})`);
|
||||
return kunde.id;
|
||||
}
|
||||
|
||||
export async function adminAnlegen(db: PrismaClient): Promise<void> {
|
||||
const email = process.env["SEED_ADMIN_EMAIL"]?.trim().toLowerCase();
|
||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
||||
if (!email || !passwort) {
|
||||
console.log(" Administrator: uebersprungen (SEED_ADMIN_EMAIL oder SEED_ADMIN_PASSWORD fehlt)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Gesucht wird ueber die Adresse: eine geaenderte SEED_ADMIN_EMAIL legt also
|
||||
// einen zweiten Administrator an, statt den ersten umzubenennen. Das ist
|
||||
// gewollt – umbenennen waere eine Aenderung an einem Konto, das laengst benutzt
|
||||
// sein kann.
|
||||
const vorhanden = await db.benutzer.findUnique({ where: { email } });
|
||||
if (vorhanden) {
|
||||
console.log(` Administrator: vorhanden (${email})`);
|
||||
return;
|
||||
}
|
||||
// Das Passwort wird nur als argon2-Hash gespeichert, nie im Klartext.
|
||||
await db.benutzer.create({
|
||||
data: {
|
||||
email,
|
||||
name: "Administrator",
|
||||
rolle: Rolle.ADMIN,
|
||||
passwortHash: await hash(passwort),
|
||||
aktiv: true,
|
||||
},
|
||||
});
|
||||
console.log(` Administrator: angelegt (${email})`);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
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<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>();
|
||||
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<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;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { AustauschSchema, type Austausch } from "@/lib/vorlagen-format";
|
||||
import { GeraeteTyp, VersionStatus } from "../../src/generated/prisma/enums.js";
|
||||
import type { PrismaClient } from "../../src/generated/prisma/client.js";
|
||||
|
||||
/**
|
||||
* Die sieben Vorlagen des Auftrags (#017).
|
||||
*
|
||||
* Sie stehen als **JSON im Austauschformat** neben dieser Datei und nicht als
|
||||
* TypeScript-Literale. Drei Gruende:
|
||||
*
|
||||
* 1. Dasselbe Schema wie Import und Export prueft sie (`AustauschSchema`). Eine
|
||||
* Vorlage, die der Seed anlegt, ist damit genau das, was auch ein Redakteur
|
||||
* einspielen koennte — und ein Fehler faellt hier auf und nicht erst beim
|
||||
* ersten Lauf.
|
||||
* 2. Wer eine Vorlage anpassen will, aendert eine Datei und braucht keinen
|
||||
* TypeScript-Übersetzer.
|
||||
* 3. Die Dateien lassen sich unveraendert exportieren, weitergeben und in eine
|
||||
* andere Installation einspielen.
|
||||
*
|
||||
* **Angelegt wird direkt, nicht ueber `vorlageImportieren`.** Der Import erzeugt
|
||||
* absichtlich einen Entwurf (der Redakteur sieht ihn sich an und veroeffentlicht
|
||||
* dann) und schreibt einen Protokolleintrag mit dem handelnden Benutzer. Der
|
||||
* Seed hat keinen Menschen, der handelt, und braucht **veroeffentlichte**
|
||||
* Versionen: ohne sie liesse sich kein Lauf starten.
|
||||
*/
|
||||
|
||||
const HIER = dirname(fileURLToPath(import.meta.url));
|
||||
const VORLAGEN_ORDNER = join(HIER, "vorlagen");
|
||||
|
||||
export type VorlagenVorgabe = {
|
||||
/** Dateiname, damit eine Fehlermeldung sagt, wo nachzusehen ist. */
|
||||
readonly datei: string;
|
||||
readonly dokument: Austausch;
|
||||
};
|
||||
|
||||
/**
|
||||
* Liest alle Vorlagendateien und prueft sie.
|
||||
*
|
||||
* Reihenfolge nach Dateinamen — deshalb tragen sie eine fuehrende Nummer. Sie
|
||||
* entscheidet, in welcher Reihenfolge die Vorlagen entstehen, und das ist die
|
||||
* Reihenfolge des Auftrags.
|
||||
*/
|
||||
export function vorlagenLesen(): readonly VorlagenVorgabe[] {
|
||||
const dateien = readdirSync(VORLAGEN_ORDNER)
|
||||
.filter((name) => name.endsWith(".json"))
|
||||
.sort();
|
||||
return dateien.map((datei) => {
|
||||
const roh: unknown = JSON.parse(readFileSync(join(VORLAGEN_ORDNER, datei), "utf8"));
|
||||
const gelesen = AustauschSchema.safeParse(roh);
|
||||
if (!gelesen.success) {
|
||||
const erste = gelesen.error.issues[0];
|
||||
throw new Error(
|
||||
`Die Vorlagendatei ${datei} passt nicht zum Austauschformat: ` +
|
||||
`${erste?.path.join(".") ?? "?"}: ${erste?.message ?? "unbekannt"}`,
|
||||
);
|
||||
}
|
||||
return { datei, dokument: gelesen.data };
|
||||
});
|
||||
}
|
||||
|
||||
export type GesaeteVorlage = {
|
||||
readonly name: string;
|
||||
readonly vorlageId: string;
|
||||
readonly versionId: string;
|
||||
readonly neu: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Legt die Vorlagen als veroeffentlichte Version 1 an.
|
||||
*
|
||||
* Idempotent ueber den **Namen**: gibt es die Vorlage schon, bleibt sie
|
||||
* unangetastet — auch dann, wenn jemand sie inzwischen bearbeitet hat. Ein
|
||||
* zweiter Seed-Lauf darf die Arbeit eines Redakteurs nicht zurueckdrehen, und
|
||||
* eine veroeffentlichte Version wird ohnehin nie veraendert.
|
||||
*/
|
||||
export async function vorlagenSaeen(db: PrismaClient): Promise<readonly GesaeteVorlage[]> {
|
||||
const ergebnis: GesaeteVorlage[] = [];
|
||||
/** Vorlagen, die es gibt, aber ohne veroeffentlichte Version – siehe unten. */
|
||||
let uebersprungen = 0;
|
||||
|
||||
for (const { dokument } of vorlagenLesen()) {
|
||||
const vorhanden = await db.vorlage.findFirst({
|
||||
// Auch eine **archivierte** Vorlage gilt als vorhanden: eine zweite gleichen
|
||||
// Namens danebenzustellen waere keine Hilfe, sondern eine Dublette in jeder
|
||||
// Liste. Wer die Beispielvorlage zurueck will, holt sie aus dem Archiv.
|
||||
where: { name: dokument.name, kundeId: null },
|
||||
include: {
|
||||
versionen: {
|
||||
where: { status: VersionStatus.VEROEFFENTLICHT },
|
||||
orderBy: { version: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
const veroeffentlichte = vorhanden?.versionen[0];
|
||||
if (vorhanden && veroeffentlichte) {
|
||||
ergebnis.push({
|
||||
name: dokument.name,
|
||||
vorlageId: vorhanden.id,
|
||||
versionId: veroeffentlichte.id,
|
||||
neu: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (vorhanden) {
|
||||
/*
|
||||
* Es gibt die Vorlage, aber keine veroeffentlichte Version — typischerweise,
|
||||
* weil jemand genau diese Datei ueber „Vorlage aus einer Datei einspielen“
|
||||
* eingelesen hat: der Import legt absichtlich einen Entwurf an. Sie
|
||||
* trotzdem zu fuellen hiesse, eine zweite Version 1 anzulegen; der
|
||||
* eindeutige Index `(vorlageId, version)` haelt das auf, und der Seed
|
||||
* braeche mitten im Lauf mit einem rohen Prisma-Fehler ab — vor den
|
||||
* Stammdaten und vor den Demo-Kunden (Reviewbefund zu #017).
|
||||
*/
|
||||
console.log(` Vorlage „${dokument.name}“: Entwurf vorhanden, bleibt unberührt`);
|
||||
uebersprungen += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Alles in einer Transaktion: eine Vorlage ohne Abschnitte stuende sonst in
|
||||
// der Liste und liesse sich starten, ohne dass es etwas abzuarbeiten gaebe.
|
||||
const angelegt = await db.$transaction(async (tx) => {
|
||||
const vorlage =
|
||||
vorhanden ??
|
||||
(await tx.vorlage.create({
|
||||
data: {
|
||||
name: dokument.name,
|
||||
beschreibung: dokument.beschreibung ?? null,
|
||||
kategorie: dokument.kategorie ?? null,
|
||||
schlagworte: [...dokument.schlagworte],
|
||||
},
|
||||
}));
|
||||
|
||||
/*
|
||||
* Erst Entwurf, dann veroeffentlichen — denselben Weg geht auch ein
|
||||
* Redakteur, und die Datenbank laesst keinen anderen zu: seit #010
|
||||
* verbieten Trigger jedes Schreiben an Abschnitten, Variablen und
|
||||
* Schritten einer veroeffentlichten Version. Eine Version gleich als
|
||||
* VEROEFFENTLICHT anzulegen und danach zu fuellen scheitert deshalb mit
|
||||
* „Version … ist veroeffentlicht und unveraenderlich“ — richtig so.
|
||||
*/
|
||||
const version = await tx.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
|
||||
if (dokument.struktur.variablen.length > 0) {
|
||||
await tx.vorlagenVariable.createMany({
|
||||
data: dokument.struktur.variablen.map((variable, position) => ({
|
||||
versionId: version.id,
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ,
|
||||
optionen: [...variable.optionen],
|
||||
pflicht: variable.pflicht,
|
||||
position,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [position, abschnitt] of dokument.struktur.abschnitte.entries()) {
|
||||
const zeile = await tx.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: abschnitt.titel, position },
|
||||
});
|
||||
if (abschnitt.schritte.length === 0) continue;
|
||||
await tx.vorlagenSchritt.createMany({
|
||||
data: abschnitt.schritte.map((schritt, schrittPosition) => ({
|
||||
abschnittId: zeile.id,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext ?? null,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
// Der Inhalt kommt aus geparstem JSON und ist damit ein Json-Wert;
|
||||
// das nachsichtige Schema traegt nur eine Index-Signatur auf
|
||||
// `unknown`, die Prisma nicht als solchen erkennt.
|
||||
konfig: schritt.konfig as object,
|
||||
dokuUrl: schritt.dokuUrl ?? null,
|
||||
position: schrittPosition,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
await tx.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: {
|
||||
status: VersionStatus.VEROEFFENTLICHT,
|
||||
veroeffentlichtAm: new Date(),
|
||||
// Bleibt leer: es gibt keinen angemeldeten Menschen, der
|
||||
// veroeffentlicht haette. Die Migration laesst das ausdruecklich zu.
|
||||
veroeffentlichtVonId: null,
|
||||
aenderungsnotiz: "Erste Fassung aus dem Seed.",
|
||||
},
|
||||
});
|
||||
|
||||
return { vorlageId: vorlage.id, versionId: version.id };
|
||||
});
|
||||
|
||||
ergebnis.push({ name: dokument.name, ...angelegt, neu: true });
|
||||
}
|
||||
|
||||
if (uebersprungen > 0) {
|
||||
console.log(` ${uebersprungen} Vorlage(n) übersprungen: vorhanden, aber ohne veröffentlichte Version`);
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Vorlage auf der Geraeteseite als Schnellstart vorgeschlagen wird.
|
||||
*
|
||||
* Die Zuordnung steht im Auftrag und haengt am Geraetetyp, nicht am Kunden —
|
||||
* deshalb traegt `GeraetetypVorlage` keine `kundeId`. Verwiesen wird auf die
|
||||
* **Vorlage**, nicht auf eine Version: ein Schnellstart soll die jeweils aktuelle
|
||||
* veroeffentlichte Fassung starten und nicht die von damals.
|
||||
*/
|
||||
export const TYP_VORGABEN: readonly { readonly typ: GeraeteTyp; readonly vorlagen: readonly string[] }[] = [
|
||||
{ typ: GeraeteTyp.PC, vorlagen: ["PC Neuinstallation"] },
|
||||
{ typ: GeraeteTyp.NOTEBOOK, vorlagen: ["PC Neuinstallation"] },
|
||||
{
|
||||
typ: GeraeteTyp.SERVER,
|
||||
vorlagen: ["Server-Inbetriebnahme Windows-VM", "Backup-Restore-Test"],
|
||||
},
|
||||
{ typ: GeraeteTyp.FIREWALL, vorlagen: ["Firewall-Rollout OPNsense"] },
|
||||
];
|
||||
|
||||
/**
|
||||
* Legt die Schnellstart-Vorgaben an.
|
||||
*
|
||||
* Verlangt, dass es die genannten Vorlagen gibt: ein Tippfehler im Namen waere
|
||||
* sonst eine Geraeteseite ohne Schnellstart — und niemand wuesste, warum.
|
||||
*/
|
||||
export async function typVorgabenSaeen(
|
||||
db: PrismaClient,
|
||||
vorlagen: readonly GesaeteVorlage[],
|
||||
): Promise<number> {
|
||||
const nachName = new Map(vorlagen.map((eintrag) => [eintrag.name, eintrag.vorlageId]));
|
||||
let angelegt = 0;
|
||||
|
||||
for (const vorgabe of TYP_VORGABEN) {
|
||||
for (const [position, name] of vorgabe.vorlagen.entries()) {
|
||||
const vorlageId = nachName.get(name);
|
||||
if (!vorlageId) {
|
||||
throw new Error(
|
||||
`Für den Gerätetyp ${vorgabe.typ} ist die Vorlage „${name}“ vorgesehen, ` +
|
||||
"aber es gibt sie nicht. Stimmt der Name in der JSON-Datei?",
|
||||
);
|
||||
}
|
||||
const vorhanden = await db.geraetetypVorlage.findUnique({
|
||||
where: { geraeteTyp_vorlageId: { geraeteTyp: vorgabe.typ, vorlageId } },
|
||||
});
|
||||
if (vorhanden) continue;
|
||||
await db.geraetetypVorlage.create({
|
||||
data: { geraeteTyp: vorgabe.typ, vorlageId, position },
|
||||
});
|
||||
angelegt += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return angelegt;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "PC Neuinstallation",
|
||||
"beschreibung": "Vollständige Neuinstallation eines Arbeitsplatzrechners: von der Datensicherung des alten Systems über Installation, Einbindung und Verschlüsselung bis zur Übergabe an den Benutzer.",
|
||||
"kategorie": "Client",
|
||||
"schlagworte": ["windows", "client", "neuinstallation", "bitlocker"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{
|
||||
"schluessel": "os_version",
|
||||
"beschriftung": "Windows-Version",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["Windows 11 Pro", "Windows 11 Enterprise"],
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "join_typ",
|
||||
"beschriftung": "Art der Einbindung",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["Active Directory", "Entra ID", "Hybrid", "Workgroup"],
|
||||
"pflicht": true
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Vorbereitung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Ticket und Endbenutzer im Lauf erfasst",
|
||||
"hilfetext": "Ohne Ticketnummer und zugeordnete Person lässt sich später keine Rückfrage zuordnen. Beides gehört in die Kopfdaten des Laufs, nicht in einen Kommentar.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Datensicherung des alten Systems geprüft (Profil, Desktop, Dokumente, lokale PST, Browser-Favoriten)",
|
||||
"hilfetext": "Auch nach lokal gespeicherten Daten außerhalb des Profils fragen, z. B. `C:\\Daten`.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Sondersoftware und Lizenzen des Benutzers notiert",
|
||||
"hilfetext": "Vor dem Löschen erfassen, welche Programme und Lizenzschlüssel auf dem alten System liegen — hinterher ist die Information weg. Dongles und an die Hardware gebundene Aktivierungen mit aufführen.",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true,
|
||||
"konfig": { "mehrzeilig": true }
|
||||
},
|
||||
{
|
||||
"titel": "BIOS/UEFI auf aktuellen Stand gebracht",
|
||||
"hilfetext": "Firmware vor der Installation aktualisieren, nicht danach: ein Update setzt Secure-Boot- und TPM-Einstellungen gelegentlich zurück."
|
||||
},
|
||||
{
|
||||
"titel": "Secure Boot und TPM 2.0 aktiv",
|
||||
"hilfetext": "In der Firmware prüfen, nicht nur in Windows. Ohne TPM 2.0 lässt sich BitLocker später nur mit Startschlüssel auf einem USB-Stick betreiben.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Installation",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "{{os_version}} installiert",
|
||||
"hilfetext": "Edition und Sprache müssen zur Lizenz passen. Saubere Installation auf gelöschter Systempartition, keine Aktualisierung des alten Stands.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Hostname gesetzt",
|
||||
"hilfetext": "Der Name muss dem Namensschema folgen; der Befehl startet den Rechner neu. Danach prüfen, ob der Name auch im DNS angekommen ist.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Rename-Computer -NewName \"{{hostname}}\" -Restart"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Treiber vollständig, keine unbekannten Geräte im Geräte-Manager",
|
||||
"hilfetext": "Im Geräte-Manager auch „Ausgeblendete Geräte anzeigen“ einschalten. Kein gelbes Ausrufezeichen, kein unbekanntes Gerät — fehlt etwas, ist es meist der Chipsatz-Treiber.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Windows Updates vollständig eingespielt",
|
||||
"hilfetext": "Mehrfach nach Updates suchen, bis nichts mehr kommt: Kumulativ- und Treiber-Updates erscheinen oft erst nach einem Neustart.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Installierte Version dokumentieren",
|
||||
"hilfetext": "Die Ausgabe in den nächsten Schritt übertragen. Supportfälle beziehen sich auf die Build-Nummer, nicht auf „Windows 11“.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Get-ComputerInfo | Select-Object OsName, OsVersion, OsBuildNumber"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Build-Nummer",
|
||||
"hilfetext": "Die Build aus der Ausgabe des vorigen Schritts, etwa 26100. Ohne sie ist später nicht nachvollziehbar, auf welchem Stand das Gerät ausgeliefert wurde.",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Einbindung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Einbindung durchgeführt: {{join_typ}}",
|
||||
"hilfetext": "Die Art der Einbindung entscheidet über Anmeldung und Richtlinien. Ein Gerät gehört in genau eine Domäne bzw. einen Tenant — nicht zusätzlich woanders anmelden.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Entra-Status prüfen",
|
||||
"hilfetext": "Unter „Device State“ muss `AzureAdJoined` bzw. `DomainJoined` auf YES stehen; unter „SSO State“ zeigt sich, ob ein gültiges Token vorliegt.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": { "shell": "cmd", "befehl": "dsregcmd /status" }
|
||||
},
|
||||
{
|
||||
"titel": "Gruppenrichtlinien prüfen",
|
||||
"hilfetext": "Zeigt die angewendeten Richtlinien und die Gruppenzugehörigkeit des Rechners. Fehlt eine erwartete Richtlinie, stimmt meist die OU nicht.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": { "shell": "cmd", "befehl": "gpresult /r" }
|
||||
},
|
||||
{
|
||||
"titel": "Gerät in richtiger OU bzw. Gerätegruppe",
|
||||
"hilfetext": "Die OU bestimmt, welche Richtlinien greifen. Nach dem Verschieben `gpupdate /force` und einen Neustart, sonst gilt weiter der alte Stand.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Software",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "RMM-Agent installiert und meldet sich",
|
||||
"hilfetext": "Nicht nur installieren, sondern in der Konsole nachsehen, ob das Gerät online erscheint und der richtigen Gruppe zugeordnet ist.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Virenschutz/EDR installiert und aktiv",
|
||||
"hilfetext": "Echtzeitschutz aktiv, Signaturen aktuell, Gerät im Portal sichtbar. Zwei Scanner parallel behindern sich gegenseitig — der ersetzte muss weg.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Office installiert und aktiviert",
|
||||
"hilfetext": "Eine Anwendung öffnen und unter „Konto“ prüfen, dass die richtige Lizenz und das richtige Konto hinterlegt sind.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Standardsoftware laut Firmenstandard installiert",
|
||||
"hilfetext": "Der Firmenstandard steht in der Dokumentation, nicht im Kopf. Abweichungen im nächsten Schritt festhalten.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Zusätzlich installierte Sondersoftware",
|
||||
"hilfetext": "Alles, was über den Standard hinausgeht, mit Version und Lizenzherkunft notieren — beim nächsten Gerätetausch ist das die Vorlage.",
|
||||
"typ": "TEXT",
|
||||
"konfig": { "mehrzeilig": true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Sicherheit",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "BitLocker auf allen festen Laufwerken aktiv",
|
||||
"hilfetext": "Datenpartitionen mit verschlüsseln, nicht nur C:. Wechseldatenträger nur, wenn die Richtlinie es vorsieht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "BitLocker-Status prüfen",
|
||||
"hilfetext": "Erwartet werden „Schutzstatus: Der Schutz ist aktiviert“ und „Verschlüsselt: 100 %“. Solange die Verschlüsselung noch läuft, ist das Laufwerk nicht geschützt.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "shell": "cmd", "befehl": "manage-bde -status C:" }
|
||||
},
|
||||
{
|
||||
"titel": "Recovery-Key in AD/Entra bzw. Passwortmanager hinterlegt",
|
||||
"hilfetext": "Den Schlüssel niemals in diese Checkliste schreiben.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Lokales Adminkonto nach Standard (LAPS aktiv oder Passwort im Passwortmanager)",
|
||||
"hilfetext": "Kein gemeinsames Kennwort über mehrere Geräte. Mit LAPS prüfen, ob das Kennwort tatsächlich im Verzeichnis ankommt.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Windows-Firewall in allen Profilen aktiv",
|
||||
"hilfetext": "Domäne, Privat und Öffentlich einzeln prüfen. Manche Installationspakete schalten ein einzelnes Profil ab und lassen es so.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Benutzer",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Daten zurückgespielt und vom Benutzer stichprobenartig geprüft",
|
||||
"hilfetext": "Die Person öffnet selbst zwei oder drei Dateien und ihr Mailarchiv. Erst danach gilt die Sicherung als brauchbar — und erst danach darf das Altgerät gelöscht werden.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Drucker und Netzlaufwerke eingerichtet",
|
||||
"hilfetext": "Einmal probedrucken und in jedem Laufwerk eine Datei speichern. Ein verbundenes Laufwerk heißt nicht, dass Schreibrechte bestehen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Mailprofil eingerichtet, Senden/Empfangen getestet",
|
||||
"hilfetext": "Testmail nach außen und zurück. Signatur, Freigabekalender und zusätzliche Postfächer nicht vergessen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Funktionstest gemeinsam mit {{benutzer}} durchgeführt",
|
||||
"hilfetext": "Die Programme durchgehen, die die Person täglich braucht. Was hier auffällt, kostet zehn Minuten; was später auffällt, einen zweiten Termin.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Abschluss",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Inventar und Dokumentation aktualisiert",
|
||||
"hilfetext": "Seriennummer, Standort, Besitzer und Kaufdatum nachziehen, damit ein Garantiefall später zuzuordnen ist.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Gerät im Monitoring sichtbar",
|
||||
"hilfetext": "Im Monitoring nachsehen, ob das Gerät Werte liefert und die Schwellwerte gesetzt sind. Ein installierter Agent allein sagt nichts.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Verbleib Altgerät bzw. alter Datenträger",
|
||||
"hilfetext": "Erst eintragen, wenn die Daten nachweislich zurückgespielt und geprüft sind. „Sicher gelöscht“ heißt überschrieben, nicht formatiert.",
|
||||
"typ": "AUSWAHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"optionen": ["Sicher gelöscht", "Datenträger vernichtet", "Eingelagert", "Kein Altgerät"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Gerät an {{benutzer}} übergeben",
|
||||
"hilfetext": "Übergabe mit Datum festhalten. Bei Notebooks Netzteil, Tasche und Dockingstation mitzählen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Ticket {{ticket}} geschlossen",
|
||||
"hilfetext": "Im Ticket kurz festhalten, was getauscht wurde und wo die Dokumentation liegt. Der Laufzettel ersetzt den Abschlusskommentar nicht.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Mitarbeiter-Onboarding",
|
||||
"beschreibung": "Neues Benutzerkonto im Active Directory oder in Entra ID anlegen, Hardware bereitstellen und den ersten Arbeitstag begleiten.",
|
||||
"kategorie": "Benutzer",
|
||||
"schlagworte": ["onboarding", "eintritt", "benutzerkonto", "active directory", "entra id"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{
|
||||
"schluessel": "konto_typ",
|
||||
"beschriftung": "Kontotyp",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["Active Directory", "Entra ID"],
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "vorname",
|
||||
"beschriftung": "Vorname",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "nachname",
|
||||
"beschriftung": "Nachname",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "upn",
|
||||
"beschriftung": "UPN (User Principal Name)",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "sam",
|
||||
"beschriftung": "Anmeldename (sAMAccountName)",
|
||||
"typ": "TEXT"
|
||||
},
|
||||
{
|
||||
"schluessel": "mail",
|
||||
"beschriftung": "Mail-Adresse",
|
||||
"typ": "TEXT"
|
||||
},
|
||||
{
|
||||
"schluessel": "abteilung",
|
||||
"beschriftung": "Abteilung",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "ou",
|
||||
"beschriftung": "Ziel-OU (leer lassen: nimmt die Ziel-OU der Abteilung)",
|
||||
"typ": "TEXT"
|
||||
},
|
||||
{
|
||||
"schluessel": "gruppen",
|
||||
"beschriftung": "Gruppen, kommagetrennt (leer lassen: nimmt die Standardgruppen der Abteilung)",
|
||||
"typ": "TEXT"
|
||||
},
|
||||
{
|
||||
"schluessel": "eintrittsdatum",
|
||||
"beschriftung": "Eintrittsdatum",
|
||||
"typ": "DATUM",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "vorgesetzter",
|
||||
"beschriftung": "Vorgesetzter",
|
||||
"typ": "TEXT"
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Vorbereitung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Anforderung vollständig: Abteilung, Vorgesetzter, Eintrittsdatum, benötigte Zugriffe, Hardware",
|
||||
"hilfetext": "Eintritt am {{eintrittsdatum}} — bis dahin müssen Konto, Postfach und Gerät stehen. Fehlt eine dieser Angaben, steht der Lauf später fest. Einmal beim Vorgesetzten nachfragen ist billiger, als Konto, OU und Gruppen hinterher zu korrigieren.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Referenzbenutzer für Berechtigungen (falls genannt)",
|
||||
"hilfetext": "Ein bestehendes Konto derselben Rolle als Vorbild für Gruppen und Ablagerechte. Nicht blind kopieren: gewachsene Sonderrechte wandern sonst mit. Ist keiner genannt, das hier vermerken.",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Namensschema geprüft: UPN {{upn}}, Anmeldename {{sam}}, Mail {{mail}}",
|
||||
"hilfetext": "Vorschläge kommen aus den Namensregeln in den Einstellungen, OU und Gruppen aus der Abteilung. Vor dem Anlegen im Verzeichnis prüfen, ob der Name schon vergeben ist.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Konto anlegen (Active Directory)",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Konto anlegen",
|
||||
"hilfetext": "Dieser Abschnitt gilt nur für den Kontotyp „Active Directory“ (gewählt: {{konto_typ}}). Trifft er nicht zu, wird er als Ganzes über „Abschnitt trifft nicht zu“ erledigt — nicht Schritt für Schritt abgehakt.\n\nDas Passwort im Befehl entsteht bei jeder Anzeige neu und wird nirgends gespeichert — weder im Schrittergebnis noch im Protokoll oder im Ausdruck. Deshalb: Befehl ausführen und das Passwort im selben Zug in den Passwortmanager übertragen.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "New-ADUser -Name \"{{vorname}} {{nachname}}\" -GivenName \"{{vorname}}\" -Surname \"{{nachname}}\" `\n -DisplayName \"{{vorname}} {{nachname}}\" -SamAccountName \"{{sam}}\" -UserPrincipalName \"{{upn}}\" `\n -EmailAddress \"{{mail}}\" -Department \"{{abteilung}}\" -Path \"{{ou}}\" `\n -AccountPassword (ConvertTo-SecureString \"{{generiertes_passwort}}\" -AsPlainText -Force) `\n -ChangePasswordAtLogon $true -Enabled $true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Initialpasswort im Passwortmanager hinterlegt und sicher an den Benutzer bzw. Vorgesetzten übermittelt",
|
||||
"hilfetext": "Das Passwort aus dem Befehl oben wird nicht gespeichert.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Gruppen zuweisen",
|
||||
"hilfetext": "Setzt die Standardgruppen der Abteilung aus {{gruppen}}. Leerzeichen um die Kommata sind unschädlich, `Trim()` entfernt sie; die Gruppen müssen im Verzeichnis aber genau so heißen.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "\"{{gruppen}}\".Split(\",\") | ForEach-Object { Add-ADGroupMember -Identity $_.Trim() -Members \"{{sam}}\" }"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Vorgesetzten setzen",
|
||||
"hilfetext": "`{{vorgesetzter_sam}}` ist ein eingebauter Platzhalter und kommt aus dem Mitarbeiter am Lauf. Solange die Verknüpfung fehlt, den Anmeldenamen von {{vorgesetzter}} von Hand einsetzen.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Set-ADUser -Identity \"{{sam}}\" -Manager \"{{vorgesetzter_sam}}\""
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Kontrolle",
|
||||
"hilfetext": "Zeigt Name, UPN, Abteilung, Vorgesetzten und Gruppen in einer Liste. Die Ausgabe wirklich lesen: eine fehlende Gruppe fällt sonst erst als fehlender Zugriff auf.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Get-ADUser -Identity \"{{sam}}\" -Properties MemberOf, Department, Manager | Format-List Name, UserPrincipalName, Department, Manager, MemberOf"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Konto anlegen (Entra ID)",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Konto anlegen",
|
||||
"hilfetext": "Dieser Abschnitt gilt nur für den Kontotyp „Entra ID“ (gewählt: {{konto_typ}}). Trifft er nicht zu, wird er als Ganzes über „Abschnitt trifft nicht zu“ erledigt — nicht Schritt für Schritt abgehakt.\n\n`Connect-MgGraph` fragt einmal nach Anmeldung und Zustimmung. Das Passwort im Befehl entsteht bei jeder Anzeige neu und wird nirgends gespeichert, also sofort weiterverarbeiten.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Connect-MgGraph -Scopes \"User.ReadWrite.All\",\"Group.ReadWrite.All\"\nNew-MgUser -DisplayName \"{{vorname}} {{nachname}}\" -GivenName \"{{vorname}}\" -Surname \"{{nachname}}\" `\n -UserPrincipalName \"{{upn}}\" -MailNickname \"{{sam}}\" -Department \"{{abteilung}}\" -UsageLocation \"DE\" `\n -AccountEnabled -PasswordProfile @{ Password = \"{{generiertes_passwort}}\"; ForceChangePasswordNextSignIn = $true }"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Initialpasswort im Passwortmanager hinterlegt und sicher übermittelt",
|
||||
"hilfetext": "Das Passwort aus dem Befehl oben wird nicht gespeichert und ist nach dem Verlassen der Ansicht nicht mehr abrufbar. Nie zusammen mit dem UPN im selben Kanal versenden.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Gruppen zuweisen",
|
||||
"hilfetext": "Sucht jede Gruppe aus {{gruppen}} über ihren Anzeigenamen. Findet der Filter nichts, bleibt `$g` leer und der folgende Aufruf schlägt fehl — dann die Schreibweise prüfen.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "$u = Get-MgUser -UserId \"{{upn}}\"\n\"{{gruppen}}\".Split(\",\") | ForEach-Object { $g = Get-MgGroup -Filter \"displayName eq '$($_.Trim())'\"; New-MgGroupMember -GroupId $g.Id -DirectoryObjectId $u.Id }"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Zugewiesene Lizenz",
|
||||
"hilfetext": "Lizenz per Gruppe (gruppenbasierte Lizenzierung) oder `Set-MgUserLicense`.",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Konto, gemeinsam",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "MFA-Registrierung vorbereitet bzw. durchgeführt",
|
||||
"hilfetext": "Entweder ein befristeter Zugangspass für die Selbstregistrierung oder die gemeinsame Einrichtung am ersten Tag. Ohne MFA kommt der Benutzer bei bedingtem Zugriff nicht an sein Postfach.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Postfach erreichbar, Signatur eingerichtet",
|
||||
"hilfetext": "Einmal anmelden und eine Testmail nach außen senden. Signatur nach der Vorgabe des Hauses, mit richtiger Schreibweise von Name und Funktion.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Zugänge zu Fachanwendungen angelegt",
|
||||
"hilfetext": "Nur die Anwendungen, die die Rolle braucht — nicht alles, was der Referenzbenutzer im Laufe der Jahre angesammelt hat."
|
||||
},
|
||||
{
|
||||
"titel": "Angelegte Fachanwendungs-Zugänge",
|
||||
"hilfetext": "Je Zeile eine Anwendung mit Benutzername oder Rolle. Beim Offboarding wird genau diese Liste wieder abgearbeitet.",
|
||||
"typ": "TEXT",
|
||||
"konfig": {
|
||||
"mehrzeilig": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Hardware",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Arbeitsplatzgerät bereitgestellt (eigener Lauf „PC Neuinstallation“ falls nötig)",
|
||||
"hilfetext": "Gerät, Tag-Code und Inventarnummer gehören zusammen. Die Installation selbst läuft als eigener Lauf; hier wird nur festgehalten, dass das Gerät bereitsteht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Telefon / Softphone eingerichtet",
|
||||
"hilfetext": "Durchwahl vergeben, im Telefonbuch eingetragen und einmal in beide Richtungen getestet."
|
||||
},
|
||||
{
|
||||
"titel": "Peripherie vorhanden (Monitor, Dockingstation, Headset)",
|
||||
"hilfetext": "Am Arbeitsplatz aufgebaut und angeschlossen, nicht nur aus dem Lager geholt."
|
||||
},
|
||||
{
|
||||
"titel": "Zutrittsmedium / Schlüssel beantragt",
|
||||
"hilfetext": "Ausgabe und Rückgabe werden dokumentiert — beim Offboarding wird danach gefragt."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Erster Tag",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Anmeldung gemeinsam mit dem Benutzer getestet",
|
||||
"hilfetext": "Erstanmeldung mit Passwortwechsel und MFA am eigenen Gerät. Erst danach gilt das Konto als übergeben.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Kurzeinweisung: Passwortregeln, MFA, Datenablage, Ansprechpartner IT",
|
||||
"hilfetext": "Kurz und mündlich, mit dem Hinweis, wo Daten hingehören und wie man die IT erreicht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Dokumentation und Inventar aktualisiert",
|
||||
"hilfetext": "Mitarbeiter, Gerät, Rufnummer und Zutrittsmedium eingetragen. Was hier fehlt, fehlt beim Offboarding auch.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Mitarbeiter-Offboarding",
|
||||
"beschreibung": "Konto sperren, Postfach und Daten übergeben, Hardware und Zugänge einsammeln, wenn jemand das Haus verlässt.",
|
||||
"kategorie": "Benutzer",
|
||||
"schlagworte": ["offboarding", "austritt", "benutzerkonto", "sperrung"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{
|
||||
"schluessel": "austrittsdatum",
|
||||
"beschriftung": "Austrittsdatum",
|
||||
"typ": "DATUM",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "ou_gesperrt",
|
||||
"beschriftung": "OU für deaktivierte Konten",
|
||||
"typ": "TEXT"
|
||||
},
|
||||
{
|
||||
"schluessel": "postfach_verbleib",
|
||||
"beschriftung": "Verbleib des Postfachs",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["In Shared Mailbox umwandeln", "Weiterleitung", "Export und löschen"],
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "vertreter",
|
||||
"beschriftung": "Vertreter",
|
||||
"typ": "TEXT"
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Klärung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Austrittsdatum und Zeitpunkt der Sperrung mit Vorgesetztem/HR abgestimmt",
|
||||
"hilfetext": "{{austrittsdatum}} nennt nur den Tag. Die Sperrung gehört üblicherweise auf den letzten Arbeitstag nach Dienstschluss — die genaue Uhrzeit hier schriftlich festhalten.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Verbleib von Postfach und Daten geklärt: {{postfach_verbleib}}",
|
||||
"hilfetext": "Wer bekommt Zugriff, wie lange, und was geschieht danach? Ohne diese Festlegung wird am Postfach nichts verändert.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Sperrung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Konto sperren (AD)",
|
||||
"hilfetext": "Deaktivieren, Passwort zurücksetzen und nach {{ou_gesperrt}} verschieben — in dieser Reihenfolge. Das neue Passwort wird nirgends gespeichert und soll auch von niemandem mehr benutzt werden; es verhindert eine erneute Anmeldung. Bestehende Sitzungen beendet es nicht — die Kerberos-Tickets laufen erst aus, siehe nächster Schritt.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Disable-ADAccount -Identity \"{{sam}}\"\nSet-ADAccountPassword -Identity \"{{sam}}\" -Reset -NewPassword (ConvertTo-SecureString \"{{generiertes_passwort}}\" -AsPlainText -Force)\nMove-ADObject -Identity (Get-ADUser \"{{sam}}\").DistinguishedName -TargetPath \"{{ou_gesperrt}}\""
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Konto sperren (Entra ID)",
|
||||
"hilfetext": "Deaktiviert das Konto und widerruft die Aktualisierungstoken. Bereits ausgegebene Zugriffstoken können noch bis zu einer Stunde gelten — bei Eile zusätzlich das Passwort ändern.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Update-MgUser -UserId \"{{upn}}\" -AccountEnabled:$false\nRevoke-MgUserSignInSession -UserId \"{{upn}}\""
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Aktive Sitzungen und Token widerrufen (AD: Kerberos-Tickets laufen aus, bei Bedarf `Set-ADUser -Replace @{msDS-KeyVersionNumber=...}` nicht nötig; Hinweis auf Entra-Befehl oben)",
|
||||
"hilfetext": "Im AD genügt das Zurücksetzen des Passworts, die Kerberos-Tickets laufen von selbst aus. In Entra ID erledigt das `Revoke-MgUserSignInSession` aus dem Schritt oben. Offene Sitzungen an Terminalservern trennt man zusätzlich von Hand.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "MFA-Methoden und App-Passwörter entfernt",
|
||||
"hilfetext": "Sonst bleibt eine zweite Tür offen: registrierte Authenticator-Apps und Telefonnummern liegen beim Benutzer, nicht bei der IT.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "VPN-, WLAN- und Remotezugänge entfernt",
|
||||
"hilfetext": "Auch Zertifikate, hinterlegte Profile und geteilte Zugangsdaten. Ein gesperrtes Konto hilft nicht, wenn ein VPN-Zertifikat weiter gültig ist.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Mobile Geräte aus MDM entfernt bzw. Firmendaten gelöscht",
|
||||
"hilfetext": "Firmengeräte vollständig zurücksetzen, private Geräte nur um die Firmendaten bereinigen (selektives Löschen).",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Daten",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Abwesenheitsnotiz mit Verweis auf {{vertreter}} gesetzt",
|
||||
"hilfetext": "Intern und extern getrennt formulieren und den Austritt nur so weit erwähnen wie nötig. Die Notiz wirkt auch dann noch, wenn das Postfach eine Shared Mailbox geworden ist.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Postfach umwandeln",
|
||||
"hilfetext": "Nur wenn {{postfach_verbleib}} die Umwandlung vorsieht. Eine Shared Mailbox braucht keine eigene Lizenz — deshalb steht dieser Schritt vor der Lizenzfreigabe.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Set-Mailbox -Identity \"{{upn}}\" -Type Shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Zugriff auf Postfach und OneDrive/Home-Laufwerk an {{vertreter}} übergeben oder Daten gesichert",
|
||||
"hilfetext": "Zugriff befristet vergeben und die Frist notieren. Wird nichts übergeben, gehört eine Sicherung an einen Ort, der die Aufbewahrungsfrist übersteht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Aus Gruppen und Verteilern entfernt",
|
||||
"hilfetext": "Auch aus Verteilern, Teams und geteilten Ablagen. Was hier stehen bleibt, taucht Monate später als Mail an eine gesperrte Adresse wieder auf.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Lizenz freigegeben",
|
||||
"hilfetext": "Erst nach der Umwandlung in eine Shared Mailbox, sonst gehen Daten verloren.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Zugänge zu Fachanwendungen und externen Portalen gesperrt",
|
||||
"hilfetext": "Die im Onboarding festgehaltene Liste ist der Ausgangspunkt. Externe Portale werden gern vergessen, weil sie nicht am Verzeichnis hängen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Gesperrte externe Zugänge",
|
||||
"hilfetext": "Je Zeile ein Portal mit dem Datum der Sperrung. Das ist der Nachweis, wenn später jemand danach fragt.",
|
||||
"typ": "TEXT",
|
||||
"konfig": {
|
||||
"mehrzeilig": true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Hardware",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Geräte zurückerhalten (PC/Notebook, Telefon, Token, Zutrittsmedium)",
|
||||
"hilfetext": "Gegen die Inventarliste prüfen und die Rückgabe quittieren lassen. Datenträger erst danach löschen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Fehlende oder beschädigte Gegenstände",
|
||||
"hilfetext": "Fehlendes hier festhalten und dem Vorgesetzten melden — mündlich ist es nach zwei Wochen nicht mehr belegbar.",
|
||||
"typ": "TEXT",
|
||||
"konfig": {
|
||||
"mehrzeilig": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Inventar aktualisiert",
|
||||
"hilfetext": "Geräte vom Mitarbeiter lösen und ihren neuen Zustand eintragen (Lager, Nachfolger oder Ausmusterung). Der Tag-Code bleibt am Gerät und wird nie neu vergeben.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Abschluss",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Termin für endgültige Kontolöschung",
|
||||
"hilfetext": "Üblich sind drei bis sechs Monate nach dem Austritt, abgestimmt mit der Aufbewahrungsfrist. Bis dahin bleibt das Konto deaktiviert stehen.",
|
||||
"typ": "DATUM",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Dokumentation aktualisiert, Ticket geschlossen",
|
||||
"hilfetext": "Im Ticket festhalten, was mit Postfach, Daten und Geräten geschehen ist. Der Lauf selbst bleibt als Nachweis im Archiv.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Server-Inbetriebnahme Windows-VM",
|
||||
"beschreibung": "Inbetriebnahme einer neuen Windows-Server-VM: Planung und Adressvergabe, Installation, Domänenbeitritt, Absicherung sowie Aufnahme in Backup und Monitoring.",
|
||||
"kategorie": "Server",
|
||||
"schlagworte": ["windows", "server", "vm", "inbetriebnahme"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{ "schluessel": "vmid", "beschriftung": "VM-Kennung im Hypervisor", "typ": "TEXT" },
|
||||
{ "schluessel": "ip_adresse", "beschriftung": "IP-Adresse", "typ": "TEXT", "pflicht": true },
|
||||
{ "schluessel": "vlan", "beschriftung": "VLAN", "typ": "TEXT" },
|
||||
{
|
||||
"schluessel": "rolle",
|
||||
"beschriftung": "Rolle (z. B. Fileserver)",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"schluessel": "os_version",
|
||||
"beschriftung": "Windows-Server-Version",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["Windows Server 2022", "Windows Server 2025"]
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Planung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Ressourcen festgelegt (vCPU, RAM, Datenträger) und dokumentiert",
|
||||
"hilfetext": "Knapp dimensionieren und später erweitern ist einfacher als der umgekehrte Weg: RAM und Datenträger lassen sich vergrößern, verkleinern praktisch nicht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "IP {{ip_adresse}} reserviert und in IP-Dokumentation eingetragen, VLAN {{vlan}}",
|
||||
"hilfetext": "Die Adresse vor dem Anlegen der VM reservieren, sonst vergibt der DHCP-Bereich sie womöglich ein zweites Mal. VLAN und Verwendungszweck gleich mit eintragen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "DNS-Name nach Namensschema: {{hostname}}",
|
||||
"hilfetext": "A-Record und PTR anlegen, nicht nur den A-Record. Ohne Rückwärtsauflösung melden sich Backup- und Monitoring-Agenten mit der IP statt mit dem Namen.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "VM und Betriebssystem",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "VM angelegt (VirtIO SCSI, VirtIO-Netzwerk, QEMU Guest Agent aktiviert, UEFI + TPM)",
|
||||
"hilfetext": "Die VirtIO-Treiber-ISO schon beim Setup einbinden, sonst findet die Installation keinen Datenträger. UEFI und TPM lassen sich nachträglich nur mit Aufwand ergänzen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "{{os_version}} installiert, VirtIO-Treiber und Guest Agent installiert",
|
||||
"hilfetext": "Der Guest Agent ist erst dann wirklich aktiv, wenn der Hypervisor die IP-Adresse der VM anzeigt. Dann funktionieren auch geordnetes Herunterfahren und konsistente Sicherungen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Statische IP, DNS-Server und Gateway gesetzt",
|
||||
"hilfetext": "Ein Server holt seine Adresse nicht per DHCP. Danach Namensauflösung in beide Richtungen prüfen und das Gateway anpingen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Hostname gesetzt",
|
||||
"hilfetext": "Vor dem Domänenbeitritt umbenennen, nicht danach — sonst steht das Computerkonto im Verzeichnis unter dem alten Namen.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": {
|
||||
"shell": "powershell",
|
||||
"befehl": "Rename-Computer -NewName \"{{hostname}}\" -Restart"
|
||||
}
|
||||
},
|
||||
{
|
||||
"titel": "Windows Updates vollständig",
|
||||
"hilfetext": "Mehrfach durchlaufen lassen, bis nichts mehr kommt, und erst danach die Rolle installieren. Das künftige Wartungsfenster gleich festlegen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Zeitzone korrekt, Zeitsynchronisation prüfen",
|
||||
"hilfetext": "Mehr als fünf Minuten Zeitversatz lassen Kerberos-Anmeldungen scheitern. In der Ausgabe auf „Source“ achten: ein Domänenmitglied synchronisiert gegen den Domänencontroller, nicht gegen einen Internetserver.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "shell": "cmd", "befehl": "w32tm /query /status" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Einbindung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Domänenbeitritt, richtige OU",
|
||||
"hilfetext": "Das Computerkonto gehört in die vorgesehene Server-OU, nicht in den Standard-Container „Computers“ — dort greifen die Richtlinien nicht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Vertrauensstellung prüfen",
|
||||
"hilfetext": "Muss `True` liefern. Ein `False` deutet auf ein zurückgesetztes Computerkonto hin, etwa nach dem Zurückspielen eines älteren Snapshots.",
|
||||
"typ": "BEFEHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "shell": "powershell", "befehl": "Test-ComputerSecureChannel -Verbose" }
|
||||
},
|
||||
{
|
||||
"titel": "Rolle eingerichtet: {{rolle}}",
|
||||
"hilfetext": "Nur die Rolle installieren, die der Server tragen soll. Jede zusätzliche Rolle vergrößert Angriffsfläche und Abhängigkeiten bei Neustarts.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Lizenz aktiviert",
|
||||
"hilfetext": "Den Aktivierungsstatus prüfen, nicht nur den Schlüssel eintragen. Bei KMS und AVMA kommt die Aktivierung erst nach dem Domänenbeitritt bzw. über den Host zustande.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Sicherheit",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Lokales Administratorpasswort im Passwortmanager bzw. LAPS aktiv",
|
||||
"hilfetext": "Für jeden Server ein eigenes Kennwort. Ein gemeinsames lokales Adminkennwort ist der kürzeste Weg von einem übernommenen Server zu allen anderen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "RDP nur für berechtigte Gruppe, NLA aktiv",
|
||||
"hilfetext": "RDP nicht für „Jeder“ öffnen und nie direkt aus dem Internet erreichbar machen. Mit aktiver NLA scheitert ein Anmeldeversuch, bevor überhaupt eine Sitzung aufgebaut wird.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Virenschutz/EDR aktiv, Ausnahmen laut Rolle gesetzt",
|
||||
"hilfetext": "Ausnahmen nur für die Pfade und Prozesse der Rolle setzen und begründet dokumentieren. Eine pauschale Ausnahme für ein ganzes Laufwerk hebt den Schutz auf.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Firewall-Regeln auf das Nötige beschränkt",
|
||||
"hilfetext": "Nur die Ports der Rolle, und nur aus den Netzen, die sie brauchen. Nach dem Einrichten von außen nachmessen, statt der Regelliste zu vertrauen.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Betrieb",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "In Backup aufgenommen, erster Sicherungslauf erfolgreich",
|
||||
"hilfetext": "Es zählt der erfolgreiche erste Lauf, nicht der angelegte Auftrag. Aufbewahrungsdauer und Sicherungsziel gleich mitprüfen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Testwiederherstellung einer Datei durchgeführt",
|
||||
"hilfetext": "Eine Sicherung, aus der noch nie etwas zurückgeholt wurde, ist keine. Eine einzelne Datei an einen anderen Ort wiederherstellen und den Inhalt ansehen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Monitoring eingerichtet (Agent, Host, Trigger)",
|
||||
"hilfetext": "Der Agent allein genügt nicht: Trigger für Datenträgerfüllstand, die Dienste der Rolle und Erreichbarkeit setzen und einen Alarm testweise auslösen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "RMM-Agent installiert",
|
||||
"hilfetext": "Im Portal prüfen, ob der Server online erscheint und der richtigen Gruppe zugeordnet ist. Wartungsfenster hinterlegen, damit Patches nicht mitten am Tag laufen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Dokumentation erstellt (Zweck, Rolle, IP, Abhängigkeiten, Verantwortlicher)",
|
||||
"hilfetext": "Wichtig ist, was ein Kollege um drei Uhr nachts braucht: Zweck, Abhängigkeiten, Verantwortlicher und wo die Zugangsdaten liegen.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Firewall-Rollout OPNsense",
|
||||
"beschreibung": "Austausch oder Neuaufbau einer OPNsense-Firewall: von den Providerdaten über Regelwerk und VPN bis zur Abnahme und zur außer Betrieb genommenen Altfirewall.",
|
||||
"kategorie": "Netzwerk",
|
||||
"schlagworte": ["firewall", "opnsense", "netzwerk", "vpn", "rollout"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{
|
||||
"schluessel": "wan_typ",
|
||||
"beschriftung": "WAN-Typ",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["DHCP", "Statisch", "PPPoE"],
|
||||
"pflicht": true
|
||||
},
|
||||
{ "schluessel": "lan_netz", "beschriftung": "LAN-Netz", "typ": "TEXT", "pflicht": true },
|
||||
{
|
||||
"schluessel": "vpn_typ",
|
||||
"beschriftung": "VPN-Typ",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["WireGuard", "OpenVPN", "IPsec", "Keines"]
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Vorbereitung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Zugangsdaten des Providers vorhanden, WAN-Typ: {{wan_typ}}",
|
||||
"hilfetext": "Bei PPPoE Benutzername und Passwort, bei einer statischen Leitung Adresse, Netzmaske und Gateway. Fehlt eine Angabe, steht der Umbau mitten im Wartungsfenster still.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Netzplan mit VLANs und Adressbereichen abgestimmt, LAN: {{lan_netz}}",
|
||||
"hilfetext": "Jedes VLAN braucht ID, Adressbereich und Zweck. Überschneidungen mit den Netzen der Gegenstellen fallen sonst erst beim ersten VPN-Tunnel auf.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Wartungsfenster mit den betroffenen Abteilungen vereinbart",
|
||||
"hilfetext": "Der Umbau trennt Internet und alle Dienste hinter der Firewall. Telefonie, Filialanbindung und externe Dienstleister ausdrücklich mitnennen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Konfiguration der Altfirewall gesichert",
|
||||
"hilfetext": "Vollständiger Konfigurationsexport, dazu Ausdruck oder Screenshots des Regelwerks. Das ist der einzige Rückweg, wenn später eine Regel fehlt.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Grundkonfiguration",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Aktuelle Firmware installiert",
|
||||
"hilfetext": "Vor dem Einrichten aktualisieren, nicht danach: ein Update startet die Firewall neu und setzt Dienste neu auf.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Firmware-Version",
|
||||
"hilfetext": "Genau so eintragen, wie das Dashboard sie anzeigt. Damit lässt sich ein späteres Fehlverhalten einer Version zuordnen.",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Hostname, Domain, Zeitzone, NTP gesetzt",
|
||||
"hilfetext": "Eine falsche Uhr macht Zertifikate, VPN-Aushandlung und Logauswertung unbrauchbar. NTP-Quelle nicht nur eintragen, sondern die Synchronisation prüfen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Admin-Zugang: eigenes Konto, Passwort im Passwortmanager, Root-Login per Passwort eingeschränkt",
|
||||
"hilfetext": "Persönliches Konto je Techniker, damit das Log zeigt, wer etwas geändert hat. root bleibt für den Notfall, möglichst nur per Schlüssel.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "WebGUI nur aus Management-Netz erreichbar, HTTPS",
|
||||
"hilfetext": "Von außen gegenproben: die Oberfläche darf auf der WAN-Seite nicht antworten. Ein selbstsigniertes Zertifikat ist in Ordnung, unverschlüsseltes HTTP nicht.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "WAN eingerichtet und Internetzugang getestet",
|
||||
"hilfetext": "Nicht nur den Link prüfen, sondern Namensauflösung und einen echten Abruf. Bei PPPoE auf die MTU achten, sonst brechen einzelne Seiten ab.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "LAN und VLANs mit DHCP eingerichtet",
|
||||
"hilfetext": "Je Netz Bereich, Gateway, DNS und Leasedauer. Feste Reservierungen für Drucker und Server aus der Altkonfiguration übernehmen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "DNS-Resolver konfiguriert",
|
||||
"hilfetext": "Interne Zonen an den Domänencontroller weiterleiten, alles andere nach außen auflösen. Sonst findet kein Client mehr seine Freigaben.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Regelwerk",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Regeln pro Netz nach Konzept, keine Any-Any-Regeln zwischen den VLANs",
|
||||
"hilfetext": "Jede Regel mit Beschreibung anlegen. Die Trennung der Netze ist der ganze Sinn der Übung, und eine einzige Any-Any-Regel hebt sie wieder auf.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Gäste- und IoT-Netz vom internen Netz getrennt",
|
||||
"hilfetext": "Aus dem Gastnetz gegenproben: Ping und Zugriff auf eine interne Freigabe müssen scheitern. Auch den Weg über DNS und Multicast mitprüfen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Portweiterleitungen nur soweit nötig und dokumentiert",
|
||||
"hilfetext": "Jede Weiterleitung ist ein Loch nach innen. Quelladressen einschränken, wo es geht, und RDP niemals offen ins Internet stellen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Eingerichtete Portweiterleitungen",
|
||||
"hilfetext": "Je Zeile externer Port, Ziel und Zweck. Wer das nicht notiert, sucht in einem Jahr, wozu Port 8443 einmal gehörte.",
|
||||
"typ": "TEXT",
|
||||
"konfig": { "mehrzeilig": true }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "VPN und Dienste",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "VPN eingerichtet: {{vpn_typ}}",
|
||||
"hilfetext": "Der Adressbereich des Tunnels darf sich mit keinem Netz auf beiden Seiten überschneiden. Zugriff auf das Nötige begrenzen, nicht auf alles."
|
||||
},
|
||||
{
|
||||
"titel": "VPN-Zugriff von extern getestet",
|
||||
"hilfetext": "Aus einem fremden Netz testen, etwa über das Mobilfunknetz. Ein Test aus dem eigenen LAN sagt über den Zugang von draußen nichts."
|
||||
},
|
||||
{
|
||||
"titel": "Dynamisches DNS eingerichtet (falls keine feste IP)",
|
||||
"hilfetext": "Nach dem Einrichten eine Aktualisierung erzwingen und den Namen auflösen. Ohne feste Adresse hängt der ganze VPN-Zugang daran."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Betrieb",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Automatische Konfigurationssicherung eingerichtet",
|
||||
"hilfetext": "Ziel außerhalb der Firewall wählen, sonst ist die Sicherung mit dem Gerät verloren. Einmal eine Sicherung herunterladen und hineinsehen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Syslog an zentralen Logserver",
|
||||
"hilfetext": "Lokal hält die Firewall nur wenige Tage vor. Erst ein externer Empfänger macht Regelfehler und Angriffe im Nachhinein nachvollziehbar.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Monitoring eingerichtet (SNMP/Agent, WAN-Verfügbarkeit)",
|
||||
"hilfetext": "Neben der Erreichbarkeit auch Durchsatz, Auslastung und Zustand des WAN-Gateways erfassen. Einen Alarm einmal auslösen und die Benachrichtigung abwarten.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Benachrichtigungen bei Firmware-Updates geklärt",
|
||||
"hilfetext": "Festlegen, wer von neuen Versionen erfährt und wer über den Einbau entscheidet. Sicherheitsupdates einer Firewall dulden keine Wochen.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Abnahme",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Funktionstest vor Ort: Internet, interne Dienste, Drucker, Telefonie, VPN",
|
||||
"hilfetext": "Mit einem echten Arbeitsplatz durchgehen, nicht vom Technikerlaptop aus. Telefonie zuletzt prüfen, denn SIP scheitert am ehesten an NAT-Einstellungen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Altfirewall außer Betrieb, zurückgesetzt bzw. sicher gelöscht",
|
||||
"hilfetext": "Bis zur Abnahme nur vom Netz nehmen, erst danach zurücksetzen. Auf dem Gerät stehen Zugangsdaten, Zertifikate und VPN-Schlüssel.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Netzwerkdokumentation aktualisiert",
|
||||
"hilfetext": "VLANs, Adressbereiche, Regelwerk, Weiterleitungen und Zugänge nachziehen. Die Dokumentation von gestern beschreibt noch die Altfirewall.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Abnahme durch den Verantwortlichen",
|
||||
"hilfetext": "Mit Namen und Datum festhalten. Was danach kommt, ist eine Änderung und kein Teil des Rollouts mehr.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Monatlicher Patchday",
|
||||
"beschreibung": "Der wiederkehrende Ablauf für Updates an Servern, Hypervisor, Netzgeräten und Clients: ankündigen, einspielen, kontrollieren und berichten.",
|
||||
"kategorie": "Wartung",
|
||||
"schlagworte": ["patchday", "updates", "wartung", "monatlich"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{
|
||||
"schluessel": "monat",
|
||||
"beschriftung": "Monat (z. B. 2026-10)",
|
||||
"typ": "TEXT",
|
||||
"pflicht": true
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Vorbereitung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Wartungsfenster angekündigt",
|
||||
"hilfetext": "Mindestens einen Werktag vorher, mit Zeitraum und betroffenen Diensten. Neustarts von Terminalservern und Domänencontrollern ausdrücklich nennen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Bekannte Probleme der aktuellen Updates geprüft",
|
||||
"hilfetext": "Herstellerhinweise und einschlägige Foren durchsehen. Ein zurückgezogenes Update erspart einen Abend Fehlersuche.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Letzte Sicherung aller betroffenen Systeme erfolgreich",
|
||||
"hilfetext": "Nicht nur den Jobstatus ansehen, sondern das Datum des letzten fehlerfreien Wiederherstellungspunkts. Ohne Sicherung kein Patch.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "VM-Snapshot vor kritischen Updates",
|
||||
"hilfetext": "`<vmid>` durch die Nummer der VM ersetzen. Ein Snapshot ist kein Backup und kostet Speicher, also nach der Kontrolle wieder entfernen.",
|
||||
"typ": "BEFEHL",
|
||||
"konfig": { "shell": "bash", "befehl": "qm snapshot <vmid> prepatch" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Durchführung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Server gepatcht und neu gestartet, Reihenfolge beachtet (Domänencontroller nicht gleichzeitig)",
|
||||
"hilfetext": "Einen Domänencontroller nach dem anderen und die Replikation zwischendurch prüfen. Anwendungsserver erst, wenn ihre Datenbank wieder läuft.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Hypervisor-Updates geprüft bzw. eingespielt",
|
||||
"hilfetext": "Gäste vorher verschieben oder sauber herunterfahren. Knoten eines Clusters auf gleichem Stand halten, Gasterweiterungen mitziehen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Firewall-, Switch- und AP-Firmware geprüft",
|
||||
"hilfetext": "Netzgeräte zuletzt und einzeln, damit der eigene Zugriff nicht abreißt. Vorher die Konfiguration sichern.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Clients: Update-Status im RMM geprüft",
|
||||
"hilfetext": "Nach ausstehenden Neustarts und nach Geräten sehen, die seit Wochen nichts melden. Ein Client, der stumm bleibt, ist das eigentliche Problem.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Anzahl Clients mit ausstehenden Updates",
|
||||
"hilfetext": "Der Wert aus dem RMM nach dem Durchlauf. Über Monate verglichen zeigt er, ob die Verteilung wirklich greift.",
|
||||
"typ": "ZAHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "min": 0 }
|
||||
},
|
||||
{
|
||||
"titel": "Drittanbieter-Software aktualisiert (Browser, PDF, Java, Laufzeitumgebungen)",
|
||||
"hilfetext": "Über diese Programme kommen Angriffe herein, seltener über das Betriebssystem. Altlasten, die niemand mehr braucht, besser deinstallieren.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Kontrolle",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Alle Dienste nach Neustart gestartet, Monitoring ohne neue Alarme",
|
||||
"hilfetext": "Besonders auf Dienste achten, die verzögert starten oder an einem Dienstkonto hängen. Das Monitoring einige Minuten beobachten, nicht nur einmal ansehen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Anmeldung und Kernanwendungen stichprobenartig getestet",
|
||||
"hilfetext": "Mit einem normalen Benutzerkonto anmelden, nicht als Administrator. ERP, Mail und Drucken kurz antesten.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Sicherung nach dem Patchday erfolgreich gelaufen",
|
||||
"hilfetext": "Der erste Lauf nach den Updates ist der wichtigste: geänderte Agenten und Treiber lassen Jobs gern scheitern.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Snapshots wieder entfernt",
|
||||
"hilfetext": "Liegengebliebene Snapshots füllen den Speicher und bremsen die VM. Erst löschen, wenn die Kontrolle abgeschlossen ist.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Auffälligkeiten und zurückgestellte Updates",
|
||||
"hilfetext": "Je Zeile System, Update und Grund der Zurückstellung. Das ist die Arbeitsliste für den nächsten Patchday.",
|
||||
"typ": "TEXT",
|
||||
"konfig": { "mehrzeilig": true }
|
||||
},
|
||||
{
|
||||
"titel": "Wartungsbericht {{monat}} abgelegt und an die IT-Leitung gemeldet",
|
||||
"hilfetext": "Kurz halten: was eingespielt wurde, was offen blieb, welcher Aufwand entstanden ist.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"format": 1,
|
||||
"name": "Backup-Restore-Test",
|
||||
"beschreibung": "Der regelmäßige Nachweis, dass sich eine Sicherung wirklich zurückholen lässt: Wiederherstellungspunkt prüfen, in einer isolierten Umgebung zurückspielen, Funktion und Dauer festhalten.",
|
||||
"kategorie": "Wartung",
|
||||
"schlagworte": ["backup", "restore", "wiederherstellung", "test", "wartung"],
|
||||
"struktur": {
|
||||
"variablen": [
|
||||
{ "schluessel": "system", "beschriftung": "System", "typ": "TEXT", "pflicht": true },
|
||||
{
|
||||
"schluessel": "restore_typ",
|
||||
"beschriftung": "Art der Wiederherstellung",
|
||||
"typ": "AUSWAHL",
|
||||
"optionen": ["Einzeldatei", "Komplette VM", "Datenbank", "Postfach"],
|
||||
"pflicht": true
|
||||
}
|
||||
],
|
||||
"abschnitte": [
|
||||
{
|
||||
"titel": "Vorbereitung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Testumfang festgelegt: {{restore_typ}} von {{system}}",
|
||||
"hilfetext": "Vorher festlegen, was als Erfolg gilt: welche Datei, welcher Dienst, welche Anmeldung. Sonst wird aus dem Test ein Herumprobieren.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Isolierte Zielumgebung vorhanden (kein Konflikt mit dem Produktivsystem)",
|
||||
"hilfetext": "Eigenes Netz oder getrennte Netzwerkkarte, geänderter Hostname. Ein wiederhergestellter Domänencontroller im Produktivnetz richtet echten Schaden an.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Prüfung der Sicherungen",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Sicherungsjobs der letzten 30 Tage ohne ungeklärte Fehler",
|
||||
"hilfetext": "Auch Warnungen ansehen: übersprungene Dateien und abgebrochene Teiljobs sind die häufigste Ursache für eine unbrauchbare Wiederherstellung.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Datum des verwendeten Wiederherstellungspunkts",
|
||||
"hilfetext": "Nicht ungeprüft den neuesten Punkt nehmen. Ein etwas älterer zeigt eher, ob die Aufbewahrungskette hält.",
|
||||
"typ": "DATUM",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Externe bzw. unveränderliche Kopie vorhanden und aktuell",
|
||||
"hilfetext": "Die Kopie außer Haus oder mit Schreibschutz ist die, die einen Verschlüsselungsangriff übersteht. Ihr Alter prüfen, nicht nur ihre Existenz.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Integritätsprüfung (Verify) des Wiederherstellungspunkts erfolgreich",
|
||||
"hilfetext": "Die Prüfung liest die Sicherung wirklich durch. Ein stiller Lesefehler auf dem Sicherungsmedium fällt anders nicht auf.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Wiederherstellung",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Wiederherstellung durchgeführt",
|
||||
"hilfetext": "In die Testumgebung zurückspielen, nie über das Original. Auffälligkeiten während des Laufs gleich notieren.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Dauer der Wiederherstellung",
|
||||
"hilfetext": "Von Start bis benutzbar messen, nicht nur die Kopierzeit. Die Zahl ist die Grundlage für die zugesagte Wiederanlaufzeit.",
|
||||
"typ": "ZAHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "min": 0, "einheit": "Minuten" }
|
||||
},
|
||||
{
|
||||
"titel": "Wiederhergestellte Daten bzw. System auf Funktion geprüft (Dateien öffnen, Dienst startet, Anmeldung möglich)",
|
||||
"hilfetext": "Eine Datei zählt erst, wenn sie sich öffnen lässt. Bei Datenbanken den Dienst starten und eine Abfrage ausführen.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Ergebnis",
|
||||
"hilfetext": "Alles außer „Erfolgreich“ braucht eine Erklärung im Textfeld am Ende des Laufzettels.",
|
||||
"typ": "AUSWAHL",
|
||||
"pflicht": true,
|
||||
"konfig": { "optionen": ["Erfolgreich", "Mit Einschränkungen", "Fehlgeschlagen"] }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"titel": "Abschluss",
|
||||
"schritte": [
|
||||
{
|
||||
"titel": "Testumgebung wieder entfernt",
|
||||
"hilfetext": "Wiederhergestellte Systeme und Daten löschen, nicht nur abschalten. Eine vergessene Kopie ist ein Datenschutzproblem.",
|
||||
"pflicht": true
|
||||
},
|
||||
{
|
||||
"titel": "Festgestellte Mängel und Maßnahmen",
|
||||
"hilfetext": "Je Zeile Mangel, geplante Maßnahme und Verantwortlicher. Ein Test ohne Folgerung ändert nichts.",
|
||||
"typ": "TEXT",
|
||||
"konfig": { "mehrzeilig": true }
|
||||
},
|
||||
{
|
||||
"titel": "Ergebnis dokumentiert und der IT-Leitung mitgeteilt",
|
||||
"hilfetext": "Mit Datum, Wiederherstellungspunkt, Dauer und Ergebnis. Dieser Nachweis wird in Audits und von Versicherern verlangt.",
|
||||
"pflicht": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+73
-81
@@ -1,19 +1,37 @@
|
||||
import "dotenv/config";
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "../src/generated/prisma/client.js";
|
||||
import { Betriebsmodus, Rolle } from "../src/generated/prisma/enums.js";
|
||||
import { Betriebsmodus } from "../src/generated/prisma/enums.js";
|
||||
import { demoKundenSaeen } from "./seed-daten/dienstleistung.js";
|
||||
import {
|
||||
adminAnlegen,
|
||||
eigeneOrganisationAnlegen,
|
||||
einstellungAnlegen,
|
||||
modusLesen,
|
||||
} from "./seed-daten/grundbestand.js";
|
||||
import { beispielQuelleSaeen, stammdatenSaeen } from "./seed-daten/stammdaten.js";
|
||||
import { typVorgabenSaeen, vorlagenSaeen } from "./seed-daten/vorlagen.js";
|
||||
|
||||
/**
|
||||
* Grundbestand, ohne den die Anwendung nicht bedienbar waere: der Betriebsmodus,
|
||||
* die eigene Organisation und ein Administrator.
|
||||
* Der Seed: was eine frische Installation braucht, um bedienbar zu sein (#017).
|
||||
*
|
||||
* Der Seed ist idempotent – zweimal hintereinander aufgerufen aendert der zweite
|
||||
* Lauf nichts. Er laeuft im Compose-Stand bewusst NICHT automatisch mit, sondern
|
||||
* ist ein dokumentierter Handgriff (siehe README).
|
||||
*
|
||||
* Die sieben Vorlagen, die Beispiel-Mitarbeiter und die Demo-Kunden kommen mit
|
||||
* #017 dazu.
|
||||
* Angelegt werden: Betriebsmodus samt Namensregeln, die eigene Organisation, ein
|
||||
* Administrator, die sieben Vorlagen als veroeffentlichte Version 1 und ein
|
||||
* Beispielbestand aus Abteilungen, Personen, Geraeten und Tags. Im Modus INTERN
|
||||
* haengt der an der eigenen Organisation und bekommt eine **deaktivierte**
|
||||
* Beispiel-Verzeichnisquelle dazu; im Modus DIENSTLEISTUNG haengt er an den
|
||||
* Demo-Kunden — „erzeugt stattdessen“, so steht es im Auftrag.
|
||||
*
|
||||
* Die einzelnen Schritte liegen unter `prisma/seed-daten/`, damit Tests sie
|
||||
* einzeln aufrufen koennen: diese Datei fuehrt beim Import den ganzen Seed aus.
|
||||
*
|
||||
* Was schon da ist, bleibt, wie es ist. Der Seed richtet ein; er raeumt nicht auf
|
||||
* und stellt nichts zurueck. Wer eine Vorlage bearbeitet oder eine Ziel-OU
|
||||
* geaendert hat, bekommt sie beim naechsten Lauf nicht ueberschrieben.
|
||||
*/
|
||||
|
||||
const url = process.env["DATABASE_URL"];
|
||||
@@ -23,84 +41,58 @@ if (!url) {
|
||||
|
||||
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
|
||||
|
||||
/** Akzeptiert den im Auftrag dokumentierten Wert SERVICE als Alias. */
|
||||
function modusLesen(): Betriebsmodus {
|
||||
const roh = (process.env["SEED_MODE"] ?? "INTERN").trim().toUpperCase();
|
||||
if (roh === "DIENSTLEISTUNG" || roh === "SERVICE") return Betriebsmodus.DIENSTLEISTUNG;
|
||||
if (roh === "INTERN") return Betriebsmodus.INTERN;
|
||||
throw new Error(`SEED_MODE kennt nur INTERN und DIENSTLEISTUNG (Alias SERVICE), nicht "${roh}".`);
|
||||
}
|
||||
|
||||
async function einstellungAnlegen(modus: Betriebsmodus): Promise<void> {
|
||||
// Nur anlegen, nicht ueberschreiben: ein spaeterer Seed-Lauf darf einen im
|
||||
// Betrieb umgeschalteten Modus nicht zuruecksetzen.
|
||||
const vorhanden = await prisma.einstellung.findUnique({ where: { id: "global" } });
|
||||
if (vorhanden) {
|
||||
console.log(` Einstellung: vorhanden, Modus ${vorhanden.modus} bleibt`);
|
||||
return;
|
||||
}
|
||||
await prisma.einstellung.create({ data: { id: "global", modus } });
|
||||
console.log(` Einstellung: angelegt, Modus ${modus}`);
|
||||
}
|
||||
|
||||
async function eigeneOrganisationAnlegen(): Promise<string> {
|
||||
const name = process.env["SEED_ORG_NAME"]?.trim() || "Eigene Organisation";
|
||||
const kuerzel = (process.env["SEED_ORG_KUERZEL"]?.trim() || "EIGEN").toUpperCase();
|
||||
if (!/^[A-Z0-9]{2,6}$/.test(kuerzel)) {
|
||||
throw new Error(`SEED_ORG_KUERZEL muss 2 bis 6 Grossbuchstaben oder Ziffern sein, nicht "${kuerzel}".`);
|
||||
}
|
||||
|
||||
// Bewusst nicht aktualisieren: wer Name oder Kuerzel im Betrieb geaendert hat,
|
||||
// soll das nicht beim naechsten Seed-Lauf zurueckbekommen. Geaenderte Werte in
|
||||
// der Umgebung bleiben deshalb wirkungslos, sobald die Zeile existiert.
|
||||
const vorhanden = await prisma.kunde.findFirst({ where: { istEigeneOrganisation: true } });
|
||||
if (vorhanden) {
|
||||
console.log(` Eigene Organisation: vorhanden (${vorhanden.name})`);
|
||||
return vorhanden.id;
|
||||
}
|
||||
const kunde = await prisma.kunde.create({
|
||||
data: { name, kuerzel, istEigeneOrganisation: true },
|
||||
});
|
||||
console.log(` Eigene Organisation: angelegt (${kunde.name}, ${kunde.kuerzel})`);
|
||||
return kunde.id;
|
||||
}
|
||||
|
||||
async function adminAnlegen(): Promise<void> {
|
||||
const email = process.env["SEED_ADMIN_EMAIL"]?.trim().toLowerCase();
|
||||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
||||
if (!email || !passwort) {
|
||||
console.log(" Administrator: uebersprungen (SEED_ADMIN_EMAIL oder SEED_ADMIN_PASSWORD fehlt)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Gesucht wird ueber die Adresse: eine geaenderte SEED_ADMIN_EMAIL legt also
|
||||
// einen zweiten Administrator an, statt den ersten umzubenennen. Das ist
|
||||
// gewollt – umbenennen waere eine Aenderung an einem Konto, das laengst benutzt
|
||||
// sein kann.
|
||||
const vorhanden = await prisma.benutzer.findUnique({ where: { email } });
|
||||
if (vorhanden) {
|
||||
console.log(` Administrator: vorhanden (${email})`);
|
||||
return;
|
||||
}
|
||||
// Das Passwort wird nur als argon2-Hash gespeichert, nie im Klartext.
|
||||
await prisma.benutzer.create({
|
||||
data: {
|
||||
email,
|
||||
name: "Administrator",
|
||||
rolle: Rolle.ADMIN,
|
||||
passwortHash: await hash(passwort),
|
||||
aktiv: true,
|
||||
},
|
||||
});
|
||||
console.log(` Administrator: angelegt (${email})`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const modus = modusLesen();
|
||||
console.log("Seed laeuft:");
|
||||
await einstellungAnlegen(modus);
|
||||
await eigeneOrganisationAnlegen();
|
||||
await adminAnlegen();
|
||||
await einstellungAnlegen(prisma, modus);
|
||||
const eigeneId = await eigeneOrganisationAnlegen(prisma);
|
||||
await adminAnlegen(prisma);
|
||||
|
||||
const vorlagen = await vorlagenSaeen(prisma);
|
||||
const neueVorlagen = vorlagen.filter((eintrag) => eintrag.neu).length;
|
||||
console.log(` Vorlagen: ${vorlagen.length} vorhanden, davon ${neueVorlagen} neu angelegt`);
|
||||
|
||||
const typVorgaben = await typVorgabenSaeen(prisma, vorlagen);
|
||||
console.log(` Schnellstart je Geraetetyp: ${typVorgaben} neu`);
|
||||
|
||||
/*
|
||||
* Der Beispielbestand haengt an dem, der ihn im jeweiligen Modus braucht:
|
||||
*
|
||||
* - INTERN: an der eigenen Organisation. Sie ist das Haus.
|
||||
* - DIENSTLEISTUNG: an der Musterfirma. Der Auftrag verlangt, dass `LZ-7K3F9Q`
|
||||
* dort auf `MUS-PC-001` fuehrt — „erzeugt **stattdessen**“. Beides zugleich
|
||||
* ginge ohnehin nicht: ein Tag-Code gehoert genau einem Geraet.
|
||||
*/
|
||||
if (modus === Betriebsmodus.INTERN) {
|
||||
const bestand = await stammdatenSaeen(prisma, eigeneId);
|
||||
console.log(
|
||||
` Beispielbestand: ${bestand.abteilungen} Abteilungen, ${bestand.personen} Personen, ` +
|
||||
`${bestand.geraete} Geraete, ${bestand.offeneTags} freie Tags (jeweils neu)`,
|
||||
);
|
||||
|
||||
// Das Verzeichnis gibt es nur im Modus INTERN (#008) – im Modus
|
||||
// DIENSTLEISTUNG waere die Beispielquelle eine Zeile, die niemand sieht.
|
||||
const quelle = await beispielQuelleSaeen(prisma, eigeneId);
|
||||
console.log(
|
||||
quelle
|
||||
? " Verzeichnisquelle: Beispiel angelegt (deaktiviert, Adresse und Passwort ersetzen)"
|
||||
: " Verzeichnisquelle: vorhanden",
|
||||
);
|
||||
} else {
|
||||
const kunden = await demoKundenSaeen(prisma);
|
||||
for (const kunde of kunden) {
|
||||
const bestand = kunde.bestand;
|
||||
const zahlen = bestand
|
||||
? `${bestand.abteilungen} Abteilungen, ${bestand.personen} Personen, ` +
|
||||
`${bestand.geraete} Geraete, ${bestand.offeneTags} freie Tags (jeweils neu)`
|
||||
: "kein Bestand";
|
||||
console.log(
|
||||
` Kunde ${kunde.kuerzel}: ${kunde.neu ? "angelegt" : "vorhanden"} ` +
|
||||
`(${kunde.kunde}, ${kunde.hostname}) – ${zahlen}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Seed fertig.");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user