Commit 4c19aad6 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

La machine voit avant l'humain, et n'a pas besoin d'attendre qu'on lise

Kastell savait être piloté par un outil d'exercice. Il ne savait pas être
déclenché par un système de détection — supervision, SIEM, gestion technique du
bâtiment, sonde — alors que c'est le déclencheur le plus fréquent d'une alerte
réelle.

La clé d'intégration gagne une portée « alerte », étanche dans les deux sens.
Une clé d'exercice n'ouvre jamais de crise réelle ; et un automate branché sur
la production ne fabrique pas d'exercices que personne n'a demandés. La seconde
moitié de la règle n'était pas demandée : elle découle de la première, et
l'omettre aurait laissé une asymétrie sans raison.

L'origine « externe » est journalisée avec la référence de l'émetteur. Une
crise ouverte par une sonde et une crise ouverte par un humain ne s'analysent
pas de la même façon, et six mois plus tard on ne s'en souvient pas.

La mobilisation peut partir dans le même appel : une sonde n'en fera pas deux.

── L'anti-doublon, dans la même migration et pour une raison

Un système de détection ne déclenche pas une fois : il déclenche tant que la
condition dure. Une sonde qui perd sa liaison réémet toutes les trente
secondes, et sans garde-fou Kastell ouvrirait cent vingt crises en une heure —
chacune avec sa cellule, ses SMS et ses relances. Le premier déclenchement est
une alerte ; les suivants sont du bruit qui lui ressemble, ce qui est pire :
on apprend à ignorer.

Le même défaut existe entre humains, sous une autre forme. Deux personnes de la
cellule qui réagissent au même événement lancent le même appel à trente
secondes d'écart, et l'annuaire reçoit tout en double. Personne ne le remarque
sur le moment ; tout le monde le remarque après. Un seul mécanisme sert les
deux.

Refusé, et non silencieusement ignoré : celui qui appelle doit savoir que rien
n'a été créé, sans quoi il attendra une crise qui n'existe pas. Le refus
désigne ce qui existe déjà et dit sur quelle fenêtre il repose.

Le rapprochement se fait d'abord sur la référence fournie par l'émetteur —
c'est lui qui sait que sa sonde n°14 réémet, et aucune comparaison de libellé
ne le saura mieux. À défaut, sur l'intitulé et le type : moins sûr, et toujours
mieux que rien.

La fenêtre est en secondes et propre à l'organisation, parce que la bonne valeur
dépend de ce qui déclenche. Zéro la désactive : il faut que ce soit possible
pour l'organisation qui déduplique déjà en amont — refaire son travail à sa
place la ferait douter du nôtre.

── La consultation d'état

Ce que la clé rend est un état, jamais un contenu : phase, gravité, avancement
des mobilisations. Le journal d'une crise réelle reste hors de portée, par la
même règle que pour le compte root — administrer n'est pas lire. Un système de
détection a besoin de savoir si son alerte a été prise en charge ; il n'a pas
besoin de lire ce que la cellule s'est dit.

Seize garanties neuves. Deux ont d'abord échoué pour une raison qui n'était pas
celle qu'elles contrôlaient — le quota de trois crises actives rend aussi 409.
La section a désormais son organisation à elle : éprouver l'anti-doublon dans
une organisation saturée ne dit rien de l'anti-doublon.

Co-Authored-By: Claude (RCA)
parent e93626f7
Loading
Loading
Loading
Loading
+50 −0
Changes for apps/api/src/db/migrations/0034_alerte_anti_doublon.sql: 50 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Déclenchement par système tiers, et anti-doublon (RG-501, RG-502, RG-221).
--
-- Kastell savait être piloté par un outil d'exercice. Il ne savait pas être
-- déclenché par un système de détection — supervision, SIEM, GTB, sonde — alors
-- que c'est le déclencheur le plus fréquent d'une alerte réelle : la machine
-- voit avant l'humain, et les minutes qu'on gagne là sont celles qui comptent.
--
-- La clé d'intégration gagne donc une portée « alerte ». Elle est distincte de
-- « exercice » et le restera : une clé d'exercice ne doit jamais pouvoir ouvrir
-- une crise réelle, et la réciproque vaut tout autant — un automate branché sur
-- la production n'a pas à pouvoir fabriquer des exercices que personne n'a
-- demandés.
--
-- ── Pourquoi l'anti-doublon vient dans la même migration ───────────────────
--
-- Parce qu'un système de détection ne déclenche pas une fois. Il déclenche
-- tant que la condition dure : une sonde qui perd sa liaison réémet toutes les
-- trente secondes, et sans garde-fou Kastell ouvrirait cent vingt crises en une
-- heure — chacune avec sa cellule, ses SMS et ses relances. Le premier
-- déclenchement est une alerte ; les suivants sont du bruit qui ressemble à une
-- alerte, ce qui est pire.
--
-- Le délai est en secondes et paramétrable, par organisation. Zéro le désactive
-- — il faut que ce soit possible, pour l'organisation qui pilote elle-même la
-- déduplication en amont et n'a pas besoin qu'on la refasse.
alter table organisation
  add column anti_doublon_secondes integer not null default 60
    check (anti_doublon_secondes between 0 and 86400);

comment on column organisation.anti_doublon_secondes is
  'Fenêtre pendant laquelle un même déclenchement est refusé. 0 : désactivé.';

-- ── Ce qui fait qu'une alerte est « la même » ──────────────────────────────
--
-- La référence du système émetteur, quand il en fournit une : c'est lui qui
-- sait que sa sonde n°14 réémet, et aucun rapprochement sur le libellé ne le
-- saura mieux. À défaut, on rapproche sur l'intitulé et le type — moins sûr,
-- mais toujours mieux que rien, et c'est le cas de l'outil qui ne numérote pas
-- ses alertes.
alter table crise add column reference_externe text;

comment on column crise.reference_externe is
  'L''identifiant de l''alerte chez le système qui l''a déclenchée. Sert au rapprochement.';

create index crise_reference_externe on crise (organisation_id, reference_externe, declenchee_at)
  where reference_externe is not null;

-- Le rapprochement par libellé n'a de sens que sur une fenêtre courte : cet
-- index sert la requête qui la balaie, et rien d'autre.
create index crise_recente on crise (organisation_id, declenchee_at desc);
+108 −0
Changes for apps/api/src/domaine/antidoublon.ts: 108 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { Sql } from "../db/client.js";

/**
 * Anti-doublon (RG-221).
 *
 * Un système de détection ne déclenche pas une fois : il déclenche tant que la
 * condition dure. Une sonde qui perd sa liaison réémet toutes les trente
 * secondes, et sans garde-fou Kastell ouvrirait cent vingt crises en une heure,
 * chacune avec sa cellule, ses SMS et ses relances. Le premier déclenchement
 * est une alerte ; les suivants sont du bruit qui ressemble à une alerte, ce
 * qui est pire — on apprend à ignorer.
 *
 * Le même défaut existe entre humains, sous une autre forme : deux personnes
 * de la cellule qui réagissent au même événement lancent la même mobilisation
 * à trente secondes d'écart, et l'annuaire reçoit tout en double. Personne ne
 * le remarque sur le moment, tout le monde le remarque après.
 *
 * D'où un seul mécanisme, appliqué aux deux : une fenêtre en secondes, propre à
 * l'organisation, pendant laquelle un déclenchement identique est refusé plutôt
 * que rejoué. Refusé, et non silencieusement ignoré : celui qui appelle doit
 * savoir que rien n'a été créé, sans quoi il attendra une crise qui n'existe
 * pas.
 */

export interface Doublon {
  /** Ce qui existe déjà, et qu'il fallait ne pas recréer. */
  id: string;
  quand: Date;
  /** Le temps écoulé depuis, arrondi à la seconde : c'est ce qu'on dit. */
  secondes: number;
  /** La fenêtre en vigueur, pour que le refus dise sur quelle règle il repose. */
  fenetre: number;
}

async function fenetre(sql: Sql, organisationId: string): Promise<number> {
  const [o] = await sql<{ anti_doublon_secondes: number }[]>`
    select anti_doublon_secondes from organisation where id = ${organisationId}`;
  return o?.anti_doublon_secondes ?? 0;
}

const ecart = (quand: Date) => Math.max(0, Math.round((Date.now() - quand.getTime()) / 1000));

/**
 * Une alerte identique vient-elle d'être déclenchée ?
 *
 * Deux rapprochements, dans cet ordre. La référence du système émetteur
 * d'abord : c'est lui qui sait que sa sonde n°14 réémet, et aucune comparaison
 * de libellé ne le saura mieux. L'intitulé et le type ensuite, pour l'outil qui
 * ne numérote pas ses alertes — moins sûr, et toujours mieux que rien.
 *
 * Les crises closes ne comptent pas : rouvrir après clôture est une décision,
 * pas un doublon.
 */
export async function alerteRecente(
  sql: Sql, organisationId: string,
  quoi: { reference?: string | null | undefined; intitule: string; type: string },
): Promise<Doublon | null> {
  const secondes = await fenetre(sql, organisationId);
  if (secondes === 0) return null;

  const [c] = quoi.reference
    ? await sql<{ id: string; declenchee_at: Date }[]>`
        select id, declenchee_at from crise
         where organisation_id = ${organisationId}
           and reference_externe = ${quoi.reference}
           and close_at is null
           and declenchee_at > now() - make_interval(secs => ${secondes})
         order by declenchee_at desc limit 1`
    : await sql<{ id: string; declenchee_at: Date }[]>`
        select id, declenchee_at from crise
         where organisation_id = ${organisationId}
           and intitule = ${quoi.intitule} and type = ${quoi.type}
           and close_at is null
           and declenchee_at > now() - make_interval(secs => ${secondes})
         order by declenchee_at desc limit 1`;

  return c ? { id: c.id, quand: c.declenchee_at, secondes: ecart(c.declenchee_at), fenetre: secondes } : null;
}

/**
 * La même mobilisation vient-elle de partir ?
 *
 * Le rapprochement se fait sur le texte exact, dans la même crise. C'est
 * volontairement étroit : deux appels dont les messages diffèrent d'un mot ne
 * sont pas le même appel, et refuser le second ferait taire quelqu'un qui avait
 * quelque chose à ajouter.
 *
 * Les campagnes closes comptent, elles : une mobilisation refermée parce que
 * son objectif était atteint puis relancée à l'identique dans la minute est le
 * cas même qu'on veut arrêter.
 */
export async function mobilisationRecente(
  sql: Sql, criseId: string, message: string,
): Promise<Doublon | null> {
  const [c] = await sql<{ organisation_id: string }[]>`
    select organisation_id from crise where id = ${criseId}`;
  if (!c) return null;
  const secondes = await fenetre(sql, c.organisation_id);
  if (secondes === 0) return null;

  const [m] = await sql<{ id: string; lancee_at: Date }[]>`
    select id, lancee_at from mobilisation
     where crise_id = ${criseId} and message = ${message}
       and lancee_at > now() - make_interval(secs => ${secondes})
     order by lancee_at desc limit 1`;

  return m ? { id: m.id, quand: m.lancee_at, secondes: ecart(m.lancee_at), fenetre: secondes } : null;
}
+7 −2
Changes for apps/api/src/domaine/crise.ts: 7 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -18,6 +18,8 @@ export interface OuvertureCrise {
  gravite: Gravite;
  exercice?: boolean;
  cellules?: { code: string; libelle: string }[];
  /** L'identifiant de l'alerte chez le système qui l'a déclenchée (RG-501). */
  referenceExterne?: string | null | undefined;
}

export interface CriseOuverte {
@@ -44,9 +46,11 @@ export async function ouvrirCrise(sql: Sql, o: OuvertureCrise, ctx: Contexte): P
    // la décrit — seule exception au principe « rien avant le journal », et
    // elle ne porte aucun état métier que l'événement ne redirait.
    const [crise] = await tx<{ id: string }[]>`
      insert into crise (organisation_id, intitule, type, gravite, exercice, preparation_version)
      insert into crise (organisation_id, intitule, type, gravite, exercice,
                         preparation_version, reference_externe)
      values (${o.organisationId}, ${o.intitule}, ${o.type}, ${o.gravite},
              ${o.exercice ?? false}, ${preparationVersion})
              ${o.exercice ?? false}, ${preparationVersion},
              ${o.referenceExterne ?? null})
      returning id`;
    if (!crise) throw new Error("Création de crise refusée.");

@@ -59,6 +63,7 @@ export async function ouvrirCrise(sql: Sql, o: OuvertureCrise, ctx: Contexte): P
        gravite: o.gravite,
        exercice: o.exercice ?? false,
        preparation_version: preparationVersion,
        reference_externe: o.referenceExterne ?? null,
      },
      ctx,
    });
+153 −4
Changes for apps/api/src/routes/integration.ts: 153 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -3,6 +3,8 @@ import { z } from "zod";
import { GENRES, GRAVITES, personneSchema } from "@kastell/shared";
import type { Sql } from "../db/client.js";
import { clore, consigner, ouvrirCrise } from "../domaine/crise.js";
import * as antidoublon from "../domaine/antidoublon.js";
import * as mobilisation from "../domaine/mobilisation.js";
import * as annuaire from "../preparation/annuaire.js";
import { empreinte, engendrer } from "../auth/jetons.js";
import { REDACTEURS, requerirMembre } from "./garde.js";
@@ -19,7 +21,7 @@ import { REDACTEURS, requerirMembre } from "./garde.js";
 * envoyé — sans quoi un exercice relu ressemblerait à une crise réelle.
 */

export const PORTEES = ["exercice", "lecture", "annuaire"] as const;
export const PORTEES = ["exercice", "lecture", "annuaire", "alerte"] as const;
export type Portee = (typeof PORTEES)[number];

declare module "fastify" {
@@ -28,7 +30,9 @@ declare module "fastify" {
  }
}

export function enregistrerRoutesIntegration(app: FastifyInstance, sql: Sql): void {
export function enregistrerRoutesIntegration(
  app: FastifyInstance, sql: Sql, urlPublique: string,
): void {
  const org = (req: FastifyRequest) => z.object({ id: z.string().uuid() }).parse(req.params).id;

  /** Contexte d'écriture des injections : jamais rattachées à un compte humain. */
@@ -69,12 +73,42 @@ export function enregistrerRoutesIntegration(app: FastifyInstance, sql: Sql): vo
  }

  // ── Gestion des clés, côté organisation ──────────────────────────────────
  app.get("/organisations/:id/integrations", { preHandler: requerirMembre(sql) }, async (req) => ({
  app.get("/organisations/:id/integrations", { preHandler: requerirMembre(sql) },
    async (req) => {
      const [o] = await sql<{ anti_doublon_secondes: number }[]>`
        select anti_doublon_secondes from organisation where id = ${org(req)}`;
      return {
        cles: await sql`
          select id, libelle, portees, cree_at, expire_at, revoque_at, dernier_usage_at, usages
            from cle_integration where organisation_id = ${org(req)}
           order by cree_at desc`,
  }));
        antiDoublonSecondes: o?.anti_doublon_secondes ?? 0,
      };
    });

  /**
   * La fenêtre anti-doublon (RG-221).
   *
   * Réglée par l'organisation et non par l'instance : la bonne valeur dépend de
   * ce qui déclenche. Une sonde qui réémet toutes les trente secondes en demande
   * plus qu'un déclenchement humain. Zéro la désactive, pour l'organisation qui
   * déduplique déjà en amont — refaire son travail à sa place la ferait douter
   * du nôtre.
   */
  app.patch("/organisations/:id/anti-doublon",
    { preHandler: requerirMembre(sql, REDACTEURS) }, async (req, reply) => {
      const { secondes } = z.object({
        secondes: z.number().int().min(0).max(86400),
      }).parse(req.body);
      await sql`update organisation set anti_doublon_secondes = ${secondes}
                 where id = ${org(req)}`;
      return reply.send({
        secondes,
        message: secondes === 0
          ? "Anti-doublon désactivé : chaque déclenchement ouvrira une crise."
          : `Un déclenchement identique sera refusé pendant ${secondes} s.`,
      });
    });

  app.post("/organisations/:id/integrations",
    { preHandler: requerirMembre(sql, REDACTEURS) }, async (req, reply) => {
@@ -186,6 +220,121 @@ export function enregistrerRoutesIntegration(app: FastifyInstance, sql: Sql): vo
      return reply.send({ message: "Exercice clos." });
    });

  // ── Déclenchement par un système tiers (RG-501) ──────────────────────────
  //
  // La supervision, le SIEM, la GTB, la sonde : la machine voit avant l'humain,
  // et les minutes gagnées là sont celles qui comptent. La portée est distincte
  // de « exercice », et le restera dans les deux sens — une clé d'exercice ne
  // doit jamais ouvrir une crise réelle, et un automate branché sur la
  // production n'a pas à fabriquer des exercices que personne n'a demandés.
  app.post("/integration/alertes", { preHandler: requerirCle("alerte") },
    async (req, reply) => {
      const c = z.object({
        intitule: z.string().trim().min(3).max(200),
        type: z.string().trim().min(2).max(60),
        gravite: z.enum(GRAVITES).default("majeure"),
        /**
         * L'identifiant de l'alerte chez l'émetteur (RG-221).
         *
         * C'est lui qui sait que sa sonde n°14 réémet. Fourni, le
         * rapprochement se fait dessus ; absent, sur l'intitulé et le type,
         * ce qui est moins sûr et vaut toujours mieux que rien.
         */
        reference: z.string().trim().max(200).nullish(),
        cellules: z.array(z.object({
          code: z.string().trim().min(2).max(40),
          libelle: z.string().trim().min(2).max(120),
        })).default([]),
        /** Mobiliser dans la foulée, sans second appel : la sonde n'en fera pas deux. */
        mobiliser: z.object({
          message: z.string().trim().min(10).max(1200),
          roles: z.array(z.string().trim().max(80)).max(40).default([]),
          objectif: z.number().int().positive().max(10000).nullish(),
        }).nullish(),
      }).parse(req.body);

      const organisationId = req.integration!.organisationId;

      const doublon = await antidoublon.alerteRecente(sql, organisationId, {
        reference: c.reference, intitule: c.intitule, type: c.type,
      });
      if (doublon) {
        return reply.code(409).send({
          code: "doublon",
          crise_id: doublon.id,
          message: `Une alerte identique a été déclenchée il y a ${doublon.secondes} s. `
            + `Rien n'a été créé : la fenêtre anti-doublon est de ${doublon.fenetre} s.`,
        });
      }

      const ouverte = await ouvrirCrise(sql, {
        organisationId, intitule: c.intitule, type: c.type, gravite: c.gravite,
        exercice: false, cellules: c.cellules, referenceExterne: c.reference ?? null,
      }, ctxExterne);

      let mobilisee: { mobilisationId: string; envoyes: number } | null = null;
      if (c.mobiliser) {
        const cibles = await mobilisation.ciblesDepuisAnnuaire(sql, ouverte.criseId,
          c.mobiliser.roles.length > 0 ? c.mobiliser.roles : undefined);
        if (cibles.length > 0) {
          const r = await mobilisation.lancer(sql, {
            criseId: ouverte.criseId, message: c.mobiliser.message, cibles,
            objectif: c.mobiliser.objectif ?? null,
          }, urlPublique, ctxExterne);
          mobilisee = { mobilisationId: r.mobilisationId, envoyes: r.envoyes };
        }
      }

      return reply.code(201).send({
        ...ouverte,
        mobilisation: mobilisee,
        note: "Crise réelle ouverte par un système tiers. L'origine « externe » est "
          + "inscrite au journal : au rejeu, on distingue ce qu'une machine a déclenché "
          + "de ce qu'une cellule a décidé.",
      });
    });

  /**
   * Consultation d'état (RG-502).
   *
   * Ce que la clé rend est un état, non un contenu : phase, gravité, avancement
   * des mobilisations. Le journal d'une crise réelle reste hors de portée, et
   * c'est la même règle que pour le compte root — administrer n'est pas lire.
   * Un système de détection a besoin de savoir si son alerte a été prise en
   * charge ; il n'a pas besoin de lire ce que la cellule s'est dit.
   */
  app.get("/integration/alertes/:criseId", { preHandler: requerirCle("alerte") },
    async (req, reply) => {
      const { criseId } = z.object({ criseId: z.string().uuid() }).parse(req.params);
      const [crise] = await sql<{ organisation_id: string; intitule: string; type: string;
        gravite: string; phase: string; exercice: boolean; declenchee_at: Date;
        close_at: Date | null; reference_externe: string | null }[]>`
        select organisation_id, intitule, type, gravite, phase, exercice,
               declenchee_at, close_at, reference_externe
          from crise where id = ${criseId}`;
      if (!crise || crise.organisation_id !== req.integration!.organisationId) {
        return reply.code(404).send({ code: "introuvable", message: "Crise inconnue." });
      }

      const campagnes = await mobilisation.etat(sql, criseId);
      return reply.send({
        crise: {
          id: criseId, intitule: crise.intitule, type: crise.type,
          gravite: crise.gravite, phase: crise.phase, exercice: crise.exercice,
          declenchee_at: crise.declenchee_at, close_at: crise.close_at,
          reference: crise.reference_externe,
        },
        mobilisations: campagnes.map((m) => ({
          id: m.id, lancee_at: m.lancee_at, close_at: m.close_at,
          envoyes: Number(m.envoyes), accuses: Number(m.accuses),
          sans_reponse: Number(m.sans_reponse),
          objectif: m.objectif, atteint: m.atteint,
        })),
        note: "État seul. Le contenu du journal d'une crise réelle n'est pas lisible "
          + "par une clé d'intégration.",
      });
    });

  /** Lecture pour l'évaluation : l'outil d'exercice note ce qui s'est passé. */
  /**
   * L'annuaire déposé par le SIRH du client (P1).
+1 −1
Changes for apps/api/src/serveur.ts: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -207,7 +207,7 @@ export async function construireServeur(
    enregistrerRoutesTableaux(api, sql);
    enregistrerRoutesMobilisation(api, env, sql);
    enregistrerRoutesRestitution(api, sql);
    enregistrerRoutesIntegration(api, sql);
    enregistrerRoutesIntegration(api, sql, env.KASTELL_PUBLIC_URL);
    enregistrerRoutesEtat(api, sql, env);
    enregistrerRoutesMobile(api, sql, env);
    enregistrerRoutesCanaux(api, sql, env);
Loading