Commit e878fb36 authored by Kourser's avatar Kourser
Browse files

Amorçage : une instance neuve n'avait aucune porte d'entrée

Une base fraîchement migrée ne contient aucun compte, et rien ne
permettait d'en créer un. L'inscription publique est fermée par défaut,
et même ouverte elle produit un administrateur d'ORGANISATION, jamais
d'instance : l'organisation système et le premier INSTANCE_ADMIN
n'existaient que dans le jeu de démonstration.

Restaient donc deux issues à qui installe pour de bon : ouvrir son
instance avec des identifiants publiés dans le README, ou écrire à la
main dans PostgreSQL. Le message d'erreur du code disait lui-même
« rejouez le seed », c'est-à-dire : servez-vous des comptes de démo.

    printf '%s' "$mdp" | docker compose exec -T app \
      node apps/api/dist/scripts/amorcer-instance.js \
      --email=... --nom=...

Le mot de passe se lit sur l'entrée standard, jamais en argument : la
ligne de commande d'un processus est lisible par tout utilisateur de la
machine, et resterait dans l'historique du shell.

La commande refuse de s'exécuter si un administrateur d'instance existe
déjà — elle sert à amorcer, pas à s'ajouter en douce à une instance en
service — et renvoie alors vers les gestes qui conviennent : la console
d'instance pour les comptes suivants, « mfa:reset » pour un second
facteur perdu.

L'organisation système est créée par « upsert » sur le même couple
slug/nom que le seed : les deux chemins d'amorçage doivent converger
vers UNE organisation système, pas en créer chacun la sienne.

Comme « mfa:reset », la commande demande un accès au serveur et à la
base : c'est ce qui en fait un privilège acceptable, puisque quiconque
en dispose peut de toute façon écrire directement dans la base.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent bbc06cfa
Loading
Loading
Loading
Loading
+22 −1
Changes for README.md: 22 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -33,13 +33,34 @@ docker compose up --build

L'application est disponible sur http://localhost:3000. Les migrations de base de données sont appliquées automatiquement au démarrage.

Créer les organisations et comptes de démonstration (deux tenants cloisonnés) :
### Premier compte d'administration

Une base neuve ne contient **aucun compte**, et l'inscription publique est fermée par
défaut : sans cette étape, personne ne peut se connecter. Le mot de passe se transmet
par un tube, jamais en argument — la ligne de commande d'un processus est lisible par
tout utilisateur de la machine.

```bash
printf '%s' 'votre-mot-de-passe' | docker compose exec -T app \
  node apps/api/dist/scripts/amorcer-instance.js \
  --email=admin@mon-domaine.fr --nom='Prénom Nom'
```

La commande refuse de s'exécuter si l'instance a déjà un administrateur : les comptes
suivants se créent depuis la console d'instance. Le second facteur est exigé dès la
première connexion, prévoyez l'appareil qui le portera.

### Jeu de données de démonstration

Deux organisations cloisonnées, leurs comptes et un exercice prêt à jouer :

```bash
docker compose exec app node_modules/.bin/ts-node apps/api/prisma/seed.ts
```

> Comptes de démo : `admin@meridien.exercice` et `admin@helios.exercice` — mot de passe `ChangeMoi123!` (à changer).
> Ces identifiants sont publics : réservez ce jeu à un poste de test, jamais à une
> instance accessible.

Vérifier l'état du service :

+2 −1
Changes for apps/api/package.json: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -12,7 +12,8 @@
    "db:migrate": "prisma migrate dev",
    "db:migrate:deploy": "prisma migrate deploy",
    "db:seed": "ts-node prisma/seed.ts",
    "mfa:reset": "ts-node src/scripts/reset-mfa.ts"
    "mfa:reset": "ts-node src/scripts/reset-mfa.ts",
    "instance:amorcer": "ts-node src/scripts/amorcer-instance.ts"
  },
  "prisma": {
    "seed": "ts-node prisma/seed.ts"
+2 −1
Changes for apps/api/prisma/schema.prisma: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -28,7 +28,8 @@ model Tenant {
  status       TenantStatus     @default(ACTIVE)
  /// Organisation systeme : elle heberge les comptes d'administration de
  /// l'instance et n'est jamais listee comme une organisation cliente.
  /// Une seule existe, creee par le seed.
  /// Une seule existe, creee par le seed ou par l'amorcage d'instance
  /// (apps/api/src/scripts/amorcer-instance.ts).
  isSystem     Boolean          @default(false)

  /// Organisation EPHEMERE du bac a sable public (EV-7) : elle est detruite,
+2 −1
Changes for apps/api/src/instance/tenants.service.ts: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -288,7 +288,8 @@ export class TenantsService {
    const tenant = await this.prisma.tenant.findFirst({ where: { isSystem: true } });
    if (!tenant) {
      throw new NotFoundException(
        "Organisation système absente : rejouez le seed pour la créer",
        "Organisation système absente : amorcez l’instance pour la créer " +
          "(apps/api/dist/scripts/amorcer-instance.js)",
      );
    }
    return tenant.id;
+139 −0
Changes for apps/api/src/scripts/amorcer-instance.ts: 139 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { PrismaClient } from '@prisma/client';
import { hash } from 'bcryptjs';
import { PASSWORD_MIN_LENGTH } from '@enkadenn/shared';

/**
 * Cree la premiere porte d'entree d'une instance neuve, depuis le SERVEUR.
 *
 * POURQUOI CETTE COMMANDE EXISTE. Une base fraichement migree ne contient aucun
 * compte, et rien ne permettait d'en creer un : l'inscription publique est
 * fermee par defaut, et meme ouverte elle produit un administrateur
 * d'ORGANISATION, jamais d'instance. Le seul chemin restant etait le jeu de
 * demonstration — dont les mots de passe sont publies dans le README, ce que
 * l'installateur deconseille lui-meme sur une instance accessible. Autrement
 * dit : soit on ouvrait son instance avec des identifiants connus de tous, soit
 * on ecrivait a la main dans PostgreSQL.
 *
 * Elle demande un acces au serveur et a la base, comme `reset-mfa` : c'est ce
 * qui en fait un privilege acceptable, puisque quiconque en dispose peut de
 * toute facon ecrire directement dans la base.
 *
 * Usage, depuis la machine hote :
 *   printf '%s' "$mot_de_passe" |
 *     docker compose exec -T app node apps/api/dist/scripts/amorcer-instance.js \
 *       --email=admin@mon-domaine.fr --nom='Prenom Nom'
 *
 * En developpement :
 *   printf '%s' "$mot_de_passe" |
 *     pnpm --filter @enkadenn/api instance:amorcer -- --email=... --nom=...
 *
 * LE MOT DE PASSE SE LIT SUR L'ENTREE STANDARD, jamais en argument : la ligne de
 * commande d'un processus est lisible par tout utilisateur de la machine (`ps`),
 * et resterait dans l'historique du shell.
 *
 * La commande refuse de s'executer si un administrateur d'instance existe deja :
 * elle sert a amorcer, pas a s'ajouter en douce a une instance en service. Les
 * comptes suivants se creent depuis la console d'instance.
 */

function arg(name: string): string | undefined {
  const prefix = `--${name}=`;
  const found = process.argv.find((a) => a.startsWith(prefix));
  return found?.slice(prefix.length);
}

/** Lit l'entree standard en entier, sans le retour a la ligne final du shell. */
async function lireEntree(): Promise<string> {
  if (process.stdin.isTTY) return '';
  let brut = '';
  process.stdin.setEncoding('utf8');
  for await (const morceau of process.stdin) brut += morceau;
  return brut.replace(/\r?\n$/, '');
}

async function main(): Promise<void> {
  const email = arg('email')?.trim().toLowerCase();
  const nom = arg('nom')?.trim();

  if (!email || !nom) {
    console.error("Usage : --email=<adresse> --nom=<nom affiche>, mot de passe sur l'entree standard.");
    process.exitCode = 1;
    return;
  }
  // Volontairement permissif : la seule contrainte utile ici est qu'une adresse
  // servira a se connecter et a recevoir un lien de reinitialisation.
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    console.error('Adresse de courriel invalide.');
    process.exitCode = 1;
    return;
  }

  const motDePasse = await lireEntree();
  if (!motDePasse) {
    console.error("Aucun mot de passe recu sur l'entree standard.");
    console.error("Rappel : passez-le par un tube, pas en argument (`ps` afficherait l'argument).");
    process.exitCode = 1;
    return;
  }
  if (motDePasse.length < PASSWORD_MIN_LENGTH) {
    console.error(`Mot de passe trop court : ${PASSWORD_MIN_LENGTH} caracteres minimum.`);
    process.exitCode = 1;
    return;
  }

  const prisma = new PrismaClient();
  try {
    const deja = await prisma.membership.findFirst({
      where: { role: 'INSTANCE_ADMIN' },
      select: { user: { select: { email: true } } },
    });
    if (deja) {
      console.error(
        [
          "Cette instance a deja un administrateur : " + deja.user.email + '.',
          '',
          "  L'amorcage ne sert qu'a ouvrir une instance neuve. Les comptes",
          "  suivants se creent depuis la console d'instance, qui trace qui les",
          '  a crees. Si cet administrateur a perdu son second facteur :',
          '      node apps/api/dist/scripts/reset-mfa.js --email=' + deja.user.email + ' --confirm',
          "  S'il a perdu son mot de passe, utilisez « Mot de passe oublie »",
          '  sur la page de connexion, une fois un serveur de courriel configure.',
        ].join('\n'),
      );
      process.exitCode = 1;
      return;
    }

    const existant = await prisma.user.findUnique({ where: { email }, select: { id: true } });
    if (existant) {
      console.error(`Un compte porte deja l'adresse ${email}. Choisissez-en une autre.`);
      process.exitCode = 1;
      return;
    }

    // Meme couple slug/nom que le jeu de demonstration : les deux chemins
    // d'amorcage doivent converger vers UNE organisation systeme, pas en creer
    // chacun la sienne. `upsert` la reutilise si le seed est passe avant.
    const tenant = await prisma.tenant.upsert({
      where: { slug: 'instance' },
      update: { isSystem: true },
      create: { slug: 'instance', name: "Administration de l'instance", isSystem: true },
    });

    const user = await prisma.user.create({
      data: { email, displayName: nom, passwordHash: await hash(motDePasse, 12) },
    });
    await prisma.membership.create({
      data: { userId: user.id, tenantId: tenant.id, role: 'INSTANCE_ADMIN' },
    });

    console.log(`Administrateur d'instance cree : ${email}`);
    console.log(
      "Le second facteur lui sera demande des sa premiere connexion : gardez un appareil sous la main.",
    );
  } finally {
    await prisma.$disconnect();
  }
}

void main();