Commit 0d6b03e2 authored by Kourser's avatar Kourser
Browse files

Ouverture au public : inscription, liens par courriel, invitations

Étape 4 du socle multi-organisations. L'inscription publique existe, fermée par
défaut, ouvrable en modérée ou en libre-service. Les deux formes partagent le même
socle — passer de l'une à l'autre ne fait que retirer l'étape d'approbation.

Le point dur était annoncé : le produit n'envoyait AUCUN courriel, et c'est une
promesse écrite dans SECURITY.md. La sortie n'est pas d'y renoncer mais de scinder
la promesse en deux. `platform-mail` envoie les messages qui font vivre les
comptes ; il n'est jamais appelé depuis le moteur d'exercice — vérifiable par grep,
et c'est désormais documenté comme un invariant — et il reste inerte sans SMTP
configuré, journalisant ce qu'il aurait envoyé sans aucun appel réseau. Le moteur
d'exercice reste hermétique ; la plateforme qui l'héberge ne peut plus l'être.

Un seul mécanisme pour trois usages (activation, mot de passe oublié, invitation) :
un jeton de 32 octets dont seule l'empreinte SHA-256 est stockée, comme les liens
d'accès joueur. La primitive est maintenant partagée — `common/one-time-token` —
plutôt que dupliquée.

Ce que je me suis interdit :

- Aucun oracle d'énumération. « Mot de passe oublié » répond pareil pour une
  adresse connue et inconnue ; un lien invalide, expiré ou déjà consommé donne le
  même message. Distinguer ces cas permettrait de tester l'existence des comptes.
- Consommer un lien invalide tous les autres liens d'activation et de
  réinitialisation en cours pour cette adresse. Un lien encore valide après un
  changement de mot de passe serait une porte laissée ouverte.
- `passwordHash` d'un compte en attente d'activation est le hash bcrypt d'un
  secret jeté, et non une valeur bidon : `compare` doit pouvoir le lire pour
  répondre « faux » proprement plutôt que lever.

Le mot de passe ne transite plus en clair quand le courriel est configuré — c'était
le trou que j'avais signalé. Sans SMTP, le comportement historique subsiste, et la
réponse dit lequel des deux a été appliqué.

Limitation de débit sans nouvelle dépendance : fenêtre glissante, module pur testé
(une fenêtre fixe autorise deux fois le plafond à cheval sur sa frontière). Le
compteur vit en mémoire du processus — garde-fou contre l'abus ordinaire, pas
contre une attaque distribuée ; c'est écrit dans le code et dans SECURITY.md.

Les invitations ferment la porte laissée ouverte à l'étape 1 : un admin
d'organisation peut enfin ajouter quelqu'un qui a un compte ailleurs, mais par
consentement — l'intéressé accepte lui-même.

nodemailer 6.9.16 (MIT) est la seule dépendance ajoutée, épinglée et inscrite dans
DEPENDENCIES.md avec sa licence, sa maintenance, et le fait qu'elle est la seule
capable d'émettre du trafic sortant.

À FAIRE côté exploitant : `.env.example` doit recevoir SMTP_HOST, MAIL_FROM,
SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASSWORD. Je n'ai pas pu l'éditer, ce
fichier étant hors de mes permissions ; les variables sont documentées dans
SECURITY.md.

Non vérifié : aucune chaîne Node dans cet environnement, donc rien n'a été compilé
ni testé, et `pnpm install` reste à lancer pour nodemailer. Contrôles manuels :
équilibre des délimiteurs, absence de cycle entre modules Nest, 644 clés i18n sans
doublon, classes CSS présentes, tables hors cloisonnement bien absentes de
TENANT_MODELS, aucun appel du mailer depuis le moteur.

Co-Authored-By: default avatarClaude (RCA) <noreply@anthropic.com>
parent f7da4b07
Loading
Loading
Loading
Loading
+7 −1
Original line number Diff line number Diff line
@@ -2,7 +2,11 @@

Conformément à la politique interne, toute dépendance tierce est documentée avec sa **licence** et une **évaluation de son niveau de maintenance**. Les versions sont **épinglées** (aucune version flottante) dans les `package.json` ; le fichier `pnpm-lock.yaml` fait foi.

_Dernière mise à jour : 2026-07-20 (lot 0)._
_Dernière mise à jour : 2026-08-20 (socle multi-organisations)._

> **nodemailer** est la seule dépendance capable d'émettre du trafic réseau sortant. Elle est
> confinée au module `platform-mail`, qui n'est **jamais** appelé depuis le moteur d'exercice, et
> reste **inerte** sans configuration SMTP (cf. `SECURITY.md`, « Confinement »).

## Dépendances applicatives (runtime)

@@ -17,6 +21,7 @@ _Dernière mise à jour : 2026-07-20 (lot 0)._
| class-validator | 0.14.1 | MIT | Active (validation des DTO) |
| class-transformer | 0.5.1 | MIT | Active (requis par le ValidationPipe) |
| cookie-parser | 1.4.7 | MIT | Stable (lecture du cookie de session) |
| nodemailer | 6.9.16 | MIT | Très active (référence de l'envoi SMTP en Node, ~20 M téléchargements/semaine, sans dépendance tierce) |
| reflect-metadata | 0.2.2 | Apache-2.0 | Stable (requis par NestJS) |
| rxjs | 7.8.1 | Apache-2.0 | Très active |
| react, react-dom | 18.3.1 | MIT | Très active (Meta) |
@@ -38,6 +43,7 @@ _Dernière mise à jour : 2026-07-20 (lot 0)._
| ts-node | 10.9.2 | MIT | Stable |
| @nestjs/cli, /schematics, /testing | 10.x | MIT | Active |
| @types/bcryptjs | 2.4.6 | MIT | Types |
| @types/nodemailer | 6.4.17 | MIT | Types |
| @types/cookie-parser | 1.4.8 | MIT | Types |
| tsconfig-paths | 4.2.0 | MIT | Stable |

+55 −1
Original line number Diff line number Diff line
@@ -7,6 +7,13 @@ Cythin est un outil d'**entraînement**. L'environnement de simulation est **her
- Aucun email, SMS, message ou publication n'est réellement envoyé à l'extérieur. « Envoyer »
  signifie écrire en base de données et diffuser en temps réel **à l'intérieur de l'exercice**.
- Aucun module de simulation n'effectue d'appel réseau sortant vers un service tiers.
- **Une seule exception, hors simulation** : le module `platform-mail` envoie les courriels
  qui font vivre les *comptes* — activation, mot de passe oublié, invitation. Il n'est jamais
  appelé depuis le moteur d'exercice (vérifiable : aucun `import` de `platform-mail` sous
  `engine/` ni `exercises/`), et il reste **inerte** tant que `SMTP_HOST` et `MAIL_FROM` ne
  sont pas renseignés — il journalise alors ce qu'il aurait envoyé, sans aucun appel réseau.
  Deux couches, deux promesses : le moteur d'exercice reste hermétique, la plateforme qui
  l'héberge ne peut plus l'être dès lors qu'elle est publique.
- Les adresses, comptes, domaines et documents sont **fictifs** (ex. `@meridien.exercice`).

## Cloisonnement multi-tenant
@@ -16,6 +23,42 @@ entités métier et **injecté automatiquement** dans chaque requête via une ex
(`apps/api/src/prisma/tenant-scope.ts`), pour qu'aucun code ne puisse lire ou écrire les données
d'un autre tenant. Cet invariant est couvert par des tests unitaires.

## Inscription publique et liens envoyés par courriel

L'inscription est **fermée par défaut**. L'administrateur de l'instance l'ouvre en mode
*modérée* (une demande est déposée, il l'approuve) ou *libre-service*. Les deux partagent le
même socle ; passer de l'une à l'autre ne fait que retirer l'étape d'approbation.

- **Un seul mécanisme pour trois usages** — activation, mot de passe oublié, invitation :
  un jeton de 32 octets aléatoires dont **seule l'empreinte SHA-256 est stockée**, comme les
  liens d'accès joueur. La valeur en clair n'existe que dans le lien envoyé, et n'est jamais
  journalisée.
- **Usage unique, et durée adaptée à l'usage** : 2 h pour un mot de passe oublié, 7 jours pour
  une activation, 14 pour une invitation. Consommer un lien invalide **tous** les autres liens
  d'activation et de réinitialisation en cours pour cette adresse — un lien encore valide
  serait une porte laissée ouverte.
- **Aucun oracle d'énumération** : « mot de passe oublié » répond la même chose pour une
  adresse connue et inconnue, et un lien invalide, expiré ou déjà utilisé donne un message
  identique. Distinguer ces cas permettrait de tester l'existence des comptes.
- **Le mot de passe ne transite plus en clair** quand le courriel est configuré : la
  réinitialisation par un administrateur envoie un lien, et un compte créé s'ouvre par un lien
  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é.
- **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
  niveau du reverse proxy. Derrière un proxy, `trust proxy` doit être actif pour que la clé
  soit l'IP du client et non celle du proxy.
- **Les invitations sont consenties** : l'intéressé accepte lui-même. C'est la seule voie par
  laquelle un administrateur d'organisation peut ajouter quelqu'un qui a déjà un compte
  ailleurs ; la création directe le refuse, précisément pour qu'on ne rattache personne sans
  son accord.

Variables d'environnement à renseigner pour activer l'envoi : `SMTP_HOST`, `MAIL_FROM`, et
selon le serveur `SMTP_PORT` (587 par défaut), `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`.
`APP_URL` doit pointer sur l'URL publique, sans quoi les liens envoyés seront inutilisables.

## Appartenances et portées d'autorité

Un compte est une **identité globale** ; c'est le lien `Membership` qui le rattache à une
@@ -192,7 +235,18 @@ lui appartient ; elle n'est pas prise par défaut à sa place.
  test qui vérifie qu'aucun identifiant ne ressort de l'agrégation.
- **Journal d'audit** : les actions sensibles (connexions, création/suppression d'utilisateurs,
  suppression d'exercices, purges) sont tracées sur la sortie standard, **sans secret ni donnée
  personnelle superflue**.
  personnelle superflue**. Les actions d'administration de l'instance sont en outre persistées
  et consultables (`InstanceAudit`).
- **Rôle de sous-traitant** : en exploitation SaaS, chaque organisation cliente reste
  responsable de traitement et l'exploitant de l'instance devient **sous-traitant**. Cela
  suppose un accord de sous-traitance, un registre, la déclaration des sous-traitants
  ultérieurs (hébergeur, service SMTP) et une procédure de notification de violation. C'est
  un travail contractuel, hors code, mais il conditionne la mise en service.
- **Jetons de compte** : les liens envoyés par courriel portent une adresse email. Ils sont
  purgés dès qu'ils sont expirés, et sept jours après usage (`AccountService.purgeStaleTokens`).
- **Demandes d'inscription** : elles contiennent nom et adresse d'un contact avant même qu'une
  organisation existe. Elles ne sont pas purgées automatiquement à ce jour — à traiter selon
  votre politique de conservation.

## Signalement d'une vulnérabilité

+2 −0
Original line number Diff line number Diff line
@@ -23,6 +23,7 @@
    "@nestjs/core": "10.4.7",
    "@nestjs/jwt": "10.2.0",
    "@nestjs/platform-express": "10.4.7",
    "nodemailer": "6.9.16",
    "@nestjs/platform-socket.io": "10.4.7",
    "@nestjs/serve-static": "4.0.2",
    "@nestjs/websockets": "10.4.7",
@@ -45,6 +46,7 @@
    "@types/express": "4.17.21",
    "@types/jest": "29.5.14",
    "@types/multer": "1.4.12",
    "@types/nodemailer": "6.4.17",
    "@types/node": "22.8.7",
    "jest": "29.7.0",
    "prisma": "5.22.0",
+66 −0
Original line number Diff line number Diff line
-- Ouverture au public : inscription (interrupteur global), jetons a usage unique
-- envoyes par courriel, et demandes d'inscription a moderer.
--
-- Ces trois tables sont HORS cloisonnement : elles precedent l'existence de
-- l'organisation, ou s'utilisent avant toute session.

-- CreateEnum
CREATE TYPE "SignupMode" AS ENUM ('DISABLED', 'MODERATED', 'OPEN');
CREATE TYPE "SignupStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
CREATE TYPE "AuthTokenKind" AS ENUM ('ACTIVATION', 'PASSWORD_RESET', 'INVITATION');

-- CreateTable
-- Une seule ligne, d'identifiant fixe : ces reglages n'appartiennent a personne.
CREATE TABLE "InstanceSettings" (
    "id" TEXT NOT NULL DEFAULT 'singleton',
    -- Fermee par defaut : ouvrir l'inscription est une decision de l'exploitant.
    "signupMode" "SignupMode" NOT NULL DEFAULT 'DISABLED',
    "updatedAt" TIMESTAMP(3) NOT NULL,

    CONSTRAINT "InstanceSettings_pkey" PRIMARY KEY ("id")
);

CREATE TABLE "SignupRequest" (
    "id" TEXT NOT NULL,
    "organisationName" TEXT NOT NULL,
    "slug" TEXT NOT NULL,
    "contactName" TEXT NOT NULL,
    "contactEmail" TEXT NOT NULL,
    "message" TEXT,
    "status" "SignupStatus" NOT NULL DEFAULT 'PENDING',
    "decidedAt" TIMESTAMP(3),
    "decidedByUserId" TEXT,
    "rejectionReason" TEXT,
    "createdTenantId" TEXT,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT "SignupRequest_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "SignupRequest_status_idx" ON "SignupRequest"("status");
CREATE INDEX "SignupRequest_contactEmail_idx" ON "SignupRequest"("contactEmail");

-- Seule l'empreinte du jeton est stockee : la valeur en clair n'existe que dans
-- le lien envoye, comme pour les liens d'acces joueur.
CREATE TABLE "AuthToken" (
    "id" TEXT NOT NULL,
    "kind" "AuthTokenKind" NOT NULL,
    "tokenHash" TEXT NOT NULL,
    "email" TEXT NOT NULL,
    "userId" TEXT,
    "tenantId" TEXT,
    "role" "UserRole",
    "expiresAt" TIMESTAMP(3) NOT NULL,
    "usedAt" TIMESTAMP(3),
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT "AuthToken_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "AuthToken_tokenHash_key" ON "AuthToken"("tokenHash");
CREATE INDEX "AuthToken_email_idx" ON "AuthToken"("email");
CREATE INDEX "AuthToken_expiresAt_idx" ON "AuthToken"("expiresAt");

-- Ligne unique de reglages, creee des maintenant pour eviter un cas « absente ».
INSERT INTO "InstanceSettings" ("id", "signupMode", "updatedAt")
VALUES ('singleton', 'DISABLED', CURRENT_TIMESTAMP);
+81 −0
Original line number Diff line number Diff line
@@ -140,6 +140,87 @@ model Membership {
  @@index([userId])
}

/// Ouverture de l'inscription publique.
///
/// MODERATED : le formulaire cree une demande, un administrateur d'instance
/// approuve. OPEN : l'organisation est creee immediatement, l'adresse etant
/// verifiee par le lien d'activation. DISABLED : aucun formulaire.
enum SignupMode {
  DISABLED
  MODERATED
  OPEN
}

/// Reglages globaux de l'instance. Une seule ligne, d'identifiant fixe : ces
/// reglages n'appartiennent a aucune organisation.
model InstanceSettings {
  id         String     @id @default("singleton")
  signupMode SignupMode @default(DISABLED)
  updatedAt  DateTime   @updatedAt
}

enum SignupStatus {
  PENDING
  APPROVED
  REJECTED
}

/// Demande d'inscription, en attente d'approbation (mode MODERATED).
///
/// Hors cloisonnement : elle precede l'existence de l'organisation.
model SignupRequest {
  id               String       @id @default(cuid())
  organisationName String
  slug             String
  contactName      String
  contactEmail     String
  message          String?
  status           SignupStatus @default(PENDING)
  decidedAt        DateTime?
  decidedByUserId  String?
  rejectionReason  String?
  /// Organisation creee a l'approbation, si elle l'a ete.
  createdTenantId  String?
  createdAt        DateTime     @default(now())

  @@index([status])
  @@index([contactEmail])
}

/// Nature d'un jeton a usage unique envoye par courriel.
enum AuthTokenKind {
  /// Premiere prise de main sur un compte cree pour vous.
  ACTIVATION
  /// Reinitialisation de mot de passe demandee par l'interesse.
  PASSWORD_RESET
  /// Invitation a rejoindre une organisation, acceptee par l'interesse.
  INVITATION
}

/// Jeton a usage unique transmis par courriel.
///
/// Hors cloisonnement : il est utilise AVANT toute session. Comme les liens
/// joueur, seule l'empreinte SHA-256 est stockee — la valeur en clair n'existe
/// que dans le lien envoye.
model AuthToken {
  id        String        @id @default(cuid())
  kind      AuthTokenKind
  tokenHash String        @unique
  email     String
  /// Compte concerne. Null pour une invitation adressee a un inconnu.
  userId    String?
  /// Organisation concernee (invitation).
  tenantId  String?
  /// Role propose par l'invitation.
  role      UserRole?
  expiresAt DateTime
  usedAt    DateTime?
  createdAt DateTime      @default(now())

  @@index([email])
  @@index([expiresAt])
}

/// Journal d'audit des actions d'ADMINISTRATION DE L'INSTANCE.
///
/// Volontairement HORS cloisonnement, et volontairement sans relation vers
Loading