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
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
This commit is contained in:
parent
10bc0cf898
commit
10b69f3b2b
@ -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 && \
|
||||||
|
|||||||
167
apps/backend/scripts/setup/admin-password.js
Normal file
167
apps/backend/scripts/setup/admin-password.js
Normal 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);
|
||||||
|
});
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}`);
|
||||||
|
|||||||
@ -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 {}
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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) ----------------------------------------------
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user