Commit 333495df authored by Kourser's avatar Kourser
Browse files

Deux portes pour entrer, aucune pour sortir

L'organisation se composait dans un seul sens. L'invitation et l'ouverture de
compte sur place y faisaient entrer ; rien n'en sortait, et aucun rôle ne se
changeait après coup. Un compte entré y restait, au rôle qu'on lui avait
donné. Pour un outil de crise c'est franc : quelqu'un quitte l'entreprise et
son compte garde l'accès aux crises réelles, à leur main courante et à leur
dossier de préparation.

Le même trou existait un cran plus bas. « cellule.membre_retire » était
déclaré, projeté, rejoué — et pas une ligne du produit ne l'émettait. On
entrait en cellule sans en sortir, si bien qu'une composition ne pouvait que
grandir. C'est cette liste qu'on regarde pour savoir à qui confier une action :
gonflée de gens partis, elle la fait confier à quelqu'un qui ne répondra pas.

── Trois décisions qui méritent d'être dites

Les sessions ne sont pas révoquées au retrait. Un compte peut appartenir à
plusieurs organisations, et le déconnecter partout pour un départ d'ici serait
une conséquence qu'on n'a pas demandée. L'accès tombe quand même à la requête
suivante : chaque garde relit le rôle en base, il n'existe aucun cache.

Le retrait referme les cellules des crises ouvertes. Sans cela la composition
afficherait encore quelqu'un qui ne peut plus entrer.

Le journal ne bouge pas. Ce que la personne a consigné reste, à son nom. Un
journal qu'on nettoierait au départ des gens ne vaudrait plus rien le jour où
l'on cherche qui a décidé quoi.

── Ce qu'on ne peut pas faire

Retirer ou rétrograder le dernier propriétaire. Une organisation sans
propriétaire n'a plus personne pour inviter, brancher un canal ni fermer un
agent : elle est vivante et ingouvernable, et seule la console d'instance peut
la rattraper. C'est un état dans lequel on se met d'un clic, en fin de
réorganisation.

Sortir quelqu'un d'une cellule n'est pas le sortir de la crise : il reste
membre de l'organisation et garde l'accès. Confondre les deux coûterait une
crise entière à quelqu'un qu'on voulait seulement déplacer.

── Ce que l'injection a appris

Quatre pannes jouées ensemble n'en ont fait rougir qu'une : la première
faussait le scénario des suivantes. Jouées séparément, trois sur quatre étaient
bien attrapées.

La quatrième ne l'était pas du tout. Rien n'affirmait que la ligne de cellule
est gardée et datée plutôt que supprimée — le contrôle de rembobinage lit le
journal, que la suppression laisse intact, et celui de composition cherche
« retire_at is null », que satisfait aussi une ligne absente. On aurait eu une
projection amnésique sous un journal complet, ce qui est la pire des deux
moitiés. Le contrôle manquant est écrit, et prouvé rouge.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 7d217c0c
Loading
Loading
Loading
Loading
Loading
+158 −1
Changes for apps/api/src/auth/comptes.ts: 158 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -12,6 +12,7 @@ import { LIMITES, effacerTentatives, noterTentative, verifierLimite } from "./li
import * as mdp from "./mots_de_passe.js";
import * as sessions from "./sessions.js";
import * as totp from "./totp.js";
import { retirerMembreDeCellule } from "../domaine/crise.js";

/**
 * Parcours d'authentification.
@@ -40,7 +41,13 @@ export type CodeErreur =
  | "email_deja_pris"
  | "mot_de_passe_faible"
  | "invitation_invalide"
  | "operation_refusee";
  | "operation_refusee"
  // Composer l'organisation : trois refus distincts, parce qu'ils appellent
  // trois gestes différents — corriger l'adresse, ne rien faire, ou nommer
  // quelqu'un d'abord.
  | "introuvable"
  | "sans_effet"
  | "dernier_proprietaire";

export class ErreurAuth extends Error {
  constructor(public readonly code: CodeErreur, message: string) {
@@ -573,6 +580,156 @@ export interface Invitation {
 * Désormais l'invitation existe d'abord. Ce qui échoue à partir se dit, et le
 * lien reste transmissible autrement.
 */

// ═══════════════════════════════════════════════════════════════════════════
// Défaire ce qu'on a fait : changer un rôle, retirer un membre
// ═══════════════════════════════════════════════════════════════════════════
//
// Deux chemins menaient dans une organisation — l'invitation et l'ouverture de
// compte sur place — et aucun n'en sortait. Un membre entré y restait, au rôle
// qu'on lui avait donné, indéfiniment. Pour un outil de crise c'est un défaut
// franc : quelqu'un quitte l'entreprise, et son compte garde l'accès aux
// crises réelles, à leur main courante et à leur dossier de préparation.
//
// Ce qui est retiré, c'est l'accès — jamais la trace. Ce que la personne a
// consigné pendant qu'elle était là reste au journal, à son nom. Un journal
// qu'on nettoierait au départ des gens ne vaudrait plus rien le jour où l'on
// cherche qui a décidé quoi.

/** Qui a le droit de composer l'organisation. Le même couple que pour inviter. */
async function exigerCompositeur(
  sql: Sql, organisationId: string, parCompteId: string,
): Promise<void> {
  const role = await roleDans(sql, parCompteId, organisationId);
  if (role !== "proprietaire" && role !== "directeur_crise") {
    throw new ErreurAuth("operation_refusee",
      "Seul un propriétaire ou un directeur de crise compose l'organisation.");
  }
}

/**
 * Combien de propriétaires, sans compter celui-ci.
 *
 * Une organisation sans propriétaire n'a plus personne pour inviter, retirer,
 * brancher un canal ni fermer un agent : elle est vivante et ingouvernable, et
 * seule la console d'instance peut alors la rattraper. C'est le genre d'état
 * dans lequel on se met d'un clic, à la fin d'une réorganisation.
 */
async function autresProprietaires(
  sql: Sql, organisationId: string, sauf: string,
): Promise<number> {
  const [r] = await sql<{ n: string }[]>`
    select count(*)::text as n from membre
     where organisation_id = ${organisationId} and role = 'proprietaire'
       and compte_id <> ${sauf}`;
  return Number(r?.n ?? 0);
}

export interface ChangementRole {
  organisationId: string;
  compteId: string;
  role: RoleOrganisation;
  parCompteId: string;
}

export async function changerRoleMembre(
  sql: Sql, d: ChangementRole,
): Promise<{ de: string; vers: string }> {
  await exigerCompositeur(sql, d.organisationId, d.parCompteId);

  const actuel = await roleDans(sql, d.compteId, d.organisationId);
  if (!actuel) {
    throw new ErreurAuth("introuvable", "Cette personne n'est pas membre de l'organisation.");
  }
  if (actuel === d.role) {
    throw new ErreurAuth("sans_effet", `Cette personne est déjà ${d.role}.`);
  }
  if (actuel === "proprietaire"
    && (await autresProprietaires(sql, d.organisationId, d.compteId)) === 0) {
    throw new ErreurAuth("dernier_proprietaire",
      "C'est le dernier propriétaire : l'organisation n'aurait plus personne pour "
      + "l'administrer. Nommez d'abord quelqu'un d'autre.");
  }

  await sql.begin(async (tx) => {
    await tx`
      update membre set role = ${d.role}
       where organisation_id = ${d.organisationId} and compte_id = ${d.compteId}`;
    await journaliser(tx, {
      type: "membre.role_modifie", acteurId: d.parCompteId,
      organisationId: d.organisationId,
      charge: { organisation_id: d.organisationId, compte_id: d.compteId,
        de: actuel, vers: d.role },
    });
  });
  return { de: actuel, vers: d.role };
}

export interface RetraitMembre {
  organisationId: string;
  compteId: string;
  parCompteId: string;
  motif: string;
}

/**
 * Retirer quelqu'un de l'organisation.
 *
 * Les sessions ne sont pas révoquées, et c'est délibéré : un compte peut
 * appartenir à plusieurs organisations, et le déconnecter partout pour un
 * départ d'ici serait une conséquence qu'on n'a pas demandée. L'accès tombe
 * quand même à la requête suivante — chaque garde relit le rôle en base, il
 * n'existe aucun cache.
 *
 * Les cellules des crises ouvertes sont refermées : sans cela, la composition
 * afficherait encore quelqu'un qui ne peut plus entrer, et l'on confierait une
 * action à un nom qui ne répondra pas.
 */
export async function retirerMembre(
  sql: Sql, d: RetraitMembre,
): Promise<{ role: string; cellulesFermees: number }> {
  await exigerCompositeur(sql, d.organisationId, d.parCompteId);

  const actuel = await roleDans(sql, d.compteId, d.organisationId);
  if (!actuel) {
    throw new ErreurAuth("introuvable", "Cette personne n'est pas membre de l'organisation.");
  }
  if (actuel === "proprietaire"
    && (await autresProprietaires(sql, d.organisationId, d.compteId)) === 0) {
    throw new ErreurAuth("dernier_proprietaire",
      "C'est le dernier propriétaire : le retirer laisserait l'organisation sans "
      + "personne pour l'administrer. Nommez d'abord quelqu'un d'autre.");
  }

  // Les cellules d'abord, hors de la transaction qui retire : chacune écrit au
  // journal de sa crise, qui a sa propre chaîne d'empreintes.
  const cellules = await sql<{ crise_id: string; cellule_id: string }[]>`
    select c.crise_id, cm.cellule_id
      from cellule_membre cm
      join cellule c on c.id = cm.cellule_id
      join crise k on k.id = c.crise_id
     where cm.compte_id = ${d.compteId} and cm.retire_at is null
       and k.organisation_id = ${d.organisationId} and k.close_at is null`;
  let cellulesFermees = 0;
  for (const c of cellules) {
    const fait = await retirerMembreDeCellule(sql, c.crise_id, c.cellule_id, d.compteId,
      "Retiré de l'organisation.", { acteurId: d.parCompteId, origine: "humaine" });
    if (fait) cellulesFermees += 1;
  }

  await sql.begin(async (tx) => {
    await tx`
      delete from membre
       where organisation_id = ${d.organisationId} and compte_id = ${d.compteId}`;
    await journaliser(tx, {
      type: "membre.retire", acteurId: d.parCompteId, organisationId: d.organisationId,
      charge: { organisation_id: d.organisationId, compte_id: d.compteId,
        role: actuel, motif: d.motif, cellules_fermees: cellulesFermees },
    });
  });
  return { role: actuel, cellulesFermees };
}

export async function inviter(sql: Sql, d: DemandeInvitation): Promise<Invitation> {
  const email = normaliserEmail(d.email);
  await verifierLimite(sql, "invitation_organisation", d.organisationId);
+32 −0
Changes for apps/api/src/domaine/crise.ts: 32 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -219,6 +219,38 @@ export class RefusCellule extends Error {
  }
}

/**
 * Sortir quelqu'un d'une cellule (EF-302a).
 *
 * L'événement existait, la projection le rejouait, et rien ne l'émettait
 * jamais : on entrait en cellule sans pouvoir en sortir. Une composition qui
 * ne peut que grandir cesse en quelques heures de décrire qui est réellement
 * là — et c'est cette liste qu'on regarde pour savoir à qui confier une
 * action.
 *
 * La ligne n'est pas supprimée mais datée : qui siégeait à H+02 doit rester
 * lisible au rembobinage, même si la personne n'y est plus à H+06.
 */
export async function retirerMembreDeCellule(
  sql: Sql, criseId: string, celluleId: string, compteId: string,
  motif: string | undefined, ctx: Contexte,
): Promise<EvenementEnregistre | null> {
  const [present] = await sql`
    select 1 from cellule_membre cm join cellule c on c.id = cm.cellule_id
     where cm.cellule_id = ${celluleId} and cm.compte_id = ${compteId}
       and cm.retire_at is null and c.crise_id = ${criseId}`;
  if (!present) return null;

  return (await sql.begin((tx) =>
    enregistrer(tx, {
      criseId, celluleId, type: "cellule.membre_retire",
      charge: { cellule_id: celluleId, compte_id: compteId,
        ...(motif ? { motif } : {}) },
      ctx,
    }),
  )) as EvenementEnregistre;
}

export async function ajouterMembre(
  sql: Sql, criseId: string, celluleId: string, compteId: string,
  role: "animateur" | "membre" | "externe" | "observateur",
+5 −0
Changes for apps/api/src/langues/en.ts: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -327,4 +327,9 @@ export const EN: Record<string, string> = {
  "Seul le directeur de crise consent à faire lire les transcriptions.":
    "Only the crisis director consents to transcripts being read.",
  "Écartée. L'écartement reste au bilan.": "Discarded. The discard stays in the report.",
  "Cette personne n'est pas dans cette cellule.": "This person is not in this cell.",
  "Seul le directeur de crise ou l'animateur de la cellule en sort quelqu'un.":
    "Only the crisis director or the cell's facilitator removes someone from it.",
  "Sortie de la cellule. Elle reste membre de l'organisation et garde l'accès à la crise.":
    "Removed from the cell. They remain a member of the organisation and keep access to the crisis.",
};
+58 −0
Changes for apps/api/src/routes/auth.ts: 58 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -37,6 +37,11 @@ const STATUTS: Record<comptes.CodeErreur, number> = {
  mot_de_passe_faible: 400,
  invitation_invalide: 400,
  operation_refusee: 403,
  introuvable: 404,
  // Deux refus qui ne sont pas des erreurs du demandeur mais des états du
  // monde : la demande est bien formée, c'est l'organisation qui s'y oppose.
  sans_effet: 409,
  dernier_proprietaire: 409,
};

export function enregistrerRoutesAuth(app: FastifyInstance, env: Env, sql: Sql): void {
@@ -339,6 +344,59 @@ export function enregistrerRoutesAuth(app: FastifyInstance, env: Env, sql: Sql):
    };
  });

  /**
   * Changer le rôle d'un membre.
   *
   * Sous « requerirMembre » sans liste de rôles : le droit se vérifie dans le
   * domaine, comme pour l'invitation, parce que la règle n'est pas « tel rôle »
   * mais « tel rôle, et pas le dernier propriétaire ». Un garde qui dirait la
   * moitié de la règle laisserait croire que l'autre moitié n'existe pas.
   */
  app.post("/organisations/:id/membres/:sousId/role",
    { preHandler: requerirMembre(sql) }, async (req, reply) => {
      const { id, sousId } = z.object({
        id: z.string().uuid(), sousId: z.string().uuid(),
      }).parse(req.params);
      const c = z.object({
        role: z.enum(["proprietaire", "preparation", "directeur_crise", "membre",
          "observateur"]),
      }).parse(req.body);
      const r = await comptes.changerRoleMembre(sql, {
        organisationId: id, compteId: sousId, role: c.role,
        parCompteId: req.auth!.compte.id,
      });
      return reply.send({
        message: `Rôle changé de « ${r.de} » à « ${r.vers} ». Effectif à la requête suivante.`,
      });
    });

  /**
   * Retirer un membre.
   *
   * Le motif est exigé, comme partout où l'on retire un accès : six mois plus
   * tard, « pourquoi cette personne n'est-elle plus là » est une question qui
   * se pose, et le journal doit y répondre sans qu'on aille chercher quelqu'un.
   */
  app.post("/organisations/:id/membres/:sousId/retrait",
    { preHandler: requerirMembre(sql) }, async (req, reply) => {
      const { id, sousId } = z.object({
        id: z.string().uuid(), sousId: z.string().uuid(),
      }).parse(req.params);
      const c = z.object({
        motif: z.string().trim().min(3, "Dites pourquoi : le journal le gardera.").max(500),
      }).parse(req.body);
      const r = await comptes.retirerMembre(sql, {
        organisationId: id, compteId: sousId, parCompteId: req.auth!.compte.id,
        motif: c.motif,
      });
      return reply.send({
        message: r.cellulesFermees > 0
          ? `Retiré de l'organisation, et de ${r.cellulesFermees} cellule(s) de crises `
            + "en cours. Ce qui a été consigné reste au journal."
          : "Retiré de l'organisation. Ce qui a été consigné reste au journal.",
      });
    });

  app.post("/organisations/:id/invitations", { preHandler: requerirSession() }, async (req, reply) => {
    const { id } = z.object({ id: z.string().uuid() }).parse(req.params);
    const c = z.object({
+40 −0
Changes for apps/api/src/routes/crise.ts: 40 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -144,6 +144,46 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
    return reply.code(201).send({ seq: ev.seq });
  });

  /**
   * Sortir quelqu'un d'une cellule.
   *
   * Même droit que pour l'y faire entrer : le directeur de crise ou
   * l'animateur de la cellule. Une composition qu'on peut remplir sans pouvoir
   * la vider cesse en quelques heures de décrire qui est là — et c'est cette
   * liste qu'on regarde pour savoir à qui confier une action.
   *
   * La personne reste membre de l'organisation : elle garde l'accès à la
   * crise, elle n'est simplement plus de cette cellule-là. Confondre les deux
   * ferait perdre une crise entière à quelqu'un qu'on voulait déplacer.
   */
  app.post("/crises/:criseId/cellules/:sousId/membres/:membreId/retrait", acces,
    async (req, reply) => {
      const celluleId = sousId(req);
      if (!await peutComposer(req, celluleId)) {
        return reply.code(403).send({
          code: "role_insuffisant",
          message: "Seul le directeur de crise ou l'animateur de la cellule en sort "
            + "quelqu'un.",
        });
      }
      const { membreId } = z.object({ membreId: z.string().uuid() }).parse(req.params);
      const c = z.object({
        motif: z.string().trim().max(500).nullish(),
      }).parse(req.body ?? {});
      const ev = await crises.retirerMembreDeCellule(
        sql, criseId(req), celluleId, membreId, c.motif ?? undefined, ctx(req));
      if (!ev) {
        return reply.code(404).send({
          code: "introuvable", message: "Cette personne n'est pas dans cette cellule.",
        });
      }
      return reply.send({
        seq: ev.seq,
        message: "Sortie de la cellule. Elle reste membre de l'organisation et garde "
          + "l'accès à la crise.",
      });
    });

  // ── Ouverture et cockpit ─────────────────────────────────────────────────
  app.post("/organisations/:id/crises", { preHandler: requerirMembre(sql,
    ["proprietaire", "directeur_crise"]) }, async (req, reply) => {
Loading