Commit 9127184e authored by Kourser's avatar Kourser
Browse files

Un poste acheté le matin, et la cellule entre avant midi

Le produit savait conduire une crise préparée. Il ne savait pas être installé
le jour même, sur une machine posée dans la salle, par quelqu'un qui n'a rien
préparé — et c'est le cas qu'on rencontre.

Le contexte sécurisé, d'abord, parce que rien ne tient sans lui. Servie en
http sur une adresse locale, une instance n'est pas une origine sécurisée :
getUserMedia est refusé, donc toute la visioconférence est morte, et elle ne
le dit qu'au premier appel, en réunion. L'installateur engendre désormais un
certificat auto-signé avec son subjectAltName — sans lui les navigateurs
refusent même après acceptation — et affiche l'empreinte à comparer à voix
haute, ce qui transforme « continuer quand même » en vérification. Les trois
boutons de copie qui ne faisaient rien sur une telle origine sont réparés.

La porte de jonction ensuite : un QR, une adresse et un code à six chiffres,
affichés quinze minutes. Vingt-cinq personnes entrent en scannant au lieu de
vingt-cinq formulaires d'invitation. Le code n'est pas dans le QR, il n'est
gardé que haché, le débit est limité, et chaque entrée va au journal — avec
l'ouverture de la porte, sans quoi on saurait qui est passé sans savoir qui
avait décidé d'ouvrir.

La posture « instance de salle » lève l'obligation de second facteur pour le
seul compte qu'elle bloquait : celui qui installe. Elle ne filtre rien, elle
signale. Le déclencheur de 0005 la lit comme il lit déjà le mode test, et
table vide vaut toujours production.

Le dossier n'ouvre plus sur un reproche : à la version zéro, on montre la
mise en route au lieu d'une note de 0 sur 100. Et il n'est plus vide — un
fonds de dotation verse fiches réflexes, modèles, étiquettes, tiers,
échéances réglementaires et trames à imprimer. Les numéros ont été relevés
aux sources officielles, les délais portent leur fondement, et ce qui n'a pas
pu être vérifié est laissé sans chiffre : une échéance fausse rassure.

Rien n'est écrasé à l'import, les fiches versées comptent comme non relues,
et chaque élément dit de quel pack et de quelle version il vient.

Non vérifié : les migrations 0058 à 0061 n'ont jamais été jouées, faute de
base sur la machine de développement.

Co-Authored-By: Claude (RCA)
parent 9869e7b7
Loading
Loading
Loading
Loading
Loading
+10 −0
Changes for .gitignore: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -27,3 +27,13 @@ public/
# Polices d'Excalidraw, rapatriées depuis node_modules par
# outils/polices-excalidraw.mjs : construites, pas versionnées.
apps/web/public/

# Le certificat de salle et sa clé, engendrés par outils/installer.sh.
#
# La clé privée d'abord : elle naît sur la machine qui sert la crise et n'en
# sort jamais. Un « git add . » dans un dépôt de travail suffirait sinon à la
# publier, et personne ne relit la liste des fichiers ajoutés le matin d'une
# crise. Le certificat suit, non par secret — il est public par nature — mais
# parce qu'il ne vaut que pour l'adresse d'une machine : versionné, il
# reviendrait sur une autre installation où il ne correspond à rien.
docker/tls/
+84 −1
Changes for apps/api/src/administration/reglages.ts: 84 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -4,7 +4,7 @@ import {
} from "@kastell/shared";
import { antivirusClamav, definirAntivirus } from "../adaptateurs/antivirus.js";
import { definirMedia, mediaLiveKit } from "../adaptateurs/media.js";
import { definirTransport, transportConsole } from "../adaptateurs/notify.js";
import { definirTransport, envoyer, transportActuel, transportConsole } from "../adaptateurs/notify.js";
import { definirPoussee, pousseeRelais } from "../adaptateurs/poussee.js";
import { transportSmtp } from "../adaptateurs/smtp.js";
import type { Sql, Tx } from "../db/client.js";
@@ -102,6 +102,23 @@ export async function listerReglages(sql: Sql, env: Env): Promise<ReglageVu[]> {
export async function appliquerReglages(sql: Sql, env: Env): Promise<void> {
  const c = await configurationEffective(sql, env);

  /*
   * Ce qui n'est pas repris ici, et qu'il faut dire : KASTELL_PUBLIC_URL et
   * KASTELL_SOURCE_URL.
   *
   * Elles se posent, se valident, s'inscrivent au journal et s'affichent
   * « console » — et rien ne bouge. Leurs lecteurs tiennent l'objet Env issu
   * du démarrage : les liens des courriels de connexion, le drapeau « secure »
   * du cookie, l'adresse annoncée au serveur MCP, celle de l'AGPL §13. Aucun
   * ne relit cette table, et certains ont copié la chaîne à l'enregistrement
   * des routes — la recopier dans Env ne les atteindrait même pas tous.
   *
   * Ce sont donc les deux seuls réglages de la liste qui demandent un
   * redémarrage, et c'est l'écran de configuration qui le dit — pas la
   * documentation d'exploitation, que personne n'ouvre en corrigeant une
   * adresse publique fausse.
   */

  if (c.CLAMAV_HOST) {
    definirAntivirus(antivirusClamav(c.CLAMAV_HOST, Number(c.CLAMAV_PORT ?? 3310)));
  }
@@ -137,6 +154,72 @@ export async function appliquerReglages(sql: Sql, env: Env): Promise<void> {
  }
}

/**
 * Un vrai message, parce que « vérifier » ne prouve pas « remettre ».
 *
 * Le transport sait déjà s'éprouver sans rien envoyer — c'est ce que fait la
 * sonde des services du jour J : elle ouvre la connexion, négocie le
 * chiffrement, s'authentifie et raccroche. Cela couvre le relais injoignable
 * et le mot de passe faux, et pas le reste : un expéditeur que le relais
 * refuse, un domaine sans SPF, une boîte qui classe tout en indésirable. On
 * découvrait ces trois-là à la première mobilisation réelle, c'est-à-dire au
 * seul moment où il était trop tard pour les corriger.
 *
 * Le message part à l'adresse du compte qui l'a demandé, et à aucune autre :
 * essayer sur quelqu'un d'autre, c'est envoyer un courriel qu'on n'a pas lu à
 * une personne qui ne l'attend pas.
 */
export async function essayerCourriel(
  sql: Sql, env: Env, destinataire: string,
): Promise<{ ok: boolean; detail: string }> {
  const transport = transportActuel();
  // Un essai qui réussit sans que rien ne sorte de la machine serait le pire
  // des trois résultats possibles : il rassure. En développement, l'absence de
  // relais laisse en place le transport qui écrit dans les traces.
  if (transport.nom === "console") {
    return { ok: false, detail: "Aucun relais n'est configuré : le message est "
      + "parti dans les traces du serveur, et personne ne l'a reçu. "
      + "Renseignez SMTP_HOST et SMTP_FROM." };
  }

  try {
    await envoyer({
      destinataire,
      sujet: "Kastell — essai de configuration",
      texte: "Essai de configuration Kastell, demandé depuis la console root.\n"
        + "Si vous lisez ceci, le relais de courriel remet réellement.\n"
        + "Aucune action n'est attendue de votre part.",
    });
    return { ok: true, detail: `Message remis à ${transport.cible ?? transport.nom}. `
      + "Vérifiez qu'il arrive, et qu'il n'arrive pas en indésirable." };
  } catch (e) {
    return { ok: false, detail: sansSecrets(
      e instanceof Error ? e.message : "Échec sans détail.",
      await configurationEffective(sql, env)) };
  }
}

/**
 * Le relais répond en citant le dialogue, et le dialogue porte l'identifiant.
 *
 * Un refus d'authentification revient sous la forme « Invalid login: 535 … »
 * suivi de ce que le serveur a vu passer — et AUTH LOGIN fait passer le mot de
 * passe, en base64. Ce message-là remonte à l'écran et peut finir dans une
 * capture collée à un ticket. La règle de l'instance est qu'un secret ne se
 * relit pas ; elle ne peut pas s'arrêter au bord du message d'erreur.
 */
function sansSecrets(message: string, c: Record<string, string | undefined>): string {
  let net = message;
  for (const cle of REGLAGES.filter((r) => r.genre === "secret").map((r) => r.cle)) {
    const valeur = c[cle];
    if (!valeur) continue;
    for (const forme of [valeur, Buffer.from(valeur, "utf8").toString("base64")]) {
      net = net.split(forme).join("…");
    }
  }
  return net;
}

export class ReglageRefuse extends Error {
  constructor(message: string) {
    super(message);
+9 −3
Changes for apps/api/src/auth/amorce.ts: 9 added lines, 3 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, modeTest } from "../noyau/administration.js";
import { amorcerRoot, journaliser, relacheSecondFacteur } from "../noyau/administration.js";
import * as comptes from "./comptes.js";
import { empreinte, engendrer } from "./jetons.js";
import * as mdp from "./mots_de_passe.js";
@@ -181,13 +181,19 @@ export async function etatAmorce(sql: Sql | Tx, compteId: string): Promise<EtatA
  // (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);
  //
  // La posture de salle (migration 0058) relâche le même octroi, et tombe donc
  // dans le même piège si on l'oublie ici : une instance levée dans une salle
  // de crise répondrait 403 partout en attendant une application
  // d'authentification que personne n'a. C'est exactement ce qu'elle existe
  // pour éviter.
  const relache = await relacheSecondFacteur(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 && !test && c?.totp_actif_at == null,
    doitActiverSecondFacteur: destineARoot && !relache && c?.totp_actif_at == null,
    destineARoot,
  };
}
+208 −3
Changes for apps/api/src/auth/comptes.ts: 208 added lines, 3 removed lines.
Original line number Diff line number Diff line
import { randomUUID } from "node:crypto";
import { exigerQuota } from "../administration/quotas.js";
import type { Sql } from "../db/client.js";
import type { Sql, Tx } from "../db/client.js";
import * as typesCrise from "../preparation/typesCrise.js";
import { journaliser } from "../noyau/administration.js";
import { envoyer } from "../adaptateurs/notify.js";
@@ -1096,6 +1096,196 @@ export async function inviter(sql: Sql, d: DemandeInvitation): Promise<Invitatio
  return { invitationId, lien, envois };
}

// ═══════════════════════════════════════════════════════════════════════════
// Le pont entre l'annuaire de préparation et les comptes
// ═══════════════════════════════════════════════════════════════════════════
//
// `personne.compte_id` existait en base et aucun chemin d'invitation ne
// l'alimentait. On importait deux cents fiches en CSV, puis on ressaisissait
// chaque adresse dans l'écran des membres pour ouvrir l'accès : une double
// saisie qui tombe au pire moment, et un lien qui manque ensuite ailleurs —
// l'écran de la première heure compose les cellules à partir de `compte_id`,
// et rendait donc « sans compte » des gens qui en avaient un.
//
// L'écriture ne crée pas de version du dossier, et c'est la règle déjà retenue
// pour `verifie_at` : le contenu de la fiche n'a pas bougé, seul son lien vers
// un compte est posé. Une version par personne rendrait l'historique du
// dossier illisible au premier rattachement de deux cents lignes.

/** Vrai si une fiche a été rattachée. Faux quand il n'y en avait aucune à lier. */
export async function lierFicheAuCompte(
  // « Sql | Tx », comme partout où une fonction doit pouvoir être appelée dans
  // une transaction ouverte ailleurs : ses trois appelants — invitation
  // acceptée, inscription sur invitation, compte ouvert sur place — rattachent
  // la fiche dans la transaction qui crée le compte. En dehors, un compte
  // pourrait exister une fraction de seconde sans sa fiche, et c'est
  // précisément le lien manquant qu'on est en train de réparer.
  sql: Sql | Tx,
  d: { organisationId: string; compteId: string; personneId?: string | null;
    email?: string | null },
): Promise<boolean> {
  // La fiche désignée quand on la connaît — c'est le cas du geste parti de
  // l'annuaire, où l'on sait exactement de quelle ligne on vient. Sinon
  // l'adresse, qui est déjà la clé de rapprochement du dépôt en bloc.
  if (d.personneId) {
    const r = await sql`
      update personne set compte_id = ${d.compteId}
       where id = ${d.personneId} and organisation_id = ${d.organisationId}
         and jusqu_a_version is null and not supprimee and compte_id is null`;
    return r.count > 0;
  }

  const email = d.email ? normaliserEmail(d.email) : null;
  if (!email) return false;
  // Une seule fiche, jamais deux : deux homonymes qui partagent une adresse de
  // service existent, et les rattacher toutes les deux au même compte
  // inventerait un lien que personne n'a demandé. L'adresse personnelle passe
  // devant la professionnelle — c'est elle qui identifie durablement quelqu'un
  // dans ce produit, et elle qui survit à la chute de la messagerie d'entreprise.
  const r = await sql`
    update personne set compte_id = ${d.compteId}
     where id = (select id from personne_courante
                  where organisation_id = ${d.organisationId} and compte_id is null
                    and (lower(email_perso) = ${email} or lower(email_pro) = ${email})
                  order by (lower(email_perso) = ${email}) desc, nom
                  limit 1)
       and jusqu_a_version is null and compte_id is null`;
  return r.count > 0;
}

/** Ce qu'une ligne du lot apporte : l'adresse, et ce que la fiche sait déjà. */
export interface EntreeLot {
  email: string;
  /** La fiche d'annuaire d'où l'adresse vient, quand le geste en vient. */
  personneId?: string | null | undefined;
  /** Le nom de la fiche. La planche s'imprime avec, pas avec l'adresse. */
  nom?: string | null | undefined;
}

export interface LigneLot {
  email: string;
  nom: string | null;
  etat: "invitee" | "deja_membre" | "echec";
  /** Le lien en clair, et cette fois seulement — voir `Invitation.lien`. */
  lien: string | null;
  envois: Remise[];
  /** Ce qui s'est passé pour CETTE ligne. Un lot muet est inutilisable. */
  motif: string | null;
  rattachee: boolean;
}

/**
 * Une adresse vérifiée ici, et pas seulement au bord de la requête.
 *
 * Une colonne de vingt-cinq adresses collée depuis un tableur en contient
 * toujours une qui n'en est pas une — un en-tête oublié, un « n/a ». Refuser
 * le lot entier pour elle ferait perdre les vingt-quatre autres, et c'est
 * exactement ce que le schéma de la route ferait s'il validait chaque ligne.
 */
const RESSEMBLE_A_UNE_ADRESSE = /^[^\s@<>,;]+@[^\s@.<>,;]+\.[^\s@<>,;]+$/;

export interface DemandeLot {
  organisationId: string;
  entrees: EntreeLot[];
  role: RoleOrganisation;
  parCompteId: string;
  urlPublique: string;
  canaux?: { courriel?: boolean } | undefined;
}

/**
 * Convier un lot (EF-107a).
 *
 * Vingt-cinq personnes à convier, c'étaient vingt-cinq formulaires. Ce qui est
 * réemployé ici, c'est `inviter` lui-même, appelé ligne par ligne : une
 * seconde implémentation de l'invitation divergerait de la première au premier
 * correctif, et l'une des deux serait celle qu'on n'a pas relue.
 *
 * Ce qui justifie quand même un chemin à part, c'est le compte rendu. Une
 * boucle dans le navigateur rendrait vingt-cinq refus indépendants, dont
 * certains indiscernables d'une coupure réseau, et l'opérateur n'aurait nulle
 * part la liste de ce qui est parti. Un lot qui répond « 23 sur 25 » sans dire
 * lesquels ne sert à rien. Chaque ligne repart donc avec son état et son motif.
 *
 * Aucune limite n'est desserrée au passage : chaque invitation compte dans le
 * plafond horaire de l'organisation comme si elle avait été émise seule. Un lot
 * qui dépasse ce plafond voit ses dernières lignes refusées — et le dit,
 * ligne par ligne, plutôt que de tomber en bloc.
 */
export async function inviterEnLot(sql: Sql, d: DemandeLot): Promise<LigneLot[]> {
  // Vérifié une fois ici bien que `inviter` le revérifie : sans cela, un
  // rédacteur qui n'a pas qualité pour inviter recevrait vingt-cinq lignes
  // en échec au lieu d'un refus qui se lit.
  const role = await roleDans(sql, d.parCompteId, d.organisationId);
  if (role !== "proprietaire" && role !== "directeur_crise") {
    throw new ErreurAuth("operation_refusee",
      "Seul un propriétaire ou un directeur de crise invite.");
  }

  const lignes: LigneLot[] = [];
  const vues = new Set<string>();

  for (const entree of d.entrees) {
    const email = normaliserEmail(entree.email);
    const nom = entree.nom?.trim() || null;
    const echec = (motif: string, rattachee = false): LigneLot =>
      ({ email, nom, etat: "echec", lien: null, envois: [], motif, rattachee });

    if (!RESSEMBLE_A_UNE_ADRESSE.test(email)) {
      lignes.push(echec("Ce n'est pas une adresse électronique."));
      continue;
    }
    if (vues.has(email)) {
      lignes.push(echec("Adresse répétée dans le lot : une seule invitation part."));
      continue;
    }
    vues.add(email);

    try {
      const [compte] = await sql<{ id: string }[]>`
        select id from compte where lower(email) = ${email}`;

      // Le rattachement d'abord quand le compte existe déjà : c'est ce qu'on
      // vient chercher, et il vaut même pour quelqu'un qui est membre depuis
      // six mois sans que sa fiche d'annuaire le sache.
      const rattachee = compte
        ? await lierFicheAuCompte(sql, {
          organisationId: d.organisationId, compteId: compte.id,
          personneId: entree.personneId ?? null, email,
        })
        : false;

      if (compte) {
        const sien = await roleDans(sql, compte.id, d.organisationId);
        if (sien) {
          lignes.push({
            email, nom, etat: "deja_membre", lien: null, envois: [], rattachee,
            motif: `Déjà membre (${sien}) : aucune invitation n'est partie.`,
          });
          continue;
        }
      }

      const r = await inviter(sql, {
        organisationId: d.organisationId, email, role: d.role,
        parCompteId: d.parCompteId, urlPublique: d.urlPublique,
        canaux: { courriel: d.canaux?.courriel !== false, sms: false },
      });
      // Sans compte, le rattachement ne peut pas avoir lieu maintenant : il n'y
      // a rien à lier tant que l'invitation n'est pas acceptée. Il se fera là,
      // par l'adresse de l'invitation.
      lignes.push({
        email, nom, etat: "invitee", lien: r.lien, envois: r.envois,
        motif: null, rattachee,
      });
    } catch (e) {
      lignes.push(echec(e instanceof Error ? e.message : "Invitation refusée."));
    }
  }

  return lignes;
}

export interface DemandeLaissezPasser {
  organisationId: string;
  email: string;
@@ -1177,6 +1367,9 @@ export async function ouvrirCompteAvecLaissezPasser(
    if (!compte) throw new ErreurAuth("operation_refusee", "Ouverture refusée.");
    await tx`insert into membre (organisation_id, compte_id, role)
             values (${d.organisationId}, ${compte.id}, ${d.role})`;
    await lierFicheAuCompte(tx, {
      organisationId: d.organisationId, compteId: compte.id, email,
    });
    await journaliser(tx, {
      type: "compte.ouvert_par_tiers", acteurId: d.parCompteId,
      organisationId: d.organisationId,
@@ -1307,6 +1500,11 @@ export async function inscrireSurInvitation(
              where id = ${invitationId}`;
    await tx`insert into membre (organisation_id, compte_id, role)
             values (${inv.organisation_id}, ${compte.id}, ${inv.role})`;
    // Le compte n'existait pas quand le lot est parti : c'est ici, et pas
    // avant, que la fiche d'annuaire peut enfin désigner quelqu'un.
    await lierFicheAuCompte(tx, {
      organisationId: inv.organisation_id, compteId: compte.id, email,
    });

    await journaliser(tx, {
      type: "compte.inscrit", acteurId: compte.id, organisationId: inv.organisation_id,
@@ -1352,10 +1550,10 @@ export async function accepterInvitation(
    const invitationId = j?.donnees?.invitation_id;
    if (!invitationId) throw new ErreurAuth("invitation_invalide", "Invitation invalide ou expirée.");

    const [inv] = await tx<{ organisation_id: string; role: string }[]>`
    const [inv] = await tx<{ organisation_id: string; role: string; email: string }[]>`
      update invitation set accepte_at = now(), accepte_par = ${compteId}
       where id = ${invitationId} and accepte_at is null and revoque_at is null
      returning organisation_id, role`;
      returning organisation_id, role, email`;
    if (!inv) throw new ErreurAuth("invitation_invalide", "Invitation déjà utilisée ou révoquée.");

    const [deja] = await tx`
@@ -1367,6 +1565,13 @@ export async function accepterInvitation(
      values (${inv.organisation_id}, ${compteId}, ${inv.role})
      on conflict (organisation_id, compte_id) do update set role = excluded.role`;

    // L'adresse de l'invitation, pas celle du compte : c'est elle qui figure
    // dans la fiche d'annuaire d'où le lot est parti. Quelqu'un qui se connecte
    // sous une autre adresse rejoint quand même, et sa fiche le désigne.
    await lierFicheAuCompte(tx, {
      organisationId: inv.organisation_id, compteId, email: inv.email,
    });

    await journaliser(tx, {
      type: "invitation.acceptee", acteurId: compteId, organisationId: inv.organisation_id,
      charge: { invitation_id: invitationId, organisation_id: inv.organisation_id, compte_id: compteId },
+14 −0
Changes for apps/api/src/auth/limites.ts: 14 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -22,6 +22,20 @@ export const LIMITES = {
  lien_email: { seuil: 4, fenetreSecondes: 900 },
  inscription_ip: { seuil: 6, fenetreSecondes: 3600 },
  invitation_organisation: { seuil: 40, fenetreSecondes: 3600 },
  /**
   * Les essais de code sur une porte de jonction, comptés par porte.
   *
   * Six chiffres : un million de combinaisons, quarante essais par heure. Mais
   * le seuil n'est pas dimensionné contre la force brute — la porte ne vit que
   * quinze minutes, et son identifiant change à chaque ouverture, ce qui remet
   * le compteur à zéro. Il l'est contre le martèlement d'un point ouvert.
   *
   * Généreux, parce que le compteur est partagé par toute la salle : vingt-cinq
   * personnes qui recopient six chiffres de loin se trompent. Une réussite
   * efface l'ardoise, de sorte qu'une entrée sur deux suffit à ce que les
   * maladroits ne ferment la porte à personne.
   */
  porte_jonction: { seuil: 40, fenetreSecondes: 3600 },
  /**
   * Échange d'un code d'enrôlement contre une identité d'appareil.
   *
Loading