From 05876838b08f85f1553502ee2491a1a5695d4a9c Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 27 Aug 2026 06:38:23 +0200 Subject: [PATCH] feat: Admin-Seed automatisch beim Deploy ausfuehren Bisher wurde nirgends automatisch ein erster Admin-User angelegt - prisma/seed.ts existierte, war aber weder im Dockerfile-CMD noch sonst irgendwo verdrahtet. Auf einer frischen Datenbank haette also niemand sich einloggen koennen. - Dockerfile-CMD: migrate:deploy && seed && node dist/main.js - ts-node/typescript von devDependencies zu dependencies verschoben (seed.ts laeuft als rohes TypeScript zur Laufzeit, kein Kompilierschritt), tsconfig.json zusaetzlich ins Runtime-Image kopiert - seed.ts gegen Unique-Constraint-Race bei mehreren gleichzeitig startenden Pod-Replicas gehaertet (P2002 abgefangen statt Absturz) - Login-Banner im Log nur beim tatsaechlichen Erst-Anlegen (URL aus FRONTEND_ORIGIN, Benutzername, Passwort) - bewusst nicht bei jedem Neustart, sonst wuerden veraltete/geaenderte Zugangsdaten weiter geloggt und Credentials landen unnoetig dauerhaft in den Logs Seed ist bereits idempotent per E-Mail-Check (auch bei deaktivierten Usern), UsersService.assertNotLastSystemRoleUser verhindert ohnehin das Deaktivieren des letzten aktiven Admins - kein Aenderungsbedarf dort, siehe Konversation. Verifiziert: tsc/eslint clean, 297/297 Tests, Build clean. Migration und Schema live gegen die lokale DB bestaetigt (ueber WSL direkt, da Windows->WSL-Portweiterleitung weiterhin kaputt ist); der eigentliche Seed-Lauf selbst konnte nicht End-to-End gegen die DB verifiziert werden (gleiches Verbindungsproblem), Banner-Ausgabe isoliert smoke-getestet. --- ARCHITECTURE.md | 4 +- ApiCore-Backend/Dockerfile | 16 ++++---- ApiCore-Backend/package-lock.json | 24 ++--------- ApiCore-Backend/package.json | 9 ++-- ApiCore-Backend/prisma/seed.ts | 52 ++++++++++++++++++----- ApiCore-Frontend/package-lock.json | 66 ++++++++++++++++++++++++++++++ 6 files changed, 128 insertions(+), 43 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7cd4db7..35fe5ec 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -105,7 +105,9 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige ## 8. Backup & Restore -**Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt. Der Backend-Container führt `migrate deploy` selbst beim Start aus, bevor der Server Verbindungen annimmt (Dockerfile-`CMD`: `npm run migrate:deploy && node dist/main.js`) – kein separater Kubernetes-`Job`. Bei `replicas: 2` starten mehrere Pods potenziell gleichzeitig; das ist unproblematisch, da Prisma für `migrate deploy` einen datenbankseitigen Advisory-Lock nutzt – eine Instanz wendet die Migrationen an, die anderen warten kurz und finden danach nichts mehr zu tun. `deploy/k8s/backend/deployment.yaml` hat dafür eine `startupProbe` mit großzügigem `failureThreshold`, damit liveness/readiness den Container nicht neu starten, während die (beim allerersten Deploy ggf. länger laufende) Migration noch durchläuft. +**Schema-Migrationen (Reproduzierbarkeit):** Ausschließlich `prisma/migrations/` als Quelle der Wahrheit. Lokal/Entwicklung: `npx prisma migrate dev`. Gegen jede Produktions- oder Staging-Datenbank ausschließlich `npx prisma migrate deploy` (idempotent, wendet nur fehlende Migrationen an, kein Schema-Drift). `prisma db push` wird an keiner Stelle im Code, in Skripten oder im Dockerfile verwendet und ist für Produktions-/Staging-Umgebungen untersagt. Der Backend-Container führt `migrate deploy` und danach `npm run seed` selbst beim Start aus, bevor der Server Verbindungen annimmt (Dockerfile-`CMD`: `npm run migrate:deploy && npm run seed && node dist/main.js`) – kein separater Kubernetes-`Job`. Bei `replicas: 2` starten mehrere Pods potenziell gleichzeitig; das ist unproblematisch, da Prisma für `migrate deploy` einen datenbankseitigen Advisory-Lock nutzt und `prisma/seed.ts` per Unique-Constraint auf `User.email` idempotent ist (verlierender Pod bekommt einen `P2002`-Fehler, den `seed.ts` abfängt und als No-op behandelt statt den Container abstürzen zu lassen) – eine Instanz wendet die Migrationen/den Seed an, die anderen finden danach nichts mehr zu tun. `deploy/k8s/backend/deployment.yaml` hat dafür eine `startupProbe` mit großzügigem `failureThreshold`, damit liveness/readiness den Container nicht neu starten, während die (beim allerersten Deploy ggf. länger laufende) Migration noch durchläuft. + +**Erster Admin-Zugang:** `prisma/seed.ts` legt beim allerersten Start (leere `User`-Tabelle) einen Administrator an (`SEED_ADMIN_EMAIL`/`SEED_ADMIN_PASSWORD`, Default `admin@apicore.local`/`ChangeMe123!`) und gibt URL (`FRONTEND_ORIGIN`), Benutzername und Passwort einmalig im Container-Log aus. Existiert der User schon (auch deaktiviert), passiert nichts – kein erneutes Log, kein erneutes Anlegen, damit spätere Passwortänderungen nicht durch veraltete Logs verwirrend überschrieben wirken und Zugangsdaten nicht bei jedem Neustart in die Logs wandern. Ersetzen: neuen Admin über `/settings/users` anlegen, dann den alten deaktivieren (Löschen gibt es nicht, siehe "Historie statt Löschen") – die Lockout-Sperre in `UsersService.assertNotLastSystemRoleUser` verhindert ohnehin, den letzten aktiven Admin zu deaktivieren. **Datensicherung:** Seit der Entscheidung, PostgreSQL selbst im Cluster zu betreiben (siehe CLAUDE.md), liegt die Backup-Verantwortung bei diesem Projekt, nicht mehr bei einem externen Cluster-/DBA-Betrieb. Aktuell **noch nicht umgesetzt** (offener Punkt, siehe TASKS.md) – empfohlene Strategie: diff --git a/ApiCore-Backend/Dockerfile b/ApiCore-Backend/Dockerfile index 554eafc..807a3f3 100644 --- a/ApiCore-Backend/Dockerfile +++ b/ApiCore-Backend/Dockerfile @@ -15,15 +15,17 @@ COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY --from=builder /app/dist ./dist COPY --from=builder /app/generated ./generated -# schema.prisma, migrations/ und prisma.config.ts: noetig fuer -# `npm run migrate:deploy`, das der Container beim Start selbst ausfuehrt -# (siehe CMD unten), bevor der Server Verbindungen annimmt. +# schema.prisma, migrations/, seed.ts und prisma.config.ts: noetig fuer +# `npm run migrate:deploy` und `npm run seed`, die der Container beim Start +# selbst ausfuehrt (siehe CMD unten), bevor der Server Verbindungen annimmt. +# tsconfig.json: ts-node (Laufzeit-Dependency, siehe package.json) braucht es, +# um seed.ts direkt auszufuehren, ohne separaten Kompilierschritt. COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./prisma.config.ts +COPY --from=builder /app/tsconfig.json ./tsconfig.json EXPOSE 3001 USER node # migrate deploy ist idempotent und nutzt einen DB-seitigen Advisory-Lock, -# daher unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas: -# eine Instanz wendet die Migrationen an, die anderen warten kurz und finden -# danach nichts mehr zu tun. -CMD ["sh", "-c", "npm run migrate:deploy && node dist/main.js"] +# seed ist idempotent per Unique-Constraint auf User.email (siehe seed.ts) - +# beides unproblematisch bei mehreren gleichzeitig startenden Pod-Replicas. +CMD ["sh", "-c", "npm run migrate:deploy && npm run seed && node dist/main.js"] diff --git a/ApiCore-Backend/package-lock.json b/ApiCore-Backend/package-lock.json index 1f120b4..fc20b18 100644 --- a/ApiCore-Backend/package-lock.json +++ b/ApiCore-Backend/package-lock.json @@ -31,7 +31,9 @@ "prisma": "^7.9.1", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "ts-node": "^10.9.2", + "typescript": "^5.7.3" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -59,9 +61,7 @@ "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.2", - "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" } }, @@ -750,7 +750,6 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -763,7 +762,6 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -2063,7 +2061,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2084,7 +2081,6 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -3251,28 +3247,24 @@ "version": "1.0.13", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.13.tgz", "integrity": "sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==", - "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -4740,7 +4732,6 @@ "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4776,7 +4767,6 @@ "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -4951,7 +4941,6 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -5894,7 +5883,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -6204,7 +6192,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -9089,7 +9076,6 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, "license": "ISC" }, "node_modules/makeerror": { @@ -11826,7 +11812,6 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -11990,7 +11975,6 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12211,7 +12195,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -12615,7 +12598,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/ApiCore-Backend/package.json b/ApiCore-Backend/package.json index cda3ae4..52a83ec 100644 --- a/ApiCore-Backend/package.json +++ b/ApiCore-Backend/package.json @@ -19,7 +19,8 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", - "migrate:deploy": "prisma migrate deploy" + "migrate:deploy": "prisma migrate deploy", + "seed": "ts-node prisma/seed.ts" }, "dependencies": { "@nestjs/common": "^11.0.1", @@ -44,7 +45,9 @@ "prisma": "^7.9.1", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "ts-node": "^10.9.2", + "typescript": "^5.7.3" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -72,9 +75,7 @@ "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.2", - "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" }, "jest": { diff --git a/ApiCore-Backend/prisma/seed.ts b/ApiCore-Backend/prisma/seed.ts index d11d5f1..66b026d 100644 --- a/ApiCore-Backend/prisma/seed.ts +++ b/ApiCore-Backend/prisma/seed.ts @@ -1,12 +1,27 @@ import 'dotenv/config'; import * as bcrypt from 'bcryptjs'; import { PrismaPg } from '@prisma/adapter-pg'; -import { PrismaClient } from '../generated/prisma'; +import { Prisma, PrismaClient } from '../generated/prisma'; const BCRYPT_SALT_ROUNDS = 12; const DEFAULT_ADMIN_EMAIL = 'admin@apicore.local'; const DEFAULT_ADMIN_PASSWORD = 'ChangeMe123!'; +// Only printed the moment the account is actually created (first deploy on +// an empty database), never on later restarts: by then the real admin may +// have changed the password via /profile, so repeating it would just be +// stale and would leak credentials into logs indefinitely for no reason. +function printLoginBanner(email: string, password: string): void { + const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'; + const line = '='.repeat(60); + console.log(line); + console.log('Seed: Admin-Zugang eingerichtet.'); + console.log(` URL: ${url}`); + console.log(` Benutzer: ${email}`); + console.log(` Passwort: ${password}`); + console.log(line); +} + async function main(): Promise { const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), @@ -25,15 +40,32 @@ async function main(): Promise { const adminRole = await prisma.role.findUniqueOrThrow({ where: { key: 'ADMIN' }, }); - await prisma.user.create({ - data: { - email, - passwordHash, - name: 'Administrator', - roleId: adminRole.id, - }, - }); - console.log(`Seed: created admin user ${email}.`); + try { + await prisma.user.create({ + data: { + email, + passwordHash, + name: 'Administrator', + roleId: adminRole.id, + }, + }); + printLoginBanner(email, password); + } catch (error: unknown) { + // P2002 = unique constraint violation on email: another replica's + // startup won this race between the existence check above and this + // create() call. The user now exists either way, so this is a no-op, + // not a failure - container startup must not crash on it. + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + console.log( + `Seed: admin user ${email} was created concurrently, skipping.`, + ); + } else { + throw error; + } + } await prisma.$disconnect(); } diff --git a/ApiCore-Frontend/package-lock.json b/ApiCore-Frontend/package-lock.json index 1c4b4b4..995628b 100644 --- a/ApiCore-Frontend/package-lock.json +++ b/ApiCore-Frontend/package-lock.json @@ -3377,6 +3377,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",