From 10b69f3b2b15a21c92b6e81ab54d3c335bc6f4e3 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 15 Sep 2026 15:21:05 +0200 Subject: [PATCH] fix admin monter --- apps/backend/Dockerfile | 4 + apps/backend/scripts/setup/admin-password.js | 167 ++++++++++ .../scripts/setup/generate-admin-hash.js | 15 +- apps/backend/src/app.module.ts | 10 + .../src/application/auth/auth.service.ts | 10 + .../typeorm/admin-bootstrap.module.ts | 13 + .../typeorm/admin-bootstrap.service.spec.ts | 192 +++++++++++ .../typeorm/admin-bootstrap.service.ts | 306 ++++++++++++++++++ infra/prod/k8s/base/02-configmap-backend.yaml | 19 +- infra/prod/k8s/base/03-secrets.template.yaml | 27 +- 10 files changed, 734 insertions(+), 29 deletions(-) create mode 100644 apps/backend/scripts/setup/admin-password.js create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.module.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.service.spec.ts create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.service.ts diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 921c1a2..4f8aa7b 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -61,6 +61,10 @@ COPY --from=builder --chown=nestjs:nodejs /app/src ./src # Copy startup script (includes migrations) COPY --chown=nestjs:nodejs scripts/setup/startup.js ./startup.js +# Recovery command: set an admin password without SMTP +# docker exec -it node admin-password.js +COPY --chown=nestjs:nodejs scripts/setup/admin-password.js ./admin-password.js + # Create logs and uploads directories RUN mkdir -p /app/logs && \ mkdir -p /app/src/infrastructure/storage/csv-storage/rates && \ diff --git a/apps/backend/scripts/setup/admin-password.js b/apps/backend/scripts/setup/admin-password.js new file mode 100644 index 0000000..6275ccb --- /dev/null +++ b/apps/backend/scripts/setup/admin-password.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node +/** + * Commande de secours : définit le mot de passe d'un administrateur, sans SMTP. + * + * Dans le conteneur backend (le script est copié dans l'image) : + * docker exec -it node admin-password.js admin@xpeditis.com + * En local : + * cd apps/backend && node scripts/setup/admin-password.js admin@xpeditis.com + * + * Le compte est créé s'il n'existe pas, sinon promu ADMIN et réactivé, puis son + * mot de passe est remplacé. Le mot de passe est saisi sans écho : ni argument + * de ligne de commande (visible dans `ps` et l'historique du shell), ni + * variable d'environnement. Il peut aussi être passé sur l'entrée standard + * pour une exécution scriptée. + * + * Connexion à la base : variables DATABASE_* déjà présentes dans le conteneur. + */ + +'use strict'; + +const readline = require('readline'); +const argon2 = require('argon2'); +const { Client } = require('pg'); + +// Mêmes paramètres que auth.service.ts. +const ARGON2_OPTIONS = { type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 4 }; +const MIN_LENGTH = 12; +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Saisie masquée sur un terminal ; lecture directe si l'entrée est redirigée. */ +function readSecret(prompt) { + return new Promise((resolve, reject) => { + if (!process.stdin.isTTY) { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => (data += chunk)); + process.stdin.on('end', () => resolve(data.replace(/\r?\n$/, ''))); + process.stdin.on('error', reject); + return; + } + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const onKeypress = () => { + readline.clearLine(process.stdout, 0); + readline.cursorTo(process.stdout, 0); + process.stdout.write(prompt); + }; + process.stdout.write(prompt); + process.stdin.on('data', onKeypress); + rl.question('', answer => { + process.stdin.removeListener('data', onKeypress); + rl.close(); + process.stdout.write('\n'); + resolve(answer); + }); + }); +} + +function strengthProblems(password) { + const problems = []; + if (password.length < MIN_LENGTH) problems.push(`au moins ${MIN_LENGTH} caractères`); + if (!/[a-z]/.test(password)) problems.push('une minuscule'); + if (!/[A-Z]/.test(password)) problems.push('une majuscule'); + if (!/[0-9]/.test(password)) problems.push('un chiffre'); + return problems; +} + +function env(name, fallback) { + const value = (process.env[name] || '').trim(); + return value || fallback; +} + +async function main() { + const email = (process.argv[2] || '').trim().toLowerCase(); + if (!EMAIL_PATTERN.test(email)) { + console.error('Usage : node admin-password.js '); + process.exit(1); + } + + const password = await readSecret(`Nouveau mot de passe pour ${email} : `); + if (process.stdin.isTTY) { + const confirmation = await readSecret('Confirmation : '); + if (confirmation !== password) { + console.error('Les deux saisies diffèrent.'); + process.exit(1); + } + } + + const problems = strengthProblems(password); + if (problems.length > 0) { + console.error(`Mot de passe refusé. Il manque : ${problems.join(', ')}.`); + process.exit(1); + } + + const passwordHash = await argon2.hash(password, ARGON2_OPTIONS); + + const client = new Client({ + host: env('DATABASE_HOST', 'localhost'), + port: Number(env('DATABASE_PORT', '5432')), + user: env('DATABASE_USER', 'xpeditis'), + password: process.env.DATABASE_PASSWORD, + database: env('DATABASE_NAME', 'xpeditis_dev'), + // Même règle que data-source.ts : en production, pg_hba n'accepte que SSL. + ssl: process.env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false, + }); + + await client.connect(); + try { + await client.query('BEGIN'); + + const existing = await client.query('SELECT "id" FROM "users" WHERE "email" = $1', [email]); + + if (existing.rows.length > 0) { + // Réactiver ou promouvoir n'est jamais bloqué par le déclencheur + // « au moins un administrateur actif ». + await client.query( + `UPDATE "users" + SET "password_hash" = $2, "role" = 'ADMIN', "is_active" = true, "updated_at" = NOW() + WHERE "id" = $1`, + [existing.rows[0].id, passwordHash] + ); + console.log(`Compte ${email} : mot de passe défini, rôle ADMIN, compte actif.`); + } else { + const organization = await client.query( + `INSERT INTO "organizations" + ("name", "type", "address_street", "address_city", "address_postal_code", "address_country") + VALUES ($1, 'FREIGHT_FORWARDER', $2, $3, $4, $5) + ON CONFLICT ("name") DO UPDATE SET "updated_at" = NOW() + RETURNING "id"`, + [ + env('BOOTSTRAP_ADMIN_ORG_NAME', 'Xpeditis'), + env('BOOTSTRAP_ADMIN_ORG_STREET', 'A completer'), + env('BOOTSTRAP_ADMIN_ORG_CITY', 'A completer'), + env('BOOTSTRAP_ADMIN_ORG_POSTAL_CODE', '00000'), + env('BOOTSTRAP_ADMIN_ORG_COUNTRY', 'FR').toUpperCase(), + ] + ); + await client.query( + `INSERT INTO "users" + ("organization_id", "email", "password_hash", "role", "first_name", "last_name", + "is_email_verified", "is_active") + VALUES ($1, $2, $3, 'ADMIN', $4, $5, true, true)`, + [ + organization.rows[0].id, + email, + passwordHash, + env('BOOTSTRAP_ADMIN_FIRST_NAME', 'Admin'), + env('BOOTSTRAP_ADMIN_LAST_NAME', 'Xpeditis'), + ] + ); + console.log(`Administrateur ${email} créé et actif.`); + } + + await client.query('COMMIT'); + console.log('Vous pouvez vous connecter. Changez ce mot de passe depuis l’interface si besoin.'); + } catch (error) { + await client.query('ROLLBACK').catch(() => undefined); + throw error; + } finally { + await client.end(); + } +} + +main().catch(error => { + console.error('Échec :', error.message); + process.exit(1); +}); diff --git a/apps/backend/scripts/setup/generate-admin-hash.js b/apps/backend/scripts/setup/generate-admin-hash.js index 5add982..ccce62f 100644 --- a/apps/backend/scripts/setup/generate-admin-hash.js +++ b/apps/backend/scripts/setup/generate-admin-hash.js @@ -8,12 +8,15 @@ * argument de ligne de commande (visible dans `ps` et dans l'historique du * shell), ni variable d'environnement, ni fichier temporaire. * - * RAPPEL — le mode SANS mot de passe est préférable. - * Si votre chaîne SMTP fonctionne, ne renseignez que BOOTSTRAP_ADMIN_EMAIL : - * le compte est alors créé sans mot de passe utilisable et vous le définissez - * via « mot de passe oublié ». Aucun secret n'existe nulle part, il n'y a donc - * rien à faire fuiter. Ce script n'est utile que si vous devez pouvoir vous - * connecter avant que l'envoi de courriels ne soit opérationnel. + * Le hash est appliqué à CHAQUE lancement du backend (AdminBootstrapService) : + * - au compte BOOTSTRAP_ADMIN_EMAIL s'il ne s'est encore jamais connecté ; + * - à tout compte si BOOTSTRAP_ADMIN_RESET_PASSWORD=true (retirez ensuite + * les deux variables, sinon un mot de passe changé depuis l'interface + * serait remplacé au lancement suivant). + * + * Sans hash, le compte est créé sans mot de passe utilisable : il faut alors + * « mot de passe oublié » (SMTP requis) ou, sans SMTP, la commande de secours : + * docker exec -it node admin-password.js */ 'use strict'; diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index f4f58c9..8b3fbba 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -39,6 +39,7 @@ import { CacheModule } from './infrastructure/cache/cache.module'; import { CarrierModule } from './infrastructure/carriers/carrier.module'; import { SecurityModule } from './infrastructure/security/security.module'; import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module'; +import { AdminBootstrapModule } from './infrastructure/persistence/typeorm/admin-bootstrap.module'; // Import global guards import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard'; @@ -97,6 +98,13 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; STRIPE_PLATINIUM_MONTHLY_PRICE_ID: Joi.string().optional(), STRIPE_PLATINIUM_YEARLY_PRICE_ID: Joi.string().optional(), LOG_EXPORTER_URL: Joi.string().uri().default('http://xpeditis-log-exporter:3200'), + // Administrateur garanti a chaque lancement (AdminBootstrapService). + // Pas de .email() ici : Joi rejette les TLD hors liste IANA et une + // adresse refusee empecherait l'API entiere de demarrer. Le service + // valide l'adresse et journalise une erreur sans bloquer. + BOOTSTRAP_ADMIN_EMAIL: Joi.string().allow('').optional(), + BOOTSTRAP_ADMIN_PASSWORD_HASH: Joi.string().allow('').optional(), + BOOTSTRAP_ADMIN_RESET_PASSWORD: Joi.string().valid('true', 'false', '').default('false'), }), }), @@ -183,6 +191,8 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard'; CacheModule, CarrierModule, CsvRateModule, + // Un administrateur exploitable a chaque lancement (base neuve comprise) + AdminBootstrapModule, // Feature modules AuthModule, diff --git a/apps/backend/src/application/auth/auth.service.ts b/apps/backend/src/application/auth/auth.service.ts index 17f20f8..3eb1718 100644 --- a/apps/backend/src/application/auth/auth.service.ts +++ b/apps/backend/src/application/auth/auth.service.ts @@ -207,6 +207,16 @@ export class AuthService { throw new UnauthorizedException('Invalid credentials'); } + // last_login_at n'etait jamais renseigne. L'amorcage de l'administrateur + // s'en sert pour ne jamais ecraser le mot de passe d'un compte deja utilise. + try { + user.recordLogin(); + await this.userRepository.save(user); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Could not record last login for ${email}: ${message}`); + } + const tokens = await this.generateTokens(user, rememberMe); this.logger.log(`User logged in successfully: ${email}`); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.module.ts b/apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.module.ts new file mode 100644 index 0000000..f5ac33d --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AdminBootstrapService } from './admin-bootstrap.service'; + +/** + * Garantit un administrateur exploitable à chaque lancement du backend + * (voir admin-bootstrap.service.ts). Le DataSource vient de TypeOrmModule.forRoot. + */ +@Module({ + imports: [ConfigModule], + providers: [AdminBootstrapService], +}) +export class AdminBootstrapModule {} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.service.spec.ts b/apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.service.spec.ts new file mode 100644 index 0000000..8b76830 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.service.spec.ts @@ -0,0 +1,192 @@ +import { Logger } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { + AdminBootstrapService, + decideAdminBootstrap, + ExistingAccount, + readAdminBootstrapConfig, +} from './admin-bootstrap.service'; + +const HASH = '$argon2id$v=19$m=65536,t=3,p=4$c2VsLWRlLXRlc3Q$aGFzaC1kZS10ZXN0LWFkbWluLTEyMzQ1Njc4'; + +const account = (overrides: Partial = {}): ExistingAccount => ({ + id: 'u-1', + role: 'ADMIN', + isActive: true, + lastLoginAt: null, + passwordHash: '$argon2id$v=19$m=65536,t=3,p=4$aleatoire$inutilisable', + ...overrides, +}); + +describe('decideAdminBootstrap', () => { + it('cree le compte absent, avec le hash fourni', () => { + expect(decideAdminBootstrap(null, { passwordHash: HASH, resetPassword: false })).toEqual({ + create: true, + promote: false, + activate: false, + applyPassword: true, + }); + }); + + it("applique le hash a un administrateur d'amorcage qui ne s'est jamais connecte", () => { + // Cas reproduit : migration passee sans hash, compte inutilisable. + const decision = decideAdminBootstrap(account(), { passwordHash: HASH, resetPassword: false }); + expect(decision.applyPassword).toBe(true); + }); + + it("n'ecrase jamais le mot de passe d'un administrateur deja connecte", () => { + const decision = decideAdminBootstrap(account({ lastLoginAt: new Date() }), { + passwordHash: HASH, + resetPassword: false, + }); + expect(decision).toEqual({ + create: false, + promote: false, + activate: false, + applyPassword: false, + }); + }); + + it('reinitialise le mot de passe sur demande explicite', () => { + const decision = decideAdminBootstrap(account({ lastLoginAt: new Date() }), { + passwordHash: HASH, + resetPassword: true, + }); + expect(decision.applyPassword).toBe(true); + }); + + it('est idempotent : un hash deja applique ne declenche rien', () => { + const decision = decideAdminBootstrap(account({ passwordHash: HASH }), { + passwordHash: HASH, + resetPassword: true, + }); + expect(decision).toEqual({ + create: false, + promote: false, + activate: false, + applyPassword: false, + }); + }); + + it('promeut et reactive un compte existant, sans hash', () => { + const decision = decideAdminBootstrap(account({ role: 'USER', isActive: false }), { + resetPassword: false, + }); + expect(decision).toEqual({ + create: false, + promote: true, + activate: true, + applyPassword: false, + }); + }); +}); + +describe('readAdminBootstrapConfig', () => { + const reader = (values: Record) => (key: string) => values[key]; + + it('ne fait rien sans BOOTSTRAP_ADMIN_EMAIL', () => { + expect(readAdminBootstrapConfig(reader({}))).toBeNull(); + }); + + it('refuse un mot de passe en clair a la place du hash', () => { + const logger = { error: jest.fn() }; + const config = readAdminBootstrapConfig( + reader({ + BOOTSTRAP_ADMIN_EMAIL: 'Admin@Xpeditis.com', + BOOTSTRAP_ADMIN_PASSWORD_HASH: 'Password123!', + }), + logger + ); + expect(config?.email).toBe('admin@xpeditis.com'); + expect(config?.passwordHash).toBeUndefined(); + expect(logger.error).toHaveBeenCalled(); + }); + + it('lit le drapeau de reinitialisation', () => { + const config = readAdminBootstrapConfig( + reader({ + BOOTSTRAP_ADMIN_EMAIL: 'admin@xpeditis.com', + BOOTSTRAP_ADMIN_PASSWORD_HASH: HASH, + BOOTSTRAP_ADMIN_RESET_PASSWORD: 'TRUE', + }) + ); + expect(config).toMatchObject({ passwordHash: HASH, resetPassword: true }); + }); +}); + +describe('AdminBootstrapService', () => { + beforeEach(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => jest.restoreAllMocks()); + + function build(settings: Record, existing: Record | null) { + const executed: Array<{ sql: string; params: unknown[] }> = []; + const manager = { + query: jest.fn(async (sql: string, params: unknown[] = []) => { + executed.push({ sql, params }); + if (sql.includes('FROM "users" WHERE "email"')) return existing ? [existing] : []; + if (sql.includes('INSERT INTO "organizations"')) return [{ id: 'org-1' }]; + return []; + }), + } as unknown as EntityManager; + const dataSource = { + transaction: jest.fn(async (work: (m: EntityManager) => Promise) => work(manager)), + query: jest.fn(async () => [{ n: 1 }]), + } as unknown as DataSource; + const config = { get: jest.fn((key: string) => settings[key]) }; + const service = new AdminBootstrapService(config as never, dataSource); + return { service, executed, dataSource }; + } + + it("rend exploitable l'administrateur d'amorcage cree sans mot de passe", async () => { + const { service, executed } = build( + { BOOTSTRAP_ADMIN_EMAIL: 'admin@xpeditis.com', BOOTSTRAP_ADMIN_PASSWORD_HASH: HASH }, + { id: 'u-1', role: 'ADMIN', is_active: true, last_login_at: null, password_hash: 'aleatoire' } + ); + + await service.run(); + + const update = executed.find(q => q.sql.includes('UPDATE "users"')); + expect(update?.params).toEqual(['u-1', true, HASH]); + }); + + it("cree l'administrateur et son organisation sur une base neuve", async () => { + const { service, executed } = build( + { BOOTSTRAP_ADMIN_EMAIL: 'admin@xpeditis.com', BOOTSTRAP_ADMIN_PASSWORD_HASH: HASH }, + null + ); + + await service.run(); + + const insert = executed.find(q => q.sql.includes('INSERT INTO "users"')); + expect(insert?.params).toEqual(['org-1', 'admin@xpeditis.com', HASH, 'Admin', 'Xpeditis']); + }); + + it('serialise les replicas qui demarrent ensemble (verrou avant la lecture du compte)', async () => { + const { service, executed } = build({ BOOTSTRAP_ADMIN_EMAIL: 'admin@xpeditis.com' }, null); + + await service.run(); + + expect(executed[0].sql).toContain('pg_advisory_xact_lock'); + expect(executed[1].sql).toContain('FROM "users" WHERE "email"'); + }); + + it("ne bloque jamais le demarrage de l'API", async () => { + const { service, dataSource } = build({ BOOTSTRAP_ADMIN_EMAIL: 'admin@xpeditis.com' }, null); + (dataSource.transaction as jest.Mock).mockRejectedValueOnce(new Error('base indisponible')); + + await expect(service.onApplicationBootstrap()).resolves.toBeUndefined(); + }); + + it('ne touche pas la base sans configuration', async () => { + const { service, dataSource } = build({}, null); + + await service.run(); + + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.service.ts b/apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.service.ts new file mode 100644 index 0000000..e55dc66 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/admin-bootstrap.service.ts @@ -0,0 +1,306 @@ +/** + * Amorçage de l'administrateur, à CHAQUE lancement du backend. + * + * POURQUOI CE N'EST PLUS (SEULEMENT) UNE MIGRATION + * ------------------------------------------------ + * La migration 1756000000001-BootstrapAdminFromEnv ne s'exécute qu'une fois. + * Lancé sur une base neuve avec NODE_ENV=production, le backend obtenait : + * - un administrateur créé SANS mot de passe utilisable (sans + * BOOTSTRAP_ADMIN_PASSWORD_HASH), récupérable seulement par l'email + * « mot de passe oublié », donc dépendant du SMTP ; + * - aucune correction possible par la configuration : ajouter le hash puis + * relancer ne changeait rien, la migration étant déjà enregistrée ; + * - aucune création si un ADMIN actif existait déjà, même inutilisable. + * Reproduit sur une base vide : connexion refusée (401) pour tous les comptes. + * + * CE QUE FAIT CE SERVICE + * ---------------------- + * Si BOOTSTRAP_ADMIN_EMAIL est renseigné, le compte est garanti ADMIN et actif, + * et créé s'il n'existe pas. BOOTSTRAP_ADMIN_PASSWORD_HASH est appliqué : + * - à un compte qui ne s'est encore jamais connecté (compte d'amorçage) ; + * - ou à tout compte si BOOTSTRAP_ADMIN_RESET_PASSWORD=true (à retirer + * ensuite : tant qu'il reste, un mot de passe changé depuis l'interface + * serait remplacé au lancement suivant). + * En dehors de ces deux cas, le mot de passe choisi par l'administrateur n'est + * jamais touché. + * + * Sans configuration exploitable, le service l'annonce dans les journaux avec + * la commande de secours (scripts/setup/admin-password.js). Il ne bloque jamais + * le démarrage : une API sans administrateur vaut mieux qu'une API arrêtée. + */ + +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DataSource, EntityManager } from 'typeorm'; +import * as crypto from 'crypto'; +import * as argon2 from 'argon2'; + +/** Paramètres Argon2id du projet (cf. auth.service.ts). */ +const ARGON2_OPTIONS = { + type: argon2.argon2id, + memoryCost: 65536, + timeCost: 3, + parallelism: 4, +} as const; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const RECOVERY_COMMAND = 'docker exec -it node admin-password.js '; + +export interface AdminBootstrapConfig { + email: string; + passwordHash?: string; + resetPassword: boolean; + firstName: string; + lastName: string; + organization: { + name: string; + street: string; + city: string; + postalCode: string; + country: string; + }; +} + +export interface ExistingAccount { + id: string; + role: string; + isActive: boolean; + lastLoginAt: Date | null; + passwordHash: string; +} + +export interface AdminBootstrapDecision { + create: boolean; + promote: boolean; + activate: boolean; + applyPassword: boolean; +} + +/** + * Règle de décision, sans accès à la base. + * Idempotente : relancer avec la même configuration ne produit aucun changement. + */ +export function decideAdminBootstrap( + account: ExistingAccount | null, + config: Pick +): AdminBootstrapDecision { + if (!account) { + return { create: true, promote: false, activate: false, applyPassword: !!config.passwordHash }; + } + + const hashAlreadyApplied = account.passwordHash === config.passwordHash; + const applyPassword = + !!config.passwordHash && + !hashAlreadyApplied && + (config.resetPassword || account.lastLoginAt === null); + + return { + create: false, + promote: account.role !== 'ADMIN', + activate: !account.isActive, + applyPassword, + }; +} + +/** Lit la configuration d'amorçage ; `null` si aucun email n'est fourni. */ +export function readAdminBootstrapConfig( + get: (key: string) => string | undefined, + logger?: Pick +): AdminBootstrapConfig | null { + const value = (key: string, fallback = '') => (get(key) ?? fallback).toString().trim(); + + const email = value('BOOTSTRAP_ADMIN_EMAIL').toLowerCase(); + if (!email) return null; + + if (!EMAIL_PATTERN.test(email)) { + logger?.error(`[amorçage admin] BOOTSTRAP_ADMIN_EMAIL invalide : "${email}" — ignoré.`); + return null; + } + + let passwordHash: string | undefined = value('BOOTSTRAP_ADMIN_PASSWORD_HASH') || undefined; + if (passwordHash && !passwordHash.startsWith('$argon2')) { + // Jamais de mot de passe en clair : il serait resté dans l'environnement. + logger?.error( + '[amorçage admin] BOOTSTRAP_ADMIN_PASSWORD_HASH doit être un hash Argon2 (« $argon2… »), ' + + 'jamais un mot de passe en clair — ignoré. Générez-le avec scripts/setup/generate-admin-hash.js.' + ); + passwordHash = undefined; + } + + const country = value('BOOTSTRAP_ADMIN_ORG_COUNTRY', 'FR').toUpperCase(); + + return { + email, + passwordHash, + resetPassword: value('BOOTSTRAP_ADMIN_RESET_PASSWORD').toLowerCase() === 'true', + firstName: value('BOOTSTRAP_ADMIN_FIRST_NAME', 'Admin') || 'Admin', + lastName: value('BOOTSTRAP_ADMIN_LAST_NAME', 'Xpeditis') || 'Xpeditis', + organization: { + name: value('BOOTSTRAP_ADMIN_ORG_NAME', 'Xpeditis') || 'Xpeditis', + street: value('BOOTSTRAP_ADMIN_ORG_STREET', 'A completer') || 'A completer', + city: value('BOOTSTRAP_ADMIN_ORG_CITY', 'A completer') || 'A completer', + postalCode: value('BOOTSTRAP_ADMIN_ORG_POSTAL_CODE', '00000') || '00000', + country: /^[A-Z]{2}$/.test(country) ? country : 'FR', + }, + }; +} + +@Injectable() +export class AdminBootstrapService implements OnApplicationBootstrap { + private readonly logger = new Logger(AdminBootstrapService.name); + + constructor( + private readonly configService: ConfigService, + private readonly dataSource: DataSource + ) {} + + async onApplicationBootstrap(): Promise { + try { + await this.run(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error( + `[amorçage admin] Échec : ${message}. Commande de secours : ${RECOVERY_COMMAND}` + ); + } + } + + async run(): Promise { + const config = readAdminBootstrapConfig( + key => this.configService.get(key), + this.logger + ); + + if (config) { + await this.dataSource.transaction(manager => this.applyConfig(manager, config)); + } + + await this.warnIfNoActiveAdmin(); + } + + private async applyConfig(manager: EntityManager, config: AdminBootstrapConfig): Promise { + // En production, 2 a 4 replicas demarrent en meme temps et executent tous + // cet amorcage. Sans verrou, chacun verrait le compte absent et tenterait + // de le creer : le verrou de transaction les fait passer un par un. + await manager.query(`SELECT pg_advisory_xact_lock(hashtext('xpeditis:admin_bootstrap'))`); + + const rows: Array<{ + id: string; + role: string; + is_active: boolean; + last_login_at: Date | null; + password_hash: string; + }> = await manager.query( + `SELECT "id", "role", "is_active", "last_login_at", "password_hash" FROM "users" WHERE "email" = $1`, + [config.email] + ); + + const account: ExistingAccount | null = rows[0] + ? { + id: rows[0].id, + role: rows[0].role, + isActive: rows[0].is_active, + lastLoginAt: rows[0].last_login_at, + passwordHash: rows[0].password_hash, + } + : null; + + const decision = decideAdminBootstrap(account, config); + + if (decision.create) { + await this.createAdmin(manager, config); + return; + } + + if (!decision.promote && !decision.activate && !decision.applyPassword) { + if (config.resetPassword) { + this.logger.warn( + '[amorçage admin] BOOTSTRAP_ADMIN_RESET_PASSWORD=true est toujours défini : retirez-le, ' + + 'sinon un mot de passe changé depuis l’interface sera remplacé au prochain lancement.' + ); + } + return; + } + + await manager.query( + `UPDATE "users" + SET "role" = 'ADMIN', + "is_active" = true, + "password_hash" = CASE WHEN $2::boolean THEN $3 ELSE "password_hash" END, + "updated_at" = NOW() + WHERE "id" = $1`, + [account!.id, decision.applyPassword, config.passwordHash ?? ''] + ); + + const changes = [ + decision.promote ? 'promu ADMIN' : '', + decision.activate ? 'réactivé' : '', + decision.applyPassword ? 'mot de passe défini depuis BOOTSTRAP_ADMIN_PASSWORD_HASH' : '', + ].filter(Boolean); + this.logger.log(`[amorçage admin] ${config.email} : ${changes.join(', ')}.`); + + if (decision.applyPassword && config.resetPassword) { + this.logger.warn( + '[amorçage admin] Mot de passe réinitialisé. Retirez maintenant BOOTSTRAP_ADMIN_RESET_PASSWORD ' + + 'et BOOTSTRAP_ADMIN_PASSWORD_HASH de l’environnement.' + ); + } + } + + private async createAdmin(manager: EntityManager, config: AdminBootstrapConfig): Promise { + // users.organization_id est NOT NULL : l'organisation doit exister d'abord. + const organization: Array<{ id: string }> = await manager.query( + `INSERT INTO "organizations" + ("name", "type", "address_street", "address_city", "address_postal_code", "address_country") + VALUES ($1, 'FREIGHT_FORWARDER', $2, $3, $4, $5) + ON CONFLICT ("name") DO UPDATE SET "updated_at" = NOW() + RETURNING "id"`, + [ + config.organization.name, + config.organization.street, + config.organization.city, + config.organization.postalCode, + config.organization.country, + ] + ); + + // Sans hash fourni : secret aléatoire immédiatement perdu (aucune connexion + // possible tant qu'un mot de passe n'est pas défini). + const passwordHash = + config.passwordHash ?? + (await argon2.hash(crypto.randomBytes(48).toString('hex'), ARGON2_OPTIONS)); + + await manager.query( + `INSERT INTO "users" + ("organization_id", "email", "password_hash", "role", "first_name", "last_name", + "is_email_verified", "is_active") + VALUES ($1, $2, $3, 'ADMIN', $4, $5, true, true)`, + [organization[0].id, config.email, passwordHash, config.firstName, config.lastName] + ); + + if (config.passwordHash) { + this.logger.log( + `[amorçage admin] Administrateur ${config.email} créé : connexion possible avec le mot de passe correspondant au hash fourni.` + ); + } else { + this.logger.warn( + `[amorçage admin] Administrateur ${config.email} créé SANS mot de passe utilisable. ` + + `Définissez-le avec « mot de passe oublié » (SMTP requis) ou, sans SMTP : ${RECOVERY_COMMAND}` + ); + } + } + + /** Signale une plateforme sans administrateur actif, avec la marche à suivre. */ + private async warnIfNoActiveAdmin(): Promise { + const result: Array<{ n: number }> = await this.dataSource.query( + `SELECT count(*)::int AS n FROM "users" WHERE "role" = 'ADMIN' AND "is_active" = true` + ); + if ((result[0]?.n ?? 0) === 0) { + this.logger.error( + '[amorçage admin] Aucun administrateur actif. Renseignez BOOTSTRAP_ADMIN_EMAIL ' + + `(et BOOTSTRAP_ADMIN_PASSWORD_HASH) puis relancez, ou exécutez : ${RECOVERY_COMMAND}` + ); + } + } +} diff --git a/infra/prod/k8s/base/02-configmap-backend.yaml b/infra/prod/k8s/base/02-configmap-backend.yaml index db32c0c..9299a5a 100644 --- a/infra/prod/k8s/base/02-configmap-backend.yaml +++ b/infra/prod/k8s/base/02-configmap-backend.yaml @@ -77,18 +77,17 @@ data: LOG_EXPORTER_URL: "http://xpeditis-log-exporter:3200" # --- Premier administrateur ------------------------------------------------ - # Lu par la migration 1756000000001-BootstrapAdminFromEnv, qui remplace le - # compte de demonstration admin@xpeditis.com / Password123!. + # Lu a CHAQUE demarrage des pods par AdminBootstrapService (et, sur une base + # neuve, par la migration 1756000000001-BootstrapAdminFromEnv). Le compte est + # garanti ADMIN et actif, et cree s'il n'existe pas ; les replicas qui + # demarrent ensemble sont serialises par un verrou PostgreSQL. # - # Renseigne SEUL (sans BOOTSTRAP_ADMIN_PASSWORD_HASH dans le Secret), il cree - # un compte ADMIN actif SANS mot de passe utilisable : vous definissez le - # votre via « mot de passe oublie ». Aucun secret n'existe alors nulle part. + # Mot de passe : BOOTSTRAP_ADMIN_PASSWORD_HASH dans le Secret (recommande au + # premier deploiement). Sans hash, le compte n'a PAS de mot de passe + # utilisable : « mot de passe oublie » (SMTP requis) ou, sans SMTP : + # kubectl -n xpeditis-prod exec -it deploy/xpeditis-backend -- node admin-password.js ops@xpeditis.com # - # La migration ne fait rien s'il existe deja un administrateur actif : elle ne - # peut donc pas en creer un second lors d'un deploiement ulterieur. - # - # Cette adresse doit etre RELEVABLE : c'est par elle que passe le lien de - # definition du mot de passe. + # Cette adresse doit etre RELEVABLE. BOOTSTRAP_ADMIN_EMAIL: "ops@xpeditis.com" BOOTSTRAP_ADMIN_FIRST_NAME: "Admin" BOOTSTRAP_ADMIN_LAST_NAME: "Xpeditis" diff --git a/infra/prod/k8s/base/03-secrets.template.yaml b/infra/prod/k8s/base/03-secrets.template.yaml index a83841d..1935923 100644 --- a/infra/prod/k8s/base/03-secrets.template.yaml +++ b/infra/prod/k8s/base/03-secrets.template.yaml @@ -75,22 +75,23 @@ stringData: STRIPE_PLATINIUM_MONTHLY_PRICE_ID: "REMPLACER_price_" STRIPE_PLATINIUM_YEARLY_PRICE_ID: "REMPLACER_price_" - # --- Premier administrateur (FACULTATIF) ----------------------------------- - # LAISSEZ VIDE dans le cas nominal. + # --- Mot de passe du premier administrateur -------------------------------- + # RECOMMANDE au premier deploiement : sans lui, BOOTSTRAP_ADMIN_EMAIL (voir + # ConfigMap) est cree sans mot de passe utilisable, et la premiere connexion + # depend alors de l'email « mot de passe oublie ». # - # Vide + BOOTSTRAP_ADMIN_EMAIL renseigne dans le ConfigMap : le compte ADMIN - # est cree sans mot de passe utilisable, et vous definissez le votre via - # « mot de passe oublie ». Aucun secret n'existe nulle part -- rien a faire - # fuiter, et la reception du courriel prouve au passage que SMTP fonctionne. - # - # Ne renseignez ce champ que si vous devez pouvoir vous connecter AVANT que - # l'envoi de courriels ne soit operationnel. Dans ce cas : # cd apps/backend && node scripts/setup/generate-admin-hash.js - # Le hash reste attaquable hors ligne : changez le mot de passe des la - # premiere connexion, puis RETIREZ cette valeur et reappliquez le Secret. # - # Un mot de passe en clair place ici fait echouer la migration : la valeur - # doit commencer par "$argon2". + # Collez le hash tel quel : en YAML Kubernetes, les $ ne se doublent PAS + # (contrairement a Docker Compose / Portainer, ou il faut ecrire $$). + # + # Applique au demarrage si le compte ne s'est jamais connecte, ou sur demande + # avec BOOTSTRAP_ADMIN_RESET_PASSWORD: "true" dans le ConfigMap. Le mot de + # passe d'un administrateur deja connecte n'est jamais ecrase. + # + # Le hash reste attaquable hors ligne : apres la premiere connexion, changez + # le mot de passe, RETIREZ cette valeur et reappliquez le Secret. + # La valeur doit commencer par "$argon2" (un mot de passe en clair est refuse). BOOTSTRAP_ADMIN_PASSWORD_HASH: "" # --- Pappers (registre SIRET) ----------------------------------------------