Commit e097ba50 authored by Kourser's avatar Kourser
Browse files

Console root : administrer une instance sans pouvoir lire les crises

Achève §7 du cahier des charges. La console avance du lot L10 au lot L0 : il
fallait des sessions à protéger avant de pouvoir la garder, et elles existent
désormais.

La règle qui structure tout
  Le root administre — organisations, comptes, quotas, intégrité — et ne lit
  pas le contenu des crises au titre de son statut. Le seul passage vers le
  contenu est le bris de glace : motif circonstancié obligatoire, borné à huit
  heures par une contrainte du schéma, journalisé au nom de l'organisation, et
  consultable par elle. `GET /organisations/:id/acces-exceptionnels` sert la
  même requête aux deux côtés : un historique que seul l'exploitant pourrait
  consulter ne serait pas une transparence mais une archive privée.

On ne devient pas root sans second facteur
  Porté par un déclencheur, pas par une intention. La console exige une
  présentation de moins de quinze minutes ; un root sans TOTP détiendrait un
  droit qu'il ne pourrait jamais exercer, et croirait en disposer le jour où il
  faut s'en servir. La migration retire les droits accordés avant cette
  contrainte plutôt que de laisser dormir l'exception.

  Et le dernier root ne peut pas se retirer son propre droit : un exploitant
  enfermé dehors le jour d'une crise serait un défaut de conception, pas une
  mesure de sécurité. L'état d'instance alerte tant qu'il n'y en a qu'un.

Ce que la console refuse de faire
  La suppression d'une organisation est logique, jamais physique : les journaux
  de ses crises closes survivent, leur valeur probante ne dépendant pas de
  l'existence d'un contrat — un litige survient souvent après. Une crise en
  cours bloque la suppression. Réinitialiser le second facteur d'un root est
  refusé explicitement : ce serait lui retirer son droit par un chemin détourné.

  À un compte non root, la console répond 404 et non 403 : elle ne se signale
  pas à qui n'y a pas droit.

Quotas
  Vérifiés au point d'usage — acceptation d'invitation, ouverture de crise —
  dans la transaction de l'opération contrôlée, sans quoi deux demandes
  concurrentes franchiraient toutes deux un plafond atteint. Un quota que rien
  n'applique est une ligne de configuration décorative.

  Leur dépassement n'est pas journalisé, pour deux raisons qui se renforcent :
  l'inscrire serait vain, l'exception annulant la transaction qui vient de
  l'écrire ; et remplir un journal inaltérable et sans purge d'un refus que
  n'importe quel membre peut provoquer en boucle serait une prise offerte —
  même raisonnement que pour les échecs de connexion. Ce qui mérite une trace,
  c'est la modification du plafond, pas sa rencontre.

Entretien
  Un accès exceptionnel qui resterait ouvert faute de balayage serait
  exactement la dérive que le dispositif cherche à empêcher : un droit
  exceptionnel devenu permanent par inattention. Refermeture à l'échéance
  toutes les quinze minutes, journalisée avec sa cause.

Amorçage
  `pnpm amorcer-root <adresse>` ouvre la porte de l'auto-hébergeur, qui n'a
  personne pour lui accorder ce droit. Sur instance vierge uniquement ; ensuite
  l'octroi passe par la console et laisse une trace nominative.

Vérification
  `pnpm verif` passe de 76 à 115 contrôles et réinitialise désormais le schéma,
  en refusant de s'exécuter en production : l'amorçage du premier root ne
  s'observe que sur une instance vierge, et les journaux refusent d'être vidés.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 3b88c9a2
Loading
Loading
Loading
Loading
+29 −3
Original line number Diff line number Diff line
@@ -12,7 +12,7 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.

| Lot | Contenu | État |
|-----|---------|------|
| **L0** | Socle, conteneurisation, compte root, authentification | 🟢 fait — reste la console d'administration (L10) |
| **L0** | Socle, conteneurisation, authentification, console root | 🟢 fait |
| **L1** | Noyau de persistance, main courante, rembobinage | 🟡 en cours — noyau et lecture à date faits, filtres et export PDF à venir |
| L2 | Dossier de préparation (annuaire, tiers, fiches réflexes) | ⚪ |
| L3+ | Décisions, chat, GED, visio, tableau blanc, mobilisation | ⚪ |
@@ -22,7 +22,8 @@ d'empreintes, le vérificateur indépendant, la reconstitution d'un état passé
par rejeu, la main courante avec rectification sans effacement, le compte root
avec son journal d'administration et le bris de glace, l'authentification
complète (mot de passe, lien à usage unique, TOTP, codes de secours, sessions,
invitations, multi-organisation), et la pile Docker.
invitations, multi-organisation), la console d'administration avec ses quotas,
et la pile Docker.

---

@@ -52,7 +53,13 @@ pnpm dev
pnpm verif
```

Ce contrôle applique les migrations, joue une crise complète, puis vérifie une
**Ce contrôle réinitialise le schéma** et refuse de s'exécuter si
`KASTELL_ENV=production` : certaines garanties ne s'observent que sur une
instance vierge, l'amorçage du premier root notamment — et les journaux
refusent d'être vidés, ce qui est tout leur intérêt. Sur une instance en
service, utilisez la vérification d'intégrité de la console.

Il applique les migrations, joue une crise complète, puis vérifie une
à une les garanties de la boîte noire : refus de modification et de suppression
d'un événement, séquence sans trou, chaîne d'empreintes valide, détection d'une
falsification par un vérificateur qui n'utilise pas la base, et reconstitution
@@ -76,6 +83,10 @@ apps/api/src/
    sessions.ts      deux durées : inactivité 12 h, absolue 7 jours
    comptes.ts       inscription, connexion, second facteur, invitations
    limites.ts       limitation de débit, en base pour survivre au redémarrage
  administration/ console root — administrer sans lire
    console.ts       organisations, comptes, droit root
    etat.ts          état d'instance, intégrité globale, bris de glace
    quotas.ts        appliqués au point d'usage, pas seulement affichés
  domaine/        opérations métier, qui n'écrivent que via le noyau
  verification/   suites de contrôle, une par domaine
  db/migrations/  le schéma fait autorité, y compris sur les garanties
@@ -134,6 +145,21 @@ Le journal d'administration a les mêmes garanties que le journal de crise :
ajout seul, séquence sans trou, chaînage d'empreintes. Les actes du root sont
précisément ceux qu'il ne faut pas pouvoir effacer — y compris par le root.

**On ne devient pas root sans second facteur** — la contrainte est portée par
un déclencheur. La console en exige une présentation de moins de quinze
minutes : un root sans TOTP détiendrait un droit qu'il ne pourrait jamais
exercer, et croirait en disposer le jour où il faut s'en servir.

Amorçage du premier root sur une instance neuve :

```bash
pnpm amorcer-root vous@exemple.org "Amorçage de l'instance"
```

Ensuite l'octroi passe par la console et laisse une trace nominative. Accordez
le droit à un second compte sans tarder : un root unique qui perd son téléphone
enferme l'exploitant dehors.

Voir §7 du cahier des charges.

## Choix techniques notables
+350 −0
Original line number Diff line number Diff line
import type { Sql } from "../db/client.js";
import { journaliser } from "../noyau/administration.js";
import { estRoot } from "../noyau/administration.js";
import { desactiverTotp, ErreurAuth } from "../auth/comptes.js";
import { VERSION } from "../version.js";

/**
 * Opérations de la console d'administration (§7 du cahier des charges).
 *
 * Toutes passent par le journal d'administration, en ajout seul et chaîné.
 * Aucune ne touche au contenu d'une crise : lire une main courante réclame un
 * accès exceptionnel, qui vit dans `noyau/administration.ts`.
 */

async function exigerRoot(sql: Sql, compteId: string): Promise<void> {
  if (!(await estRoot(sql, compteId))) {
    throw new ErreurAuth("operation_refusee", "Opération réservée au compte root.");
  }
}

// ═══════════════════════════════════════════════════════════════════════════
// Le droit root lui-même (EA-01, EA-02, EA-14)
// ═══════════════════════════════════════════════════════════════════════════

export interface RootListe {
  compte_id: string;
  email: string;
  nom: string;
  accorde_at: Date;
  accorde_par_email: string | null;
}

export async function listerRoots(sql: Sql): Promise<RootListe[]> {
  return sql<RootListe[]>`
    select r.compte_id, c.email, c.nom, r.accorde_at,
           p.email as accorde_par_email
      from root_actif r
      join compte c on c.id = r.compte_id
      left join compte p on p.id = r.accorde_par
     order by r.accorde_at`;
}

export async function accorderRoot(
  sql: Sql, cibleId: string, parCompteId: string, motif: string,
): Promise<void> {
  await exigerRoot(sql, parCompteId);
  if (motif.trim().length < 10) {
    throw new ErreurAuth("operation_refusee", "Un motif circonstancié est requis.");
  }
  await sql.begin(async (tx) => {
    // Le déclencheur de la migration 0005 refuse un compte sans second facteur :
    // la console exige une présentation récente, un root sans TOTP ne pourrait
    // jamais y entrer.
    await tx`
      insert into root_instance (compte_id, accorde_par, motif)
      values (${cibleId}, ${parCompteId}, ${motif})
      on conflict (compte_id) do update
        set retire_at = null, accorde_par = ${parCompteId},
            accorde_at = now(), motif = ${motif}`;
    await journaliser(tx, {
      type: "instance.root_accorde", acteurId: parCompteId,
      charge: { compte_id: cibleId, motif },
    });
  });
}

export async function retirerRoot(
  sql: Sql, cibleId: string, parCompteId: string, motif?: string,
): Promise<void> {
  await exigerRoot(sql, parCompteId);
  await sql.begin(async (tx) => {
    const [restants] = await tx<{ n: string }[]>`select count(*) as n from root_actif`;
    // EA-14 : un exploitant enfermé dehors le jour d'une crise serait un défaut
    // de conception, pas une mesure de sécurité.
    if (Number(restants?.n ?? 0) <= 1) {
      throw new ErreurAuth("operation_refusee",
        "Dernier compte root : accordez le droit à un autre compte avant de retirer celui-ci.");
    }
    const r = await tx`
      update root_instance set retire_at = now()
       where compte_id = ${cibleId} and retire_at is null`;
    if (r.count === 0) throw new ErreurAuth("operation_refusee", "Ce compte n'est pas root.");
    await journaliser(tx, {
      type: "instance.root_retire", acteurId: parCompteId,
      charge: motif ? { compte_id: cibleId, motif } : { compte_id: cibleId },
    });
  });
}

// ═══════════════════════════════════════════════════════════════════════════
// Organisations (EA-04, EA-06)
// ═══════════════════════════════════════════════════════════════════════════

export interface OrganisationEtat {
  id: string;
  nom: string;
  cree_at: Date;
  suspendue_at: Date | null;
  supprimee_at: Date | null;
  quota_membres: number;
  quota_crises_actives: number;
  quota_stockage_octets: string;
  retention_enregistrements_jours: number;
  membres: string;
  crises_actives: string;
  crises_total: string;
  acces_en_cours: string;
}

export async function listerOrganisations(
  sql: Sql, o: { recherche?: string | undefined; inclureSupprimees?: boolean | undefined } = {},
): Promise<OrganisationEtat[]> {
  return sql<OrganisationEtat[]>`
    select * from organisation_etat
     where true
       ${o.inclureSupprimees ? sql`` : sql`and supprimee_at is null`}
       ${o.recherche ? sql`and nom ilike ${"%" + o.recherche + "%"}` : sql``}
     order by nom
     limit 200`;
}

export async function detailOrganisation(sql: Sql, id: string): Promise<OrganisationEtat | null> {
  const [o] = await sql<OrganisationEtat[]>`select * from organisation_etat where id = ${id}`;
  return o ?? null;
}

export async function renommerOrganisation(
  sql: Sql, id: string, nom: string, parCompteId: string,
): Promise<void> {
  await exigerRoot(sql, parCompteId);
  const propre = nom.trim();
  if (propre.length < 2) throw new ErreurAuth("operation_refusee", "Nom trop court.");
  await sql.begin(async (tx) => {
    const [avant] = await tx<{ nom: string }[]>`
      select nom from organisation where id = ${id} for update`;
    if (!avant) throw new ErreurAuth("operation_refusee", "Organisation inconnue.");
    await tx`update organisation set nom = ${propre} where id = ${id}`;
    await journaliser(tx, {
      type: "organisation.renommee", acteurId: parCompteId, organisationId: id,
      charge: { organisation_id: id, de: avant.nom, vers: propre },
    });
  });
}

export async function suspendreOrganisation(
  sql: Sql, id: string, motif: string, parCompteId: string,
): Promise<void> {
  await exigerRoot(sql, parCompteId);
  if (motif.trim().length < 10) {
    throw new ErreurAuth("operation_refusee", "Un motif circonstancié est requis.");
  }
  await sql.begin(async (tx) => {
    const r = await tx`
      update organisation set suspendue_at = now(), motif_suspension = ${motif}
       where id = ${id} and suspendue_at is null and supprimee_at is null`;
    if (r.count === 0) {
      throw new ErreurAuth("operation_refusee", "Organisation inconnue ou déjà suspendue.");
    }
    await journaliser(tx, {
      type: "organisation.suspendue", acteurId: parCompteId, organisationId: id,
      charge: { organisation_id: id, motif },
    });
  });
}

export async function reactiverOrganisation(
  sql: Sql, id: string, parCompteId: string, motif?: string,
): Promise<void> {
  await exigerRoot(sql, parCompteId);
  await sql.begin(async (tx) => {
    const r = await tx`
      update organisation set suspendue_at = null, motif_suspension = null
       where id = ${id} and suspendue_at is not null and supprimee_at is null`;
    if (r.count === 0) throw new ErreurAuth("operation_refusee", "Organisation non suspendue.");
    await journaliser(tx, {
      type: "organisation.reactivee", acteurId: parCompteId, organisationId: id,
      charge: motif ? { organisation_id: id, motif } : { organisation_id: id },
    });
  });
}

/**
 * EA-04 — Suppression logique. Les journaux des crises closes survivent : leur
 * valeur probante ne dépend pas de l'existence d'un contrat, et un litige
 * survient souvent après la fin de la relation commerciale.
 */
export async function supprimerOrganisation(
  sql: Sql, id: string, motif: string, parCompteId: string,
): Promise<{ crisesConservees: number }> {
  await exigerRoot(sql, parCompteId);
  if (motif.trim().length < 10) {
    throw new ErreurAuth("operation_refusee", "Un motif circonstancié est requis.");
  }
  return (await sql.begin(async (tx) => {
    const [o] = await tx<{ nom: string; actives: string; total: string }[]>`
      select o.nom,
             (select count(*) from crise c where c.organisation_id = o.id and c.phase <> 'close') as actives,
             (select count(*) from crise c where c.organisation_id = o.id) as total
        from organisation o where o.id = ${id} and o.supprimee_at is null for update`;
    if (!o) throw new ErreurAuth("operation_refusee", "Organisation inconnue ou déjà supprimée.");
    if (Number(o.actives) > 0) {
      throw new ErreurAuth("operation_refusee",
        `${o.actives} crise(s) en cours : clôturez-les avant de supprimer l'organisation.`);
    }

    await tx`update organisation
                set supprimee_at = now(), motif_suppression = ${motif}, suspendue_at = now()
              where id = ${id}`;
    await tx`update session s set revoque_at = now(), motif_revocation = 'organisation supprimée'
              where s.revoque_at is null
                and s.compte_id in (select compte_id from membre where organisation_id = ${id})
                and not exists (select 1 from membre m2 join organisation o2 on o2.id = m2.organisation_id
                                 where m2.compte_id = s.compte_id and m2.organisation_id <> ${id}
                                   and o2.supprimee_at is null)`;

    const crisesConservees = Number(o.total);
    await journaliser(tx, {
      type: "organisation.supprimee", acteurId: parCompteId, organisationId: id,
      charge: { organisation_id: id, nom: o.nom, motif, crises_conservees: crisesConservees },
    });
    return { crisesConservees };
  })) as { crisesConservees: number };
}

export interface MajQuotas {
  quota_membres?: number | undefined;
  quota_crises_actives?: number | undefined;
  quota_stockage_octets?: number | undefined;
  retention_enregistrements_jours?: number | undefined;
}

export async function definirQuotas(
  sql: Sql, id: string, majs: MajQuotas, parCompteId: string,
): Promise<void> {
  await exigerRoot(sql, parCompteId);
  const champs = Object.entries(majs).filter(([, v]) => v !== undefined) as [string, number][];
  if (champs.length === 0) throw new ErreurAuth("operation_refusee", "Aucune modification.");

  await sql.begin(async (tx) => {
    const [avant] = await tx<Record<string, number>[]>`
      select quota_membres, quota_crises_actives, quota_stockage_octets,
             retention_enregistrements_jours
        from organisation where id = ${id} for update`;
    if (!avant) throw new ErreurAuth("operation_refusee", "Organisation inconnue.");

    const modifications: Record<string, { de: number; vers: number }> = {};
    for (const [champ, valeur] of champs) {
      const ancien = Number(avant[champ]);
      if (ancien === valeur) continue;
      modifications[champ] = { de: ancien, vers: valeur };
      await tx`update organisation set ${tx(champ)} = ${valeur} where id = ${id}`;
    }
    if (Object.keys(modifications).length === 0) return;

    await journaliser(tx, {
      type: "organisation.quotas_modifies", acteurId: parCompteId, organisationId: id,
      charge: { organisation_id: id, modifications },
    });
  });
}

// ═══════════════════════════════════════════════════════════════════════════
// Comptes (EA-05)
// ═══════════════════════════════════════════════════════════════════════════

export interface CompteListe {
  id: string;
  email: string;
  nom: string;
  email_verifie: boolean;
  second_facteur: boolean;
  est_root: boolean;
  desactive_at: Date | null;
  derniere_connexion_at: Date | null;
  organisations: string;
  sessions_actives: string;
}

export async function listerComptes(sql: Sql, recherche?: string): Promise<CompteListe[]> {
  return sql<CompteListe[]>`
    select c.id, c.email, c.nom, c.email_verifie,
           (c.totp_actif_at is not null) as second_facteur,
           exists (select 1 from root_actif r where r.compte_id = c.id) as est_root,
           c.desactive_at, c.derniere_connexion_at,
           (select count(*) from membre m where m.compte_id = c.id) as organisations,
           (select count(*) from session s where s.compte_id = c.id
              and s.revoque_at is null and s.expire_at > now())     as sessions_actives
      from compte c
     where ${recherche
       ? sql`(c.email ilike ${"%" + recherche + "%"} or c.nom ilike ${"%" + recherche + "%"})`
       : sql`true`}
     order by c.cree_at desc
     limit 200`;
}

/** EA-05 : le second facteur perdu se réinitialise, il ne se lit pas. */
export async function reinitialiserSecondFacteur(
  sql: Sql, cibleId: string, parCompteId: string, motif: string,
): Promise<void> {
  await exigerRoot(sql, parCompteId);
  if (await estRoot(sql, cibleId)) {
    // Sans cela, réinitialiser le second facteur d'un root reviendrait à lui
    // retirer son droit par un chemin détourné — le déclencheur de la 0005 le
    // refuserait, mais le message serait incompréhensible.
    throw new ErreurAuth("operation_refusee",
      "Retirez d'abord le droit root de ce compte : un root ne peut pas être privé de second facteur.");
  }
  await desactiverTotp(sql, cibleId, parCompteId, motif);
  await sql.begin((tx) => journaliser(tx, {
    type: "compte.second_facteur_reinitialise", acteurId: parCompteId,
    charge: { compte_id: cibleId, motif },
  }));
}

export async function desactiverCompte(
  sql: Sql, cibleId: string, parCompteId: string, motif: string,
): Promise<void> {
  await exigerRoot(sql, parCompteId);
  if (motif.trim().length < 10) {
    throw new ErreurAuth("operation_refusee", "Un motif circonstancié est requis.");
  }
  if (cibleId === parCompteId) {
    throw new ErreurAuth("operation_refusee", "On ne désactive pas son propre compte.");
  }
  await sql.begin(async (tx) => {
    const r = await tx`
      update compte set desactive_at = now() where id = ${cibleId} and desactive_at is null`;
    if (r.count === 0) throw new ErreurAuth("operation_refusee", "Compte inconnu ou déjà désactivé.");
    await tx`update session set revoque_at = now(), motif_revocation = 'compte désactivé'
              where compte_id = ${cibleId} and revoque_at is null`;
    await journaliser(tx, {
      type: "compte.desactive", acteurId: parCompteId, charge: { compte_id: cibleId, motif },
    });
  });
}

export async function reactiverCompte(
  sql: Sql, cibleId: string, parCompteId: string, motif?: string,
): Promise<void> {
  await exigerRoot(sql, parCompteId);
  await sql.begin(async (tx) => {
    const r = await tx`
      update compte set desactive_at = null where id = ${cibleId} and desactive_at is not null`;
    if (r.count === 0) throw new ErreurAuth("operation_refusee", "Compte non désactivé.");
    await journaliser(tx, {
      type: "compte.reactive", acteurId: parCompteId,
      charge: motif ? { compte_id: cibleId, motif } : { compte_id: cibleId },
    });
  });
}
+215 −0

File added.

Preview size limit exceeded, changes collapsed.

+62 −0
Original line number Diff line number Diff line
import type { Sql, Tx } from "../db/client.js";

/**
 * Quotas (EA-06).
 *
 * Vérifiés au point d'usage plutôt qu'affichés dans une console : un quota que
 * rien n'applique est une ligne de configuration décorative.
 */

export type NomQuota = "membres" | "crises_actives" | "stockage";

export class QuotaAtteint extends Error {
  constructor(public readonly quota: NomQuota, public readonly plafond: number) {
    super(
      quota === "membres"
        ? `Le nombre maximal de membres est atteint (${plafond}).`
        : quota === "crises_actives"
          ? `Le nombre maximal de crises actives est atteint (${plafond}).`
          : `L'espace de stockage alloué est atteint (${plafond} octets).`,
    );
    this.name = "QuotaAtteint";
  }
}

/**
 * À appeler dans la transaction de l'opération contrôlée : sans cela deux
 * demandes concurrentes franchissent toutes deux un plafond atteint.
 */
export async function exigerQuota(
  tx: Tx, organisationId: string, quota: Exclude<NomQuota, "stockage">,
): Promise<void> {
  const [o] = await tx<{ plafond: number; utilise: string }[]>`
    select
      ${quota === "membres" ? tx`e.quota_membres` : tx`e.quota_crises_actives`} as plafond,
      ${quota === "membres" ? tx`e.membres` : tx`e.crises_actives`}             as utilise
    from organisation_etat e where e.id = ${organisationId}`;
  if (!o) throw new Error("Organisation inconnue.");

  // Un dépassement ne va pas au journal d'administration, pour deux raisons
  // qui se renforcent : l'inscrire ici serait vain, puisque l'exception annule
  // la transaction qui vient de l'écrire ; et le journal est inaltérable et
  // sans purge — le remplir d'un refus que n'importe quel membre peut
  // provoquer en boucle serait une prise offerte, comme pour les échecs de
  // connexion. Ce qui mérite une trace, c'est la modification du plafond
  // (`organisation.quotas_modifies`), pas sa rencontre.
  if (Number(o.utilise) >= o.plafond) throw new QuotaAtteint(quota, o.plafond);
}

export interface Quotas {
  quota_membres: number;
  quota_crises_actives: number;
  quota_stockage_octets: number;
  retention_enregistrements_jours: number;
}

export async function quotasDe(sql: Sql, organisationId: string): Promise<Quotas | null> {
  const [q] = await sql<Quotas[]>`
    select quota_membres, quota_crises_actives, quota_stockage_octets,
           retention_enregistrements_jours
      from organisation where id = ${organisationId}`;
  return q ?? null;
}
+5 −0
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 { journaliser } from "../noyau/administration.js";
import { envoyer } from "../adaptateurs/notify.js";
@@ -531,6 +532,10 @@ export async function accepterInvitation(
      returning organisation_id, role`;
    if (!inv) throw new ErreurAuth("invitation_invalide", "Invitation déjà utilisée ou révoquée.");

    const [deja] = await tx`
      select 1 from membre where organisation_id = ${inv.organisation_id} and compte_id = ${compteId}`;
    if (!deja) await exigerQuota(tx, inv.organisation_id, "membres");

    await tx`
      insert into membre (organisation_id, compte_id, role)
      values (${inv.organisation_id}, ${compteId}, ${inv.role})
Loading