Files
tessera-ctl/apps/api/src/user/admin-seed.service.ts
T
schalli 5ae9aaa0d6 feat(260925-bow): neue Benutzer bekommen die laufende Version eingetragen
- UserService.create (Admin-Anlage, beide LDAP-Wege) und AdminSeedService
  setzen lastSeenReleaseVersion = getRunningRelease(), null auf dev

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 08:50:06 +02:00

212 lines
9.4 KiB
TypeScript

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<string>('TESSERA_ADMIN_USER')
?.toLowerCase();
const email = this.configService.get<string>('TESSERA_ADMIN_EMAIL');
const password = this.configService.get<string>('TESSERA_ADMIN_PASSWORD');
const forceChange =
this.configService.get<string>('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)
}`,
);
}
}
}