Commit 54e0eeeb authored by Kourser's avatar Kourser
Browse files

Inscription modérée : prévenir les administrateurs d'instance

Une demande déposée en mode modéré n'avertissait personne. Elle attendait
qu'un administrateur pense à ouvrir sa console, et le demandeur restait
sans réponse aussi longtemps que personne n'allait regarder — la
modération, faute d'être vue, revenait à un refus silencieux.

Le courriel porte de quoi décider sans ouvrir autre chose : organisation,
contact, message du demandeur, et l'adresse de la console.

UN COURRIEL PAR DESTINATAIRE, et non un envoi groupé : les
administrateurs d'une instance n'ont pas à découvrir la liste des autres
dans un en-tête. Une adresse listée deux fois n'en reçoit qu'un.

L'ÉCHEC D'ENVOI NE REMONTE PAS, et c'est délibéré : la demande du
visiteur est déjà enregistrée quand l'avis part. La perdre parce qu'un
serveur SMTP ne répond pas serait le pire des deux maux. Sans envoi
configuré, le service journalise ce qu'il aurait envoyé — vérifié sur
l'instance, sans SMTP : la demande est acceptée et l'avis tracé.

Le libre-service n'envoie rien : il n'y a rien à modérer.

Six essais couvrent ce qui compte ici — qui est prévenu, combien de fois,
et qu'une demande survit à l'absence de destinataire.

SECURITY.md dit ce que cet avis transporte : le nom, l'adresse et le
message du demandeur, soit exactement ce que la console montre déjà à qui
le reçoit. Le journal d'audit, lui, continue de ne rien consigner de
nominatif.

Au passage, le README des scripts affirmait que la recette ne laissait
derrière elle que le second facteur. Elle laisse aussi les demandes
qu'elle dépose puis refuse : la console n'offre pas de geste pour effacer
une décision, et la purge des demandes traitées s'en charge après 90
jours.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent fff82bab
Loading
Loading
Loading
Loading
+6 −0
Changes for SECURITY.md: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -78,6 +78,12 @@ même socle ; passer de l'une à l'autre ne fait que retirer l'étape d'approbat
  d'activation. Sans SMTP, le comportement historique (mot de passe temporaire affiché une
  fois) subsiste — seul possible dans un déploiement fermé, et la réponse dit lequel des deux
  a été appliqué.
- **L'avis de modération porte des données personnelles**, et c'est assumé : le courriel
  envoyé aux administrateurs d'instance cite le nom, l'adresse et le message du demandeur —
  exactement ce qu'ils voient dans leur console, et ce sans quoi ils ne peuvent pas décider.
  Il part **une fois par destinataire**, jamais en envoi groupé : les administrateurs n'ont
  pas à découvrir la liste des autres dans un en-tête. Le journal d'audit, lui, ne consigne
  que le fait qu'une demande a été déposée, sans rien de nominatif.
- **Routes publiques plafonnées** : 3 inscriptions/h, 5 demandes de réinitialisation/h,
  10 consommations de lien/10 min, par IP. Le compteur vit en mémoire du processus : c'est un
  garde-fou contre l'abus ordinaire, pas contre une attaque distribuée — celle-ci se traite au
+102 −0
Changes for apps/api/src/account/signup-notification.spec.ts: 102 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { SignupService } from './signup.service';
import { PrismaService } from '../prisma/prisma.service';

/**
 * Avertissement des administrateurs d'instance en mode modere.
 *
 * Ce qui est eprouve ici n'est pas l'envoi lui-meme — le service de courriel a
 * ses propres essais — mais QUI est prevenu, COMBIEN de fois, et ce qu'une
 * demande deposee laisse derriere elle quand personne ne peut etre prevenu.
 */
describe('SignupService — avertissement de modération', () => {
  const DEMANDE = {
    organisationName: 'Mairie de Saint-Aubin',
    contactName: 'Camille Doriot',
    contactEmail: 'camille@saint-aubin.fr',
    message: 'Nous préparons un exercice communal.',
  };

  function build(administrateurs: string[]) {
    const cree = { ...DEMANDE, message: DEMANDE.message };
    const prisma = {
      instanceSettings: { findUnique: jest.fn().mockResolvedValue({ signupMode: 'MODERATED' }) },
      tenant: { findUnique: jest.fn().mockResolvedValue(null) },
      signupRequest: {
        findFirst: jest.fn().mockResolvedValue(null),
        create: jest.fn().mockResolvedValue(cree),
      },
      membership: {
        findMany: jest.fn().mockResolvedValue(administrateurs.map((email) => ({ user: { email } }))),
      },
    } as unknown as PrismaService;
    const audit = { record: jest.fn() };
    const mailer = { send: jest.fn().mockResolvedValue(undefined) };
    const appUrl = { base: jest.fn().mockResolvedValue('https://crise.exemple.fr') };
    const service = new SignupService(
      prisma,
      audit as never,
      {} as never,
      mailer as never,
      appUrl as never,
    );
    return { service, prisma, audit, mailer };
  }

  it('écrit à chaque administrateur d’instance, une fois chacun', async () => {
    const { service, mailer } = build(['root@exemple.fr', 'second@exemple.fr']);
    await service.submit({ ...DEMANDE } as never);

    expect(mailer.send).toHaveBeenCalledTimes(2);
    expect(mailer.send.mock.calls.map(([m]) => m.to)).toEqual([
      'root@exemple.fr',
      'second@exemple.fr',
    ]);
  });

  it('envoie un courriel PAR destinataire, sans les exposer les uns aux autres', async () => {
    const { service, mailer } = build(['root@exemple.fr', 'second@exemple.fr']);
    await service.submit({ ...DEMANDE } as never);

    for (const [message] of mailer.send.mock.calls) {
      // Le corps ne doit citer aucune autre adresse d'administrateur.
      expect(message.body).not.toContain('second@exemple.fr');
      expect(message.body).not.toContain('root@exemple.fr');
    }
  });

  it('ne compte qu’une fois un administrateur listé deux fois', async () => {
    const { service, mailer } = build(['root@exemple.fr', 'root@exemple.fr']);
    await service.submit({ ...DEMANDE } as never);
    expect(mailer.send).toHaveBeenCalledTimes(1);
  });

  it('porte de quoi décider : organisation, contact, message et adresse de la console', async () => {
    const { service, mailer } = build(['root@exemple.fr']);
    await service.submit({ ...DEMANDE } as never);

    const [message] = mailer.send.mock.calls[0];
    expect(message.subject).toContain('Mairie de Saint-Aubin');
    expect(message.body).toContain('Camille Doriot');
    expect(message.body).toContain('camille@saint-aubin.fr');
    expect(message.body).toContain('Nous préparons un exercice communal.');
    expect(message.body).toContain('https://crise.exemple.fr');
  });

  it('enregistre la demande même si aucun administrateur n’est joignable', async () => {
    const { service, prisma, mailer, audit } = build([]);
    await expect(service.submit({ ...DEMANDE } as never)).resolves.toEqual({ status: 'PENDING' });

    expect(prisma.signupRequest.create).toHaveBeenCalled();
    expect(audit.record).toHaveBeenCalledWith('signup.request');
    expect(mailer.send).not.toHaveBeenCalled();
  });

  it('n’avertit personne quand les inscriptions sont en libre-service', async () => {
    const { service, prisma, mailer } = build(['root@exemple.fr']);
    (prisma.instanceSettings.findUnique as jest.Mock).mockResolvedValue({ signupMode: 'OPEN' });
    // Le libre-service passe par `provision`, hors du champ de cet essai : seul
    // compte ici qu'AUCUN avertissement de modération ne parte.
    await service.submit({ ...DEMANDE } as never).catch(() => undefined);
    expect(mailer.send).not.toHaveBeenCalled();
  });
});
+56 −1
Changes for apps/api/src/account/signup.service.ts: 56 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -12,6 +12,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../common/audit.service';
import { deriveSlug } from '../common/slug';
import { MailerService } from '../platform-mail/mailer.service';
import { AppUrlService } from '../common/app-url.service';
import { AccountService } from './account.service';
import type { SignupDto } from './dto/signup.dto';

@@ -32,6 +33,7 @@ export class SignupService {
    private readonly audit: AuditService,
    private readonly account: AccountService,
    private readonly mailer: MailerService,
    private readonly appUrl: AppUrlService,
  ) {}

  async mode(): Promise<SignupMode> {
@@ -81,7 +83,7 @@ export class SignupService {
      if (pending) {
        throw new ConflictException('Une demande est déjà en cours pour cette adresse');
      }
      await this.prisma.signupRequest.create({
      const demande = await this.prisma.signupRequest.create({
        data: {
          organisationName: dto.organisationName.trim(),
          slug,
@@ -92,6 +94,7 @@ export class SignupService {
      });
      // Aucune donnee personnelle dans le journal : seul le fait est consigne.
      this.audit.record('signup.request');
      await this.previenirLesAdministrateurs(demande);
      return { status: 'PENDING' };
    }

@@ -100,6 +103,58 @@ export class SignupService {
    return { status: 'CREATED' };
  }

  /**
   * Previent les administrateurs d'instance qu'une demande attend leur decision.
   *
   * POURQUOI CELA EXISTE. Une demande deposee en mode modere n'avertissait
   * personne : elle attendait qu'un administrateur pense a ouvrir sa console.
   * Un demandeur restait donc sans reponse aussi longtemps que personne
   * n'allait regarder — et la moderation, faute d'etre vue, revenait a un refus
   * silencieux.
   *
   * UN COURRIEL PAR DESTINATAIRE, et non un envoi groupe : les administrateurs
   * d'une instance n'ont pas a decouvrir la liste des autres dans un en-tete.
   *
   * L'echec d'envoi ne remonte pas. `send` ne propage jamais, et c'est voulu
   * ici : la demande du visiteur est deja enregistree, la perdre parce qu'un
   * serveur SMTP ne repond pas serait le pire des deux maux. Sans envoi
   * configure, le service journalise ce qu'il aurait envoye.
   */
  private async previenirLesAdministrateurs(demande: {
    organisationName: string;
    contactName: string;
    contactEmail: string;
    message: string | null;
  }): Promise<void> {
    const membres = await this.prisma.membership.findMany({
      where: { role: 'INSTANCE_ADMIN' },
      select: { user: { select: { email: true } } },
    });
    const adresses = [...new Set(membres.map((m) => m.user.email))];
    if (adresses.length === 0) return;

    const base = await this.appUrl.base();
    const corps = [
      'Une demande d’inscription attend une décision.',
      '',
      `  Organisation : ${demande.organisationName}`,
      `  Contact      : ${demande.contactName} <${demande.contactEmail}>`,
      ...(demande.message ? ['', 'Message du demandeur :', demande.message] : []),
      '',
      `Approuver ou refuser depuis la console d’instance : ${base}`,
      '',
      'Tant qu’aucune décision n’est prise, le demandeur n’a pas d’accès.',
    ].join('\n');

    for (const to of adresses) {
      await this.mailer.send({
        to,
        subject: `Enkadenn — demande d’inscription : ${demande.organisationName}`,
        body: corps,
      });
    }
  }

  /** Slug derive du nom, garanti libre au moment de l'appel. */
  private slugLibre(organisationName: string): Promise<string> {
    return deriveSlug(
+8 −0
Changes for docs-site/docs/administration-instance.md: 8 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -35,6 +35,14 @@ Sans serveur de courriel, les **invitations sont impossibles** et le formulaire

Trois modes : **fermée** (défaut), **modérée** (une demande est déposée, l'administrateur l'approuve ou la refuse avec un motif obligatoire), ou **libre-service**. Le visiteur ne saisit que le nom de son organisation : l'identifiant public en est déduit automatiquement.

En mode **modéré**, chaque demande déposée **avertit les administrateurs d'instance par courriel** : organisation, contact, message du demandeur, et l'adresse de la console. Sans cet avis, une demande attendait qu'un administrateur pense à ouvrir sa console — et la modération, faute d'être vue, revenait à un refus silencieux.

:::note
Un courriel **par destinataire**, jamais un envoi groupé : les administrateurs d'une instance n'ont pas à découvrir la liste des autres dans un en-tête.

Sans serveur de courriel configuré, la demande est enregistrée quand même et l'avis est seulement journalisé : perdre la demande d'un visiteur parce qu'un serveur SMTP ne répond pas serait le pire des deux maux.
:::

## Organisations

Créer, régler les plafonds d'usage (exercices, membres, stockage, rétention propre), **suspendre** et **supprimer**.
+10 −0
Changes for docs-site/docs/nouveautes.md: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -10,6 +10,16 @@ description: Journal daté des évolutions d'Enkadenn.
Ce qui a changé, du plus récent au plus ancien. Les correctifs d'affichage mineurs
ne sont pas tous listés ; les changements visibles pour un animateur ou un joueur le sont.

## 3 septembre 2026 — Les demandes d'inscription se signalent

- **Mode modéré : les administrateurs d'instance sont prévenus par courriel** dès qu'une
  demande est déposée. Elle attendait jusqu'ici qu'un administrateur pense à ouvrir sa
  console — et la modération, faute d'être vue, revenait à un refus silencieux.
- L'avis porte de quoi décider sans ouvrir autre chose : organisation, contact, message du
  demandeur, et l'adresse de la console.
- Un courriel **par destinataire**, jamais un envoi groupé. Sans serveur de courriel, la
  demande est enregistrée quand même : seul l'avis manque.

## 2 septembre 2026 — Le bac à sable se joue à plusieurs

- **Une séance de découverte se partage.** La création rend désormais une **adresse** à
Loading