dynors-auth — Modèle multi-tenant & bascule de tenant (B4)¶
Réalise le backlog
dynors-authB4 (POST /auth/tenant/switch). Complète la specdynors-auth.md. Code :dynors-platform/auth. Migration :003-auth-tenant-membership.yaml.
1. Le besoin¶
Un même utilisateur (employé ou client) peut avoir accès à plusieurs tenants (ex. un comptable qui gère deux boutiques, un consultant DYNORS sur plusieurs dossiers). Il doit pouvoir basculer d'un tenant à l'autre sans ressaisir ses identifiants, en obtenant un JWT scoper sur le tenant choisi.
2. Modèle de données¶
Deux notions distinctes — c'est la clé du modèle :
| Notion | Table | Rôle |
|---|---|---|
Compte (Account) |
auth_account |
Identité par tenant : une ligne par (tenant_code, email). Porte le aud (realm), le source (PASSWORD / LDAP / LINKED), le hash éventuel. C'est ce que pointe le sub du JWT. |
Appartenance (TenantMembership) |
auth_tenant_membership |
Autorisation d'accès : « cet email peut accéder à ce tenant_code (avec ce aud) ». Une ligne par (email, tenant_code). Source de vérité du tenant switch. |
email = demo@dawalale.local
├── membership → tenant "demo" (aud b2c-dawalale) ── Account(demo, demo@…) ← existe
└── membership → tenant "demo2" (aud b2c-dawalale) ── (pas de compte) ← provisionné au switch
Pourquoi séparer membership et compte ? L'appartenance est l'autorisation (auditable, indépendante du cycle de vie du compte) ; le compte est l'identité runtime (nécessaire car le refresh token est rattaché à un compte). Un membership peut exister avant le compte : le compte cible est alors provisionné au premier switch (source=LINKED, sans mot de passe utilisable).
L'ancrage d'identité entre tenants est l'email (un même email = un même principal). (Évolution possible : un subject/principal stable explicite — voir §6.)
3. Endpoints¶
POST /auth/tenant/switch { "tenant": "demo2" } → 200 + nouveaux cookies (JWT scoper demo2)
GET /auth/tenant/list → 200 [ {tenant, aud}, … ]
Les deux lisent l'access token dans le cookie (session en cours). /switch repose les cookies sid/rid sur le tenant cible.
4. Flux de bascule¶
POST /auth/tenant/switch { tenant: T }
│ (cookie access présent et valide)
▼
TenantSwitchService.switchTenant(token, T)
1) verify(token) → sub = accountId courant
2) Account courant → email
3) membership (email, T) actif ? ── non ──► 401 "tenant non autorisé"
4) Account (T, email) :
existe → on l'utilise
absent → provisionné (source=LINKED, aud du membership, mdp aléatoire)
5) AuthService.issueFor(compte cible) → JWT(sub=compte cible, tenant=T, aud) + refresh
▼
nouveaux cookies sid/rid (scope = T)
Le JWT reste minimal (sub, tenant, aud) — aucun rôle (→ dynors-entitlements).
5. Sécurité¶
- Session valide obligatoire : la bascule exige un access token non expiré (sinon
/auth/refreshd'abord). Pas de mot de passe (on est déjà authentifié). - Autorisation par membership actif : aucune bascule vers un tenant non listé pour l'email. Membership
INACTIVE= refus. - Comptes LINKED : provisionnés sans credentials → le login email/password est refusé dessus (garde généralisée : seul
source=PASSWORDautorise le login mot de passe). Idem LDAP. - Cookies host-only (ADR-0014) inchangés ; la bascule repose simplement de nouveaux cookies.
- Refresh multi-tenant : par défaut le refresh du tenant d'origine reste valide (l'utilisateur peut revenir) — sessions parallèles assumées. (Option durcissable : révoquer l'ancien refresh à la bascule si l'on veut une session unique.)
- Tenants sensibles (évolution) : possibilité d'exiger une ré-authentification/MFA avant bascule (non implémenté en MVP).
6. Trajectoire (non fait en MVP)¶
- Principal stable : remplacer l'ancrage par email par un identifiant de principal explicite (utile si l'email change ou diffère par tenant).
- Provisioning gouverné : aujourd'hui
LINKEDà la volée ; demain, règles (quelaud, quels droits initiaux) et événementsdynors-events. /tenant/listenrichi : libellés tenant, dernier accès, tenant courant marqué.- Révocation de membership propagée (couper l'accès en quelques secondes, cohérent avec la philosophie entitlements).
7. Tester en local¶
cd dynors-platform/auth
gradle bootRun --args='--spring.profiles.active=local'
BASE=http://localhost:8091/auth
# 1) login sur le tenant demo
curl -i -c c.txt -XPOST $BASE/login -H 'Content-Type: application/json' \
-d '{"email":"demo@dawalale.local","password":"demo1234","tenant":"demo"}'
# 2) tenants disponibles
curl -b c.txt $BASE/tenant/list # → [{"tenant":"demo",...},{"tenant":"demo2",...}]
# 3) bascule vers demo2 (compte provisionné LINKED au passage)
curl -i -b c.txt -c c.txt -XPOST $BASE/tenant/switch -H 'Content-Type: application/json' \
-d '{"tenant":"demo2"}'
# 4) le JWT est maintenant scoper demo2
curl -b c.txt $BASE/me # → {"tenant":"demo2","aud":"b2c-dawalale"}
Le seed dev (
DevSeedRunner, profillocal) crée le comptedemoet les membershipsdemo/demo2.
Réf. code : model/TenantMembership, repository/TenantMembershipRepository, service/TenantSwitchService, web/AuthController (/tenant/switch, /tenant/list). Tests : TenantSwitchServiceTest. Migration : 003-auth-tenant-membership.yaml.