KutsumKutsum
Accueil
Utilisation de l'appli
Création de questions
Installation
Détails techniques
Accueil
Utilisation de l'appli
Création de questions
Installation
Détails techniques
  • Détails techniques (utilisateurs avancés seulement)

    • Détails techniques (utilisateurs avancés seulement)
    • Architecture générale
    • 🖼️ Gestion des images
    • Base de données
    • Système de scoring
    • Services backend
    • API REST
    • Configuration et environnement
    • Tests et qualité
    • Tests E2E (Playwright) — runbook
    • Tests backend (Jest)
    • Tests frontend (Jest)
    • Déploiement et DevOps
    • Security Documentation
    • Performance & Monitoring
    • Troubleshooting Guide
    • Moodle et LTI 1.3
    • Multi-tenant Kutsum
    • Éditeur de Questions pour Enseignants
    • Landing Page Variants (App)
    • Types de questions et flux de correction
    • Compilation du validationConfig MathALÉA

Multi-tenant Kutsum

Cette page décrit comment ajouter un tenant Kutsum et, surtout, comment le valider proprement en local avant tout passage en production.

Objectif prioritaire : ne pas dégrader app.kutsum.org en introduisant un support multi-tenant partiel ou insuffisamment testé.

Architecture actuelle

Le modèle actuel sépare :

  • un frontend servi sur plusieurs hosts (app.kutsum.org, utbm.kutsum.org, ...)
  • un backend centralisé sur app.kutsum.org
  • une résolution du tenant côté frontend à partir du host demandé

En production, le frontend peut continuer à appeler le backend central via :

NEXT_PUBLIC_BACKEND_API_URL=https://app.kutsum.org/api/v1
NEXT_PUBLIC_BACKEND_BASE_URL=https://app.kutsum.org

Le backend garde une origine frontend canonique :

FRONTEND_URL=https://app.kutsum.org

Les sous-domaines tenant du même domaine frontal sont autorisés côté backend pour :

  • les requêtes API CORS
  • les connexions Socket.IO cross-origin

Ajouter un tenant

1. Créer l'organisation en base

Deux outils couvrent le cycle de vie d'un tenant :

OutilQuand l'utiliser
npm run tenant:admin (depuis app/backend/)Recommandé — interface web locale, tous les champs d'un tenant sur une seule page, sélection par dropdown, sauvegarde à chaud
npm run tenant:setup (depuis app/backend/)Wizard CLI interactif — génère les secrets SP SAML, le .env.<tenant> et le fichier de settings JSON, puis seed optionnel en base
npm run tenant <fichier.yaml> (depuis app/)Mise à jour idempotente — applique un YAML de configuration en base, sans toucher aux secrets

Pour créer ou modifier un tenant, utiliser l'interface web admin (recommandé) :

cd app/backend
npm run tenant:admin
# puis ouvrir http://localhost:7998

L'interface présente tous les champs sur une seule page (visibilité, auth, catalogue, SSO, LTI, SAML, branding, quotas), avec dropdown de sélection du tenant et sauvegarde sans relancer un wizard séquentiel.

Accès depuis un VPS distant via SSH tunnel :

ssh -L 7998:127.0.0.1:7998 user@mon-vps
# puis ouvrir http://localhost:7998 en local

Pour une utilisation ponctuelle ou en ligne de commande (script, CI), le wizard CLI reste disponible :

cd app/backend
npm run tenant:setup

Le wizard propose une à une toutes les options disponibles (authentification, visibilité, catalogue, SSO, quotas, LTI, SAML…), génère automatiquement la paire de clés SP SAML si nécessaire, et écrit :

  • .env.<tenant> — secrets runtime (LTI credentials, clés SP SAML) à injecter en production via PM2 / secrets manager
  • .tenant-settings-<tenant>.json — settings + catalogPolicy pour le seed DB (gitignorés tous les deux)

Pour appliquer ou mettre à jour les settings en base (sans secrets) :

cd app
npm run tenant scripts/tenants/utbm.yaml -- --dry-run   # vérifier le diff
npm run tenant scripts/tenants/utbm.yaml                 # appliquer

Sans organisation active en base, le host utbm.kutsum.org retourne {"error":"Organization not found"} et le frontend affiche une page d'erreur.

2. Exposer le host du tenant

Prévoir :

  • une entrée DNS pour utbm.kutsum.org
  • un vhost HTTPS pour utbm.kutsum.org
  • le même reverse proxy frontend que pour app.kutsum.org

Le point critique est de préserver le header Host, car c'est lui qui sert à résoudre le tenant.

3. Garder le backend centralisé

Le backend continue à être servi sur app.kutsum.org.

Le frontend du tenant appelle donc le backend central, ce qui impose :

  • une configuration CORS correcte sur le backend
  • une configuration Socket.IO correcte sur le backend
  • une vérification de non-régression sur app.kutsum.org

4. Ajouter le provider LTI tenant

Pour un tenant piloté par Moodle, créer un IdentityProvider actif de type LTI_13 rattaché à l'organisation du tenant.

Voir la page Moodle et LTI 1.3 pour le détail des champs.

Runbook de production

L'ajout d'un tenant en production est idempotent et piloté par la CLI de provisioning (app/scripts/provision-tenant.ts).

1. Informations à collecter côté établissement

Avant toute modification serveur, récupérer auprès de l'établissement :

  • le sous-domaine Kutsum souhaité, ex. utbm.kutsum.org
  • l'URL Moodle / LMS émettrice, ex. https://moodle.utbm.fr
  • le mode d'accès attendu : LTI uniquement, SSO direct, ou LTI + SSO
  • les options métier spécifiques déjà décidées contractuellement

Si le tenant est piloté par Moodle, l'admin Moodle devra ensuite renvoyer : issuer, clientId, deploymentId.

Ces trois valeurs sont indispensables pour l'IdentityProvider Kutsum.

2. Ce que l'admin Moodle doit faire

Le message opérationnel à envoyer à l'admin Moodle est :

  1. créer un outil externe LTI 1.3
  2. renseigner toutes les URLs Kutsum sur le host du tenant
  3. renvoyer à l'équipe Kutsum les valeurs issuer, clientId, deploymentId

Valeurs à configurer côté Moodle pour un tenant <tenant> :

Champ MoodleValeur
Nom de l'outilKutsum <Tenant>
Tool URL (Launch URL)https://<tenant>.kutsum.org/student/join/lti
URL d'initiation de connexionhttps://<tenant>.kutsum.org/api/v1/lti/oidc
URL du jeu de clés publiqueshttps://<tenant>.kutsum.org/api/v1/lti/jwks
URI(s) de redirectionhttps://<tenant>.kutsum.org/api/v1/lti/launch

3. Ce qu'il faut modifier côté nginx en production

Le modèle de déploiement : un vhost frontend par tenant, mêmes upstreams backend/frontend que pour app.kutsum.org.

Concrètement, pour utbm.kutsum.org :

  1. ajouter l'entrée DNS
  2. dupliquer le vhost app.kutsum.org
  3. remplacer server_name par utbm.kutsum.org
  4. remplacer uniquement les chemins de certificats si le certificat n'est pas wildcard
  5. conserver à l'identique les blocs /api/v1/, /api/socket.io/, /api, /
  6. conserver proxy_set_header Host $host

Le fichier nginx.example (racine du dépôt) sert de modèle. Le point de vigilance principal est la préservation du header Host, qui déclenche la résolution du tenant côté frontend et les autorisations cross-origin côté backend.

4. Provisionner le tenant en base

4a. Nouveau tenant — interface web admin (recommandé)

Depuis app/backend/, démarrer le serveur d'admin local :

cd app/backend
npm run tenant:admin

L'interface écoute sur http://127.0.0.1:7998 (loopback uniquement — inaccessible depuis l'extérieur).

Accès depuis un VPS distant :

ssh -L 7998:127.0.0.1:7998 user@mon-vps

L'interface permet de :

  • Sélectionner un tenant existant ou créer un nouveau.
  • Modifier tous les champs (auth, SSO, LTI, SAML, branding, quotas, catalogue) sans repasser tout un wizard séquentiel.
  • Générer le keypair RSA SP SAML directement depuis le navigateur (bouton dédié).
  • Sauvegarder — écrit .env.<tenant> + .tenant-settings-<tenant>.json.
  • Lancer le seed DB sans quitter l'interface (bouton Seeder la DB).

Les clés privées SP SAML ne transitent jamais par le navigateur : elles sont écrites directement sur disque par le serveur.

4b. Alternative — wizard CLI interactif

Depuis app/backend/, lancer le wizard qui couvre toutes les options et génère les fichiers de secrets :

cd app/backend
npm run tenant:setup

À la fin du wizard, trois fichiers sont écrits (gitignorés) :

  • .env.<tenant> — vars runtime (LTI + clés SP SAML)
  • .tenant-settings-<tenant>.json — settings + catalogPolicy
  • .env.<tenant>.sp-key.pem / .env.<tenant>.sp-cert.pem — fichiers PEM bruts (pour la DSI)

Le wizard propose d'appeler directement npm run tenant:seed -- --tenant <tenant> pour seed en base. Il affiche également le bloc SP Metadata à transmettre à la DSI.

4b. Mise à jour idempotente via YAML

Pour mettre à jour les settings d'un tenant existant (sans toucher aux secrets) :

Créer ou éditer le fichier YAML de configuration

Copier app/scripts/tenants/utbm.yaml comme modèle et renseigner au minimum :

subdomain: mon-tenant
name: "Mon Établissement"
isActive: true
settings:
  auth:
    allowLocalLogin: false
    allowLocalRegistration: false
    defaultGameAccessPolicy: IDENTITY_REQUIRED
  visibility:
    isPublic: false
  branding:
    logoAssetKey: mon-tenant
identityProviders:
  - type: LTI_13
    slug: mon-tenant-moodle
    isActive: true
    config:
      issuer: "https://moodle.mon-etablissement.fr"
      clientId: "<CLIENT_ID>"
      deploymentId: "<DEPLOYMENT_ID>"
      keySetUrl: "https://moodle.mon-etablissement.fr/mod/lti/certs.php"
      authTokenUrl: "https://moodle.mon-etablissement.fr/mod/lti/token.php"
      authLoginUrl: "https://moodle.mon-etablissement.fr/mod/lti/auth.php"

Pour un tenant avec SSO Renater (SAML 2.0), ajouter dans settings :

  sso:
    federations: [renater]   # affiche « Se connecter avec Renater »
    forcedRole: STUDENT      # tous les comptes SSO créés en STUDENT

et dans identityProviders :

  - type: SAML
    slug: mon-tenant-renater
    isActive: true
    config:
      idpEntityId: "https://idp.renater.fr/..."   # métadonnées IdP
      idpSsoUrl: "https://idp.renater.fr/sso/..."
      idpCertificate: "BASE64..."
      spEntityId: "https://mon-tenant.kutsum.org"
      spCallbackUrl: "https://mon-tenant.kutsum.org/api/v1/auth/sso/callback"
      # La clé privée SP est injectée via env (jamais en DB) :
      #   MON_TENANT_SAML_SP_PRIVATE_KEY, MON_TENANT_SAML_SP_CERT

Seules les valeurs qui diffèrent du comportement permissif de app.kutsum.org sont nécessaires — toutes les clés settings ont des defaults.

Vérifier avec --dry-run (sans écriture)

cd app
npm run tenant scripts/tenants/mon-tenant.yaml -- --dry-run

Le diff affiche [ADD], [UPDATE], [NO-CHANGE] pour chaque champ.

Appliquer (idempotent)

cd app
npm run tenant scripts/tenants/mon-tenant.yaml

Relancer deux fois n'a aucun effet si rien n'a changé.

Exporter la config active pour vérification

cd app
npm run tenant -- --export mon-tenant

Affiche les settings parsés (avec defaults appliqués) et les IdentityProviders actifs.

Aide complète

cd app
npm run tenant -- --help

5. Vérifications minimales avant ouverture du tenant

  1. https://utbm.kutsum.org affiche la homepage tenant attendue.
  2. GET https://utbm.kutsum.org/api/v1/tenants/utbm/config retourne 200.
  3. aucune erreur CORS n'apparaît depuis utbm.kutsum.org.
  4. le launch LTI Moodle revient bien sur le host du tenant.
  5. https://app.kutsum.org reste inchangé.

Options tenant : OrganizationSettings

Les options tenant sont centralisées dans un contrat Zod strict : app/shared/types/core/organizationSettings.zod.ts.

La structure est versionée par sections — toutes les clés ont des valeurs par défaut correspondant au comportement permissif de app.kutsum.org :

OrganizationSettings {
  auth: {
    allowLocalLogin: boolean          // false → backend rejette email/password
    allowLocalRegistration: boolean   // false → backend rejette les créations de compte
    allowEmailLinkage: 'forbidden' | 'same-tenant' | 'cross-tenant'
    defaultGameAccessPolicy: 'OPEN' | 'IDENTITY_REQUIRED' | 'LMS_CONTEXT_REQUIRED'
  }
  identity: {
    isUsernameManaged: boolean        // true → username fourni par SSO, non éditable
    showAvatar: boolean
    allowAvatarChange: boolean
    allowAvatarChoiceOnJoin: boolean
    showRealNameInResults: boolean    // true  → vue résultats enseignant : affiche prénom+nom SSO/LTI
                                      //          (ex: "Martin Dupont") au lieu du pseudonyme Kutsum
                                      // false → pseudonyme Kutsum uniquement
  }
  features: {
    allowRoleUpgrade: boolean         // false → pas de passage STUDENT→TEACHER
    allowManualRoleAssignment: boolean
    allowGuestAccessOverride: boolean // false → politique tenant toujours appliquée
  }
  catalog: {
    accessNationalCatalog: boolean    // true  → les enseignants accèdent à la banque nationale Kutsum
                                      //          (questions de tous les auteurs, pas seulement l’org)
                                      // false → uniquement les questions créées dans l’organisation
  }
  sso: {
    federations: ('renater')[]        // active le bouton SSO + son libellé
    forcedRole: 'STUDENT' | 'TEACHER' | null
    // null → rôle dérivé de eduPersonAffiliation (faculty/staff → TEACHER, sinon STUDENT)
    // 'STUDENT' → tous les comptes SSO créés STUDENT quelle que soit l'affiliation
    // rolePolicy:'new-only' est toujours appliqué : un TEACHER existant ne peut
    //   pas être rétrogradé via SSO, même si forcedRole='STUDENT'
  }
  branding: {
    logoAssetKey: string | null       // clé du fichier SVG : app/frontend/public/tenant-assets/<clé>.svg
                                      // ex: logoAssetKey="utbm" → utbm.svg (copier manuellement le fichier)
                                      // null → logo Kutsum par défaut
    establishmentDisplayName: string | null
  }
  quotas: {
    maxQuestionsPerTeacher: number | null
    maxActivitiesPerUser: number | null
  }
  visibility: {
    isPublic: boolean                 // false → anonymes redirigés vers /login
  }
}

La constante DEFAULT_ORGANIZATION_SETTINGS correspond au comportement de app.kutsum.org (tout permissif). La constante UTBM_ORGANIZATION_SETTINGS contient les settings cibles du tenant UTBM — les deux sont exportées depuis le schéma et servent de référence pour les tests.

CatalogPolicy — filtres de catalogue par tenant

Les options de filtrage du catalogue sont dans un contrat Zod séparé : app/shared/types/core/catalogPolicy.zod.ts.

CatalogPolicy {
  studentCatalogAccess?: boolean    // true  → les étudiants peuvent parcourir le catalogue
                                    //          et lancer des sessions de pratique autonomes
                                    // false → catalogue enseignants uniquement
  allowedGradeLevels?: string[]     // filtre les niveaux visibles (ex: ["6e","5e","L1"])
                                    // absent → tous les niveaux autorisés
  allowedDisciplines?: string[]     // filtre les disciplines (ex: ["mathématiques","physique"])
                                    // absent → toutes les disciplines
  allowedSources?: string[]         // filtre les sources de questions
                                    // absent → toutes les sources
}

Ces valeurs sont stockées dans la colonne catalogPolicy de Organization (JSON) et gérées par le wizard npm run tenant:setup ou le YAML de provisioning.

Enforcement backend

  • POST /auth/login → 403 LOCAL_LOGIN_DISABLED si allowLocalLogin=false
  • POST /auth/register → 403 LOCAL_REGISTRATION_DISABLED si allowLocalRegistration=false
  • Création de partie → gameAccessPolicy initialisé à defaultGameAccessPolicy du tenant

Enforcement frontend

  • TenantGate (ClientLayout) : si isPublic=false et visiteur anonyme → redirect /login
    • exception : /student/join/:code reste accessible (accès à une partie spécifique)
  • Page de profil : champs username et avatar masqués/désactivés selon identity.*
  • Création de partie : toggle "ouvrir aux invités" masqué si allowGuestAccessOverride=false
  • Page join (/student/join) : flow guest remplacé par login SSO si defaultGameAccessPolicy != 'OPEN'

TenantPublicConfig — contrat API frontend

L'endpoint GET /api/v1/tenants/:subdomain/config expose un sous-ensemble des settings au frontend non authentifié :

TenantPublicConfig {
  subdomain: string
  name: string
  allowLocalLogin: boolean
  allowLocalRegistration: boolean
  defaultGameAccessPolicy: 'OPEN' | 'IDENTITY_REQUIRED' | 'LMS_CONTEXT_REQUIRED'
  ssoFederations: string[]          // ex. ['renater'] ou [] si pas de SSO
  branding: { logoAssetKey: string | null; establishmentDisplayName: string | null }
  features: { allowGuestAccessOverride: boolean }
  identity: { isUsernameManaged: boolean; showAvatar: boolean; allowAvatarChange: boolean; ... }
  visibility: { isPublic: boolean }
}

Tous les composants UI qui dépendent des settings tenant lisent cette config via le hook useTenantConfig() (côté client) ou le RSC fetchTenantConfig() (côté serveur).

Validation locale avant production

Le but n'est pas de rendre UTBM parfait du premier coup. Le but est de vérifier que l'ajout multi-tenant ne casse pas le tenant principal app.

Option A : simulation simple du tenant

Pour le frontend local, le dépôt fournit déjà une simulation du tenant par override :

cd app
TENANT=utbm npm run dev:tenant

ou :

cd app
TENANT=utbm npm run dev:test:tenant

Cette méthode est utile pour valider le rendu du tenant, mais elle ne teste pas les vraies contraintes DNS, reverse proxy ou CORS cross-origin.

Option B : validation locale avec vrais hosts

Pour un test plus réaliste, utiliser des hosts locaux dédiés. Le runbook validé manuellement sur Windows + WSL2 est :

  • http://app.kutsum.local:3008 → tenant principal
  • http://utbm.kutsum.local:3008 → tenant UTBM
  • http://localhost:3008 → équivalent au tenant principal app

Pré-requis validés :

  • entrées hosts sur Windows et WSL2 pour app.kutsum.local et utbm.kutsum.local
  • frontend local lancé en dev standard (npm run dev)
  • organisations seedées côté base (utbm existe dans mathquest et mathquest_test)
  • backend CORS dev autorisant *.kutsum.local
  • Next dev autorisant *.kutsum.local

Pour les tests automatisés existants, le dépôt supporte aussi toujours *.kutsum.localhost (ex. demo-univ-lti.kutsum.localhost) afin de ne pas casser l'E2E Playwright déjà en place.

Voir le scénario de référence dans app/tests/e2e/tenant-homepage.spec.ts.

Ce qu'il faut valider avant la prod

  1. app.kutsum.org continue à fonctionner sans changement visible.
  2. utbm.kutsum.org charge le bon tenant.
  3. aucune erreur CORS n'apparaît depuis utbm.kutsum.org vers https://app.kutsum.org/api/v1/*.
  4. les connexions Socket.IO depuis le tenant passent encore via le backend central sans blocage cross-origin.
  5. les routes Next /api/* utilisées pour l'auth continuent de fonctionner.
  6. le launch LTI depuis Moodle aboutit au bon tenant ou, à défaut, échoue avec un diagnostic utile.
  7. le flux post-launch ne redirige pas à tort vers app.kutsum.org.

Pour le test local Moodle UTBM, cela implique actuellement de lancer Kutsum avec FRONTEND_URL=http://utbm.kutsum.local:3008 npm run dev avant le launch.

Risques connus

1. Redirection LTI post-launch encore globale

Le backend LTI s'appuie encore sur une FRONTEND_URL globale pour certaines redirections finales. Cela peut renvoyer un utilisateur Moodle vers le tenant app alors que le launch a démarré sur utbm.

2. Régression sur le tenant principal

Tout changement CORS, Socket.IO, routing frontend ou résolution du tenant doit être validé explicitement sur app.kutsum.org.

3. Faux sentiment de sécurité avec override local

DEV_TENANT_OVERRIDE est utile pour développer vite, mais ne remplace pas un test avec de vrais hosts locaux ou de préproduction.

Le runbook local retenu pour une validation navigateur réelle est désormais le mode multi-host (app.kutsum.local / utbm.kutsum.local), pas l'override.

Recommandation de rollout

  1. Valider d'abord app en local et sur un environnement de test.
  2. Activer ensuite un tenant de démonstration local avec un host dédié.
  3. Tester le launch Moodle contre ce tenant de test.
  4. Ne passer sur un tenant de production qu'après validation explicite de la non-régression de app.kutsum.org.
Dernière mise à jour: 16/05/2026 11:24
Contributors: alexisflesch
Prev
Moodle et LTI 1.3
Next
Éditeur de Questions pour Enseignants