Commit 6aec5700 authored by Kourser's avatar Kourser
Browse files

Distribution : intégration exercice, page d'état, vérificateur indépendant

Lot L10, pour ce qui n'attend pas l'écriture du client.

L'animation d'exercice reste au produit voisin
  Kastell n'embarque aucun moteur de scénario. Il expose de quoi être piloté :
  ouvrir une crise en mode exercice, y injecter des entrées, la clôturer, et
  relire le journal pour l'évaluation.

  Le marquage « exercice » est imposé, pas demandé : une clé d'exercice ne peut
  pas ouvrir une crise réelle, ni y injecter quoi que ce soit, ni en lire le
  journal. C'est la même règle que pour le compte root — piloter n'est pas lire.

  Toute écriture arrivant par là porte l'origine « externe ». Au rejeu, on
  distingue donc ce qu'une cellule a produit de ce qu'un animateur lui a envoyé.
  Sans cette distinction, un exercice relu ressemblerait à une crise réelle.

Une page d'état qui ne dit rien de trop
  Elle répond sans session et énumère les composants configurés, jamais les
  crises en cours : une page d'état bavarde renseignerait un attaquant sur ses
  propres effets. Elle rappelle aussi, quand le service est indisponible, que le
  dossier de préparation imprimé ne dépend d'aucun système.

  À héberger ailleurs : une page servie par le service qu'elle surveille ne dit
  rien le jour où celui-ci tombe. La documentation le précise.

Le vérificateur indépendant, publié séparément
  Cent cinquante lignes, aucune dépendance, aucun serveur contacté, domaine
  public. Il valide un journal intact, détecte un contenu modifié, une
  troncature de fin et un trou au milieu — et dit ce que chaque anomalie
  signifie, plutôt que de rendre un code d'erreur.

  Éprouvé sur un export tiré directement de PostgreSQL, sans passer par le
  produit : c'est le point. Une garantie d'inaltérabilité ne vaut que si on peut
  la contrôler sans exécuter le code de celui qui la revendique.

Intégration continue et documentation
  Le critère de recette du §9 — « cloner et composer suffit » — est vérifié à
  chaque poussée, avec les images construites pour amd64 et arm64. La
  documentation d'exploitation tient en une page : où ne pas installer Kastell,
  quoi sauvegarder, comment restaurer, et ce que le produit ne fera pas à la
  place de l'exploitant.

Hors périmètre, et dit
  Application installable, accessibilité, bilinguisme, notifications poussées :
  ces exigences vivent dans le navigateur. Marquées « interface » dans le cahier
  des charges plutôt que comptées pour acquises.

Vérification
  `pnpm verif` passe de 422 à 446 contrôles.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 95a2c166
Loading
Loading
Loading
Loading
+75 −0
Original line number Diff line number Diff line
# Le critère de recette du §9 est vérifié à chaque version : « cloner et
# composer suffit ». Une installation dégradée serait une fausse promesse.
name: Vérification

on:
  push:
    branches: [main]
  pull_request:

jobs:
  garanties:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17-alpine
        env:
          POSTGRES_USER: kastell
          POSTGRES_PASSWORD: verification
          POSTGRES_DB: kastell
        options: >-
          --health-cmd "pg_isready -U kastell -d kastell"
          --health-interval 5s --health-timeout 3s --health-retries 20
        ports: ["5432:5432"]
      redis:
        image: redis:7-alpine
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s --health-timeout 3s --health-retries 20
        ports: ["6379:6379"]
      minio:
        image: bitnami/minio:latest
        env:
          MINIO_ROOT_USER: kastell
          MINIO_ROOT_PASSWORD: verification-minio
        ports: ["9000:9000"]

    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm typecheck

      - name: Les 400 et quelques garanties
        env:
          KASTELL_ENV: developpement
          KASTELL_PORT: 8080
          KASTELL_URL_PUBLIQUE: http://localhost:8080
          DATABASE_URL: postgres://kastell:verification@localhost:5432/kastell
          REDIS_URL: redis://localhost:6379
          S3_ENDPOINT: http://localhost:9000
          S3_REGION: eu-west-1
          S3_BUCKET: kastell
          S3_ACCES: kastell
          S3_SECRET: verification-minio
          SECRET_SESSION: verification-verification-verification-01
        run: pnpm --filter @kastell/api exec tsx src/verif.ts

  image:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/setup-qemu-action@v3
      - uses: docker/setup-buildx-action@v3
      - name: Construction multi-architecture (EX-25)
        uses: docker/build-push-action@v6
        with:
          context: .
          file: docker/Dockerfile
          platforms: linux/amd64,linux/arm64
          push: false
          tags: kastell:${{ github.sha }}
+20 −1
Original line number Diff line number Diff line
@@ -22,7 +22,8 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
| **L7** | Tableau blanc, modèles, instantanés | 🟢 fait côté serveur |
| **L8** | Mobilisation multicanal, relances, escalade | 🟢 fait |
| **L9** | Rembobinage complet, dossier de crise, retour d'expérience | 🟢 fait |
| L10 | Durcissement et distribution | ⚪ |
| **L10** | Intégration exercice, page d'état, vérificateur, CI, documentation | 🟢 fait côté serveur — application installable, accessibilité et bilinguisme suivent le client |
| L11–L12 | Applications mobiles, téléphonie, transcription | ⚪ hors d'atteinte sans client ni infrastructure SIP |

Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
d'empreintes, le vérificateur indépendant, la reconstitution d'un état passé
@@ -108,6 +109,9 @@ apps/api/src/
    indicateur.ts    ce qui manque, et le geste qui le comble
    campagne.ts      vérification semestrielle des coordonnées
    export.ts        tirage papier et tableur
  domaine/cloture.ts    chronologie, indicateurs, dossier de crise, retex
  domaine/mobilisation.ts  campagnes, accusés, relances, escalade
  domaine/tableaux.ts   scènes de travail, instantanés qui font foi
  domaine/reunions.ts   salles, consentement, marque-pages, rétention
  adaptateurs/media.ts      LiveKit — et nulle part ailleurs dans le produit
  domaine/documents.ts  dépôt reprenable, versions, partages à durée limitée
@@ -165,6 +169,21 @@ promesse « cloner et composer suffit » est une exigence produit. Le format
stocke ses paramètres, une migration reste possible sans invalider les
empreintes.

## Vérifier sans nous faire confiance

```bash
node outils/verifier-journal.mjs dossier-de-crise.json
```

Aucune dépendance, aucun serveur contacté, une centaine de lignes lisibles. Le
format qu'il contrôle est documenté en tête du fichier et tient en dix lignes ;
la charge canonique est transportée telle quelle dans l'export, précisément pour
que ce contrôle n'ait à réimplémenter aucune normalisation.

C'est le point : **une garantie d'inaltérabilité ne vaut que si on peut la
contrôler sans exécuter le code de celui qui la revendique.** Copiez-le,
réécrivez-le si vous préférez.

## Visioconférence : la brique est confinée

LiveKit n'existe que dans `adaptateurs/media.ts`. C'est la démonstration de
+29 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- 0014 — Clés d'intégration (EF-307)
--
-- L'animation d'exercice relève d'un produit distinct : celui-ci n'embarque
-- aucun moteur de scénario. Il expose de quoi être piloté — ouvrir une crise en
-- mode exercice, y injecter des entrées, la clôturer — et rien de plus.
--
-- Les injections portent l'origine « externe » : au rejeu, on distingue ce
-- qu'une cellule a produit de ce qu'un animateur lui a envoyé. Sans cette
-- distinction, un exercice relu ressemblerait à une crise réelle.
-- ═══════════════════════════════════════════════════════════════════════════

create table cle_integration (
  id              uuid        primary key default gen_random_uuid(),
  organisation_id uuid        not null references organisation(id) on delete cascade,
  libelle         text        not null,
  empreinte       bytea       not null unique,
  -- Portée explicite : une clé d'exercice ne doit pas pouvoir lire une crise
  -- réelle, ni une clé de lecture ouvrir quoi que ce soit.
  portees         text[]      not null default '{}',
  cree_par        uuid        references compte(id) on delete set null,
  cree_at         timestamptz not null default now(),
  expire_at       timestamptz,
  revoque_at      timestamptz,
  dernier_usage_at timestamptz,
  usages          integer     not null default 0
);
create index cle_integration_organisation on cle_integration (organisation_id)
  where revoque_at is null;
+50 −0
Original line number Diff line number Diff line
import type { FastifyInstance } from "fastify";
import type { Sql } from "../db/client.js";
import { VERSION } from "../version.js";
import { mediaConfigure } from "../adaptateurs/media.js";
import { stockageConfigure } from "../adaptateurs/stockage.js";
import { antivirus } from "../adaptateurs/antivirus.js";
import { diffusion } from "../adaptateurs/diffusion.js";

/**
 * Page d'état publique (EX-10).
 *
 * Sans session, et volontairement pauvre : elle dit si le service fonctionne,
 * jamais qui s'en sert. Une page d'état qui divulguerait le nombre de crises en
 * cours renseignerait un attaquant sur ses propres effets.
 *
 * En production, la page qui l'affiche doit être hébergée ailleurs : une page
 * d'état servie par le service qu'elle surveille ne dit rien le jour où celui-ci
 * tombe. Cet endpoint est la sonde qu'elle interroge.
 */
export function enregistrerRoutesEtat(app: FastifyInstance, sql: Sql): void {
  app.get("/etat", async (_req, reply) => {
    const debut = Date.now();
    let base = false;
    try {
      await sql`select 1`;
      base = true;
    } catch {
      base = false;
    }

    reply.header("cache-control", "no-store");
    return reply.code(base ? 200 : 503).send({
      service: "Kastell",
      version: VERSION,
      etat: base ? "operationnel" : "indisponible",
      composants: {
        base,
        stockage_objet: stockageConfigure(),
        visioconference: mediaConfigure(),
        analyse_antivirale: antivirus().nom !== "inactif",
        temps_reel: diffusion().nom,
      },
      latence_base_ms: Date.now() - debut,
      mesure_at: new Date().toISOString(),
      note: base ? null
        : "Le service ne peut pas répondre. Si vous êtes en crise, appliquez le dossier "
          + "de préparation imprimé : il ne dépend d'aucun système.",
    });
  });
}
+209 −0
Original line number Diff line number Diff line
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { GENRES, GRAVITES } from "@kastell/shared";
import type { Sql } from "../db/client.js";
import { clore, consigner, ouvrirCrise } from "../domaine/crise.js";
import { empreinte, engendrer } from "../auth/jetons.js";
import { REDACTEURS, requerirMembre } from "./garde.js";

/**
 * Interface d'intégration (EF-307).
 *
 * Kastell n'anime pas les exercices — c'est un produit distinct. Il expose de
 * quoi être piloté : ouvrir une crise en mode exercice, y injecter des entrées,
 * la clôturer.
 *
 * Toute écriture arrivant par ici porte l'origine « externe ». Au rejeu, on
 * distingue donc ce qu'une cellule a produit de ce qu'un animateur lui a
 * envoyé — sans quoi un exercice relu ressemblerait à une crise réelle.
 */

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

declare module "fastify" {
  interface FastifyRequest {
    integration?: { id: string; organisationId: string; portees: string[] };
  }
}

export function enregistrerRoutesIntegration(app: FastifyInstance, sql: Sql): 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. */
  const ctxExterne = { acteurId: null, origine: "externe" as const };

  function requerirCle(portee: Portee) {
    return async (req: FastifyRequest, reply: FastifyReply) => {
      const entete = String(req.headers.authorization ?? "");
      const cle = entete.startsWith("Bearer ") ? entete.slice(7) : null;
      if (!cle) {
        return reply.code(401).send({
          code: "cle_absente",
          message: "Présentez la clé d'intégration en en-tête Authorization: Bearer.",
        });
      }
      const [ligne] = await sql<{ id: string; organisation_id: string; portees: string[] }[]>`
        select id, organisation_id, portees from cle_integration
         where empreinte = ${empreinte(cle)} and revoque_at is null
           and (expire_at is null or expire_at > now())`;
      if (!ligne) {
        return reply.code(401).send({ code: "cle_invalide", message: "Clé inconnue ou révoquée." });
      }
      if (!ligne.portees.includes(portee)) {
        return reply.code(403).send({
          code: "portee_insuffisante",
          message: `Cette clé ne porte pas « ${portee} ». Portées accordées : `
            + `${ligne.portees.join(", ") || "aucune"}.`,
        });
      }
      await sql`
        update cle_integration set dernier_usage_at = now(), usages = usages + 1
         where id = ${ligne.id}`;
      req.integration = {
        id: ligne.id, organisationId: ligne.organisation_id, portees: ligne.portees,
      };
      return undefined;
    };
  }

  // ── Gestion des clés, côté organisation ──────────────────────────────────
  app.get("/organisations/:id/integrations", { preHandler: requerirMembre(sql) }, async (req) => ({
    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`,
  }));

  app.post("/organisations/:id/integrations",
    { preHandler: requerirMembre(sql, REDACTEURS) }, async (req, reply) => {
      const c = z.object({
        libelle: z.string().trim().min(3).max(120),
        portees: z.array(z.enum(PORTEES)).min(1),
        expireJours: z.number().int().min(1).max(3650).nullish(),
      }).parse(req.body);
      const cle = engendrer(32);
      const [creee] = await sql<{ id: string }[]>`
        insert into cle_integration (organisation_id, libelle, empreinte, portees,
                                     cree_par, expire_at)
        values (${org(req)}, ${c.libelle}, ${empreinte(cle)}, ${c.portees},
                ${req.auth!.compte.id},
                ${c.expireJours ? new Date(Date.now() + c.expireJours * 86400_000) : null})
        returning id`;
      // Seule occasion où la clé circule en clair : la base n'en garde que
      // l'empreinte, elle ne pourra pas être réaffichée.
      return reply.code(201).send({
        id: creee!.id, cle,
        note: "Notez cette clé maintenant : elle n'est stockée que hachée.",
      });
    });

  app.post("/organisations/:id/integrations/:sousId/revocation",
    { preHandler: requerirMembre(sql, REDACTEURS) }, async (req, reply) => {
      const { sousId } = z.object({ sousId: z.string().uuid() }).parse(req.params);
      const r = await sql`
        update cle_integration set revoque_at = now()
         where id = ${sousId} and organisation_id = ${org(req)} and revoque_at is null`;
      if (r.count === 0) {
        return reply.code(404).send({ code: "introuvable", message: "Clé déjà révoquée." });
      }
      return reply.send({ message: "Clé révoquée." });
    });

  // ── Pilotage par l'outil d'exercice ──────────────────────────────────────
  app.post("/integration/exercices", { preHandler: requerirCle("exercice") },
    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"),
        cellules: z.array(z.object({
          code: z.string().trim().min(2).max(40),
          libelle: z.string().trim().min(2).max(120),
        })).default([]),
      }).parse(req.body);

      // Le marquage « exercice » est imposé ici, pas demandé : une clé
      // d'exercice ne doit jamais pouvoir ouvrir une crise réelle.
      const ouverte = await ouvrirCrise(sql, {
        organisationId: req.integration!.organisationId,
        intitule: c.intitule, type: c.type, gravite: c.gravite,
        exercice: true, cellules: c.cellules,
      }, ctxExterne);
      return reply.code(201).send({
        ...ouverte,
        note: "Crise ouverte en mode exercice. Le bandeau est permanent et non masquable.",
      });
    });

  app.post("/integration/exercices/:criseId/injections",
    { preHandler: requerirCle("exercice") }, async (req, reply) => {
      const { criseId } = z.object({ criseId: z.string().uuid() }).parse(req.params);
      const c = z.object({
        celluleId: z.string().uuid().nullish(),
        genre: z.enum(GENRES).default("fait"),
        texte: z.string().trim().min(1).max(10000),
        diffusion: z.enum(["cellule", "toutes_cellules", "direction"]).default("toutes_cellules"),
      }).parse(req.body);

      const [crise] = await sql<{ organisation_id: string; exercice: boolean; phase: string }[]>`
        select organisation_id, exercice, phase from crise where id = ${criseId}`;
      if (!crise || crise.organisation_id !== req.integration!.organisationId) {
        return reply.code(404).send({ code: "introuvable", message: "Crise inconnue." });
      }
      if (!crise.exercice) {
        return reply.code(403).send({
          code: "crise_reelle",
          message: "Une clé d'exercice n'injecte rien dans une crise réelle.",
        });
      }
      if (crise.phase === "close") {
        return reply.code(409).send({ code: "crise_close", message: "Cet exercice est terminé." });
      }

      const ev = await consigner(sql, {
        criseId, celluleId: c.celluleId ?? null, genre: c.genre,
        texte: c.texte, diffusion: c.diffusion,
      }, ctxExterne);
      return reply.code(201).send({
        seq: ev.seq,
        note: "Injection journalisée avec l'origine « externe » : elle reste "
          + "distinguable au rejeu.",
      });
    });

  app.post("/integration/exercices/:criseId/cloture",
    { preHandler: requerirCle("exercice") }, async (req, reply) => {
      const { criseId } = z.object({ criseId: z.string().uuid() }).parse(req.params);
      const [crise] = await sql<{ organisation_id: string; exercice: boolean }[]>`
        select organisation_id, exercice from crise where id = ${criseId}`;
      if (!crise || crise.organisation_id !== req.integration!.organisationId
        || !crise.exercice) {
        return reply.code(404).send({ code: "introuvable", message: "Exercice inconnu." });
      }
      await clore(sql, criseId, "Exercice terminé par l'outil d'animation.", ctxExterne);
      return reply.send({ message: "Exercice clos." });
    });

  /** Lecture pour l'évaluation : l'outil d'exercice note ce qui s'est passé. */
  app.get("/integration/exercices/:criseId/journal", { preHandler: requerirCle("lecture") },
    async (req, reply) => {
      const { criseId } = z.object({ criseId: z.string().uuid() }).parse(req.params);
      const [crise] = await sql<{ organisation_id: string; exercice: boolean }[]>`
        select organisation_id, exercice from crise where id = ${criseId}`;
      if (!crise || crise.organisation_id !== req.integration!.organisationId) {
        return reply.code(404).send({ code: "introuvable", message: "Crise inconnue." });
      }
      if (!crise.exercice) {
        return reply.code(403).send({
          code: "crise_reelle",
          message: "Une clé d'intégration ne lit pas le contenu d'une crise réelle. "
            + "C'est la même règle que pour le compte root : administrer n'est pas lire.",
        });
      }
      const evenements = await sql`
        select seq, type, occurred_at, origine, charge from evenement
         where crise_id = ${criseId} order by seq`;
      return reply.send({ evenements });
    });
}
Loading