Commit 9262f70d authored by Kourser's avatar Kourser
Browse files

La console root peut recouvrir le .env, et pas n'importe où

Configurer par variables d'environnement est le bon choix pour qui tient la
machine, et un mauvais pour qui tient l'instance sans tenir la machine :
changer un relais SMTP ne devrait pas demander un accès SSH et un redémarrage.
Un onglet Configuration recouvre donc le fichier, sans le toucher.

L'étroitesse de la liste est le dispositif
  N'y figure que ce qui est adossé à un adaptateur muni d'un setter, donc
  reconfigurable à chaud : courriel, visioconférence, analyse antivirale,
  relais de heurtoir, adresse publique, offre de code. Seize réglages.

  Le reste est écarté, et pour des raisons différentes. DATABASE_URL, REDIS_URL
  et KASTELL_PORT sont consommées à la connexion ou par la composition :
  les afficher comme modifiables mentirait. S3_* se remplace à chaud, mais les
  documents déjà déposés ne suivent pas — on obtiendrait une instance qui
  marche et un dossier de crise amputé, ce qui est pire qu'un refus. APNS_* et
  FCM_* sont lues par le conteneur du relais, jamais par l'application :
  les surcharger ici n'atteindrait personne.

  Et SESSION_SECRET, qui dérive la clé chiffrant les identifiants de passerelle
  des organisations. La remplacer depuis une page web les rend illisibles,
  définitivement et sans recours. Ce n'est pas un réglage, c'est la serrure.

Les secrets se posent, ne se relisent pas
  La règle existait déjà pour les identifiants de passerelle — « administrer
  une instance n'est pas lire les secrets de ses clients ». Une page de
  configuration est précisément l'endroit où on l'oublie, parce qu'afficher la
  valeur qu'on vient de saisir paraît serviable.

  Elle est portée par la forme : le type que reçoit la page n'a pas de champ
  où faire remonter un secret. Chiffrés au repos, contexte distinct de celui
  des organisations, parce qu'un secret sert à une chose et à une seule.

  Le journal retient qu'un réglage a bougé, lequel et par qui — l'ancienne et
  la nouvelle valeur pour les réglages ordinaires, jamais pour un secret.

Retirer rend la main
  Une surcharge se retire, et la valeur du fichier reprend. C'est la sortie de
  secours : on revient d'une mauvaise saisie sans accès à la machine, ce qui
  est tout l'intérêt d'avoir la page.

Contrôlé — 23 garanties
  Que les huit variables interdites le soient, et que les poser soit refusé
  plutôt qu'ignoré. Qu'une surcharge agisse sans redémarrage — le transport
  devient « smtp » sous le contrôle. Qu'un secret n'apparaisse ni dans la
  réponse de l'API, ni en clair dans la base, ni au journal. Que retirer rende
  la main à l'environnement. Que les saisies absurdes soient refusées.

Deux défauts trouvés en écrivant ces contrôles, tous deux de la même famille :
poser un réglage reconstruit les adaptateurs, et la suite de vérification
tourne sur des doublures — une boîte qui intercepte les courriels, un antivirus
d'essai. Les remplacer par un vrai relais et un vrai ClamAV faisait échouer,
cent contrôles plus loin, des garanties sans rapport. La vérification rend
désormais chaque adaptateur tel qu'elle l'a trouvé.

Un troisième, dans l'ordre du démarrage : appliquer les surcharges après le
refus de démarrer sans courriel rendait la page inutile à qui n'a pas déjà
écrit son .env — c'est-à-dire à qui en a le plus besoin. Elles s'appliquent
avant, pour satisfaire ce contrôle et non le contourner.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent e6e26275
Loading
Loading
Loading
Loading
Loading
+218 −0
Changes for apps/api/src/administration/reglages.ts: 218 added lines, 0 removed lines.
Original line number Diff line number Diff line
import {
  REGLAGES, definitionReglage, reglageSurchargeable,
  type ReglageVu,
} from "@kastell/shared";
import { antivirusClamav, definirAntivirus } from "../adaptateurs/antivirus.js";
import { definirMedia, mediaLiveKit } from "../adaptateurs/media.js";
import { definirTransport, transportConsole } from "../adaptateurs/notify.js";
import { definirPoussee, pousseeRelais } from "../adaptateurs/poussee.js";
import { transportSmtp } from "../adaptateurs/smtp.js";
import type { Sql, Tx } from "../db/client.js";
import type { Env } from "../env.js";
import { journaliser } from "../noyau/administration.js";
import { ouvrir, sceller } from "../noyau/secrets.js";

/**
 * Surcharge des variables d'environnement depuis la console root.
 *
 * L'environnement reste la source : cette table ne fait que le recouvrir, et
 * retirer une surcharge rend la main au .env. C'est ce qui permet de sortir
 * d'une mauvaise saisie sans accès à la machine.
 *
 * Ce qui est surchargeable est décidé ailleurs — packages/shared/src/reglages.ts
 * — et cette liste est étroite à dessein. Ici, on ne fait que l'appliquer.
 */

/** Le contexte est distinct de celui des secrets d'organisation : un secret sert à une chose. */
const CONTEXTE_INSTANCE = "kastell:reglages-instance:v1";

interface LigneReglage {
  cle: string;
  valeur: string | null;
  valeur_scellee: Buffer | null;
  modifie_le: Date;
}

async function lignes(sql: Sql | Tx): Promise<LigneReglage[]> {
  return sql<LigneReglage[]>`
    select cle, valeur, valeur_scellee, modifie_le from reglage_instance`;
}

/**
 * La configuration qui sert réellement : l'environnement, recouvert.
 *
 * Rendue sous forme de dictionnaire plutôt que d'objet Env : elle ne remplace
 * pas le schéma, elle en dérive les seules clés que la console peut toucher.
 */
export async function configurationEffective(
  sql: Sql | Tx, env: Env,
): Promise<Record<string, string | undefined>> {
  const effectif: Record<string, string | undefined> = {};
  for (const r of REGLAGES) {
    const depuisEnv = (env as unknown as Record<string, unknown>)[r.cle];
    effectif[r.cle] = depuisEnv === undefined || depuisEnv === null
      ? undefined : String(depuisEnv);
  }
  for (const l of await lignes(sql)) {
    if (!reglageSurchargeable(l.cle)) continue;
    effectif[l.cle] = l.valeur_scellee !== null
      // Le secret d'instance déchiffre : sans lui, la surcharge est perdue, ce
      // qui est le comportement voulu — elle ne survit pas à sa serrure.
      ? ouvrir(env.SESSION_SECRET + CONTEXTE_INSTANCE, l.valeur_scellee)
      : l.valeur ?? "";
  }
  return effectif;
}

/**
 * Ce que la page de configuration montre.
 *
 * Un secret n'y a pas de valeur : il a un état. Le type ReglageVu ne porte
 * aucun champ où en faire remonter un — la garantie tient à la forme, pas à
 * l'attention de l'appelant.
 */
export async function listerReglages(sql: Sql, env: Env): Promise<ReglageVu[]> {
  const surcharges = new Map(
    (await lignes(sql)).map((l) => [l.cle, l]),
  );
  const effectif = await configurationEffective(sql, env);

  return REGLAGES.map((r) => {
    const surcharge = surcharges.get(r.cle);
    const valeur = effectif[r.cle];
    const renseigne = valeur !== undefined && valeur !== "";
    const vu: ReglageVu = {
      cle: r.cle, genre: r.genre, rubrique: r.rubrique, role: r.role,
      renseigne,
      source: surcharge ? "console" : renseigne ? "environnement" : "aucune",
    };
    if (r.genre !== "secret") vu.valeur = valeur ?? "";
    if (surcharge) vu.modifieLe = surcharge.modifie_le.toISOString();
    return vu;
  });
}

/**
 * Reconstruit les adaptateurs à partir de la configuration effective.
 *
 * Appelée au démarrage et après chaque modification : c'est ce qui rend la
 * surcharge utile sans redémarrage. Les adaptateurs concernés ont tous un
 * setter, et c'est précisément le critère qui a décidé de la liste.
 */
export async function appliquerReglages(sql: Sql, env: Env): Promise<void> {
  const c = await configurationEffective(sql, env);

  if (c.CLAMAV_HOST) {
    definirAntivirus(antivirusClamav(c.CLAMAV_HOST, Number(c.CLAMAV_PORT ?? 3310)));
  }

  if (c.KASTELL_PUSH_RELAY && c.KASTELL_RELAY_KEY) {
    definirPoussee(pousseeRelais(c.KASTELL_PUSH_RELAY, c.KASTELL_RELAY_KEY));
  }

  if (c.LIVEKIT_URL && c.LIVEKIT_KEY && c.LIVEKIT_SECRET) {
    definirMedia(mediaLiveKit({
      url: c.LIVEKIT_URL, urlInterne: c.LIVEKIT_INTERNAL_URL,
      cle: c.LIVEKIT_KEY, secret: c.LIVEKIT_SECRET,
      stockage: {
        endpoint: env.S3_ENDPOINT, region: env.S3_REGION, seau: env.S3_BUCKET ?? "kastell",
        acces: env.S3_ACCESS ?? "", secret: env.S3_SECRET ?? "",
      },
    }));
  }

  if (c.SMTP_HOST && c.SMTP_FROM) {
    definirTransport(transportSmtp({
      hote: c.SMTP_HOST,
      port: Number(c.SMTP_PORT ?? 587),
      implicite: c.SMTP_SECURE === "true",
      utilisateur: c.SMTP_USER || undefined,
      motDePasse: c.SMTP_PASSWORD || undefined,
      expediteur: c.SMTP_FROM,
    }));
  } else if (env.KASTELL_ENV !== "production") {
    // Retirer la surcharge d'un relais doit rendre la main au transport de
    // développement, et non laisser en place celui qu'on vient d'effacer.
    definirTransport(transportConsole);
  }
}

export class ReglageRefuse extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ReglageRefuse";
  }
}

function valider(cle: string, valeur: string): void {
  const d = definitionReglage(cle);
  if (!d) throw new ReglageRefuse(`${cle} n'est pas surchargeable depuis la console.`);
  if (d.genre === "entier" && valeur !== "" && !/^\d+$/.test(valeur)) {
    throw new ReglageRefuse(`${cle} attend un nombre entier.`);
  }
  if (d.genre === "booleen" && valeur !== "true" && valeur !== "false") {
    throw new ReglageRefuse(`${cle} attend « true » ou « false ».`);
  }
  // Une adresse publique cassée coupe les liens de connexion, donc l'accès à
  // l'instance. Le refus vaut mieux que la reprise par la machine.
  if ((cle === "KASTELL_PUBLIC_URL" || cle === "KASTELL_SOURCE_URL")
      && !/^https?:\/\/.+/.test(valeur)) {
    throw new ReglageRefuse(`${cle} attend une adresse complète, avec http:// ou https://.`);
  }
}

export async function poserReglage(
  sql: Sql, env: Env, cle: string, valeur: string, parCompteId: string,
): Promise<void> {
  valider(cle, valeur);
  const d = definitionReglage(cle)!;
  const avant = (await configurationEffective(sql, env))[cle] ?? "";

  await sql.begin(async (tx) => {
    if (d.genre === "secret") {
      const scelle = sceller(env.SESSION_SECRET + CONTEXTE_INSTANCE, valeur);
      await tx`
        insert into reglage_instance (cle, valeur, valeur_scellee, modifie_par)
        values (${cle}, null, ${scelle}, ${parCompteId})
        on conflict (cle) do update
          set valeur = null, valeur_scellee = excluded.valeur_scellee,
              modifie_le = now(), modifie_par = excluded.modifie_par`;
    } else {
      await tx`
        insert into reglage_instance (cle, valeur, valeur_scellee, modifie_par)
        values (${cle}, ${valeur}, null, ${parCompteId})
        on conflict (cle) do update
          set valeur = excluded.valeur, valeur_scellee = null,
              modifie_le = now(), modifie_par = excluded.modifie_par`;
    }

    // Le journal retient qu'un réglage a bougé, par qui — jamais un secret.
    await journaliser(tx, {
      type: "instance.reglage_pose",
      acteurId: parCompteId,
      charge: d.genre === "secret"
        ? { cle, secret: true, renseigne: valeur !== "" }
        : { cle, secret: false, de: avant, vers: valeur },
    });
  });

  await appliquerReglages(sql, env);
}

export async function retirerReglage(
  sql: Sql, env: Env, cle: string, parCompteId: string,
): Promise<void> {
  const d = definitionReglage(cle);
  if (!d) throw new ReglageRefuse(`${cle} n'est pas surchargeable depuis la console.`);

  await sql.begin(async (tx) => {
    await tx`delete from reglage_instance where cle = ${cle}`;
    await journaliser(tx, {
      type: "instance.reglage_retire",
      acteurId: parCompteId,
      charge: { cle, secret: d.genre === "secret" },
    });
  });

  await appliquerReglages(sql, env);
}
+26 −0
Changes for apps/api/src/db/migrations/0027_reglages.sql: 26 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Réglages d'instance posés depuis la console root (EX-26).
--
-- L'environnement reste la source : cette table ne fait que le surcharger, et
-- une ligne retirée rend la main au .env. C'est ce qui permet de sortir d'une
-- mauvaise saisie sans accès à la machine — supprimer la surcharge, et la
-- valeur d'origine revient.
--
-- Deux colonnes de valeur, dont exactement une est renseignée. Un secret ne
-- s'écrit jamais en clair, et un réglage ordinaire ne se chiffre pas : le
-- chiffrer le rendrait illisible à qui inspecte la base pour comprendre une
-- panne, sans rien protéger que tout le monde ne puisse déjà lire.
create table reglage_instance (
  cle            text        primary key,
  valeur         text,
  valeur_scellee bytea,
  modifie_le     timestamptz not null default now(),
  modifie_par    uuid        references compte(id) on delete set null,

  constraint reglage_une_seule_forme check (
    (valeur is not null and valeur_scellee is null)
    or (valeur is null and valeur_scellee is not null)
  )
);

comment on table reglage_instance is
  'Surcharge des variables d''environnement, posée par un root. Étroite par construction : voir packages/shared/src/reglages.ts.';
+9 −0
Changes for apps/api/src/index.ts: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@ import { definirMedia, media, mediaConfigure, mediaLiveKit } from "./adaptateurs
import { definirPoussee, pousseeRelais } from "./adaptateurs/poussee.js";
import { definirTransport, transportActuel } from "./adaptateurs/notify.js";
import { transportSmtp } from "./adaptateurs/smtp.js";
import { appliquerReglages } from "./administration/reglages.js";
import { autoriserReseauLocal } from "./adaptateurs/sortant.js";
import { demarrerEntretien } from "./entretien.js";
import { construireServeur } from "./serveur.js";
@@ -84,6 +85,14 @@ if (env.SMTP_HOST) {
  }));
}

// Les surcharges posées depuis la console recouvrent l'environnement et
// reconstruisent les adaptateurs qu'elles touchent. Avant le contrôle qui
// suit, et non après : un relais SMTP renseigné depuis la console doit
// SATISFAIRE le refus de démarrer sans courriel. L'ordre inverse rendrait la
// page inutile à qui n'a pas déjà écrit le .env — c'est-à-dire à qui en a le
// plus besoin.
await appliquerReglages(sql, env);

// Le transport « console » écrit le corps des courriels dans les traces. C'est
// ce qui permet de dérouler tout le parcours sans serveur de messagerie — et
// c'est aussi, en production, un lien de connexion valide offert à quiconque
+39 −0
Changes for apps/api/src/routes/administration.ts: 39 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -6,6 +6,7 @@ import * as etat from "../administration/etat.js";
import * as diagnostic from "../administration/diagnostic.js";
import { roleDans } from "../auth/comptes.js";
import * as sessions from "../auth/sessions.js";
import * as reglages from "../administration/reglages.js";
import { ouvrirAccesExceptionnel } from "../noyau/administration.js";
import type { Env } from "../env.js";

@@ -97,6 +98,44 @@ export function enregistrerRoutesAdministration(app: FastifyInstance, sql: Sql,
    return reply.send({ message: "Droit root retiré." });
  });

  // ── Configuration de l'instance (EX-26) ──────────────────────────────────
  //
  // La surcharge est étroite par construction : la liste des clés vit dans
  // packages/shared/src/reglages.ts, et une clé hors de cette liste est
  // refusée ici comme ailleurs. Un secret ne ressort jamais — le type qui
  // décrit la réponse ne porte aucun champ où en faire remonter un.

  app.get("/administration/reglages", console_root, async () => ({
    reglages: await reglages.listerReglages(sql, env),
  }));

  app.put("/administration/reglages/:cle", console_root, async (req, reply) => {
    const { cle } = z.object({ cle: z.string() }).parse(req.params);
    const { valeur } = z.object({ valeur: z.string() }).parse(req.body);
    try {
      await reglages.poserReglage(sql, env, cle, valeur, moi(req));
    } catch (e) {
      if (e instanceof reglages.ReglageRefuse) {
        return reply.code(400).send({ code: "reglage_refuse", message: e.message });
      }
      throw e;
    }
    return reply.send({ message: "Réglage enregistré." });
  });

  app.delete("/administration/reglages/:cle", console_root, async (req, reply) => {
    const { cle } = z.object({ cle: z.string() }).parse(req.params);
    try {
      await reglages.retirerReglage(sql, env, cle, moi(req));
    } catch (e) {
      if (e instanceof reglages.ReglageRefuse) {
        return reply.code(400).send({ code: "reglage_refuse", message: e.message });
      }
      throw e;
    }
    return reply.send({ message: "Surcharge retirée — la valeur du fichier reprend." });
  });

  // ── Organisations (EA-04, EA-06) ─────────────────────────────────────────
  app.get("/administration/organisations", console_root, async (req) => {
    const q = z.object({
+2 −0
Changes for apps/api/src/verif.ts: 2 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -29,6 +29,7 @@ import { verifierVeille } from "./verification/veille.js";
import { interceptet } from "./verification/boite.js";
import { verifierPreparation } from "./verification/preparation.js";
import { verifierTriDossier } from "./verification/tri.js";
import { verifierReglages } from "./verification/reglages.js";
import { verifierConduite } from "./verification/conduite.js";
import { verifierChat } from "./verification/chat.js";
import { verifierDocuments } from "./verification/documents.js";
@@ -332,6 +333,7 @@ await verifierApplicationMobile(rapport);
// ── Le relais de heurtoir (EM-82 à EM-85) ─────────────────────────────────
await verifierRelais(rapport);
await verifierCourriel(rapport);
await verifierReglages(sql, env, rapport);

// ── L'image et la composition (EX-27) ─────────────────────────────────────
await verifierComposition(rapport);
Loading