import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as argon2 from 'argon2'; import { GroupsService } from '../groups/groups.service'; import { getRunningRelease } from '../health/app-version'; import { forTenant } from '../prisma/prisma-tenant.extension'; import { prismaErrorCode } from '../prisma/prisma-error'; import { PrismaService } from '../prisma/prisma.service'; /** * Creates the initial Super-Admin account from Docker ENV variables on first boot. * Per D-05, D-07, D-13. * * onApplicationBootstrap läuft in genau zwei sequenziellen await-Schritten: * erst seedAdmin(), danach ensureDefaultGroupsForAllTenants(). Diese * sequenzielle Reihenfolge — nicht Nests Hook-Reihenfolge über Module * hinweg — ist der Ordering-Garant. Die Reparatur sitzt bewusst NICHT als * eigener onApplicationBootstrap-Hook in GroupsModule: GroupsModule ist * eine Dependency von UserModule, seine eigenen Hooks liefen deshalb VOR * dem Admin-Seed und würden auf einer frischen Installation den noch gar * nicht existierenden Default-Mandanten übergehen — dieselbe Falle, die * tenders/tender-scheduler.service.ts für den DÖE-Poll-Config-Seed * ausführlich dokumentiert (onModuleInit-Reihenfolge zwischen Modulen ist * unspezifiziert, onApplicationBootstrap läuft garantiert nach jedem * onModuleInit). seedAdmin() bleibt bewusst UNgekapselt: schlägt der * Admin-Seed fehl, soll der Start weiterhin laut scheitern — bestehendes * Verhalten, hier nicht aufgeweicht. */ @Injectable() export class AdminSeedService implements OnApplicationBootstrap { private readonly logger = new Logger(AdminSeedService.name); constructor( private prisma: PrismaService, private configService: ConfigService, private readonly groupsService: GroupsService, ) {} async onApplicationBootstrap() { await this.seedAdmin(); await this.ensureDefaultGroupsForAllTenants(); } private async seedAdmin() { const username = this.configService .get('TESSERA_ADMIN_USER') ?.toLowerCase(); const email = this.configService.get('TESSERA_ADMIN_EMAIL'); const password = this.configService.get('TESSERA_ADMIN_PASSWORD'); const forceChange = this.configService.get('TESSERA_FORCE_CHANGE') === 'true'; if (!username || !email || !password) { this.logger.log( 'Admin seed skipped: TESSERA_ADMIN_USER, TESSERA_ADMIN_EMAIL, or TESSERA_ADMIN_PASSWORD not set', ); return; } // Erstanlage-Pruefung beim Start (260910-das, Befund I/Aufgabe 2): // bleibt bewusst UNGEBUNDEN. HEUTE arbeitet sie richtig, weil zu diesem // Zeitpunkt noch kein Mandant existiert und `username` plattformweit // eindeutig ist -- eine gebundene Suche waere hier ohnehin nicht // formulierbar (es gibt noch keinen Mandanten, an den zu binden waere). // NACH DEM SCHARFSCHALTEN (RLS scharf, WINDOWS #18) liefert dieselbe // Abfrage fuer JEDEN Administrator `null`, weil ohne gesetzten // Mandantenkontext keine Zeile der Benutzertabelle sichtbar ist // (260910-das, Aufgabe 1, `user-ungebundene-suche-nach-benutzername-liefert-keine-zeile`). // Leere wird dann als Abwesenheit gedeutet, die natuerliche // Folgehandlung ist Anlegen (Schritt weiter unten laeuft), und das // Anlegen trifft die plattformweite Eindeutigkeit von `username` -- // siehe die Entschaerfung direkt an der Erstanlage unten. Details: // docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt // "Bereich user", (u3) Punkt 1. const exists = await this.prisma.user.findUnique({ where: { username }, }); if (exists) { this.logger.log(`Admin user "${username}" already exists, skipping seed`); return; } // Upsert default tenant. `Tenant` traegt keinen Zeilenschutz (Aufgabe 1, // `tenant-tabelle-ohne-zeilenschutz-bleibt-lesbar`) -- ungebunden lesen // und schreiben ist hier korrekt, nicht uebersehen. const tenant = await this.prisma.tenant.upsert({ where: { slug: 'default' }, update: {}, create: { name: 'Default', slug: 'default' }, }); // Ensure the default group exists BEFORE the Super-Admin user is // created, so user.create's addUserToDefaultGroup path (D-11/D-12) // finds a marked default group to join instead of relying on the // repair below to backfill the membership afterwards. await this.groupsService.ensureDefaultGroup(tenant.id); // Erstanlage des Administrators (260910-das, Befund J-Korrektur, // Aufgabe 2): GEBUNDEN an die Kennung des unmittelbar zuvor angelegten // bzw. geholten Mandanten. Die bisherige Klassifikationsbegruendung // ("es gibt strukturell keinen Mandanten zum Binden") war FALSCH -- der // Mandant ist an dieser Stelle bereits bekannt (`tenant.id` oben). // Ungebunden waere dieses Einfuegen nach dem Scharfschalten von der // Policy abgewiesen worden (Aufgabe 1, // `user-ungebundenes-einfuegen-abgelehnt`): eine FRISCHE Installation // haette ihren allerersten Administrator gar nicht anlegen koennen. const passwordHash = await argon2.hash(password); const tenantPrisma = forTenant(this.prisma, tenant.id); try { await tenantPrisma.user.create({ data: { username, email, passwordHash, role: 'SUPER_ADMIN', tenantId: tenant.id, mustChangePassword: forceChange, isActive: true, // quick-260925-bow (D-04): laufende freigegebene Version, damit der // Erst-Administrator kein "Was ist neu"-Fenster mit Altlasten sieht; // null auf dev-Staenden. lastSeenReleaseVersion: getRunningRelease(), }, }); } catch (err: unknown) { // Entschaerfung der Startsperre (260910-das, Befund I): trifft die // Erstanlage die plattformweite Eindeutigkeit von username/email // (P2002), bedeutet das an DIESER Stelle exakt dasselbe wie ein // Treffer der vorgeschalteten Pruefung oben ("Administrator existiert // bereits") -- die Pruefung hat ihn nur wegen der Unsichtbarkeit // nicht gefunden. Dieser eine Fehlerfall wird deshalb wie der bereits // vorhandene "existiert bereits"-Zweig behandelt: protokollieren, // NICHT abbrechen. Das ist KEINE Aufweichung der im Dateikopf // festgehaltenen Absicht (seedAdmin() bleibt bewusst ungekapselt) -- // JEDER ANDERE Fehler bricht den Start weiterhin ab. Nur dieser eine, // an dieser Stelle gleichbedeutende Fall wird ergaenzt. if (prismaErrorCode(err) === 'P2002') { this.logger.log( `Admin user "${username}" seed skipped: uniqueness collision on username/email (an administrator with this identity already exists, currently invisible under this tenant context) — see docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt "Bereich user"`, ); return; } throw err; } this.logger.log( `Admin user "${username}" seeded as SUPER_ADMIN in tenant "${tenant.slug}"`, ); } /** * One-time startup repair for installations that already exist without a * default group — e.g. a fresh database where migration * 20260804130130_add_groups_and_module_grants' backfill ran before any * tenant existed, or an install whose admin already exists so seedAdmin() * returned early without ever touching a tenant. Runs per-tenant in its * own try/catch and is additionally wrapped as a whole, so a failing * tenant.findMany or a single tenant's ensureDefaultGroup call can never * block the API from starting. * * 260910-das, Befund K: dies ist der FUENFTE Fall der * Hintergrunddienst-Falle (docs/mandantentrennung-zugriffsklassifikation.md, * Abschnitt "Der Hintergrunddienst als Falle") und der bislang EINZIGE, * der auf BEIDEN Haelften bereits richtig ist -- uebergreifender Treiber * (`this.prisma.tenant.findMany`, UNGEBUNDEN, korrekt weil `Tenant` * keinen Zeilenschutz traegt), gebundener Rumpf * (`groupsService.ensureDefaultGroup(tenant.id)`, seit 260909-jts * vollstaendig ueber `forTenant()`/`withTenantTransaction()`). Bewusst * NICHT verschwiegen: der aeussere `try/catch` unten verschluckt jeden * Fehler des Treibers in eine Protokollzeile -- laeuft die Mandantenliste * nach dem Scharfschalten aus irgendeinem Grund leer, entsteht keine * Fehlermeldung, sondern gar keine Ausgabe. Die Reparatur meldet nur, * wenn sie etwas GETAN hat. */ private async ensureDefaultGroupsForAllTenants() { try { const tenants = await this.prisma.tenant.findMany({ select: { id: true, slug: true }, }); let repaired = 0; for (const tenant of tenants) { try { const group = await this.groupsService.ensureDefaultGroup(tenant.id); if (group !== null) { repaired += 1; } } catch (err) { this.logger.error( `Default-group repair failed for tenant "${tenant.slug}": ${ err instanceof Error ? err.message : String(err) }`, ); } } if (repaired > 0) { this.logger.warn( `Default-group repair created a default group for ${repaired} tenant(s) that had none`, ); } } catch (err) { this.logger.error( `Default-group repair could not load tenants: ${ err instanceof Error ? err.message : String(err) }`, ); } } }