Commit b4ea2452 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Une instance de test ne réclame pas de second facteur

L'installateur demande « cette instance est-elle en production ? ». Répondre
non ne changeait rien : le déclencheur EA-03 exigeait toujours un second
facteur actif pour accorder le droit root, et l'on ne pouvait pas essayer le
produit sans monter une application d'authentification.

Le mode est désormais posé en base, dans une table à lui — et non dans
« reglage_instance », qui se modifie à chaud depuis la console root. Relâcher
une garantie de sécurité ne doit pas être un réglage d'affichage : cela se
décide à l'installation, pas à l'usage.

La table vide vaut production. C'est le sens qui échoue du bon côté : une
instance dont on ne sait rien est traitée comme réelle.

Quatre points appliquaient la contrainte, et je n'en avais d'abord vu que
trois. Le quatrième — « etatAmorce » — ne se contentait pas de piloter l'écran
d'amorce : un preHandler du serveur en dépend et répondait 403 à toute l'API.
Le défaut ne se voyait donc pas comme « l'écran insiste », mais comme « rien
ne fonctionne ».

Une cinquième chose manquait, et c'était la plus discrète : « accorderSiAmorce »
n'était appelé que depuis l'activation du second facteur. En mode test, où
cette activation n'a pas lieu, personne ne devenait jamais root. La pose du
mot de passe l'appelle maintenant.

La suite de garanties a rattrapé une divergence que mon propre commentaire
annonçait : j'avais conditionné « requerirRoot » sur la variable
d'environnement quand tout le reste lisait la table. Les deux peuvent différer
— c'est précisément pourquoi la table existe. La lecture est unifiée, et
n'intervient que sur le chemin déjà périmé.

Le bandeau dit ce qu'il faut et rien de plus : « Instance de test — ne pas
utiliser pour de la production ». Il ne détaille pas quelles garanties sont
relâchées. Cette liste intéresse celui qui installe, à qui l'installateur la
donne ; l'afficher à tout visiteur serait un mode d'emploi.

La sonde d'état publie le mode sans session, et c'est assumé : le bandeau doit
paraître dès l'écran de connexion, avant qu'on sache qui regarde. Une
divulgation, oui — préférable à son contraire, une instance de test prise pour
une vraie et sur laquelle on verse un annuaire réel.

KASTELL_ENV perd sa valeur par défaut. Deviner « développement » quand la
variable manque était le mauvais défaut : une composition incomplète en
production se serait tue.

main.tsx porte aussi l'aiguillage vers la page de partage public, qui vient
avec le lot d'écrans suivant.

Co-Authored-By: Claude (RCA)
parent b9c0320c
Loading
Loading
Loading
Loading
+7 −2
Changes for apps/api/src/auth/amorce.ts: 7 added lines, 2 removed lines.
Original line number Diff line number Diff line
import { randomBytes } from "node:crypto";
import { envoyer } from "../adaptateurs/notify.js";
import type { Sql, Tx } from "../db/client.js";
import { amorcerRoot, journaliser } from "../noyau/administration.js";
import { amorcerRoot, journaliser, modeTest } from "../noyau/administration.js";
import * as comptes from "./comptes.js";
import { empreinte, engendrer } from "./jetons.js";
import * as mdp from "./mots_de_passe.js";
@@ -177,12 +177,17 @@ export async function etatAmorce(sql: Sql | Tx, compteId: string): Promise<EtatA
    select mdp_a_changer, totp_actif_at from compte where id = ${compteId}`;
  const [a] = await sql`select 1 from amorce_root where compte_id = ${compteId}`;
  const destineARoot = Boolean(a);
  // En mode test, l'octroi du droit root n'exige plus de second facteur
  // (migration 0029). Le parcours d'amorçage doit suivre : sans cela il
  // réclamait un TOTP que plus rien n'imposait, et le garde de serveur.ts
  // refusait toute l'API en 403 tant qu'il n'était pas posé.
  const test = await modeTest(sql);
  return {
    doitChangerMotDePasse: Boolean(c?.mdp_a_changer),
    // Le second facteur n'est imposé qu'aux comptes destinés à root : l'exiger
    // de tout le monde serait une autre décision, qui ne se prend pas au
    // détour d'un amorçage.
    doitActiverSecondFacteur: destineARoot && c?.totp_actif_at == null,
    doitActiverSecondFacteur: destineARoot && !test && c?.totp_actif_at == null,
    destineARoot,
  };
}
+53 −0
Changes for apps/api/src/db/migrations/0029_mode_instance.sql: 53 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Mode de l'instance : production, ou test (EA-03).
--
-- L'obligation de second facteur pour devenir root est utile en exploitation
-- et coûteuse en test : sur une instance jetable, elle impose d'activer un
-- TOTP avant de pouvoir seulement ouvrir la console qu'on veut essayer. Le
-- résultat prévisible est qu'on finit par désarmer le garde pour de bon.
--
-- La contrainte devient donc conditionnelle, et l'état dont elle dépend est
-- posé par l'application à son démarrage, à partir de KASTELL_ENV. Trois
-- propriétés en découlent, et ce sont elles qui rendent la chose acceptable :
--
--   1. L'état n'est pas dans « reglage_instance ». Ce qui s'y trouve est
--      modifiable à chaud depuis la console root ; un interrupteur capable de
--      désarmer l'authentification forte n'a rien à faire à portée de clic.
--      Ici, changer de mode demande de changer le .env et de redémarrer.
--
--   2. L'absence vaut production. Tant que personne n'a rien posé, la table
--      est vide et la contrainte s'applique — c'est ce qui permet à « pnpm
--      verif » de contrôler la garantie réelle sur une base neuve, et c'est le
--      comportement qu'on veut si l'écriture au démarrage échoue.
--
--   3. Le mode ne relâche que l'octroi. Il ne rend root personne, et il ne
--      touche pas aux droits déjà accordés : un root né en mode test survit au
--      passage en production, mais ne peut pas entrer dans la console, qui
--      exige une présentation récente du second facteur qu'il n'a pas.
create table mode_instance (
  ligne_unique boolean     primary key default true check (ligne_unique),
  production   boolean     not null,
  pose_at      timestamptz not null default now()
);

comment on table mode_instance is
  'Mode de l''instance, posé au démarrage depuis KASTELL_ENV. Table vide = production.';

-- ── Un root a nécessairement un second facteur, sauf en test (EA-03) ───────
-- La console exige un second facteur récent ; un root qui n'en aurait pas ne
-- pourrait jamais y entrer. Autant refuser l'octroi que laisser exister un
-- droit inutilisable dont on croirait disposer le jour où il faut s'en servir.
--
-- En mode test, cette raison tombe : la console y est ouverte sans présentation
-- récente, donc le droit accordé est réellement exerçable.
create or replace function root_exige_second_facteur() returns trigger
language plpgsql as $$
begin
  if new.retire_at is null
     and not coalesce((select not production from mode_instance), false)
     and not exists (select 1 from compte c
                      where c.id = new.compte_id and c.totp_actif_at is not null) then
    raise exception 'root: un second facteur actif est requis pour ce droit (EA-03)'
      using errcode = 'restrict_violation';
  end if;
  return new;
end $$;
+14 −1
Changes for apps/api/src/env.ts: 14 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -6,7 +6,20 @@ import { z } from "zod";
 * un kastell de crise ne découvre pas sa mauvaise configuration le jour J.
 */
const schema = z.object({
  KASTELL_ENV: z.enum(["developpement", "production"]).default("developpement"),
  /**
   * Sans valeur par défaut, et c'est délibéré (EA-03).
   *
   * Le mode test lève l'obligation de second facteur pour devenir root. Une
   * valeur par défaut ferait de ce relâchement le comportement d'une instance
   * dont la variable a simplement été oubliée — l'inverse exact de ce qu'on
   * veut d'un défaut. Mieux vaut refuser de démarrer et le dire.
   */
  KASTELL_ENV: z.enum(["developpement", "production"], {
    required_error:
      "KASTELL_ENV est obligatoire : « production » ou « developpement ». Le mode "
      + "test lève l'obligation de second facteur pour le compte root, il ne peut "
      + "pas être choisi par omission.",
  }),
  KASTELL_PORT: z.coerce.number().int().positive().default(8080),
  KASTELL_PUBLIC_URL: z.string().url(),

+13 −0
Changes for apps/api/src/index.ts: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -12,6 +12,7 @@
import { chargerEnv } from "./env.js";
import { ouvrirBase } from "./db/client.js";
import { migrer } from "./db/migrate.js";
import { poserMode } from "./noyau/administration.js";
import { definirDiffusion, diffusion, diffusionRedis } from "./adaptateurs/diffusion.js";
import { antivirus, antivirusClamav, definirAntivirus } from "./adaptateurs/antivirus.js";
import { definirStockage, stockageS3 } from "./adaptateurs/stockage.js";
@@ -41,6 +42,18 @@ console.log("Migrations :");
const faites = await migrer(sql);
if (faites.length === 0) console.log("  (aucune, base à jour)");

// Le mode est posé après les migrations et avant que la première requête
// entre : c'est lui que consulte le déclencheur qui impose — ou non — un
// second facteur pour devenir root (migration 0029).
await poserMode(sql, env.KASTELL_ENV === "production");
if (env.KASTELL_ENV !== "production") {
  console.warn(
    "⚠  Instance en mode test : l'obligation de second facteur pour devenir\n"
    + "   root est levée, et la console n'exige pas de présentation récente.\n"
    + "   Ne servez jamais de vraie crise avec KASTELL_ENV=developpement.",
  );
}

if (env.REDIS_URL) definirDiffusion(diffusionRedis(env.REDIS_URL));

if (env.S3_BUCKET && env.S3_ACCESS && env.S3_SECRET) {
+36 −3
Changes for apps/api/src/noyau/administration.ts: 36 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -65,6 +65,34 @@ export async function estRoot(sql: Sql, compteId: string): Promise<boolean> {
  return r !== undefined;
}

/**
 * Le mode de l'instance, posé au démarrage depuis KASTELL_ENV (migration 0029).
 *
 * Écrit ici et nulle part ailleurs : « reglage_instance » est modifiable à
 * chaud depuis la console root, et un interrupteur capable de désarmer
 * l'authentification forte n'a rien à faire à portée de clic.
 */
export async function poserMode(sql: Sql, production: boolean): Promise<void> {
  await sql`
    insert into mode_instance (ligne_unique, production, pose_at)
    values (true, ${production}, now())
    on conflict (ligne_unique) do update
       set production = excluded.production, pose_at = excluded.pose_at`;
}

/**
 * Vrai si l'instance tourne en mode test.
 *
 * Lu en base plutôt que dans l'environnement : c'est la même source que le
 * déclencheur, donc l'application et le schéma ne peuvent pas diverger. Table
 * vide — une base neuve, une vérification, un démarrage interrompu — vaut
 * production : l'absence d'affirmation ne relâche rien.
 */
export async function modeTest(sql: Sql | Tx): Promise<boolean> {
  const [m] = await sql<{ production: boolean }[]>`select production from mode_instance`;
  return m !== undefined && !m.production;
}

/**
 * Premier root d'une instance fraîche (EA-02).
 *
@@ -77,9 +105,14 @@ export async function amorcerRoot(sql: Sql, compteId: string, motif: string): Pr
    if (deja) return false;

    // Le déclencheur de la migration 0005 impose un second facteur ; son
    // message serait obscur pour quelqu'un qui installe l'outil.
    const [pret] = await tx`
      select 1 from compte where id = ${compteId} and totp_actif_at is not null`;
    // message serait obscur pour quelqu'un qui installe l'outil. En mode test
    // le déclencheur ne l'impose plus (migration 0029) : ce pré-contrôle doit
    // suivre, sans quoi il refuserait ce que la base accepte.
    const test = await modeTest(tx);
    const [pret] = test
      ? [1]
      : await tx`select 1 from compte
                  where id = ${compteId} and totp_actif_at is not null`;
    if (!pret) {
      throw new Error(
        "Ce compte n'a pas de second facteur actif. Activez-le depuis l'application " +
Loading