Compare commits

..

6 Commits

Author SHA1 Message Date
David
10b69f3b2b fix admin monter
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m32s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m20s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Unit Tests (push) Successful in 10m45s
Dev CI / Notify Failure (push) Has been skipped
CD Preprod / Backend — Lint (push) Successful in 10m24s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m18s
CD Preprod / Backend — Unit Tests (push) Successful in 10m16s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m45s
CD Preprod / Backend — Integration Tests (push) Successful in 9m56s
CD Preprod / Build Frontend (push) Successful in 56s
CD Preprod / Build Log Exporter (push) Successful in 32s
CD Preprod / Build Backend (push) Successful in 6m48s
CD Preprod / Deploy to Preprod (push) Successful in 23s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
2026-09-15 15:21:05 +02:00
David
10bc0cf898 Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Lint (push) Successful in 10m32s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m21s
CD Preprod / Backend — Unit Tests (push) Successful in 10m16s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m45s
CD Preprod / Backend — Integration Tests (push) Successful in 10m0s
CD Preprod / Build Backend (push) Successful in 7m40s
CD Preprod / Build Log Exporter (push) Successful in 31s
CD Preprod / Build Frontend (push) Successful in 29m57s
CD Preprod / Deploy to Preprod (push) Successful in 23s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
2026-09-14 21:12:25 +02:00
David
02e14c4fba Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Lint (push) Successful in 10m31s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m20s
CD Preprod / Backend — Unit Tests (push) Successful in 10m14s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m44s
CD Preprod / Backend — Integration Tests (push) Successful in 10m0s
CD Preprod / Build Backend (push) Successful in 15m17s
CD Preprod / Build Log Exporter (push) Successful in 32s
CD Preprod / Build Frontend (push) Successful in 48m17s
CD Preprod / Deploy to Preprod (push) Successful in 23s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
2026-09-14 14:47:19 +02:00
David
0cbc50e827 Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Lint (push) Successful in 10m28s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m20s
CD Preprod / Backend — Unit Tests (push) Successful in 10m13s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m48s
CD Preprod / Backend — Integration Tests (push) Successful in 10m0s
CD Preprod / Build Backend (push) Successful in 7m46s
CD Preprod / Build Log Exporter (push) Successful in 35s
CD Preprod / Build Frontend (push) Successful in 31m1s
CD Preprod / Deploy to Preprod (push) Successful in 24s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
2026-08-13 17:43:35 +02:00
David
dcd459ee90 Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Lint (push) Successful in 10m28s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m12s
CD Preprod / Backend — Unit Tests (push) Successful in 10m8s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m41s
CD Preprod / Backend — Integration Tests (push) Successful in 9m56s
CD Preprod / Build Backend (push) Successful in 42s
CD Preprod / Build Log Exporter (push) Successful in 30s
CD Preprod / Build Frontend (push) Successful in 27m19s
CD Preprod / Deploy to Preprod (push) Successful in 23s
CD Preprod / Notify Failure (push) Has been skipped
CD Preprod / Notify Success (push) Successful in 2s
2026-07-28 14:24:27 +02:00
David
8168a4dd02 Merge branch 'dev' into preprod
All checks were successful
CD Preprod / Backend — Lint (push) Successful in 10m21s
CD Preprod / Frontend — Lint & Type-check (push) Successful in 11m5s
CD Preprod / Backend — Unit Tests (push) Successful in 10m9s
CD Preprod / Frontend — Unit Tests (push) Successful in 10m41s
CD Preprod / Build Backend (push) Successful in 14m44s
CD Preprod / Build Log Exporter (push) Successful in 32s
CD Preprod / Backend — Integration Tests (push) Successful in 9m56s
CD Preprod / Build Frontend (push) Successful in 27m7s
CD Preprod / Deploy to Preprod (push) Successful in 23s
CD Preprod / Notify Success (push) Successful in 1s
CD Preprod / Notify Failure (push) Has been skipped
2026-07-20 22:41:43 +02:00
10 changed files with 734 additions and 29 deletions

View File

@ -61,6 +61,10 @@ COPY --from=builder --chown=nestjs:nodejs /app/src ./src
# Copy startup script (includes migrations) # Copy startup script (includes migrations)
COPY --chown=nestjs:nodejs scripts/setup/startup.js ./startup.js COPY --chown=nestjs:nodejs scripts/setup/startup.js ./startup.js
# Recovery command: set an admin password without SMTP
# docker exec -it <backend-container> node admin-password.js <email>
COPY --chown=nestjs:nodejs scripts/setup/admin-password.js ./admin-password.js
# Create logs and uploads directories # Create logs and uploads directories
RUN mkdir -p /app/logs && \ RUN mkdir -p /app/logs && \
mkdir -p /app/src/infrastructure/storage/csv-storage/rates && \ mkdir -p /app/src/infrastructure/storage/csv-storage/rates && \

View File

@ -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 <conteneur-backend> 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 <email-administrateur>');
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);
});

View File

@ -8,12 +8,15 @@
* argument de ligne de commande (visible dans `ps` et dans l'historique du * argument de ligne de commande (visible dans `ps` et dans l'historique du
* shell), ni variable d'environnement, ni fichier temporaire. * shell), ni variable d'environnement, ni fichier temporaire.
* *
* RAPPEL — le mode SANS mot de passe est préférable. * Le hash est appliqué à CHAQUE lancement du backend (AdminBootstrapService) :
* Si votre chaîne SMTP fonctionne, ne renseignez que BOOTSTRAP_ADMIN_EMAIL : * - au compte BOOTSTRAP_ADMIN_EMAIL s'il ne s'est encore jamais connecté ;
* le compte est alors créé sans mot de passe utilisable et vous le définissez * - à tout compte si BOOTSTRAP_ADMIN_RESET_PASSWORD=true (retirez ensuite
* via « mot de passe oublié ». Aucun secret n'existe nulle part, il n'y a donc * les deux variables, sinon un mot de passe changé depuis l'interface
* rien à faire fuiter. Ce script n'est utile que si vous devez pouvoir vous * serait remplacé au lancement suivant).
* connecter avant que l'envoi de courriels ne soit opérationnel. *
* 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 <conteneur-backend> node admin-password.js <email>
*/ */
'use strict'; 'use strict';

View File

@ -39,6 +39,7 @@ import { CacheModule } from './infrastructure/cache/cache.module';
import { CarrierModule } from './infrastructure/carriers/carrier.module'; import { CarrierModule } from './infrastructure/carriers/carrier.module';
import { SecurityModule } from './infrastructure/security/security.module'; import { SecurityModule } from './infrastructure/security/security.module';
import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module'; import { CsvRateModule } from './infrastructure/carriers/csv-loader/csv-rate.module';
import { AdminBootstrapModule } from './infrastructure/persistence/typeorm/admin-bootstrap.module';
// Import global guards // Import global guards
import { ApiKeyOrJwtGuard } from './application/guards/api-key-or-jwt.guard'; 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_MONTHLY_PRICE_ID: Joi.string().optional(),
STRIPE_PLATINIUM_YEARLY_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'), 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, CacheModule,
CarrierModule, CarrierModule,
CsvRateModule, CsvRateModule,
// Un administrateur exploitable a chaque lancement (base neuve comprise)
AdminBootstrapModule,
// Feature modules // Feature modules
AuthModule, AuthModule,

View File

@ -207,6 +207,16 @@ export class AuthService {
throw new UnauthorizedException('Invalid credentials'); 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); const tokens = await this.generateTokens(user, rememberMe);
this.logger.log(`User logged in successfully: ${email}`); this.logger.log(`User logged in successfully: ${email}`);

View File

@ -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 {}

View File

@ -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> = {}): 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<string, string>) => (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<string, string>, existing: Record<string, unknown> | 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<void>) => 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();
});
});

View File

@ -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 <conteneur-backend> node admin-password.js <email>';
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<AdminBootstrapConfig, 'passwordHash' | 'resetPassword'>
): 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<Logger, 'error'>
): 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<void> {
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<void> {
const config = readAdminBootstrapConfig(
key => this.configService.get<string>(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<void> {
// 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<void> {
// 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<void> {
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}`
);
}
}
}

View File

@ -77,18 +77,17 @@ data:
LOG_EXPORTER_URL: "http://xpeditis-log-exporter:3200" LOG_EXPORTER_URL: "http://xpeditis-log-exporter:3200"
# --- Premier administrateur ------------------------------------------------ # --- Premier administrateur ------------------------------------------------
# Lu par la migration 1756000000001-BootstrapAdminFromEnv, qui remplace le # Lu a CHAQUE demarrage des pods par AdminBootstrapService (et, sur une base
# compte de demonstration admin@xpeditis.com / Password123!. # 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 # Mot de passe : BOOTSTRAP_ADMIN_PASSWORD_HASH dans le Secret (recommande au
# un compte ADMIN actif SANS mot de passe utilisable : vous definissez le # premier deploiement). Sans hash, le compte n'a PAS de mot de passe
# votre via « mot de passe oublie ». Aucun secret n'existe alors nulle part. # 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 # Cette adresse doit etre RELEVABLE.
# 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.
BOOTSTRAP_ADMIN_EMAIL: "ops@xpeditis.com" BOOTSTRAP_ADMIN_EMAIL: "ops@xpeditis.com"
BOOTSTRAP_ADMIN_FIRST_NAME: "Admin" BOOTSTRAP_ADMIN_FIRST_NAME: "Admin"
BOOTSTRAP_ADMIN_LAST_NAME: "Xpeditis" BOOTSTRAP_ADMIN_LAST_NAME: "Xpeditis"

View File

@ -75,22 +75,23 @@ stringData:
STRIPE_PLATINIUM_MONTHLY_PRICE_ID: "REMPLACER_price_" STRIPE_PLATINIUM_MONTHLY_PRICE_ID: "REMPLACER_price_"
STRIPE_PLATINIUM_YEARLY_PRICE_ID: "REMPLACER_price_" STRIPE_PLATINIUM_YEARLY_PRICE_ID: "REMPLACER_price_"
# --- Premier administrateur (FACULTATIF) ----------------------------------- # --- Mot de passe du premier administrateur --------------------------------
# LAISSEZ VIDE dans le cas nominal. # 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 # 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 # Collez le hash tel quel : en YAML Kubernetes, les $ ne se doublent PAS
# doit commencer par "$argon2". # (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: "" BOOTSTRAP_ADMIN_PASSWORD_HASH: ""
# --- Pappers (registre SIRET) ---------------------------------------------- # --- Pappers (registre SIRET) ----------------------------------------------