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 :
| Outil | Quand 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, ouLTI + 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 :
- créer un outil externe
LTI 1.3 - renseigner toutes les URLs Kutsum sur le host du tenant
- renvoyer à l'équipe Kutsum les valeurs
issuer,clientId,deploymentId
Valeurs à configurer côté Moodle pour un tenant <tenant> :
| Champ Moodle | Valeur |
|---|---|
| Nom de l'outil | Kutsum <Tenant> |
| Tool URL (Launch URL) | https://<tenant>.kutsum.org/student/join/lti |
| URL d'initiation de connexion | https://<tenant>.kutsum.org/api/v1/lti/oidc |
| URL du jeu de clés publiques | https://<tenant>.kutsum.org/api/v1/lti/jwks |
| URI(s) de redirection | https://<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 :
- ajouter l'entrée DNS
- dupliquer le vhost
app.kutsum.org - remplacer
server_nameparutbm.kutsum.org - remplacer uniquement les chemins de certificats si le certificat n'est pas wildcard
- conserver à l'identique les blocs
/api/v1/,/api/socket.io/,/api,/ - 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
https://utbm.kutsum.orgaffiche la homepage tenant attendue.GET https://utbm.kutsum.org/api/v1/tenants/utbm/configretourne 200.- aucune erreur CORS n'apparaît depuis
utbm.kutsum.org. - le launch LTI Moodle revient bien sur le host du tenant.
https://app.kutsum.orgreste 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→ 403LOCAL_LOGIN_DISABLEDsiallowLocalLogin=falsePOST /auth/register→ 403LOCAL_REGISTRATION_DISABLEDsiallowLocalRegistration=false- Création de partie →
gameAccessPolicyinitialisé àdefaultGameAccessPolicydu tenant
Enforcement frontend
TenantGate(ClientLayout) : siisPublic=falseet visiteur anonyme → redirect/login- exception :
/student/join/:codereste accessible (accès à une partie spécifique)
- exception :
- 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 sidefaultGameAccessPolicy != '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 principalhttp://utbm.kutsum.local:3008→ tenant UTBMhttp://localhost:3008→ équivalent au tenant principalapp
Pré-requis validés :
- entrées hosts sur Windows et WSL2 pour
app.kutsum.localetutbm.kutsum.local - frontend local lancé en dev standard (
npm run dev) - organisations seedées côté base (
utbmexiste dansmathquestetmathquest_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
app.kutsum.orgcontinue à fonctionner sans changement visible.utbm.kutsum.orgcharge le bon tenant.- aucune erreur CORS n'apparaît depuis
utbm.kutsum.orgvershttps://app.kutsum.org/api/v1/*. - les connexions Socket.IO depuis le tenant passent encore via le backend central sans blocage cross-origin.
- les routes Next
/api/*utilisées pour l'auth continuent de fonctionner. - le launch LTI depuis Moodle aboutit au bon tenant ou, à défaut, échoue avec un diagnostic utile.
- 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
- Valider d'abord
appen local et sur un environnement de test. - Activer ensuite un tenant de démonstration local avec un host dédié.
- Tester le launch Moodle contre ce tenant de test.
- Ne passer sur un tenant de production qu'après validation explicite de la non-régression de
app.kutsum.org.