Commit 7cbe95f2 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Une crise n'est pas toujours informatique

Cinq types étaient écrits dans le code : rançongiciel, fuite de données,
indisponibilité, compromission de messagerie, autre. Ils décrivent bien une
crise cyber. Ils décrivent mal une inondation, un accident industriel, un rappel
produit, la perte d'un site.

Ce n'était pas une gêne d'affichage, et c'est ce qui m'a décidé à le traiter :
les fiches réflexes s'indexent sur ce type. Une organisation qui aurait écrit la
fiche « rupture de la chaîne du froid » n'aurait pu l'attacher à aucun type,
donc elle ne serait jamais apparue. L'énumération fermée ne limitait pas le
vocabulaire, elle limitait la préparation.

Les types deviennent un registre du dossier, versionné comme les autres. Les
cinq existants sont posés à la création d'une organisation, et repris pour
toutes celles en place — à la version zéro, non à la version courante. C'est
délibéré : ils ont toujours existé du point de vue des fiches déjà écrites, et
une crise gelée sur une version ancienne doit continuer de résoudre son type.
Les poser à la version courante aurait rendu muettes toutes les crises
antérieures.

Le code est stable, le libellé libre. La crise inscrit le code au journal au
déclenchement et les fiches s'apparient dessus : si l'appariement portait sur le
libellé, renommer « Indisponibilité majeure » en « Indisponibilité du SI »
romprait celui de toutes les crises passées, et le rembobinage montrerait des
fiches qui n'ont jamais été proposées. C'est la même raison qui fait que la
composition du cockpit porte un code de cellule et non son nom.

Le code est donc immuable — et la règle est portée par le schéma, pas par
l'interface. Le champ est grisé, ce qui ne suffit pas : le registre est servi par
une boucle générique qui accepte la modification partielle de n'importe quelle
colonne. Un déclencheur refuse qu'une même entrée porte deux codes, et il ne
pourra pas être contourné par une route qu'on ajoutera plus tard.

Retirer un type ne touche ni les crises passées ni les fiches qui le visent : le
dossier est versionné. Il disparaît seulement des choix du déclenchement.

Sept garanties, dont celle qui n'était pas possible avant : une fiche attachée à
un type maison, proposée pour ce type et pour lui seul.

Ces fichiers portent aussi le libellé partagé des cellules, qui servait jusqu'ici
au seul écran de déclenchement — et l'onglet du dossier qui vient avec le lot
suivant.

Co-Authored-By: Claude (RCA)
parent 16a3c0b3
Loading
Loading
Loading
Loading
+5 −0
Changes for apps/api/src/auth/comptes.ts: 5 added lines, 0 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 * as typesCrise from "../preparation/typesCrise.js";
import { journaliser } from "../noyau/administration.js";
import { envoyer } from "../adaptateurs/notify.js";
import * as codes from "./codes_secours.js";
@@ -92,6 +93,10 @@ export async function inscrire(sql: Sql, i: Inscription): Promise<{ compteId: st
    await tx`insert into membre (organisation_id, compte_id, role)
             values (${org.id}, ${compte.id}, 'proprietaire')`;

    // Les types de crise par défaut : une organisation neuve doit pouvoir
    // déclencher sans avoir d'abord constitué un vocabulaire (EF-303a).
    await typesCrise.poserDefauts(tx, org.id);

    await journaliser(tx, {
      type: "compte.inscrit", acteurId: compte.id, organisationId: org.id,
      charge: { compte_id: compte.id, organisation_id: org.id },
+94 −0
Changes for apps/api/src/db/migrations/0036_types_crise.sql: 94 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Les types de crise appartiennent à l'organisation (EF-303a).
--
-- Cinq types étaient écrits dans le code : rançongiciel, fuite de données,
-- indisponibilité, compromission de messagerie, autre. Ils décrivent bien une
-- crise cyber et mal tout le reste — une inondation, un accident industriel, un
-- rappel produit, une perte de site n'y entrent pas.
--
-- Ce n'était pas qu'une gêne d'affichage. Les fiches réflexes s'indexent sur ce
-- type : une organisation qui aurait écrit la fiche « rupture de la chaîne du
-- froid » n'aurait pu l'attacher à aucun type, donc elle ne serait jamais
-- apparue. L'énumération fermée ne limitait pas le vocabulaire, elle limitait
-- la préparation.
--
-- ── Pourquoi un code et un libellé ────────────────────────────────────────
--
-- La crise inscrit son type au journal au déclenchement, et les fiches
-- s'apparient sur cette valeur. Si l'appariement portait sur le libellé,
-- renommer « Indisponibilité majeure » en « Indisponibilité du SI » romprait
-- l'appariement de toutes les crises passées, et le rembobinage montrerait des
-- fiches qui n'ont jamais été proposées.
--
-- Le code est donc stable et le libellé libre. C'est la même raison qui fait
-- que la composition du cockpit porte un code de cellule et non son nom.

create table type_crise (
  organisation_id uuid    not null references organisation(id) on delete cascade,
  id              uuid    not null default gen_random_uuid(),
  depuis_version  integer not null,
  jusqu_a_version integer,
  supprime        boolean not null default false,

  -- Stable : c'est lui qu'une crise inscrit et qu'une fiche vise.
  code            text    not null check (code ~ '^[a-z][a-z0-9_]{1,39}$'),
  nom             text    not null,
  notes           text,
  verifie_at      timestamptz,
  primary key (id, depuis_version)
);
create index type_crise_courant_idx on type_crise (organisation_id)
  where jusqu_a_version is null;

-- Un code ne vaut qu'une fois dans une organisation : deux types de même code
-- rendraient l'appariement des fiches ambigu, et l'ambiguïté se découvrirait le
-- jour où la fiche n'apparaît pas.
create unique index type_crise_code_unique on type_crise (organisation_id, code)
  where jusqu_a_version is null and not supprime;

create view type_crise_courant as
  select * from type_crise where jusqu_a_version is null and not supprime;

comment on table type_crise is
  'Les types de crise d''une organisation. Les fiches réflexes s''indexent sur le code.';

-- ── L'existant, repris tel quel ───────────────────────────────────────────
--
-- Les cinq types sont posés pour chaque organisation, à la version zéro plutôt
-- qu'à la version courante. C'est délibéré : ils ont toujours existé du point de
-- vue des fiches déjà écrites, et une crise gelée sur une version ancienne doit
-- continuer de résoudre son type. Les poser à la version courante aurait rendu
-- muettes toutes les crises antérieures.
insert into type_crise (organisation_id, depuis_version, code, nom, verifie_at)
select o.id, 0, t.code, t.nom, now()
  from organisation o
  cross join (values
    ('rancongiciel',             'Rançongiciel'),
    ('fuite_donnees',            'Fuite de données'),
    ('indisponibilite',          'Indisponibilité majeure'),
    ('compromission_messagerie', 'Compromission de messagerie'),
    ('autre',                    'Autre')
  ) as t(code, nom);

-- ── Le code ne se change pas ──────────────────────────────────────────────
--
-- L'interface grise le champ, et cela ne suffit pas : le registre est servi par
-- une boucle générique qui accepte une modification partielle de n'importe
-- quelle colonne. La règle est donc portée par le schéma, où elle ne peut pas
-- être contournée par une route qu'on ajoutera plus tard.
--
-- Le versionnement rend la vérification simple : modifier une entrée insère une
-- nouvelle ligne portant le même identifiant. Il suffit qu'aucune ligne de même
-- identifiant ne porte un autre code.
create or replace function type_crise_code_immuable() returns trigger
language plpgsql as $$
begin
  if exists (select 1 from type_crise where id = new.id and code <> new.code) then
    raise exception 'Le code d''un type de crise ne se change pas : les fiches réflexes '
      'et les crises déjà ouvertes s''apparient dessus. Le libellé, lui, se corrige.'
      using errcode = 'restrict_violation';
  end if;
  return new;
end $$;

create trigger type_crise_code_immuable before insert on type_crise
  for each row execute function type_crise_code_immuable();
+3 −1
Changes for apps/api/src/preparation/dossier.ts: 3 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -18,7 +18,7 @@ import type { Sql, Tx } from "../db/client.js";
export type TablePreparation =
  | "personne" | "tiers" | "fiche_reflexe" | "document_kit"
  | "application_metier" | "partie_prenante" | "modele_message"
  | "disposition_cockpit" | "mobilisation_type";
  | "disposition_cockpit" | "mobilisation_type" | "type_crise";

/** Colonnes gérées par le versionnement : jamais fournies par l'appelant. */
const RESERVEES = new Set(["organisation_id", "id", "depuis_version", "jusqu_a_version"]);
@@ -46,6 +46,7 @@ export const TRI: Record<TablePreparation, readonly string[]> = {
  modele_message: ["libelle"],
  disposition_cockpit: ["nom"],
  mobilisation_type: ["nom"],
  type_crise: ["nom"],
};

const COLONNE_SUPPRESSION: Record<TablePreparation, string> = {
@@ -58,6 +59,7 @@ const COLONNE_SUPPRESSION: Record<TablePreparation, string> = {
  modele_message: "supprime",
  disposition_cockpit: "supprimee",
  mobilisation_type: "supprimee",
  type_crise: "supprime",
};

export class DossierIntrouvable extends Error {
+59 −0
Changes for apps/api/src/preparation/typesCrise.ts: 59 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { Sql, Tx } from "../db/client.js";
import { TYPES_CRISE_DEFAUT } from "@kastell/shared";
import { registre } from "./continuite.js";
import * as dossier from "./dossier.js";

/**
 * Les types de crise d'une organisation (EF-303a).
 *
 * Cinq types étaient écrits dans le code, et ils décrivent bien une crise cyber
 * — mal une inondation, un accident industriel, un rappel produit. Ce n'était
 * pas qu'une gêne d'affichage : les fiches réflexes s'indexent sur ce type, et
 * l'énumération fermée ne limitait donc pas le vocabulaire, elle limitait la
 * préparation.
 *
 * Le code est stable et le libellé libre. La crise inscrit le code au journal
 * au déclenchement, et les fiches s'apparient dessus : si l'appariement portait
 * sur le libellé, renommer un type romprait celui de toutes les crises passées.
 */

export interface TypeCrise {
  id: string;
  code: string;
  nom: string;
  notes: string | null;
  verifie_at: Date | null;
}

export const types = registre<TypeCrise>("type_crise", "les types de crise");

/**
 * Poser les types par défaut, à la création d'une organisation.
 *
 * À la version zéro, comme la migration qui a repris l'existant : ils ont
 * toujours existé du point de vue des fiches, et une crise gelée sur une
 * version ancienne doit continuer de résoudre son type.
 *
 * Silencieux si les types sont déjà là — la fonction doit pouvoir être appelée
 * deux fois sans conséquence.
 */
export async function poserDefauts(tx: Tx, organisationId: string): Promise<void> {
  for (const t of TYPES_CRISE_DEFAUT) {
    const [existe] = await tx`
      select 1 from type_crise
       where organisation_id = ${organisationId} and code = ${t.code}
         and jusqu_a_version is null and not supprime`;
    if (existe) continue;
    await dossier.ecrire(tx, "type_crise", organisationId, 0, null, {
      code: t.code, nom: t.nom, verifie_at: new Date(),
    });
  }
}

/** Le libellé d'un code, ou le code lui-même — jamais rien. */
export async function libelle(
  sql: Sql, organisationId: string, code: string, version: number | null = null,
): Promise<string> {
  const liste = await types.lister(sql, organisationId, version);
  return liste.find((t) => t.code === code)?.nom ?? code;
}
+50 −0
Changes for apps/api/src/verification/preparation.ts: 50 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -13,6 +13,7 @@ import * as fiches from "../preparation/fiches.js";
import * as importer from "../preparation/import.js";
import * as indicateur from "../preparation/indicateur.js";
import * as tiers from "../preparation/tiers.js";
import * as typesCrise from "../preparation/typesCrise.js";
import { jetonDans, vider } from "./boite.js";
import type { Rapport } from "./rapport.js";

@@ -228,6 +229,55 @@ export async function verifierPreparation(
  r.verifie("EF-209", `le score progresse quand on comble un manque (${etat.score} → ${apresAjout.score})`,
    apresAjout.score > etat.score);

  // ── Les types de crise appartiennent à l'organisation (EF-303a) ──────────
  //
  // Cinq types étaient écrits dans le code. Ils décrivent bien une crise cyber
  // et mal une inondation — et comme les fiches réflexes s'indexent dessus,
  // l'énumération fermée ne limitait pas le vocabulaire, elle limitait la
  // préparation.
  r.titre("Types de crise");

  const vocabulaire = await typesCrise.types.lister(sql, organisationId);
  r.verifie("EF-303a", "une organisation neuve reçoit les cinq types par défaut",
    vocabulaire.length === 5 && vocabulaire.some((t) => t.code === "rancongiciel"),
    String(vocabulaire.length));
  r.verifie("EF-303a", "et ils sont posés à la version zéro, pour qu'une crise gelée sur "
    + "une version ancienne résolve encore son type",
    (await typesCrise.types.lister(sql, organisationId, 0)).length === 5);

  const inondation = await typesCrise.types.creer(sql, organisationId, {
    code: "inondation", nom: "Inondation du site",
  } as never, compteId);
  r.verifie("EF-303a", "l'organisation ajoute un type de son métier",
    (await typesCrise.types.lister(sql, organisationId)).some((t) => t.code === "inondation"));

  // Ce qui était impossible avant : attacher une fiche à un type maison.
  await fiches.creer(sql, organisationId, {
    titre: "Première heure — inondation", types_crise: ["inondation"], roles: [], ordre: 1,
    contenu: "1. Couper l'électricité du niveau bas.\n2. Évacuer le personnel.",
  }, compteId);
  const pourInondation = await fiches.pour(sql, organisationId, "inondation");
  r.verifie("EF-303a", "une fiche s'indexe sur ce type, et lui est proposée",
    pourInondation.some((f) => f.titre === "Première heure — inondation"));
  const horsInondation = await fiches.pour(sql, organisationId, "rancongiciel");
  r.verifie("EF-303a", "et elle n'apparaît pas pour un autre type",
    !horsInondation.some((f) => f.titre === "Première heure — inondation"));

  // Le code est immuable, et la règle est portée par le schéma : l'interface le
  // grise, mais le registre est servi par une boucle générique qui accepterait
  // n'importe quelle colonne.
  await r.refuse("EF-303a", "le code d'un type ne se change pas — les fiches et les crises "
    + "déjà ouvertes s'apparient dessus",
    () => typesCrise.types.modifier(sql, organisationId, inondation,
      { code: "submersion" } as never, compteId));
  r.verifie("EF-303a", "le libellé, lui, se corrige",
    await (async () => {
      await typesCrise.types.modifier(sql, organisationId, inondation,
        { nom: "Inondation — site bas" } as never, compteId);
      return (await typesCrise.types.lister(sql, organisationId))
        .find((t) => t.code === "inondation")?.nom === "Inondation — site bas";
    })());

    // ── Trous d'astreinte ────────────────────────────────────────────────────
  //
  // Savoir qui est d'astreinte ne dit pas quand personne ne l'est. Un tour
Loading