Aller au contenu

dynors-auth — Modèle multi-tenant & bascule de tenant (B4)

Réalise le backlog dynors-auth B4 (POST /auth/tenant/switch). Complète la spec dynors-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/refresh d'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=PASSWORD autorise 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 (quel aud, quels droits initiaux) et événements dynors-events.
  • /tenant/list enrichi : 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, profil local) crée le compte demo et les memberships demo/demo2.


Réf. code : model/TenantMembership, repository/TenantMembershipRepository, service/TenantSwitchService, web/AuthController (/tenant/switch, /tenant/list). Tests : TenantSwitchServiceTest. Migration : 003-auth-tenant-membership.yaml.